node:http2: do not run the frame queue inside the callback of a dequeued write
Problem
- A peer that queues 3,000 requests behind a closed flow-control window exits a default
http2.createServer()withRangeError: 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 andBun.serveHTTP/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_queuewalks the streams and writes what it can. - Writing the last frame of a write calls that write's callback.
end()in the callback callsnative.flush(), which reachesflush_stream_queueagain. - Node guards the same loop:
Http2Session::SendPendingDatareturns at once when it is already sending (node_http2.cc:1951-1953in 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
sendcount. 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).
| runtime | result |
|---|---|
| main (a950fc5307, release) | 2,177 of 3,000 callbacks, 824 uncaught RangeError |
| bun 1.4.3 | 2,253 of 3,000 callbacks, 747 uncaught RangeError (compat API) |
| this PR | 3,000 of 3,000 |
| Node v26.3.0 | 3,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.
| runtime | accepted | RSS growth |
|---|---|---|
| main | 64 MB of 64 MB offered | +64 MB |
| this PR | 6.05 MB | +5.8 MB |
| Node v26.3.0 | 2.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._finaland 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:
.text107,401,036 bytes on both. The binary is the same size.H2FrameParserstays 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,nochainover 600 writes: 1,444.63 to 1,449.21. All of it is inflush_stream_queue. sendcalls, counted with anLD_PRELOADshim: 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/, theBun.serveand fetch HTTP/2 suites,AsyncLocalStorage.test.ts,ws-server-compat-transports.test.tsand the http2 regression tests pass.- grpc-js: 28 of 29 files pass.
test-resolverfails the same five DNS cases on main. test/internal/source-lints/,cargo clippy -p bun_runtime, rustfmt and prettier are clean.- The
stream-reset floodscase "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.