Skip to content

fix(node): drain cancelled request bodies so connections don't stall - #110

Merged
dinwwwh merged 1 commit into
mainfrom
claude/towebreadablestream-cancellation-leak-024640
Sep 26, 2026
Merged

dinwwwh merged 1 commit into
mainfrom
claude/towebreadablestream-cancellation-leak-024640

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Sep 26, 2026

Copy link
Copy Markdown
Member

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 ECONNRESET about 5 s later. Over HTTP/2 the stream never closed, so sendStandardResponse never 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

  • HTTP/1 keep-alive connections keep serving requests after a handler cancels a body (a 100 MiB rejected upload drains in ~140 ms on loopback)
  • HTTP/2 streams close with NO_ERROR after a cancel, and sendStandardResponse settles
  • Draining doesn't remove any listeners, so 'readable' listeners added by other code stay attached

Behavior change

  • After a cancel, the client still uploads the rest of the body, which the server reads and throws away. Other readable streams are still destroyed on cancel.
  • node:http2 is now imported at runtime for the Http2ServerRequest check (about 10 ms the first time the package is imported).

Considered and not done

  • Resetting HTTP/2 streams with NO_ERROR after the response (what @hono/node-server does) would stop uploads early, but Node's own HTTP/2 client, when it still has body data queued, then emits aborted and never emits close.
  • Closing the HTTP/1 connection instead of draining (Hono again) loses keep-alive and needs the response object.
  • Compared with other adapters: in the same scenario, remix drains like we do, Hono closes the connection after ~500 ms, and srvx has the bug this PR fixes.

Testing

  • New HTTP/1 test: a pipelined request after a cancelled 1 MiB upload is served, and another 'readable' listener stays attached
  • New HTTP/2 test: after a cancelled 1 MiB upload the stream closes with NO_ERROR and the response emits close
  • Both new tests time out on main
  • Full suite (1205 tests) passes on Node 24; the node package and e2e suites pass on Node 22 and 26.10; the Bun and Deno e2e tests pass; tsc -b and eslint pass

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.
@pkg-pr-new

pkg-pr-new Bot commented Sep 26, 2026

Copy link
Copy Markdown
@standard-server/aws-lambda

npm i https://pkg.pr.new/@standard-server/aws-lambda@110

@standard-server/core

npm i https://pkg.pr.new/@standard-server/core@110

@standard-server/fastify

npm i https://pkg.pr.new/@standard-server/fastify@110

@standard-server/fetch

npm i https://pkg.pr.new/@standard-server/fetch@110

@standard-server/node

npm i https://pkg.pr.new/@standard-server/node@110

@standard-server/peer

npm i https://pkg.pr.new/@standard-server/peer@110

@standard-server/shared

npm i https://pkg.pr.new/@standard-server/shared@110

commit: a46b143

@codecov

codecov Bot commented Sep 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed

codspeed Bot commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 108 skipped benchmarks1


Comparing claude/towebreadablestream-cancellation-leak-024640 (a46b143) with main (901bb47)

Open in CodSpeed

Footnotes

  1. 108 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • packages/node/src/utils.ts — toWebReadableStream now detects server requests with (stream instanceof IncomingMessage && stream.method !== null) || stream instanceof Http2ServerRequest and drains the adapter's async iterator to done on cancel instead of abandoning (HTTP/1) or destroying (HTTP/2); other streams still destroy(). JSDoc updated to match. node:http2 is 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 with NGHTTP2_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.

Pullfrog  | View workflow run | Using DeepSeek Flash (default — pick a model for stronger reviews) | 𝕏

@dinwwwh
dinwwwh merged commit 068acbd into main Sep 26, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant