fix(node): drain cancelled request bodies so connections don't stall - #110
Conversation
Cancelling a `toWebReadableStream` request body (e.g. rejecting an oversized upload after the first chunk) left the rest of the upload unread. Over HTTP/1 the next request on the keep-alive connection was never served and failed with ECONNRESET at the keep-alive timeout. Over HTTP/2, destroying the `Http2ServerRequest` never touched its `Http2Stream`, which stayed paused at the flow-control window, so the stream never closed and `sendStandardResponse` never settled. Server requests (HTTP/1 and HTTP/2) are now read to the end and discarded on cancel, the way Node drains a body a handler never reads. Draining pulls the existing async iterator, so no listeners are removed.
@standard-server/aws-lambda
@standard-server/core
@standard-server/fastify
@standard-server/fetch
@standard-server/node
@standard-server/peer
@standard-server/shared
commit: |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
packages/node/src/utils.ts—toWebReadableStreamnow detects server requests with(stream instanceof IncomingMessage && stream.method !== null) || stream instanceof Http2ServerRequestand drains the adapter's async iterator todoneon cancel instead of abandoning (HTTP/1) or destroying (HTTP/2); other streams stilldestroy(). JSDoc updated to match.node:http2is now a runtime import.packages/node/src/utils.test.ts— two new cases covering HTTP/1 keep-alive reuse after a cancelled 1 MiB upload (plus a foreign'readable'listener surviving) and HTTP/2 stream closure withNGHTTP2_NO_ERROR.
I verified the tests are discriminating: reverting only utils.ts to its parent commit makes both new cases time out, while the full PR state passes packages/node/src/utils.test.ts, the node package plus tests/signal-and-cancel.test.ts, and tsc -b. I also confirmed empirically that the drain's concurrent iterator.next() next to an in-flight pull is safe on Node 24 (chunks delivered once, no throw), and that no README/CHANGELOG documents the old cancel semantics.
DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

Cancelling a request body partway through, for example rejecting an oversized upload after the first chunk, left the rest of the upload unread. Over HTTP/1 the next request on the keep-alive connection was never served and failed with
ECONNRESETabout 5 s later. Over HTTP/2 the stream never closed, sosendStandardResponsenever settled and one stream leaked per request for the life of the connection. Server requests are now read to the end and discarded on cancel, the way Node already handles a body the handler never reads.Fixes
NO_ERRORafter a cancel, andsendStandardResponsesettles'readable'listeners added by other code stay attachedBehavior change
node:http2is now imported at runtime for theHttp2ServerRequestcheck (about 10 ms the first time the package is imported).Considered and not done
NO_ERRORafter the response (what@hono/node-serverdoes) would stop uploads early, but Node's own HTTP/2 client, when it still has body data queued, then emitsabortedand never emitsclose.Testing
'readable'listener stays attachedNO_ERRORand the response emitsclosemaintsc -band eslint pass