h2 server: decode and discard HEADERS on a closed client stream instead of resetting or re-opening it

AL
22d ago
+223-27

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, so handle_headers saw !streams.contains_key(id) and treated it as a brand-new request: fired on_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 / 26bun beforebun after
RST(1) then HEADERS(1)+CONTINUATION, same readdiscard, nothing sentRST_STREAM(1, STREAM_CLOSED)discard, nothing sent
RST(1) … later read … HEADERS(1)discardnew 'stream' id 1 to JS, 200 sentdiscard
stream 1 fully answered … later HEADERS(1)discardnew 'stream' id 1 to JS, 200 sentdiscard
stream 5 opened … later HEADERS(3)discardnew 'stream' id 3 to JS, 200 sentdiscard
HPACK dynamic table after the discarded blockin syncin syncin 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's last_recv_stream_id. In handle_headers, a server-side id with no streams entry 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 gets BlockDisposition::StreamClosed.
  • BlockDisposition::StreamClosed (also reached when an existing entry is State::Closed) now means §5.1 minimal processing: finish_header_block still 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 error STREAM_CLOSED, as nghttp2 does.
  • The existing mixed last_stream_id (also raised by the engine's own send_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 is node-http2-upgrade.test.mts "tests should run on node.js" (spawns the local node --test on an .mts file) and fails identically on main.
  • All 256 test/js/node/test/parallel/test-http2-* and 5 test/sequential/test-http2-* exit 0.
  • cargo clippy -p bun_runtime -- -D warnings clean.

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.