h2 server: decode and discard HEADERS on a closed client stream instead of resetting or re-opening it
Problem
node:http2 server, Rust h2 engine (src/runtime/api/bun/h2/connection.rs). When a client sends HEADERS on a stream id that is already closed, bun did one of two wrong things depending on timing:
- If the closing RST_STREAM and the late HEADERS were parsed in the same read, bun decoded the block and answered
RST_STREAM(STREAM_CLOSED). RFC 9113 §5.1 says an endpoint "MUST NOT send frames other than PRIORITY on a closed stream"; the RST reply was RFC 7540's rule, dropped in 9113. - If the HEADERS arrived in a later read (the normal case), the closed entry had already been evicted from
streams, sohandle_headerssaw!streams.contains_key(id)and treated it as a brand-new request: firedon_stream_open, delivered it to JS as a'stream'event, and answered 200. Same for a never-used lower id (HEADERS(3) after stream 5 was opened), which §5.1.1 says is implicitly closed. That is stream-id reuse being accepted.
Measured against node v22.20.0 and v26.4.0 (nghttp2 1.69.0) with a raw-socket probe: in every one of these cases node decodes the block (HPACK stays in sync), sends nothing, surfaces nothing to JS, and keeps the connection up — nghttp2's NGHTTP2_ERR_IGN_HEADER_BLOCK path in session_on_request_headers_received.
| scenario (server side) | node 22 / 26 | bun before | bun after |
|---|---|---|---|
| RST(1) then HEADERS(1)+CONTINUATION, same read | discard, nothing sent | RST_STREAM(1, STREAM_CLOSED) | discard, nothing sent |
| RST(1) … later read … HEADERS(1) | discard | new 'stream' id 1 to JS, 200 sent | discard |
| stream 1 fully answered … later HEADERS(1) | discard | new 'stream' id 1 to JS, 200 sent | discard |
| stream 5 opened … later HEADERS(3) | discard | new 'stream' id 3 to JS, 200 sent | discard |
| HPACK dynamic table after the discarded block | in sync | in sync | in sync |
Fix
- Add
last_peer_stream_id: the highest client-initiated stream id an inbound HEADERS has opened (refused streams included; on a client, the highest promised id) — nghttp2'slast_recv_stream_id. Inhandle_headers, a server-side id with nostreamsentry at or below that mark is a closed stream, not a new one: no entry is created, nothing is opened/refused/counted, and the block getsBlockDisposition::StreamClosed. BlockDisposition::StreamClosed(also reached when an existing entry isState::Closed) now means §5.1 minimal processing:finish_header_blockstill HPACK-decodes the whole block, then returns without sending a frame or calling the sink. HEADERS on a half-closed (remote) stream still escalates to a connection errorSTREAM_CLOSED, as nghttp2 does.- The existing mixed
last_stream_id(also raised by the engine's ownsend_header_block/send_push_promise) is left as-is for GOAWAY and the RST_STREAM idle check; a separate peer-only mark is what keeps a server that has promised even ids from misreading a lower odd id as closed. Under today's embedder, which does not send through those engine APIs, the two marks hold the same value on a server. - Client-side HEADERS handling, PUSH_PROMISE, refused streams (
maxSessionMemory), trailers, DATA/RST_STREAM/WINDOW_UPDATE on closed streams: unchanged. - Three comments that described the old behavior (RST reply; "a late HEADERS for an evicted id re-opens a fresh entry") are corrected.
Tests
test/js/node/http2/h2-conformance.test.ts, describe("inbound stream lifecycle"), raw-socket client against a bun server. Each closed-stream test sends the late block as HEADERS + CONTINUATION whose CONTINUATION inserts x-bun-sync: 1 into the HPACK dynamic table, then opens a fresh stream referencing index 62 and uses a PING ack as the barrier. Asserts: nothing sent on the closed id, no GOAWAY, the probe stream is answered (so the discarded block was decoded), and only the probe stream reached JS.
- discards HEADERS following the peer's RST_STREAM in the same write
- discards HEADERS arriving in a later read for a stream the peer reset
- discards HEADERS reusing the id of a fully answered stream
- discards HEADERS on a lower stream id than one already opened
- a client stream id below the server's promised ids is still new (request 1 pushes 2/4/6, then HEADERS(3) must be served)
The first four fail on main (bun-debug built from 8a1cd8d420) and pass with this change; the fifth passes on both today and guards the peer-only mark.
Verification
Debug build on macOS arm64:
bun bd test test/js/node/http2/h2-conformance.test.ts: 66 pass.bun bd test test/js/node/http2/: 468 pass, 6 skip, 1 fail — the failure isnode-http2-upgrade.test.mts"tests should run on node.js" (spawns the localnode --teston an.mtsfile) and fails identically on main.- All 256
test/js/node/test/parallel/test-http2-*and 5test/sequential/test-http2-*exit 0. cargo clippy -p bun_runtime -- -D warningsclean.
Not in this PR (found while probing)
When RST_STREAM(1) arrives in the same read as the HEADERS that opened stream 1, bun still writes the response HEADERS/DATA for stream 1 that the JS 'stream' handler produced; node writes nothing. The handler runs synchronously from on_headers_complete and respond() serializes straight into the corked write buffer (h2_frame_parser.rs request / write_stream) before the RST later in the batch is parsed, and on_stream_reset cannot retract already-serialized bytes; request also lacks the closed-stream guard write_stream has. Separate change.