Repository navigation
Conversation
…losed A graceful close stops new streams only. The server session is closed by session.close(), by server.close(), and by a GOAWAY(NO_ERROR) from a client that called close(). The server streamHeaders handler returned early for every header block once the session was closed, so a request that was already delivered lost its 'trailers' event and req.trailers stayed empty. Apply the closed-session check only to a stream the 'stream' event has not delivered yet, like node's onSessionHeaders.
|
Warning Review paused — included plan limit reachedKeep your review moving with free on-demand reviews.
On-demand reviews are free for one more day.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Promotion and pricing detailsOn-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file. Review limit detailsOr wait 11 minutes for your next included review. Limit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Comment |
| if (!self || typeof stream !== "object" || stream.closed) return; | ||
| const status = stream[bunHTTP2StreamStatus]; | ||
| // close() only stops new streams. A block on a stream that 'stream' already delivered is its | ||
| // trailers (the StreamResponded branch below) and still gets through: node's | ||
| // onSessionHeaders checks session.closed for a new stream only. | ||
| // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/http2/core.js#L373-L381 | ||
| if (self.closed && (status & StreamState.StreamResponded) === 0) return; |
There was a problem hiding this comment.
🟣 pre-existing, not blocking: Servers that call session.close() or server.close() while a client pipelines one more request never finish shutting down: the session's 'close' event and the server.close() callback never fire, and the client waits forever for that request. The new guard at src/js/node/http2.ts:4117 drops the undelivered stream after streamStart at line 4007 already counted it in #connections, and nothing ever decrements it or resets the stream. Fix: on a closed session, refuse the new stream (rstStream REFUSED_STREAM, decrement #connections and #peerInitiatedStreams) instead of returning, at this guard and the same drop for the maxConcurrentStreams path.
A small fix can ride a push you are already making; otherwise a short reply is enough.
Extended reasoning...
The dismissing finders read the base guard and called it pre-existing; the base did drop every block, but the PR rewrites exactly this guard and adds a test that cements the drop, so the pin is now the intended contract. Trace: server calls close() (line 4669) with one stream open, so #connections is 1 and no destroy is scheduled. The client already sent HEADERS for stream 3 before it read the GOAWAY (any browser or grpc client pipelining requests). Native handle_headers (h2/connection.rs:986-995) has no post-GOAWAY refusal (going_away is written at 380 and 823 and never read), so on_stream_open fires. streamStart (line 4007) does #connections++ and creates a ServerHttp2Stream. streamHeaders returns at line 4117 because status lacks StreamResponded. No RST_STREAM leaves; the native stream sits in Open or HalfClosedRemote forever. The delivered stream finishes: streamEnd (line 4066) decrements to 1, so the #connections === 0 && self.#closed branch at line 4085 never runs and destroyIfNotDestroyedNT is never scheduled. The session 'close' event never fires, server.close(cb) never calls…
Verification: pre-existing — the base already fails by exactly the same route, and the PR's rewrite of the guard leaves the new-stream-after-close path byte-for-byte equivalent (it only lets trailers of delivered streams through). Triggering condition: a server session that has called close() (or server.close() → closeAllSessions, /home/claude/bun/src/js/node/http2.ts:6370-6376) while at least one stream is…
Fixes #43488
Problem
node:http2server closes a session gracefully while a request is open. The request trailers arrive after that. The stream never emits'trailers', andreq.trailersstays{}in the compat API. node v26.3.0 delivers the trailers.session.close(),server.close(), and a client that callsclose()all cause it.ServerHttp2Session.#Handlers.streamHeaders(src/js/node/http2.ts:4111). It returned for every header block whenself.closedwas true.Fix
self.closedcheck only to a stream that the'stream'event has not delivered yet (StreamRespondedis not set). Thestream.closedcheck stays.onSessionHeaderscheckssession.closedonly in the branch that creates a stream (core.js#L373-L381).test/js/node/http2/node-http2.test.js. bun 1.4.3 fails three. The fourth guards that a stream opened aftersession.close()is still not delivered. All four pass on node v26.3.0.Background
session.close()is the graceful shutdown of a session. It sends a GOAWAY frame, accepts no new streams, and lets the open streams finish.server.close()calls it on every session. A server session also calls it when the client sends a GOAWAY with NO_ERROR.streamHeadersfor every complete header block of a server stream. The first block becomes the'stream'event and sets theStreamRespondedbit. A later block becomes'trailers'.Notes
Reproduction (
bun repro.js, compare withnode repro.js):["stream","data body","trailers 1","end","close rstCode=0","client response"]["stream","data body","end","close rstCode=0","client response"]The client-side trigger is the common one:
client.close()right afterclient.request(). The client sends a GOAWAY with NO_ERROR, the server session callsclose()on itself, and the trailers of the open request are lost. The second test covers it. The'trailers'listener recordsstream.session.closed, so each test proves that the session was closed when the trailers arrived.The new tests do not use a timer. In the server-side tests the client sends the body and the trailers from its
'goaway'listener. In the client-side test the client callsclose()and thenreq.end()from its'response'listener, so the GOAWAY is on the wire before the body and the trailers.Mutation check: without the closed-session clause, the guard test fails with
delivered: [1, 3].History of the guard: #19558 added
self.closed || stream.closedto the client handler and to the server handler. #19616 reverted that PR because a client that calledclose()no longer got'response'(test-http2-client-upload.js). #19630 landed it again with the client check changed tostream.rstCode. The server check keptself.closed. So the client handler needs no change here.Self-review. Addressed: the comment now names the
StreamRespondedbranch, the tests assert one combined object per case, the guard test checks the GOAWAY payload, a test for the client-side close exists, the server responds before'end'so no listener can throw, the compat test listens onreq.streamfor errors, and cleanup usesdestroy(). Rejected: merging thestream.closedread into the status read. It is a micro-optimization and it is not part of this fix. Out of scope:pushStream()andping()still work aftersession.close(), and node refuses both. #43486 tracks that.Relation to #43475: that PR handles a stream that opens after
close()and edits the sameif. It keeps the old behaviour for delivered streams. The two changes are independent. The second one to merge needs a small rebase.Local runs with the debug build:
--rerun-each 8. With bun 1.4.3, 15 of 20 fail with--rerun-each 5(the guard test passes).bun bd test test/js/node/http2/node-http2.test.js(first version of this change): 373 pass, 17 fail. All 17 are 5 s timeouts of tests that spawn abun-debugchild. The debug binary needs about 4 s to start on this machine. None of them sends request trailers or closes a session.bun bd test test/js/node/http2/h2-conformance.test.ts: 69 pass, 1 timeout ("releases server stream objects once the peer resets their streams").test-http2-*files that use trailers, close, GOAWAY, or shutdown: 28 exit 0.test-http2-forget-closed-streams.js(10,000 requests) exceeds 120 s in the debug build.