Repository navigation
Conversation
… no native handle
Http2Session#destroy() marked the session disconnected and ended the socket
before parser.detach() wrote out the GOAWAY it had just corked. On a socket
with a native handle the parser writes to the handle directly, so that order
did not matter; on a JS transport (a createConnection Duplex, a Duplex passed
to server.emit('connection'), the TLS proxy Http2SecureServer uses for an
injected raw socket) the write goes through the session's write handler, which
refuses once the session is disconnected or the socket is ended, so the GOAWAY
(and the preface SETTINGS, when destroy() ran in the same tick) never reached
the peer.
Detach the parser first and disconnect/end the socket afterwards, in both the
server and the client session.
|
Warning Review limit reached
Next review available in: 12 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
Status: fix pushed, waiting on CI. Reproduced on the current release with an
The same loss reproduces for a server session on a Duplex passed to |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The reordering looks correct and matches Node's teardown order, but since it moves parser.detach() and the stream-teardown callbacks to run while #connected is still true and the socket hasn't been ended, a human look at the re-entrancy implications would be worthwhile.
Checked: #Handlers.write gates on !socket.writableEnded && self.#connected (L4494/L5531), confirming the mechanism; goaway()'s this.destroyed guard is bunHTTP2Socket === null, still set at the call site so no new throw; the catch retry path is no worse (server-side it's now actually better — #connected stays true if goaway() throws); duplexPair/Duplex are already imported in node-http2.test.js.
Extended reasoning...
Overview
This PR reorders the teardown sequence in ServerHttp2Session#destroy() and ClientHttp2Session#destroy() in src/js/node/http2.ts. Previously: set #connected = false → end socket → parser.detach(). Now: queue GOAWAY → tear down streams → parser.detach() → set #connected = false → end socket. The point is that detach() flushes the corked GOAWAY through #Handlers.write, which drops writes once #connected is false or the socket is ended — so on sockets without a native handle (the _http2_upgrade.ts Duplex, a createConnection Duplex, an injected duplexPair side) the GOAWAY was silently discarded. Nine new tests cover server/client × synchronous-in-session-event/after-preface × error/no-error, plus the TLS upgrade path (which also runs under Node).
Security risks
None identified. This is protocol-teardown ordering; no auth, crypto, or untrusted-input parsing is touched. The frame parsers in the tests read only bytes the test itself produced.
Level of scrutiny
Medium-high. HTTP/2 session destruction is a well-trodden source of ordering bugs (the file's own comments and git history attest to it), and the reorder means parser.forEachStream(destroyStreamForSessionDestroy) and parser.emitErrorToAllStreams() — both of which invoke user callbacks — now run while the socket is still writable and #connected is true. On native-handle sockets the parser already wrote directly to the handle regardless of #connected, so this makes the JS-write path consistent rather than introducing new behavior, and it matches Node's closeSession → handle.destroy → finishSessionClose order. The #destroying and #closed latches guard re-entry. Still, the interaction surface (stream _destroy, user 'close'/'error' listeners firing mid-teardown with the socket not yet ended) is exactly the class of thing a maintainer familiar with prior http2 teardown regressions should sanity-check.
Other factors
The PR description reports the full upstream test-*http2* parallel suite (278 files) and the rest of test/js/node/http2 (474 tests) pass, plus grpc-js/http2-wrapper unchanged. The comment fix in the client (See the client session → See the server session) corrects a pre-existing copy-paste. I verified the goaway() call's this.destroyed guard (bunHTTP2Socket === null) is unaffected by the move, and that the catch-and-reset-#destroying path leaves the session in a retryable state under the new ordering (on the server it's actually improved: #connected is no longer cleared before the throwing goaway()). No bugs surfaced, but this is not a mechanical change — deferring for a human pass on the callback-ordering implications.
|
On the re-entrancy question, here is what actually changes for user code that runs during
|
|
Updated 5:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit c3c4c73 has some failures in 🧪 To try this PR locally: bunx bun-pr 38158That installs a local version of the PR into your bun-38158 --bun |
Problem
session.destroy()on an HTTP/2 session whose socket has no native handle never gets its GOAWAY to the peer. This is the transport used for every connection injected into anHttp2SecureServerwithh2.emit('connection', rawSocket)(the http2-wrapper / crawlee pattern,src/js/node/_http2_upgrade.ts), for a Duplex passed toserver.emit('connection'), and for a clientcreateConnectionthat returns a Duplex.[SETTINGS, GOAWAY]from node and from a directly listening bun server. On the injected-connection path bun sends[SETTINGS]whendestroy()runs on an established session, and nothing at all when it runs synchronously from the'session'event.Http2Session#destroy()sets#connected = falseand ends the socket first, and only then callsparser.detach()(on main,src/js/node/http2.tsL4933 / L4947-4957 / L4972 for the server session and L6007 / L6035-6042 / L6061 for the client session). The GOAWAY thatthis.goaway()queued is still sitting in the parser's cork at that point;detach()is what writes it out. On a socket with a native handle the parser writes to the handle directly, so the order did not matter. Without one, the write goes through the session'swritehandler (#Handlers.write), which returns -1 whensocket.writableEndedor!#connected, so the bytes are dropped and then discarded bydetach(). Whendestroy()runs in the tick that created the session, the preface SETTINGS is still in the same cork and is lost the same way.Fix
ServerHttp2Session#destroy()andClientHttp2Session#destroy(): queue the GOAWAY, tear down the streams and detach the parser as before, and only then set#connected = falseand end the socket.detach()'s write now happens while the write handler still accepts it, so the GOAWAY goes into the socket ahead of the FIN.resume()s beforeend().parser.flush()aftergoaway()the wayclose()does, becauseflush()also drains the streams' queued DATA frames and runs their write callbacks in the middle of the teardown. nghttp2 discards everything but the GOAWAY once a session is terminated, anddetach()'s write is exactly the frames that were already serialized, so keepingdetach()as the writer keeps the native-socket output byte for byte what it is today.closeSession()destroys the streams,handle.destroy()writes the GOAWAY, andfinishSessionClose()ends the socket afterwards. Node sendsINTERNAL_ERRORfordestroy(err)andNO_ERRORfordestroy(); the tests check the codes and pass unchanged under node v26.3.0.test/js/node/http2/node-http2-upgrade.test.mts: the injected-connection server, destroyed with an error from the'session'event, with an error once the peer has the preface, and without an error. The file also runs itself under node.test/js/node/http2/node-http2.test.js: a server session on aduplexPairside injected withserver.emit('connection')(same three shapes, with and without an error) and a client session on acreateConnectionDuplex (destroy(err)anddestroy()), checking the transport received the GOAWAY before it was ended.test-*http2*files intest/js/node/test/parallel(all pass), the rest oftest/js/node/http2(474 pass), and the grpc-js and http2-wrapper suites (same results as without the change; their failures here need public DNS or alocalhostthat resolves to 127.0.0.1).Background
H2FrameParser,src/runtime/api/bun/h2_frame_parser.rs) does not write each frame as it is produced. Frames written during one tick are corked into a per-thread buffer and written out in one piece by a deferred flush, by an explicitflush(), or bydetach()as its last act.destroy()relies on the last of these.socket._handle, a plainnet.SocketorTLSSocket), it writes to the handle itself. Otherwise every write is dispatched to the session'swritehandler inhttp2.ts, which callssocket.write()on whatever stream the session was given.Http2SecureServer#emit('connection', rawSocket)is handled by_http2_upgrade.ts: it runs TLS over the raw socket's events and hands the session a plainDuplexcarrying the decrypted bytes. That Duplex deliberately has no_handle, so the session is always on the handler path there.