diff --git a/src/js/node/http2.ts b/src/js/node/http2.ts index abfd52512f03..046578bfbb33 100644 --- a/src/js/node/http2.ts +++ b/src/js/node/http2.ts @@ -2215,6 +2215,7 @@ function streamOnResume(this: Http2Stream) { // A close() on a stream that has not been submitted yet (no id): the RST_STREAM has to follow the // HEADERS frame, which is sent when the queued request becomes ready (node's finishCloseStream). function sendRstOnReady(this: Http2Stream, session: Http2Session, code: number) { + if (this[kNeverAnnounced]) return; setImmediate(rstNextTick.bind(session, this.id, code)); } function uncorkNT(stream: Http2Stream) { @@ -2542,6 +2543,8 @@ class Http2Stream extends Duplex { // No id yet (the HEADERS frame is still queued behind connect/concurrency limits): the // RST_STREAM has to be sent after the HEADERS frame, once the id is assigned. this.once("ready", sendRstOnReady.bind(this, session, code)); + } else if (this[kNeverAnnounced]) { + // The peer never saw this id: nothing to reset. } else if (this.writableFinished || code) { setImmediate(rstNextTick.bind(session, this.#id, code)); } else { @@ -4018,6 +4021,7 @@ class ServerHttp2Session extends Http2Session { if (!self || typeof stream !== "object") return; // Emit the frameError event with the frame type and error code process.nextTick(emitFrameErrorEventNT, stream, frameType, errorCode); + setImmediate(closeAfterFrameError, self, stream, errorCode); }, aborted(self: ServerHttp2Session, stream: ServerHttp2Stream, error: any, old_state: number) { if (!self || typeof stream !== "object") return; @@ -5015,6 +5019,8 @@ class ClientHttp2Session extends Http2Session { frameError: withStreamFrame( (self: ClientHttp2Session, stream: ClientHttp2Stream, frameType: number, errorCode: number) => { if (!self || typeof stream !== "object") return; + // The refused HEADERS never reached the wire: no RST_STREAM for an idle id. + stream[kNeverAnnounced] = true; // Emit the frameError event with the frame type and error code process.nextTick(emitFrameErrorEventNT, stream, frameType, errorCode); }, @@ -6564,6 +6570,15 @@ function onErrorSecureServerSession(err, socket) { function emitFrameErrorEventNT(stream, frameType, errorCode) { stream.emit("frameError", frameType, errorCode); } +// node's onFrameError (lib/internal/http2/core.js#L661-L681 at v26.3.0). Both calls are no-ops once closed. +function closeAfterFrameError(session: ServerHttp2Session, stream: ServerHttp2Stream, code: number) { + if (!stream.destroyed && !stream.headersSent) { + // No response HEADERS went out, so the writable must not end with a DATA frame. Reset first. + session[bunHTTP2Native]?.rstStream(stream.id, code); + } + stream.close(code); + session.close(); +} class Http2SecureServer extends tls.Server { timeout = 0; [kSessions] = new SafeSet(); diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index d0e9f4267aee..b18e4ad827b1 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -1962,11 +1962,88 @@ impl AbortListener for SignalRef { } } +/// Header fields staged so a block can be measured before any of them reaches the HPACK encoder. +#[derive(Default)] +struct HeaderList { + bytes: Vec, + fields: Vec, +} + +struct HeaderField { + name_len: usize, + value_len: usize, + never_index: bool, +} + +impl HeaderList { + fn push( + &mut self, + name: &[u8], + value: &[u8], + never_index: bool, + ) -> Result<(), bun_alloc::AllocError> { + self.bytes + .try_reserve(name.len() + value.len()) + .map_err(|_| bun_alloc::AllocError)?; + self.fields + .try_reserve(1) + .map_err(|_| bun_alloc::AllocError)?; + self.bytes.extend_from_slice(name); + self.bytes.extend_from_slice(value); + self.fields.push(HeaderField { + name_len: name.len(), + value_len: value.len(), + never_index, + }); + Ok(()) + } + + /// nghttp2_hd_deflate_bound: https://github.com/nodejs/node/blob/v26.3.0/deps/nghttp2/lib/nghttp2_hd.c#L1578-L1603 + fn deflate_bound(&self) -> usize { + 12 + self.fields.len() * 12 + self.bytes.len() + } + + /// A 1xx `:status`: the final response still follows on the same stream. + fn is_informational(&self) -> bool { + self.iter() + .any(|(name, value, _)| name == b":status" && value.len() == 3 && value[0] == b'1') + } + + fn iter(&self) -> impl Iterator { + let mut offset = 0usize; + self.fields.iter().map(move |field| { + let name_end = offset + field.name_len; + let value_end = name_end + field.value_len; + let name = &self.bytes[offset..name_end]; + let value = &self.bytes[name_end..value_end]; + offset = value_end; + (name, value, field.never_index) + }) + } +} + // ────────────────────────────────────────────────────────────────────────── // H2FrameParser impl — core methods // ────────────────────────────────────────────────────────────────────────── impl H2FrameParser { + /// Stages the field when `request()` has to measure the whole block first, else encodes it. + fn encode_or_stage_header( + &self, + staged: &mut Option, + encoded_headers: &mut Vec, + name: &[u8], + value: &[u8], + never_index: bool, + ) -> crate::Result<()> { + match staged { + Some(list) => Ok(list.push(name, value, never_index)?), + None => self + .encode_header_into_list(encoded_headers, name, value, never_index) + .map(|_| ()), + } + } + /// Encodes a single header into the ArrayList, growing if needed. /// Returns the number of bytes written, or error on failure. /// @@ -5386,6 +5463,10 @@ impl H2FrameParser { }; // SAFETY: stream is a *mut Stream from self.streams (heap::alloc); valid while the map entry exists let stream = unsafe { &mut *stream }; + // JS can still end a stream in the tick that reset it. A reset stream sends nothing. + if stream.state == StreamState::CLOSED { + return Ok(JSValue::UNDEFINED); + } stream.wait_for_trailers = false; let _ = this.send_data( @@ -5554,6 +5635,10 @@ impl H2FrameParser { // The header/sensitive-object getters and value coercions below can run user JS // while `stream` is borrowed. let mut stream = this.enter_stream_dispatch(stream_ptr); + // JS can still send trailers in the tick that reset the stream. A reset stream sends nothing. + if stream.state == StreamState::CLOSED { + return Ok(JSValue::UNDEFINED); + } let Some(headers_obj) = headers_arg.get_object() else { return Err(global_object.throw(format_args!("Expected headers to be an object"))); @@ -5671,7 +5756,7 @@ impl H2FrameParser { triggering_id, ErrorCode::NO_ERROR, b"", - this.last_stream_id.get(), + this.last_peer_stream_id.get(), true, ); Ok(Some(JSValue::UNDEFINED)) @@ -6484,6 +6569,8 @@ impl H2FrameParser { if encoded_headers.try_reserve(16384).is_err() { return Err(global_object.throw(format_args!("Failed to allocate header buffer"))); } + // maxSendHeaderBlockLength is checked on the whole block before the encoder sees it. + let mut staged = (this.max_send_header_block_length.get() != 0).then(HeaderList::default); // max header name length for lshpack let mut name_buffer = [0u8; 4096]; let stream_id: u32 = @@ -6604,7 +6691,8 @@ impl H2FrameParser { BStr::new(value) ); - if let Err(err) = this.encode_header_into_list( + if let Err(err) = this.encode_or_stage_header( + &mut staged, &mut encoded_headers, validated_name, value, @@ -6774,7 +6862,8 @@ impl H2FrameParser { BStr::new(value) ); - if let Err(err) = this.encode_header_into_list( + if let Err(err) = this.encode_or_stage_header( + &mut staged, &mut encoded_headers, validated_name, value, @@ -6844,7 +6933,8 @@ impl H2FrameParser { BStr::new(value) ); - if let Err(err) = this.encode_header_into_list( + if let Err(err) = this.encode_or_stage_header( + &mut staged, &mut encoded_headers, validated_name, value, @@ -6870,7 +6960,6 @@ impl H2FrameParser { } } } - let encoded_size = encoded_headers.len(); let Some(stream_ptr) = this.handle_received_stream_id(stream_id) else { return Ok(JSValue::js_number(-1.0)); @@ -7071,36 +7160,70 @@ impl H2FrameParser { } return Ok(JSValue::js_number(stream_id as f64)); } - let mut length: usize = encoded_size; if has_priority { - length += 5; flags |= HeadersFrameFlags::PRIORITY as u8; } - bun_output::scoped_log!(H2FrameParser, "request encoded_size {}", encoded_size); - - // Check if headers block exceeds maxSendHeaderBlockLength - if this.max_send_header_block_length.get() != 0 - && encoded_size > this.max_send_header_block_length.get() as usize + // Like nghttp2, priority bytes always counted: https://github.com/nodejs/node/blob/v26.3.0/deps/nghttp2/lib/nghttp2_session.c#L2095-L2101 + if let Some(staged) = &staged + && staged.deflate_bound() + StreamPriority::BYTE_SIZE + > this.max_send_header_block_length.get() as usize { + if this.is_server.get() { + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + // The JS handler closes the session gracefully, like node's onFrameError. + this.dispatch_with_2_extra( + JSH2FrameParser::Gc::onFrameError, + identifier, + JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), + JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), + ); + // DATA cannot follow a refused final response. A refused 1xx block keeps the stream open. + if !staged.is_informational() { + this.end_stream(&mut stream, ErrorCode::FRAME_SIZE_ERROR); + } + return Ok(JSValue::js_number(stream_id as f64)); + } + + // Client: the request never reached the wire, nghttp2 refuses it locally. stream.state = StreamState::CLOSED; stream.rst_code = ErrorCode::REFUSED_STREAM.0; - + let identifier = stream.get_identifier(); + identifier.ensure_still_alive(); + stream.free_resources::(this); this.dispatch_with_2_extra( JSH2FrameParser::Gc::onFrameError, - stream.get_identifier(), + identifier, JSValue::js_number(FrameType::HTTP_FRAME_HEADERS as u8 as f64), JSValue::js_number(ErrorCode::FRAME_SIZE_ERROR.0 as f64), ); - this.dispatch_with_extra( JSH2FrameParser::Gc::onStreamError, - stream.get_identifier(), + identifier, JSValue::js_number(stream.rst_code as f64), ); return Ok(JSValue::js_number(stream_id as f64)); } + if let Some(staged) = &staged { + for (name, value, never_index) in staged.iter() { + if let Err(err) = + this.encode_header_into_list(&mut encoded_headers, name, value, never_index) + { + if matches!(err, crate::Error::Alloc(_)) { + return Err( + global_object.throw(format_args!("Failed to allocate header buffer")) + ); + } + this.schedule_header_compression_session_error(); + return Ok(JSValue::js_number(stream_id as f64)); + } + } + } + let encoded_size = encoded_headers.len(); + bun_output::scoped_log!(H2FrameParser, "request encoded_size {}", encoded_size); + let actual_max_frame_size = this .remote_settings .get() @@ -7290,7 +7413,6 @@ impl H2FrameParser { // TODO: should we make use of this in the future? We validate it. } - let _ = length; Ok(JSValue::js_number(stream_id as f64)) } diff --git a/test/js/node/http2/h2-conformance.test.ts b/test/js/node/http2/h2-conformance.test.ts index 414bf6c3b68b..0cf9e30e2bcd 100644 --- a/test/js/node/http2/h2-conformance.test.ts +++ b/test/js/node/http2/h2-conformance.test.ts @@ -1928,3 +1928,74 @@ describe("stream release after a queued END_STREAM", () => { } }); }); + +describe.concurrent("a response over maxSendHeaderBlockLength (nghttp2 on_frame_not_send_callback)", () => { + // Verified against node v26.3.0 with the same raw client: the stream only carries + // RST_STREAM FRAME_SIZE_ERROR, and the session closes with GOAWAY NO_ERROR (node sends that + // GOAWAY twice). The handler keeps using the stream for the rest of the tick, and none of + // that may reach the wire. + const BIG = Buffer.alloc(300, "b").toString(); + const handlers: Record void> = { + "respond() then end()": server => + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "x-big": BIG }); + stream.end("ok"); + }), + "respond() with waitForTrailers, then sendTrailers()": server => + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "x-big": BIG }, { waitForTrailers: true }); + stream.on("wantTrailers", () => stream.sendTrailers({ "x-trailer": "1" })); + stream.end("ok"); + }), + "the compat API": server => + server.on("request", (req, res) => { + res.on("error", () => {}); + res.setHeader("x-big", BIG); + res.end("ok"); + }), + "the compat API with trailers": server => + server.on("request", (req, res) => { + res.on("error", () => {}); + res.setHeader("x-big", BIG); + res.addTrailers({ "x-trailer": "1" }); + res.end("ok"); + }), + }; + + test.each(Object.keys(handlers))("%s", async name => { + const server = http2.createServer({ maxSendHeaderBlockLength: 100 }); + handlers[name](server); + server.listen(0); + await once(server, "listening"); + const c = await RawH2.connect((server.address() as net.AddressInfo).port); + try { + const closed = once(c.socket, "close"); + c.sendPreface(); + c.sendEmptySettings(); + c.sendSettingsAck(); + c.sendFrame(FrameType.HEADERS, 0x5, 1, requestHeaderBlock("GET")); + await closed; + const frames = c.frames + .filter(f => f.streamId === 1 || f.type === FrameType.GOAWAY) + .map(f => ({ + type: f.type, + streamId: f.streamId, + code: + f.type === FrameType.GOAWAY + ? goawayErrorCode(f) + : f.type === FrameType.RST_STREAM + ? f.payload.readUInt32BE(0) + : undefined, + })); + expect(frames).toEqual([ + { type: FrameType.RST_STREAM, streamId: 1, code: ErrorCode.FRAME_SIZE_ERROR }, + { type: FrameType.GOAWAY, streamId: 0, code: ErrorCode.NO_ERROR }, + ]); + } finally { + c.destroy(); + server.close(); + } + }); +}); diff --git a/test/js/node/http2/node-http2.test.js b/test/js/node/http2/node-http2.test.js index 7cf5c3e7ab3a..83dd93b34f41 100644 --- a/test/js/node/http2/node-http2.test.js +++ b/test/js/node/http2/node-http2.test.js @@ -5796,6 +5796,463 @@ it("delivers the reserved push stream and fails the session when its headers can } }); +it("resets the stream and closes the session gracefully when respond() exceeds maxSendHeaderBlockLength", async () => { + // Verified against node v26.3.0: the server stream gets 'frameError' (HEADERS, + // FRAME_SIZE_ERROR), RST_STREAM FRAME_SIZE_ERROR goes out, and the session closes with + // GOAWAY NO_ERROR (node sends that GOAWAY twice). The pushed stream responds after the + // refused block and is still delivered: the refused block never reached the HPACK encoder. + const server = http2.createServer({ maxSendHeaderBlockLength: 200 }); + try { + const serverEvents = []; + const serverStreamClosed = Promise.withResolvers(); + server.on("stream", stream => { + stream.on("error", e => serverEvents.push(`error ${e.code} ${e.message}`)); + stream.on("frameError", (type, code) => serverEvents.push(`frameError type=${type} code=${code}`)); + stream.on("close", () => serverStreamClosed.resolve(stream.rstCode)); + stream.pushStream({ ":path": "/pushed" }, (err, push) => { + if (err) throw err; + push.on("error", () => {}); + push.respond({ ":status": 200, "x-pushed": "yes" }); + push.end("pushed"); + }); + stream.respond({ ":status": 200, "x-big": Buffer.alloc(1000, "b").toString() }); + stream.end("big"); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + const goaways = []; + client.on("goaway", (code, lastStreamID) => goaways.push({ code, lastStreamID })); + const clientClosed = new Promise(resolve => client.on("close", resolve)); + const pushResult = new Promise(resolve => { + client.on("stream", push => { + const result = { pushed: undefined, body: "", error: undefined, rstCode: undefined }; + push.setEncoding("utf8"); + push.on("push", headers => (result.pushed = headers["x-pushed"])); + push.on("data", chunk => (result.body += chunk)); + push.on("error", e => (result.error = e.code)); + push.on("close", () => resolve({ ...result, rstCode: push.rstCode })); + }); + }); + + const req = client.request({ ":path": "/big" }); + const reqResult = new Promise((resolve, reject) => { + let error; + req.on("response", () => reject(new Error("the client must not receive a response"))); + req.on("error", e => (error = e)); + req.on("close", () => resolve({ code: error?.code, message: error?.message, rstCode: req.rstCode })); + }); + req.resume(); + req.end(); + + const [reqRes, pushRes, serverRstCode] = await Promise.all([ + reqResult, + pushResult, + serverStreamClosed.promise, + clientClosed, + ]); + expect(reqRes).toEqual({ + code: "ERR_HTTP2_STREAM_ERROR", + message: "Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR", + rstCode: http2.constants.NGHTTP2_FRAME_SIZE_ERROR, + }); + expect(pushRes).toEqual({ pushed: "yes", body: "pushed", error: undefined, rstCode: 0 }); + expect(serverRstCode).toBe(http2.constants.NGHTTP2_FRAME_SIZE_ERROR); + expect(goaways).toEqual([{ code: http2.constants.NGHTTP2_NO_ERROR, lastStreamID: 1 }]); + expect(serverEvents).toEqual([ + "frameError type=1 code=6", + "error ERR_HTTP2_STREAM_ERROR Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR", + ]); + } finally { + server.close(); + } +}); + +it("streams in flight complete when another response exceeds maxSendHeaderBlockLength", async () => { + // Verified against node v26.3.0: the session closes gracefully, so /slow receives the rest of + // its body after the GOAWAY. The /late response is a header block that follows the refused + // one, and it reuses an indexed field, so it only decodes if the HPACK tables are in step. + const HALF = Buffer.alloc(1000, "a").toString(); + const server = http2.createServer({ maxSendHeaderBlockLength: 200 }); + try { + let late; + server.on("stream", (stream, headers) => { + stream.on("error", () => {}); + const path = headers[":path"]; + if (path === "/slow") { + stream.respond({ ":status": 200, "x-marker": "indexed" }); + stream.write(HALF); + // The client ends this request once it has seen the GOAWAY. + stream.on("end", () => { + stream.end(HALF); + late.respond({ ":status": 200, "x-marker": "indexed" }); + late.end("late"); + }); + stream.resume(); + } else if (path === "/late") { + late = stream; + } else { + stream.respond({ ":status": 200, "x-big": Buffer.alloc(1000, "b").toString() }); + stream.end("big"); + } + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + const goaway = new Promise(resolve => client.once("goaway", resolve)); + const clientClosed = new Promise(resolve => client.on("close", resolve)); + const collect = req => + new Promise(resolve => { + const result = { status: undefined, marker: undefined, bytes: 0, ended: false, error: undefined }; + req.on("response", headers => { + result.status = headers[":status"]; + result.marker = headers["x-marker"]; + }); + req.on("data", chunk => (result.bytes += chunk.length)); + req.on("end", () => (result.ended = true)); + req.on("error", e => (result.error = e.code)); + req.on("close", () => resolve({ ...result, rstCode: req.rstCode })); + }); + + const slow = client.request({ ":path": "/slow", ":method": "POST" }); + const slowResult = collect(slow); + const firstHalf = new Promise(resolve => slow.once("data", resolve)); + const lateResult = collect(client.request({ ":path": "/late" })); + await firstHalf; + const bigResult = collect(client.request({ ":path": "/big" })); + await goaway; + slow.end(); + + const [slowRes, lateRes, bigRes] = await Promise.all([slowResult, lateResult, bigResult, clientClosed]); + expect({ slow: slowRes, late: lateRes, big: bigRes }).toEqual({ + slow: { status: 200, marker: "indexed", bytes: 2000, ended: true, error: undefined, rstCode: 0 }, + late: { status: 200, marker: "indexed", bytes: 4, ended: true, error: undefined, rstCode: 0 }, + big: { + status: undefined, + marker: undefined, + bytes: 0, + ended: false, + error: "ERR_HTTP2_STREAM_ERROR", + rstCode: http2.constants.NGHTTP2_FRAME_SIZE_ERROR, + }, + }); + } finally { + server.close(); + } +}); + +it("respond() delivers the response after additionalHeaders() exceeds maxSendHeaderBlockLength", async () => { + // Verified against node v26.3.0: the 1xx block is dropped with 'frameError', the stream stays + // usable for the rest of the tick, and the session closes gracefully afterwards. + const server = http2.createServer({ maxSendHeaderBlockLength: 200 }); + try { + const serverEvents = []; + const serverStreamClosed = Promise.withResolvers(); + server.on("stream", stream => { + stream.on("error", e => serverEvents.push(`error ${e.code} ${e.message}`)); + stream.on("frameError", (type, code) => serverEvents.push(`frameError type=${type} code=${code}`)); + stream.on("close", () => serverStreamClosed.resolve(stream.rstCode)); + stream.additionalHeaders({ ":status": 103, "x-big": Buffer.alloc(1000, "b").toString() }); + stream.respond({ ":status": 200 }); + stream.end("body"); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + const goaways = []; + client.on("goaway", (code, lastStreamID) => goaways.push({ code, lastStreamID })); + const clientClosed = new Promise(resolve => client.on("close", resolve)); + + const req = client.request({ ":path": "/" }); + const reqResult = new Promise(resolve => { + const result = { informational: [], status: undefined, body: "", error: undefined }; + req.setEncoding("utf8"); + req.on("headers", headers => result.informational.push(headers[":status"])); + req.on("response", headers => (result.status = headers[":status"])); + req.on("data", chunk => (result.body += chunk)); + req.on("error", e => (result.error = e.code)); + req.on("close", () => resolve({ ...result, rstCode: req.rstCode })); + }); + req.end(); + + const [reqRes, serverRstCode] = await Promise.all([reqResult, serverStreamClosed.promise, clientClosed]); + expect(reqRes).toEqual({ informational: [], status: 200, body: "body", error: undefined, rstCode: 0 }); + expect(serverRstCode).toBe(http2.constants.NGHTTP2_NO_ERROR); + expect(goaways).toEqual([{ code: http2.constants.NGHTTP2_NO_ERROR, lastStreamID: 1 }]); + expect(serverEvents).toEqual(["frameError type=1 code=6"]); + } finally { + server.close(); + } +}); + +it("a refused 1xx block followed by a late respond() resets the stream without a DATA frame", async () => { + // Verified against node v26.3.0: the deferred close resets the stream with FRAME_SIZE_ERROR, + // the late respond() throws ERR_HTTP2_INVALID_STREAM, and the client session stays healthy. + // An empty DATA END_STREAM before any HEADERS would be a protocol error for the peer. + const server = http2.createServer({ maxSendHeaderBlockLength: 200 }); + try { + const serverEvents = []; + const serverStreamClosed = Promise.withResolvers(); + server.on("stream", async stream => { + stream.on("error", e => serverEvents.push(`error ${e.code} ${e.message}`)); + stream.on("frameError", (type, code) => serverEvents.push(`frameError type=${type} code=${code}`)); + stream.on("close", () => serverStreamClosed.resolve(stream.rstCode)); + stream.additionalHeaders({ ":status": 103, "x-big": Buffer.alloc(1000, "b").toString() }); + await new Promise(resolve => setImmediate(() => setImmediate(resolve))); + try { + stream.respond({ ":status": 200 }); + serverEvents.push("respond ok"); + } catch (e) { + serverEvents.push(`respond threw ${e.code}`); + } + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + const sessionErrors = []; + client.on("error", e => sessionErrors.push(e.code)); + const clientClosed = new Promise(resolve => client.on("close", resolve)); + + const req = client.request({ ":path": "/" }); + const reqResult = new Promise(resolve => { + const result = { response: false, error: undefined }; + req.on("response", () => (result.response = true)); + req.on("error", e => (result.error = e.message)); + req.on("close", () => resolve({ ...result, rstCode: req.rstCode })); + }); + req.resume(); + req.end(); + + const [reqRes, serverRstCode] = await Promise.all([reqResult, serverStreamClosed.promise, clientClosed]); + expect(reqRes).toEqual({ + response: false, + error: "Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR", + rstCode: http2.constants.NGHTTP2_FRAME_SIZE_ERROR, + }); + expect(serverRstCode).toBe(http2.constants.NGHTTP2_FRAME_SIZE_ERROR); + expect(sessionErrors).toEqual([]); + expect(serverEvents).toEqual([ + "frameError type=1 code=6", + "error ERR_HTTP2_STREAM_ERROR Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR", + "respond threw ERR_HTTP2_INVALID_STREAM", + ]); + } finally { + server.close(); + } +}); + +it("maxSendHeaderBlockLength is compared against nghttp2's pre-compression bound", async () => { + // respond({ ":status": "200" }) stages one field of 10 bytes: the bound is 12 + 12 + 10 = 34, + // plus 5 priority bytes. Verified against node v26.3.0: 39 delivers the response, 38 refuses it. + const probe = async limit => { + const server = http2.createServer({ maxSendHeaderBlockLength: limit }); + try { + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200 }, { sendDate: false, endStream: true }); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`); + client.on("error", () => {}); + try { + const req = client.request({ ":path": "/" }); + return await new Promise(resolve => { + let status; + req.on("response", headers => (status = headers[":status"])); + req.on("error", () => {}); + req.on("close", () => resolve({ status, rstCode: req.rstCode })); + req.resume(); + req.end(); + }); + } finally { + client.destroy(); + } + } finally { + server.close(); + } + }; + expect(await probe(39)).toEqual({ status: 200, rstCode: 0 }); + expect(await probe(38)).toEqual({ status: undefined, rstCode: http2.constants.NGHTTP2_FRAME_SIZE_ERROR }); +}); + +it("a client request over maxSendHeaderBlockLength leaves the session usable", async () => { + // Verified against node v26.3.0: the request is refused locally with 'frameError' and + // REFUSED_STREAM. The next request reuses an indexed field, so the server only decodes it if + // the refused block never reached the client's HPACK encoder. + const server = http2.createServer(); + try { + server.on("stream", (stream, headers) => { + stream.respond({ ":status": 200, "x-echo": String(headers["x-marker"]) }); + stream.end(); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`, { maxSendHeaderBlockLength: 300 }); + const sessionErrors = []; + client.on("error", e => sessionErrors.push(e.message)); + const send = headers => + new Promise(resolve => { + const result = { frameError: undefined, echo: undefined, error: undefined }; + const req = client.request(headers); + req.on("frameError", (type, code) => (result.frameError = [type, code])); + req.on("response", responseHeaders => (result.echo = responseHeaders["x-echo"])); + req.on("error", e => (result.error = e.message)); + req.on("close", () => resolve({ ...result, rstCode: req.rstCode })); + req.resume(); + req.end(); + }); + + const results = [ + await send({ ":path": "/a", "x-marker": "indexed" }), + await send({ ":path": "/b", "x-marker": "indexed", "x-big": Buffer.alloc(400, "b").toString() }), + await send({ ":path": "/c", "x-marker": "indexed" }), + ]; + client.destroy(); + expect({ results, sessionErrors }).toEqual({ + results: [ + { frameError: undefined, echo: "indexed", error: undefined, rstCode: 0 }, + { + // frame type 1 is HEADERS + frameError: [1, http2.constants.NGHTTP2_FRAME_SIZE_ERROR], + echo: undefined, + error: "Stream closed with error code NGHTTP2_REFUSED_STREAM", + rstCode: http2.constants.NGHTTP2_REFUSED_STREAM, + }, + { frameError: undefined, echo: "indexed", error: undefined, rstCode: 0 }, + ], + sessionErrors: [], + }); + } finally { + server.close(); + } +}); + +it("a refused client request is freed safely: close(), a body, destroy() and a late abort do not touch it", async () => { + // The native side frees the refused stream before it calls into JS, and the next inbound + // frames evict its entry. Nothing the caller does afterwards may reach the freed stream + // (ASan builds turn a stale access into a crash). + const server = http2.createServer(); + try { + server.on("stream", stream => { + stream.respond({ ":status": 200 }); + stream.end(); + }); + const port = await new Promise(resolve => server.listen(0, () => resolve(server.address().port))); + const client = http2.connect(`http://localhost:${port}`, { maxSendHeaderBlockLength: 300 }); + const sessionErrors = []; + client.on("error", e => sessionErrors.push(e.message)); + await new Promise(resolve => client.once("connect", resolve)); + const big = Buffer.alloc(400, "b").toString(); + const settle = req => + new Promise(resolve => { + const result = { status: undefined, error: undefined }; + req.on("response", headers => (result.status = headers[":status"])); + req.on("error", e => (result.error = e.message)); + req.on("close", () => resolve({ ...result, rstCode: req.rstCode })); + req.resume(); + }); + + const closed = client.request({ ":path": "/closed", "x-big": big }); + const closedResult = settle(closed); + closed.close(); + Bun.gc(true); + const controller = new AbortController(); + const aborted = client.request({ ":path": "/aborted", "x-big": big }, { signal: controller.signal }); + const abortedResult = settle(aborted); + const posted = client.request({ ":path": "/posted", ":method": "POST", "x-big": big }); + const postedResult = settle(posted); + posted.write("body"); + posted.end("more"); + const destroyed = client.request({ ":path": "/destroyed", "x-big": big }); + const destroyedResult = settle(destroyed); + destroyed.on("frameError", () => { + Bun.gc(true); + destroyed.destroy(); + }); + // close(code) must not put RST_STREAM on the wire for an id the peer never saw. + const cancelled = client.request({ ":path": "/cancelled", "x-big": big }); + const cancelledResult = settle(cancelled); + cancelled.close(http2.constants.NGHTTP2_CANCEL); + const late = client.request({ ":path": "/late", "x-big": big }); + const lateResult = settle(late); + late.on("frameError", () => late.close(http2.constants.NGHTTP2_INTERNAL_ERROR)); + const refused = await Promise.all([ + closedResult, + abortedResult, + postedResult, + destroyedResult, + cancelledResult, + lateResult, + ]); + + // A response is inbound traffic, which evicts the freed entries. + const first = client.request({ ":path": "/first" }); + const firstResult = await settle(first); + controller.abort(); + for (const req of [closed, aborted, posted, destroyed, cancelled, late]) { + req.close(); + req.destroy(); + } + Bun.gc(true); + const second = client.request({ ":path": "/second" }); + const secondResult = await settle(second); + client.destroy(); + + const refusedError = "Stream closed with error code NGHTTP2_REFUSED_STREAM"; + const rstCode = http2.constants.NGHTTP2_REFUSED_STREAM; + expect({ refused, firstResult, secondResult, sessionErrors }).toEqual({ + refused: [ + { status: undefined, error: refusedError, rstCode }, + { status: undefined, error: refusedError, rstCode }, + { status: undefined, error: refusedError, rstCode }, + { status: undefined, error: undefined, rstCode }, + { status: undefined, error: refusedError, rstCode }, + { status: undefined, error: refusedError, rstCode }, + ], + firstResult: { status: 200, error: undefined, rstCode: 0 }, + secondResult: { status: 200, error: undefined, rstCode: 0 }, + sessionErrors: [], + }); + } finally { + server.close(); + } +}); + +it("an oversized respond() over a JS Duplex transport still puts RST_STREAM and GOAWAY on the wire", async () => { + // A session on a user Duplex has no native socket: the RST_STREAM is corked by the native + // side and must still reach the transport. + const [clientSide, serverSide] = duplexPair(); + const server = http2.createServer({ maxSendHeaderBlockLength: 200 }); + let client; + try { + server.on("stream", stream => { + stream.on("error", () => {}); + stream.respond({ ":status": 200, "x-big": Buffer.alloc(1000, "b").toString() }); + stream.end("big"); + }); + server.emit("connection", serverSide); + client = http2.connect("http://localhost", { createConnection: () => clientSide }); + client.on("error", () => {}); + const goaways = []; + client.on("goaway", (code, lastStreamID) => goaways.push({ code, lastStreamID })); + const clientClosed = new Promise(resolve => client.on("close", resolve)); + + const req = client.request({ ":path": "/big" }); + const reqClosed = new Promise((resolve, reject) => { + req.on("response", () => reject(new Error("the client must not receive a response"))); + req.on("error", () => {}); + req.on("close", resolve); + }); + req.resume(); + req.end(); + + await Promise.all([reqClosed, clientClosed]); + expect(req.rstCode).toBe(http2.constants.NGHTTP2_FRAME_SIZE_ERROR); + expect(goaways).toEqual([{ code: http2.constants.NGHTTP2_NO_ERROR, lastStreamID: 1 }]); + } finally { + client?.destroy(); + server.close(); + } +}); + it("PerformanceObserver receives http2 session and stream entries", async () => { const entries = []; // Two streams (client+server) + two sessions (client+server): resolve once