node:http2: do not run the frame queue inside the callback of a dequeued write

RO
18d ago
+204-1

Problem

  • A peer that queues 3,000 requests behind a closed flow-control window exits a default http2.createServer() with RangeError: Maximum call stack size exceeded. One SETTINGS frame raises the window, and one flush then writes the queued frames. Each write callback ends its stream, which flushes again from inside that callback.
  • The same nesting lets a chain of writes run away. A client that writes 8 KB from each write callback accepts 64 MB against a peer that reads nothing. RSS grows by 64 MB.
  • flush_stream_queue (src/runtime/api/bun/h2_frame_parser.rs:2721) runs the whole queue again for each of those flushes. Its loop also serializes more frames into a write buffer that the socket already refused.

Fix

  • A flush from the JS that a dequeue runs leaves the queue to the loop on the stack (queue_dispatching). That loop goes on with the next frame, so the frames keep their order.
  • That loop stops when the session has bytes that the socket refused. A write then completes only after the socket takes its frame, which is what stops the chain.
  • Verified: four new cases in test/js/node/http2/h2-conformance.test.ts, all four red on main. Also the vendored http2 suite (256 of 256), test/js/node/http2/, the fetch and Bun.serve HTTP/2 suites, and grpc-js.

Background

  • Each stream has a queue of DATA frames that the peer's window or the socket did not take yet. flush_stream_queue walks the streams and writes what it can.
  • Writing the last frame of a write calls that write's callback. end() in the callback calls native.flush(), which reaches flush_stream_queue again.
  • Node guards the same loop: Http2Session::SendPendingData returns at once when it is already sending (node_http2.cc:1951-1953 in v26.3.0).

Downsides

  • A sender that writes from its write callbacks stops at about 6 MB when the peer reads nothing. It used to accept everything offered. Code that relies on that gets backpressure instead.
  • Cost for callers who never queue a frame: +4.6 instructions per awaited write, 0 bytes of text, 0 bytes per session, and the same send count. Uploads of 12 x 40 MB: -0.9% TCP and -0.5% TLS (30 interleaved pairs, standard error 3.5% and 2.7%).
  • A queued frame can wait for one more event-loop turn. The pass that would have written it inside a callback now writes it when the socket drains.
Notes

Repro of the crash. A peer opens with SETTINGS_INITIAL_WINDOW_SIZE = 0, sends 3,000 GET requests, and raises the window when the server has answered them all. Every response is write("x"); end(cb).

runtimeresult
main (a950fc5307, release)2,177 of 3,000 callbacks, 824 uncaught RangeError
bun 1.4.32,253 of 3,000 callbacks, 747 uncaught RangeError (compat API)
this PR3,000 of 3,000
Node v26.3.03,000 of 3,000

The same shape through the compat API (response.end("x", cb)) and through stream.end("x", cb) with trailers nests the same way. 20,000 streams pass with this PR.

Repro of the runaway. A client writes 8 KB from each write callback to a peer that opens every window and reads nothing.

runtimeacceptedRSS growth
main64 MB of 64 MB offered+64 MB
this PR6.05 MB+5.8 MB
Node v26.3.02.57 MB+2.6 MB

Main is not deterministic here: a second run of the same probe stopped at 6.73 MB. The outcome depends on which nested pass met the refused socket write first.

Why the guard alone is not enough. With only the re-entrancy guard, the outer loop keeps dequeuing frames while the write buffer grows, and the client probe above accepts all 64 MB. The stop at the refused write is what bounds it.

What the new tests check.

  • Three cases wrap stream._final and assert that the flush of one stream's end does not run inside the flush of another (nesting depth 1 for 50 streams). On main the depth is 50.
  • One case runs the 3,000-stream shape in a child and asserts its exit code and output. It is skipped on debug and ASAN builds, where 3,000 streams take about 15 s. The three depth cases cover the same code there.
  • One case asserts that a chain of writes stops while the session holds refused bytes.

Measurements (linux-x64 release builds of a950fc5307 and of this commit).

  • Size: .text 107,401,036 bytes on both. The binary is the same size. H2FrameParser stays 1,232 bytes: the flag fits in existing padding.
  • Instructions in the h2 parser functions per awaited 64-byte write, counted with qemu-x86_64 -d exec,nochain over 600 writes: 1,444.63 to 1,449.21. All of it is in flush_stream_queue.
  • send calls, counted with an LD_PRELOAD shim: 406 and 406 for 200 unary requests. 2,056 and 2,056 for a 64 MB upload in awaited 64 KB writes.
  • Wall clock of uploads over one session, interleaved A/B with the order alternating, 30 pairs: 12 x 40 MB -0.9% TCP and -0.5% TLS, 12 x 6 MB -7.5% TCP and +0.4% TLS.

Tests.

  • h2-conformance.test.ts: 89 pass on the release build, 85 pass and 1 skip on the debug build. The four new cases fail on main and on bun 1.4.3.
  • Vendored test-http2-*.js: 256 of 256.
  • test/js/node/http2/, the Bun.serve and fetch HTTP/2 suites, AsyncLocalStorage.test.ts, ws-server-compat-transports.test.ts and the http2 regression tests pass.
  • grpc-js: 28 of 29 files pass. test-resolver fails the same five DNS cases on main.
  • test/internal/source-lints/, cargo clippy -p bun_runtime, rustfmt and prettier are clean.
  • The stream-reset floods case "a RST_STREAM flood is answered with GOAWAY" times out on a loaded debug build in about 1 of 3 runs, on main's stack too (measured on the debug build of #44881's head, which does not contain this change).

Origin. #44881 handed off the other half of this: a write still completes while its bytes wait in the session. That half needs a rule for what a write in flight gets at teardown, and it waits for #44881. This PR is the part that stands alone.