From 2bacef1e949e32d2ae46461b19fa3842b06e73e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:52:04 +0000 Subject: [PATCH 1/6] node:http2: make the stream lifecycle getters total, like node endAfterHeaders threw ERR for a stream with no id yet, and flipped from true to false once the native stream was freed. It is now a JS state flag set from the END_STREAM bit of the received header block, which is what node tracks in onSessionHeaders. The native end_after_headers flag and its getter go away. rstCode reads 0 until the stream is reset (node's NGHTTP2_NO_ERROR), not undefined. A client stream reports headersSent true from construction, as node's ClientHttp2Stream constructor does. The http2.connect() listener receives the socket as its second argument. --- src/js/node/http2.ts | 27 +++++--- src/runtime/api/bun/h2_frame_parser.rs | 39 +---------- src/runtime/api/h2.classes.ts | 4 -- test/js/node/http2/node-http2.test.js | 89 ++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 51 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 1ec56d7470bc..34596c129537 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -372,6 +372,7 @@ const kSessionDestroyError = Symbol("sessionDestroyError"); // responsible for closing it exactly once. respondWithFD() descriptors belong to the caller. const kOwnsFd = Symbol("ownsFd"); const kRequestHeaders = Symbol("requestHeaders"); +const kEndAfterHeaders = Symbol("endAfterHeaders"); // True only for the synchronous extent of Http2Stream#sendTrailers()'s native call. node submits // trailers on a later turn (finishSendTrailers via setImmediate), so a stream never observes its // own close from inside sendTrailers() — the streamEnd(closed) dispatch is re-queued when this is @@ -1694,6 +1695,7 @@ const constants = { const { NGHTTP2_SESSION_SERVER, NGHTTP2_SESSION_CLIENT, + NGHTTP2_FLAG_END_STREAM, NGHTTP2_NO_ERROR, NGHTTP2_INTERNAL_ERROR, NGHTTP2_CANCEL, @@ -2296,7 +2298,12 @@ class Http2Stream extends Duplex { // async-context tracking when no AsyncLocalStorage is in use. [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); - rstCode: number | undefined = undefined; + rstCode: number = 0; + // Set from the END_STREAM flag of the received header block, like node's + // onSessionHeaders. It outlives the native stream, so a closed stream keeps the value. + [kEndAfterHeaders]: boolean = false; + // The request (client) or the response (server) header block went out. + headersSent: boolean = false; [bunHTTP2Headers]: any; [kInfoHeaders]: any; #sentTrailers: any; @@ -2495,11 +2502,7 @@ class Http2Stream extends Duplex { } get endAfterHeaders() { - const session = this[bunHTTP2Session]; - if (session) { - return session[bunHTTP2Native]?.getEndAfterHeaders(this.#id) || false; - } - return false; + return this[kEndAfterHeaders]; } get aborted() { @@ -2941,7 +2944,11 @@ class Http2Stream extends Duplex { } } } -class ClientHttp2Stream extends Http2Stream {} +class ClientHttp2Stream extends Http2Stream { + // node sets STREAM_FLAGS_HEADERS_SENT in the ClientHttp2Stream constructor: a request's + // header block is built before the stream object exists, queued or not. + headersSent: boolean = true; +} // Wrap a native→JS #Handlers callback so its body runs inside the target // stream's captured async-context frame — the JS-side equivalent of Node's @@ -3183,7 +3190,6 @@ function callStreamClose(stream: ServerHttp2Stream) { if (!stream.destroyed && !stream.closed) stream.close(); } class ServerHttp2Stream extends Http2Stream { - headersSent = false; constructor(streamId, session, headers) { super(streamId, session, headers); } @@ -4164,6 +4170,7 @@ class ServerHttp2Session extends Http2Session { // user handler — in particular, losing WantTrailer/FinalCalled breaks // any later `sendTrailers()` with ERR_HTTP2_TRAILERS_NOT_READY. stream[bunHTTP2StreamStatus] |= StreamState.StreamResponded | StreamState.Delivered; + stream[kEndAfterHeaders] = (flags & NGHTTP2_FLAG_END_STREAM) !== 0; if (onServerStreamCreatedChannel.hasSubscribers) { onServerStreamCreatedChannel.publish({ stream, headers }); } @@ -5147,6 +5154,7 @@ class ClientHttp2Session extends Http2Session { // clobbered by a stale read-modify-write (see the server-side note // at the stream handler above). stream[bunHTTP2StreamStatus] |= StreamState.StreamResponded; + stream[kEndAfterHeaders] = (flags & NGHTTP2_FLAG_END_STREAM) !== 0; if (header_status === 421) { // 421 Misdirected Request removeOriginFromSet(self, stream); @@ -5662,7 +5670,8 @@ class ClientHttp2Session extends Http2Session { } try { this.#onConnect(arguments); - listener?.$call(this, this); + // node passes the socket as the second argument, like the 'connect' event. + listener?.$call(this, this, this[bunHTTP2Socket]); } catch (e) { this.destroy(e); } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 06c2b66811a3..770fadd19df8 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1308,7 +1308,6 @@ pub struct Stream { state: StreamState, js_context: StrongOptional, // jsc.Strong.Optional wait_for_trailers: bool, - end_after_headers: bool, padding_strategy: PaddingStrategy, rst_code: u32, stream_dependency: u32, @@ -1823,7 +1822,6 @@ impl Stream { state: StreamState::OPEN, js_context: StrongOptional::empty(), wait_for_trailers: false, - end_after_headers: false, padding_strategy, rst_code: 0, stream_dependency: 0, @@ -4021,12 +4019,7 @@ impl crate::api::h2::connection::Sink for H2FrameParser { }); } - fn on_headers_complete(&self, stream_id: u32, end_stream: bool, flags: u8) { - // Bridge: the JS endAfterHeaders getter reads the legacy stream's end_after_headers flag. - if let Some(stream) = self.streams.get().get(&stream_id).copied() { - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - unsafe { (*stream).end_after_headers = end_stream }; - } + fn on_headers_complete(&self, stream_id: u32, _end_stream: bool, flags: u8) { // Materialize the accumulated block in a single native pass: the raw array, the // node-shaped headers object, and the sensitive list (a zero-field block yields an // empty array + object, matching the legacy decoder's upfront array). @@ -4890,34 +4883,6 @@ impl H2FrameParser { Ok(JSValue::UNDEFINED) } - #[bun_jsc::host_fn(method)] - pub(crate) fn get_end_after_headers( - this: &Self, - global_object: &JSGlobalObject, - callframe: &CallFrame, - ) -> JsResult { - let [stream_arg] = callframe.arguments_as_array::<1>(); - if callframe.arguments_count() < 1 { - return Err(global_object.throw(format_args!("Expected stream argument"))); - } - - if !stream_arg.is_number() { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let stream_id = stream_arg.to_u32(); - if stream_id == 0 { - return Err(global_object.throw(format_args!("Invalid stream id"))); - } - - let Some(stream) = this.streams.get().get(&stream_id).copied() else { - return Err(global_object.throw(format_args!("Invalid stream id"))); - }; - - // SAFETY: stream is *mut Stream from self.streams; valid while the map entry exists - Ok(JSValue::from(unsafe { (*stream).end_after_headers })) - } - #[bun_jsc::host_fn(method)] pub(crate) fn is_stream_aborted( this: &Self, @@ -7387,8 +7352,6 @@ impl H2FrameParser { } if end_stream { - stream.end_after_headers = true; - if wait_for_trailers { stream.state = StreamState::HALF_CLOSED_LOCAL; this.dispatch(JSH2FrameParser::Gc::onWantTrailers, stream.get_identifier()); diff --git a/src/runtime/api/h2.classes.ts b/src/runtime/api/h2.classes.ts index 8b4749d6fb05..b06741ac4298 100644 --- a/src/runtime/api/h2.classes.ts +++ b/src/runtime/api/h2.classes.ts @@ -89,10 +89,6 @@ export default [ fn: "setStreamContext", length: 2, }, - getEndAfterHeaders: { - fn: "getEndAfterHeaders", - length: 1, - }, isStreamAborted: { fn: "isStreamAborted", length: 1, diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index f73bebcc9863..3189fab05a28 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -6009,3 +6009,92 @@ describe("frames issued from inside a user-supplied Duplex transport's _write", }, ); }); + +// Getters must be total over a stream's lifecycle: node answers the same way before the stream +// has an id, while it is open, and after it closed. Bun used to throw or report undefined. +describe("stream getters over the lifecycle", () => { + const read = stream => ({ + endAfterHeaders: stream.endAfterHeaders, + rstCode: stream.rstCode, + headersSent: stream.headersSent, + }); + + it("client: a pending stream answers like node, and the values survive close", async () => { + const server = http2.createServer((req, res) => res.end("ok")); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const client = http2.connect(`http://127.0.0.1:${server.address().port}`); + try { + // Issued before 'connect', so the stream has no id yet. + const req = client.request({ ":path": "/" }); + const pending = read(req); + const { promise, resolve, reject } = Promise.withResolvers(); + let open; + req.on("response", () => (open = read(req))); + req.on("error", reject); + req.resume(); + req.on("close", resolve); + await promise; + expect(pending).toEqual({ endAfterHeaders: false, rstCode: 0, headersSent: true }); + expect(open).toEqual({ endAfterHeaders: false, rstCode: 0, headersSent: true }); + expect(read(req)).toEqual({ endAfterHeaders: false, rstCode: 0, headersSent: true }); + } finally { + client.destroy(); + } + } finally { + server.close(); + } + }); + + it("server: endAfterHeaders stays true after the stream closes", async () => { + const server = http2.createServer(); + const states = []; + server.on("stream", stream => { + states.push(read(stream)); + stream.on("close", () => states.push(read(stream))); + stream.respond({ ":status": 200 }); + stream.end("ok"); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const client = http2.connect(`http://127.0.0.1:${server.address().port}`); + try { + const req = client.request({ ":path": "/" }); + const { promise, resolve, reject } = Promise.withResolvers(); + req.on("error", reject); + req.resume(); + req.on("close", resolve); + await promise; + // The GET request block carried END_STREAM, so endAfterHeaders is true for its whole life. + expect(states[0]).toEqual({ endAfterHeaders: true, rstCode: 0, headersSent: false }); + expect(states[1]).toEqual({ endAfterHeaders: true, rstCode: 0, headersSent: true }); + } finally { + client.destroy(); + } + } finally { + server.close(); + } + }); + + it("the http2.connect() listener receives the session and the socket", async () => { + const server = http2.createServer(); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const { promise, resolve } = Promise.withResolvers(); + const client = http2.connect(`http://127.0.0.1:${server.address().port}`, {}, (session, socket) => + resolve({ sameSession: session === client, socket }), + ); + try { + const args = await promise; + expect(args.sameSession).toBe(true); + // node hands over the raw socket, not the session's socket proxy. + expect(args.socket).toBeInstanceOf(net.Socket); + expect(args.socket.remotePort).toBe(server.address().port); + } finally { + client.destroy(); + } + } finally { + server.close(); + } + }); +}); From d9a2f91006f5a6b2a2df3cd453d2d09ef78e7192 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:41:15 +0000 Subject: [PATCH 2/6] node:http2: hand the connect listener the socket captured before #onConnect() close() before the connection completes makes #onConnect() destroy the session, which drops the socket. The 'connect' event captures it first, so the listener does too. The test wires the session's error and close events to reject. --- src/js/node/http2.ts | 6 ++++-- test/js/node/http2/node-http2.test.js | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 34596c129537..fc11e8520a04 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -5668,10 +5668,12 @@ class ClientHttp2Session extends Http2Session { process.nextTick(onConnect.bind(this)); return; } + // Captured first: #onConnect() destroys the session (and drops the socket) when close() + // ran before the connection completed, and the 'connect' event still carries the socket. + const socket = this[bunHTTP2Socket]; try { this.#onConnect(arguments); - // node passes the socket as the second argument, like the 'connect' event. - listener?.$call(this, this, this[bunHTTP2Socket]); + listener?.$call(this, this, socket); } catch (e) { this.destroy(e); } diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 3189fab05a28..4fde547b83e0 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -6080,10 +6080,12 @@ describe("stream getters over the lifecycle", () => { const server = http2.createServer(); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); try { - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve, reject } = Promise.withResolvers(); const client = http2.connect(`http://127.0.0.1:${server.address().port}`, {}, (session, socket) => resolve({ sameSession: session === client, socket }), ); + client.on("error", reject); + client.on("close", () => reject(new Error("session closed before the connect listener ran"))); try { const args = await promise; expect(args.sameSession).toBe(true); From d3fd2acc78de0638e1dd338940f409f7b48c2adb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:44:13 +0000 Subject: [PATCH 3/6] node:http2: shorten three comments --- src/js/node/http2.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index fc11e8520a04..92f5ccb84f5b 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2299,10 +2299,8 @@ class Http2Stream extends Duplex { [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); rstCode: number = 0; - // Set from the END_STREAM flag of the received header block, like node's - // onSessionHeaders. It outlives the native stream, so a closed stream keeps the value. + // END_STREAM flag of the received header block (node's onSessionHeaders); kept after close. [kEndAfterHeaders]: boolean = false; - // The request (client) or the response (server) header block went out. headersSent: boolean = false; [bunHTTP2Headers]: any; [kInfoHeaders]: any; @@ -2945,8 +2943,7 @@ class Http2Stream extends Duplex { } } class ClientHttp2Stream extends Http2Stream { - // node sets STREAM_FLAGS_HEADERS_SENT in the ClientHttp2Stream constructor: a request's - // header block is built before the stream object exists, queued or not. + // node sets STREAM_FLAGS_HEADERS_SENT in its ClientHttp2Stream constructor. headersSent: boolean = true; } @@ -5668,8 +5665,7 @@ class ClientHttp2Session extends Http2Session { process.nextTick(onConnect.bind(this)); return; } - // Captured first: #onConnect() destroys the session (and drops the socket) when close() - // ran before the connection completed, and the 'connect' event still carries the socket. + // Captured before #onConnect(), which drops the socket if close() already ran. const socket = this[bunHTTP2Socket]; try { this.#onConnect(arguments); From 8b74c06f953d99fd1d737bc3b8fcfe9effc7ce1a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:29:15 +0000 Subject: [PATCH 4/6] test: await the server stream's close before reading its final state --- test/js/node/http2/node-http2.test.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 4fde547b83e0..9c0f6ecc4afe 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -6049,9 +6049,13 @@ describe("stream getters over the lifecycle", () => { it("server: endAfterHeaders stays true after the stream closes", async () => { const server = http2.createServer(); const states = []; + const serverStreamClosed = Promise.withResolvers(); server.on("stream", stream => { states.push(read(stream)); - stream.on("close", () => states.push(read(stream))); + stream.on("close", () => { + states.push(read(stream)); + serverStreamClosed.resolve(); + }); stream.respond({ ":status": 200 }); stream.end("ok"); }); @@ -6064,7 +6068,7 @@ describe("stream getters over the lifecycle", () => { req.on("error", reject); req.resume(); req.on("close", resolve); - await promise; + await Promise.all([promise, serverStreamClosed.promise]); // The GET request block carried END_STREAM, so endAfterHeaders is true for its whole life. expect(states[0]).toEqual({ endAfterHeaders: true, rstCode: 0, headersSent: false }); expect(states[1]).toEqual({ endAfterHeaders: true, rstCode: 0, headersSent: true }); From 1feb3202117eb92196220576c477dc940c106532 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:52:26 +0000 Subject: [PATCH 5/6] node:http2: set endAfterHeaders only from the block that opens the stream node's onSessionHeaders (lib/internal/http2/core.js) writes endAfterHeaders only when the header block creates the stream object. A client request stream already exists when its response arrives, so it keeps false even when the response HEADERS frame carries END_STREAM, and a trailers block never changes the value. Drop the client-side write and cover both cases. --- src/js/node/http2.ts | 4 +-- test/js/node/http2/node-http2.test.js | 50 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 92f5ccb84f5b..66b42f0cef55 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2299,7 +2299,8 @@ class Http2Stream extends Duplex { [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); rstCode: number = 0; - // END_STREAM flag of the received header block (node's onSessionHeaders); kept after close. + // END_STREAM flag of the header block that opened the stream (node's onSessionHeaders sets it + // only there: a server's request block, never a client's response or trailers). Kept after close. [kEndAfterHeaders]: boolean = false; headersSent: boolean = false; [bunHTTP2Headers]: any; @@ -5151,7 +5152,6 @@ class ClientHttp2Session extends Http2Session { // clobbered by a stale read-modify-write (see the server-side note // at the stream handler above). stream[bunHTTP2StreamStatus] |= StreamState.StreamResponded; - stream[kEndAfterHeaders] = (flags & NGHTTP2_FLAG_END_STREAM) !== 0; if (header_status === 421) { // 421 Misdirected Request removeOriginFromSet(self, stream); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 9c0f6ecc4afe..cd386ff42adf 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -6080,6 +6080,56 @@ describe("stream getters over the lifecycle", () => { } }); + // node's onSessionHeaders sets endAfterHeaders only from the header block that creates the + // stream: a later trailers block (END_STREAM) does not flip it on the server, and a client + // request stream never gets it, even when the response HEADERS frame carries END_STREAM. + it("endAfterHeaders comes only from the block that opened the stream", async () => { + const server = http2.createServer(); + const serverSide = {}; + const serverStreamClosed = Promise.withResolvers(); + server.on("stream", stream => { + serverSide.atStream = stream.endAfterHeaders; + stream.on("trailers", () => (serverSide.atTrailers = stream.endAfterHeaders)); + stream.on("end", () => { + serverSide.atEnd = stream.endAfterHeaders; + stream.respond({ ":status": 204 }, { endStream: true }); + }); + stream.on("close", () => { + serverSide.atClose = stream.endAfterHeaders; + serverStreamClosed.resolve(); + }); + stream.resume(); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + try { + const client = http2.connect(`http://127.0.0.1:${server.address().port}`); + try { + const req = client.request({ ":method": "POST", ":path": "/" }, { waitForTrailers: true }); + const clientSide = {}; + const { promise, resolve, reject } = Promise.withResolvers(); + req.on("wantTrailers", () => req.sendTrailers({ "x-t": "1" })); + req.on("response", (headers, flags) => { + clientSide.atResponse = req.endAfterHeaders; + clientSide.responseEndStream = (flags & http2.constants.NGHTTP2_FLAG_END_STREAM) !== 0; + }); + req.on("error", reject); + req.on("close", () => { + clientSide.atClose = req.endAfterHeaders; + resolve(); + }); + req.resume(); + req.end("body"); + await Promise.all([promise, serverStreamClosed.promise]); + expect(serverSide).toEqual({ atStream: false, atTrailers: false, atEnd: false, atClose: false }); + expect(clientSide).toEqual({ atResponse: false, responseEndStream: true, atClose: false }); + } finally { + client.destroy(); + } + } finally { + server.close(); + } + }); + it("the http2.connect() listener receives the session and the socket", async () => { const server = http2.createServer(); await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); From f410867aec9f6b83e98f73de437edea9fb96aaee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:54:04 +0000 Subject: [PATCH 6/6] node:http2: shorten the endAfterHeaders comment --- src/js/node/http2.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index 66b42f0cef55..c1ad14207fce 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2299,8 +2299,7 @@ class Http2Stream extends Duplex { [bunHTTP2AsyncContextFrame] = $getInternalField($asyncContext, 0); rstCode: number = 0; - // END_STREAM flag of the header block that opened the stream (node's onSessionHeaders sets it - // only there: a server's request block, never a client's response or trailers). Kept after close. + // END_STREAM flag of the header block that opened the stream, as in node's onSessionHeaders. [kEndAfterHeaders]: boolean = false; headersSent: boolean = false; [bunHTTP2Headers]: any;