Repository navigation
Conversation
…onMemory The native request() function sends the header block for a client request() and for a server respond() or additionalHeaders(). It refused the block when the session was over maxSessionMemory. On a server this closed the stream locally and wrote no frame, so the peer's request never got 'response', 'error' or 'close'. node applies the budget to new streams only (Http2Session::CanAddStream) and Http2Stream::SubmitResponse has no memory check. The check now applies to a client only.
|
Status for #43419. How I reproduced it:
With the fix, N=60 and N=200 complete over TCP and over |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughThe parser now applies session-memory rejection only to client-initiated streams. A conformance test verifies that already-open streams still receive 4 MiB and 2-byte responses after flow-control credit. ChangesHTTP/2 session memory handling
Suggested reviewers: Priority: ➖ Normal 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the server's other outbound header path for the same gate: push_promise() in h2_frame_parser.rs never applied the maxSessionMemory check, and additionalHeaders() reaches the same request() line with is_server set, so after this change every server-side submission is consistently unchecked (matching node, where only OnBeginHeadersCallback refuses). The new test's helpers (drainFirstStream, RawH2, requestHeaderBlock) are defined in the same file/describe scope, and the pre-fix behavior fails the test via waitFor timeout rather than passing vacuously.
Extended reasoning...
The two inline findings cover the remaining branches of this bug class (the maxSendHeaderBlockLength refusal a few lines below, and the client-side ordering of the memory check relative to HPACK encoding). Separately, I traced the server-side entry points that could still hit the narrowed check: push_promise() (line ~6017) has no memory-limit call at all, and additionalHeaders()/respond() both go through request() with is_server true, so the server half is now internally consistent with can_open_stream() as the only inbound gate. The test uses the existing drainFirstStream helper and RawH2.waitFor (2s default timeout rejecting on no frame), so on the unfixed code it fails by timeout/RST rather than passing.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/runtime/api/bun/h2_frame_parser.rs— Servers that set maxSendHeaderBlockLength still leave the peer hanging when respond() sends an oversized header block, the same hang shape this PR fixes one branch above. At h2_frame_parser.rs:7087-7105 the block sets stream.state CLOSED and dispatches onFrameError/onStreamError, but writes no HEADERS and no RST_STREAM. The peer waits forever on that stream, exactly like the memory refusal at 7053 did before this PR. Node sends RST_STREAM FRAME_SIZE_ERROR and tears the session down. Fix: every local refusal in request() that closes a stream must put a RST_STREAM on the wire, or the refusal must be moved before encoding so the peer stays in sync.Extended reasoning...
A server is created with maxSendHeaderBlockLength set (default is 0 at 7468, so the population is servers that opted in). A handler calls respond() with a header block whose encoded size exceeds the limit, for example a large set-cookie or a long custom header. request() encodes the block (6500-6873), then 7087 compares encoded_size and takes the refusal branch. stream.state becomes CLOSED and rst_code REFUSED_STREAM, JS gets onFrameError then onStreamError, and the function returns at 7105. Nothing was written to this.to_writer(), so the client sees neither HEADERS nor RST_STREAM and its request never settles. Also, the encoder table was mutated by the failed encode, so subsequent responses on the session decode wrong at the peer. The PR comment at 7048-7051 says a refusal here puts no frame on the wire so the peer would wait forever; that reasoning applies word for word to 7087. The dismissing finder read the base and found it identical; that is true, but the PR touched this function to fix exactly this shape and left the sibling. Remedy: emit RST_STREAM(FRAME_SIZE_ERROR) and follow…
Verification: pre-existing; acknowledged in diff: the PR description says "Not in this PR: respond() with a header block over maxSendHeaderBlockLength has the same shape (the stream closes locally, no frame is sent) ... tracked as separate work" — that note is accurate about the code, but it records the hazard rather than resolving it. Triggering condition: a server created with
maxSendHeaderBlockLength…
|
On the The branch at The inline finding about the client path has a reply in its thread. |
|
Updated 1:59 AM PT - Sep 19th, 2026
✅ @robobun, your commit fcadb16a91c852036773ea456ac522f8289721d3 passed in 🧪 To try this PR locally: bunx bun-pr 43419That installs a local version of the PR into your bun-43419 --bun |
Problem
node:http2client hangs when it makes 56 parallel requests with 200 KB responses. Requests past the 53rd get no'response','error'or'close'. Node v26.3.0 completes every request.request()(src/runtime/api/bun/h2_frame_parser.rs:7048). A server'srespond()runs it too. OvermaxSessionMemoryit closes the stream locally (Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM). No HEADERS and no RST_STREAM reach the wire.Fix
request()only for an open stream.maxSessionMemoryto new streams only.Http2Stream::SubmitResponsehas no memory check. The refusal of a new inbound stream (can_open_stream) is unchanged.test/js/node/http2/h2-conformance.test.ts(one new test, fails on bun 1.4.3 canary). Also all oftest/js/node/http2/and node's 261test-http2-*.jsfiles.Background
maxSessionMemoryis a session option in MB (default 10). Queued outbound data counts toward it. A response larger than the peer's flow-control window stays queued until WINDOW_UPDATE frames arrive.Downsides
maxSessionMemoryno longer limits queued response data for open streams, as in node. In the 200-request repro the server accepts 40,000,000 body bytes. Main accepts 10,600,000 and ends the session.request()over the limit (node:http2: encode each header block in one call, all fields or none #41520) andrespond()overmaxSendHeaderBlockLength(node:http2: respond() with a header block over maxSendHeaderBlockLength sends no frame, the client request hangs #43423).request()pays one more load and branch.Notes
Repro (real TCP sockets,
bun par.mjs 60):duplexPair()'stream'events), so the refusal came fromrespond(), not from the inbound HEADERS check.respond()also counted towardmaxSessionRejectedStreams. After 100 refusals the server sent GOAWAY with ENHANCE_YOUR_CALM. That is the N=200 row.Http2Session::OnBeginHeadersCallbackcallsCanAddStream()for a new inbound stream only.Http2Stream::AddHeaderchecks the budget for each inbound header field.Http2Stream::SubmitResponseandHttp2Session::SubmitRequesthave no check (src/node_http2.cc, v26.3.0).request()made while the session is over budget still fails with ENHANCE_YOUR_CALM. Node fails the same request with the same code, later, when the response headers do not fit the budget.request()leaves entries in the encoder table that the peer never sees, and later requests on the session fail. node:http2: encode each header block in one call, all fields or none #41520 moves the check before the encode.respond()with a header block overmaxSendHeaderBlockLengthhas the same shape (the stream closes locally, no frame is sent). Node sends RST_STREAM with FRAME_SIZE_ERROR and closes the session. That needs new wire behavior, so it is tracked in node:http2: respond() with a header block over maxSendHeaderBlockLength sends no frame, the client request hangs #43423.request()path and themaxSendHeaderBlockLengthpath in the two bullets above. The two addressed concerns are the wording of the code comment and a slow end-to-end test, which one raw-client test replaced.on_stream_rejected(src/runtime/api/bun/h2_frame_parser.rs:4167on main) also counts a malformed header block and an oversized header list (src/runtime/api/bun/h2/connection.rs:1419and:1430). It counts them for trailers and for a client session too, and the count never goes back to 0. Node counts only a new stream that it cannot create, and sets the count to 0 when it creates a stream (https://github.com/nodejs/node/blob/v26.3.0/src/node_http2.cc#L1035-L1050).maxSessionRejectedStreams: 3, bun 1.4.3 canary against node v26.3.0: 8 requests with an oversized header list end the Bun session at the 3rd (GOAWAY code 11). Node answers 8 RST_STREAM frames and keeps the session. The result is the same for 8 malformed blocks, for trailers on open streams, and for a client session. No open PR covers it. node:http2: count a refused stream where the session refuses it, not in rstStream #44248 removes the count fromrstStreamonly.