Skip to content

node:http2: reset the stream when a response header block is over maxSendHeaderBlockLength - #43523

Open
robobun wants to merge 5 commits into
mainfrom
robobun/b8258464/http2-respond-header-block-rst
Open

robobun wants to merge 5 commits into
mainfrom
robobun/b8258464/http2-respond-header-block-rst

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 server sets maxSendHeaderBlockLength, then respond() or additionalHeaders() sends a larger header block. Bun closes the stream locally and writes no frame, so the client request hangs. Node v26.3.0 sends RST_STREAM(FRAME_SIZE_ERROR), then GOAWAY(NO_ERROR).
  • The cause is the maxSendHeaderBlockLength check in request() (src/runtime/api/bun/h2_frame_parser.rs:7082). It marks the stream CLOSED with REFUSED_STREAM and returns.

Fix

  • On a server, the check calls end_stream(FRAME_SIZE_ERROR), which writes the RST_STREAM. The frameError handlers call session.close() one setImmediate later, like node's onFrameError. A client request never reached the wire, so it fails locally with REFUSED_STREAM, as in nghttp2.
  • JS learns of a native close one tick later. Until then request() on a server, send_trailers() and no_trailers() wrote frames on the closed stream. They now return early.
  • rst_stream() writes a frame for an id with no map entry only for a client's pushed stream. Before, _destroy's later reset request became a second RST_STREAM once a read released the entry.
  • Verified: test/js/node/http2/h2-conformance.test.ts (8 new tests, all fail on main). Also test/js/node/http2/, node's test-http2-*.js and grpc-js.

Background

  • maxSendHeaderBlockLength caps a header block that this side sends. nghttp2 fails a larger HEADERS frame, and node's onFrameError calls stream.close(code) and session.close().
  • RST_STREAM ends one stream. GOAWAY(NO_ERROR) stops new streams, and open streams can finish.
  • The native stream map releases a closed stream's entry at the next read. A client's pushed streams never get an entry.
  • node:http2: encode each header block in one call, all fields or none #41520 moves this check before the HPACK encode. Both diffs apply together cleanly.
Notes

Repro. On main the client prints nothing and hangs. Node and this PR print error: Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR.

import http2 from "node:http2";
const server = http2.createServer({ maxSendHeaderBlockLength: 200 });
server.on("stream", stream => {
  stream.on("error", () => {});
  stream.respond({ ":status": 200, "x-big": "b".repeat(1000) });
  stream.end("big");
});
server.listen(0, () => {
  const client = http2.connect(`http://127.0.0.1:${server.address().port}`);
  const req = client.request({ ":path": "/" });
  req.on("error", e => console.log("error:", e.message));
  req.on("close", () => { client.close(); server.close(); });
  req.end();
});

Wire output. A raw h2 client opens stream 1. The server sends its response HEADERS and holds the body back. The client then opens stream 3, which the server answers over the limit.

Handler code on stream 3 main this PR node v26.3.0
respond(), end() nothing on stream 3, no GOAWAY RST_STREAM(3, 6), GOAWAY(0) RST_STREAM(3, 6), GOAWAY(0)
compat res.setHeader(), res.end() DATA(3, END_STREAM) with no HEADERS same as above same as above
respond({ waitForTrailers }), end(), sendTrailers() trailers HEADERS(3) with no response HEADERS same as above same as above
additionalHeaders(), respond(), end() HEADERS(3), then nothing same as above 103 dropped, HEADERS(3) + DATA(3), GOAWAY(0)
pushed stream respond() nothing on the pushed stream RST_STREAM(push, 6), GOAWAY(0) same

With this PR and with node, stream 1 still delivers its body after the GOAWAY, and the connection then ends. I also ran the patched server against a node client and a Bun client. Both report NGHTTP2_FRAME_SIZE_ERROR on the failed request and complete the open one.

The second reset. _destroy asks the native side to reset the stream one setImmediate after the stream closes. While the stream's map entry exists, that call does nothing. rst_stream() also writes the frame itself for an id that has no entry. That path exists for a client's pushed streams, which never get an entry. Suppose respond() ran from a setImmediate callback and the peer sent any frame in between. The read released the entry, and a second RST_STREAM(6) went out. With the first commit of this PR alone, the new "one RST_STREAM" test fails with two resets. For a client the same call would name an id that the peer has never seen, which is a connection error. The refused client request kept its entry for that reason. It now releases the entry at once, and the new raw-server test covers that case.

Differences from node that remain.

  • Node resets the stream one setImmediate after the frame error. In node, a respond() in the same tick as a failed additionalHeaders() still goes out. A later respond() finds the stream reset. Here the stream is reset at once in both cases, and the same-tick respond() writes nothing. On main that respond() would send a block with HPACK indexes the peer does not have (see "HPACK"), and the peer then ends the connection with COMPRESSION_ERROR. A new test covers this sequence with a real client. It asserts what both runtimes agree on: no session error, the open request completes, GOAWAY(NO_ERROR).
  • With respond(headers, { endStream: true }) node sends RST_STREAM(NO_ERROR), because its writable side had already closed the stream. Here the code is FRAME_SIZE_ERROR.
  • Inside the stream's 'error' listener session.closed is still false here. The session closes one setImmediate later.

HPACK. On main the check runs after the block went through the HPACK encoder. After a refused block the encoder table is ahead of the peer. A later header block on the same session can reference an entry the peer does not have. #41520 fixes that, and this PR does not change it. Before this PR the session stayed open, so every later stream was exposed. Now only the streams that are already open are. The new tests send no header block after the refused one, so they pass with or without #41520.

Client side. The outcome for the request is unchanged: 'frameError', then ERR_HTTP2_STREAM_ERROR with NGHTTP2_REFUSED_STREAM, which node's test-http2-options-max-headers-block-length.js expects. The new part is the graceful session.close(). Before, the session stayed open. The next request on it failed with Session closed with error code 9 because of the HPACK state above.

Sibling exits of request(). The other exits that close a server stream locally are owned by other PRs: #43478 (request options passed to respond()) and #43419 (maxSessionMemory). Their diffs apply on top of this branch with no conflict. The request() guard is limited to servers because a client only calls request() with the id of a new stream.

Not covered here.

  • A pushStream() call in the same tick as the failed respond() is not guarded.
  • maxSendHeaderBlockLength is still not applied to trailers or to pushStream() headers. Node applies it to every HEADERS and PUSH_PROMISE frame.

Node cross-check. The eight new tests also pass on Node v26.3.0. I ran the file under node --test with a small shim that maps bun:test to node:test. The server cases wait for the RST_STREAM frame itself. Node acknowledges a PING inside the read callback and resets the stream one setImmediate later, so a PING ACK is not a marker for the reset.

Review changes.

  • The request() guard is limited to servers.
  • The server tests acknowledge SETTINGS. That removes a 250 ms grace timer per case.
  • rst_stream() no longer writes the second reset.
  • The refused client request releases its entry, and the comment that explained why it stayed is gone.

Suites run with the debug build:

  • test/js/node/http2/ (587 pass, 6 skip)
  • test/js/node/test/parallel/test-http2-*.js (256) and test/js/node/test/sequential/test-http2-*.js (5)
  • test/js/third_party/grpc-js/
  • the 8 other test files that use node:http2 (309 pass)
    One test in node-http2-streams-rehash.test.ts takes 4.7 to 5.0 s on a debug build. It can pass the 5 s limit in a full-directory run, and it passes alone. In the grpc-js suite the DNS and tonic tests need the network. All of them fail the same way without this change.

[human-review] gate passed · iteration 1 · 3 files touched

fails on main (without fix)
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.3 (367d939d9)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [420.49ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [131.15ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [83.67ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [47.90ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [94.65ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [61.65ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [72.99ms]
(pass) PING (checklist §3.7) > a PING on a non-zero str
... (truncated)

release without fix: 8 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [9.85ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [3.36ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [2.18ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [1.21ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [1.00ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [0.92ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [0.99ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.85ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.95ms]
(pass) WINDOW_UPDATE
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.3 (367d939d9)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [393.58ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [133.60ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [119.21ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [62.89ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [54.68ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [78.37ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [59.95ms]
(pass) PING (checklist §3.7) > a PING on a non-zero st
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1310ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/152] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/150] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[3/150] gen JS modules (bundle-modules)
Preprocess modules (11945ms)
Bundle modules (66ms)
Postprocesss modules (157ms)
Bundle Functions (663ms)
Generate Code (45ms)

[12.89s] Bundled "src/js" for production
  2595 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/30] rustc bun_ini 
[5/30] rustc bun_js_parser 
[6/30] rustc bun_resolver 
[7/30] rustc bun_router 
[8/30] rustc bun_bundler 
[9/30] rustc bun_transpiler 
[10/30] rustc bun_standalone_graph 
[11/30] rustc bun_bunfig 
[12/30] rustc bun_install 
[13/30] rustc bun_jsc 
[14/30] rustc bun_ast_jsc 
[15/30] rustc bun_semver_jsc 
[16/30] rustc bun_patch_jsc 
[17/30] rustc bun_sys_jsc 
[18/30] rustc bun_bundler_jsc 
[19/30] rustc bun_css_jsc 
[20/30] rustc bun_sourcemap_jsc 
[21/30] ru
... (truncated)
diff hotspot
src/js/node/http2.ts                      |   6 +
 src/runtime/api/bun/h2_frame_parser.rs    |  47 ++++-
 test/js/node/http2/h2-conformance.test.ts | 332 ++++++++++++++++++++++++++++++
 3 files changed, 375 insertions(+), 10 deletions(-)

gate history · 4 passed · 0 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/js/node/http2.ts                          12      7     66
src/runtime/api/bun/h2_frame_parser.rs        14      8     68
test/js/node/http2/h2-conformance.test.ts      0      0     66

@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ccaf9946-2836-4a5d-b4ee-d5f2aee6e0cb

📥 Commits

Reviewing files that changed from the base of the PR and between b7ea95a and 0bbd87f.

📒 Files selected for processing (3)
  • src/js/node/http2.ts
  • src/runtime/api/bun/h2_frame_parser.rs
  • test/js/node/http2/h2-conformance.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The HTTP/2 implementation now handles oversized headers, closed streams, missing-stream resets, and deferred frame-error session closure. Conformance tests cover server and client reset behavior, event ordering, graceful shutdown, and existing-stream continuity.

Changes

HTTP/2 frame-error handling

Layer / File(s) Summary
Parser stream and header-error handling
src/runtime/api/bun/h2_frame_parser.rs
The parser limits missing-stream resets to client-side pushed streams, suppresses output for closed streams, stops closed server requests before header encoding, and applies distinct server and client handling for oversized headers.
Deferred frame-error session shutdown
src/js/node/http2.ts
Server and client frame-error handlers emit frameError and defer session closure with setImmediate.
Oversized-header conformance coverage
test/js/node/http2/h2-conformance.test.ts
Tests cover oversized response and request headers, reset errors, event ordering, graceful NO_ERROR shutdown, invalid-frame suppression, and existing-stream activity.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: resetting the stream when a response header block exceeds maxSendHeaderBlockLength.
Description check ✅ Passed The description explains the problem, implementation, scope, limitations, and verification results. It does not use the exact template headings, but it provides the required change summary and testing…

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:49 PM PT - Sep 21st, 2026

❌ @robobun, your commit 0bbd87f has 1 failures in Build #119462 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43523

That installs a local version of the PR into your bun-43523 executable, so you can run:

bun-43523 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green.

How I reproduced it:

  • A node:http2 server with maxSendHeaderBlockLength: 200 answers a request with a 1000-byte header value. The script is in the Notes section of the PR body.
  • Bun 1.4.3-canary.1 (367d939): the client request gets no 'response', 'error' or 'close'.
  • Node v26.3.0: the request fails with Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR, and the session closes with GOAWAY(NO_ERROR).

Fix: this PR (#43523). The 8 new tests in test/js/node/http2/h2-conformance.test.ts fail with main's src/, pass with this branch, and also pass on Node v26.3.0.

CI, build 119462 on 0bbd87f: 180 of 181 jobs passed, and no node:http2 test failed on any lane. The one red job is debian 13 x64-asan, where test/js/bun/spawn/spawn.test.ts failed ("stdout reader of an unref'd child and process lifetime"). This PR does not touch Bun.spawn. The other annotations are tests that passed on a retry.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/runtime/api/bun/h2_frame_parser.rs — pre-existing: a server that sets maxSendHeaderBlockLength still puts trailer and PUSH_PROMISE header blocks over that limit on the wire, so the cap this PR enforces for respond() is bypassed there. Only request() at h2_frame_parser.rs:7103 compares encoded_size with max_send_header_block_length. send_trailers (h2_frame_parser.rs:5781) and push_promise write the block unchecked, while nghttp2 applies the limit in session_prep_frame to every HEADERS and PUSH_PROMISE. Fix: run the same check plus onFrameError and end_stream(FRAME_SIZE_ERROR) for every outbound header block (request, trailers, push promise), which covers the 3 sites.

    Extended reasoning...

    The PR is titled as resetting the stream when a response header block is over maxSendHeaderBlockLength, and its new tests only cover respond() and additionalHeaders(). The option is read once at h2_frame_parser.rs:7637-7643 into max_send_header_block_length. A repo grep shows that field is consumed at exactly one place, h2_frame_parser.rs:7103-7104 inside request(). send_trailers() computes encoded_size at h2_frame_parser.rs:5781 and goes straight to frame writing at 5794-5842; its only frameError site (5671) fires on an HPACK encode failure, not on the configured limit. push_promise() (6025 onward) writes its block at 6225-6265 with no comparison either. In nghttp2, session_prep_frame returns NGHTTP2_ERR_FRAME_SIZE_ERROR when the estimated block exceeds max_send_header_block_length for both NGHTTP2_HEADERS (including trailers) and NGHTTP2_PUSH_PROMISE, and node then emits 'frameError', resets the stream and closes the session. Trigger: http2.createServer({ maxSendHeaderBlockLength: 200 }), respond({':status':200},{waitForTrailers:true}), then sendTrailers({'x-big': 'b'.repeat(1000)})…

    Verification: pre-existing. Triggering condition: a server (or client, for trailers) created with maxSendHeaderBlockLength calls stream.sendTrailers() or stream.pushStream() with a header block whose HPACK-encoded size exceeds the limit. Mechanism verified. max_send_header_block_length is consumed at exactly one site in /home/claude/bun/src/runtime/api/bun/h2_frame_parser.rs: lines 7103-7104 inside…

  • 🟣 src/runtime/api/bun/h2_frame_parser.rs — Clients of a Bun server still hang with no 'response', 'error' or 'close' whenever the server closes a stream locally for any reason other than the header-size cap. The maxSessionMemory exit at h2_frame_parser.rs:7069 and the options/parent/weight exits at 6914, 6991 and 7033 still set CLOSED and write no RST_STREAM. Fix: every locally-closing exit of request() on a server must go through end_stream so a RST_STREAM reaches the peer, not only the 7103 branch.

    Extended reasoning...

    The PR's fix is one of five sibling exits in request() that share the same shape: set stream.state = CLOSED, set rst_code, dispatch onStreamError, return. At 7069 is_over_session_memory_limit() takes this path. Its default limit is 10 MB of session memory, so it fires for any busy connection with many buffered responses, a far larger population than the opt-in maxSendHeaderBlockLength cap this PR fixes. The peer's stream stays open forever on the client side, exactly the hang the PR title promises to remove. On the base every one of these exits hangs the client. After the PR the header-size exit resets the stream and the others still do not. REVIEW.md treats same-class sites as one concern; the PR names #43478 and #43419 as owners, but those are not in this checkout and an intentional exclusion leaves a production hang that the same three-line change (end_stream(FRAME_SIZE_ERROR or ENHANCE_YOUR_CALM)) would close. Remedy: route each server-side local close in request() through end_stream with the matching code, or extract a helper used by all five sites.

    Verification: pre-existing, acknowledged in diff: the PR description names the sibling exits as "owned by other PRs: #43478 (request options passed to respond()) and #43419 (maxSessionMemory)" — the bound is accurate: none of these exits is touched by this diff and the base commit fails by the same route. Trigger: a server stream's respond()/additionalHeaders() reaches request() while the session is over…

Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs
Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the two findings outside the diff:

The two inline findings that this diff caused are fixed in 6e41cb1 (the second RST_STREAM, and the client stream entry that stayed in the map).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the follow-up commits and found no bugs; because this changes native stream lifecycle (free_resources/end_stream from inside request()) and the on-wire behavior of node:http2 on both server and client, a human look would still be worthwhile.

What was reviewed:

  • The new server end_stream and client free_resources calls in request(): they run under the GuardedStream dispatch guard with ensure_still_alive() on the identifier, matching the existing rst_stream/send_trailers shapes, so the deferred map eviction cannot run mid-call.
  • The second-RST_STREAM concern raised last round: rst_stream() now only hand-writes for client pushed ids, and end_stream early-returns on CLOSED entries, so the deferred _destroy reset is a no-op on both branches.
  • The setImmediate(session.close) added to both frameError handlers is idempotent against the trailer path, which already sends GOAWAY and destroys the session synchronously via onEnd.
  • The HPACK encoder-state gap after a refused block remains as documented in the description (deferred to #41520); the new tests send no later header block, so they do not depend on it.
Extended reasoning...

Overview

The PR touches src/runtime/api/bun/h2_frame_parser.rs (the maxSendHeaderBlockLength branch in request(), new CLOSED-state early returns in request()/send_trailers()/no_trailers(), and a narrowing of the unknown-id branch in rst_stream()), src/js/node/http2.ts (both frameError handlers schedule a graceful session.close() one setImmediate later), and test/js/node/http2/h2-conformance.test.ts (seven new raw-frame tests). The change converts a "stream silently closed locally, peer hangs" outcome into node's observable RST_STREAM(FRAME_SIZE_ERROR) + GOAWAY(NO_ERROR) sequence on the server, and a local REFUSED_STREAM failure plus graceful close on the client.

Security risks

No new parsing of peer-controlled bytes is introduced; the changed paths are driven by locally-produced header blocks. The memory-safety-relevant part is the new free_resources::<false> and end_stream calls while holding a &mut Stream derived from this.streams: both happen inside enter_stream_dispatch, which arms the dispatch depth so the only eviction path (rewrite_read's pending-close drain) is deferred, and the identifier JSValue is pinned with ensure_still_alive() before the Strong context is detached. This is the same shape the existing rst_stream (h2_frame_parser.rs:6442-6448) and trailer-size path (5670-5679) already use. The rst_stream() unknown-id branch now writes fewer frames, not more, which removes a way to emit RST_STREAM for an id the peer never saw.

Level of scrutiny

Medium-high. It is a small diff, but it changes stream lifecycle in unsafe Rust and alters wire behavior for both server and client sessions in a Node-compat module. I did not find defects, and the earlier concerns (the second RST_STREAM after eviction, the client stream entry retained until session death with a justifying comment) are addressed in the code now: is_pushed_to_client gates the hand-written RST, end_stream early-returns on CLOSED, and the client branch frees resources immediately. The remaining known divergences from node (reset timing within the same tick, RST_STREAM(NO_ERROR) for endStream: true, HPACK table drift deferred to #41520) are stated in the description rather than hidden. No debug build was available in this checkout, so I did not execute the new tests myself; the test structure (framing-aware waitFor, PING barriers to order frames, every failure event wired to the awaited promise, bounded keepSending loops) follows the file's existing conventions.

Other factors

No CODEOWNERS entry covers the changed files. The timeline shows prior inline threads resolved by the author with follow-up commits that do correspond to the code changes I verified, so I treated them as addressed by the code rather than by the resolution metadata. The setImmediate(closeSessionAfterFrameError) addition applies to every onFrameError dispatch, including the existing trailer-size path; I checked that close() returns early when the session is already destroyed, so that path does not emit a second GOAWAY.

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-link: #43440 fixes the same hang and was opened a few hours before this PR. Merge only one of the two. They conflict in http2.ts, h2_frame_parser.rs and h2-conformance.test.ts (git merge-tree, #43440 at 4566950). This PR and #41520 merge with each other cleanly.

The comparison of the two PRs and the two possible landing orders are in #43440 (comment).

@cirospaciari

Copy link
Copy Markdown
Member

@robobun On Node v26.3.0 the PING ACK is written inside the read callback (src/node_http2.cc:981) while onFrameError closes the stream one setImmediate later (lib/internal/http2/core.js:677), so the peer reads the PING ACK before RST_STREAM(FRAME_SIZE_ERROR). Three subtests of "a header block over maxSendHeaderBlockLength (node's onFrameError)" treat the PING ACK as the point where the reset has arrived, see an empty stream 3 and fail on Node; please wait for the RST_STREAM frame itself so they pass on Node and Bun.
Repro: http2.createServer({maxSendHeaderBlockLength:200}) whose handler does respond({':status':200,'x-big':'b'.repeat(1000)}); end('body'), and a raw client that sends HEADERS+PING in one write and logs frames: Node prints PING_ACK RST_STREAM(1,6) GOAWAY, with nothing on the stream when the ACK arrives.

…SendHeaderBlockLength

A server stream whose respond() or additionalHeaders() block is over
maxSendHeaderBlockLength was closed locally with no frame on the wire,
so the peer waited forever. Write RST_STREAM(FRAME_SIZE_ERROR) for it
and close the session gracefully, as node's onFrameError does. A client
request never reached the wire, so it still fails locally with
REFUSED_STREAM.

JS learns of a native close one tick later. request() on a server,
send_trailers() and no_trailers() no longer write frames on a stream
that the native side has already closed.
…ide released

rst_stream() writes the frame itself for an id that is not in the stream
map. That exists for a client's pushed streams, which never get an
entry. Every other id had one, so a missing entry means the stream is
already closed. The stream's _destroy still asks for a reset one
setImmediate later, and once a read had released the entry that request
became a second RST_STREAM on a server, or a RST_STREAM for an idle id
on a client.

With that, a client request refused for maxSendHeaderBlockLength
releases its entry at once.
…nder node's timing

node writes a PING ACK inside the read callback and resets the stream one
setImmediate after the frame error, so a PING ACK does not mark the point
where the reset has arrived. The server cases now wait for the RST_STREAM
frame.

The additionalHeaders() case no longer calls respond() in the same tick:
node still sends that respond(), and Bun resets the stream at once. A new
case covers that sequence with a real client and asserts what both agree
on: the connection stays usable and the open request completes.
@robobun
robobun force-pushed the robobun/b8258464/http2-respond-header-block-rst branch from 59f2a21 to 0bbd87f Compare September 22, 2026 00:03
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Confirmed on Node v26.3.0, and fixed in 0bbd87f.

I ran the new describe under node --test (with a small shim that maps bun:test to node:test). Four server cases failed there. Three saw an empty stream 3 when the PING ACK arrived, as you describe. The fourth was the additionalHeaders() case, for a different reason (below).

Changes:

  • The server cases now wait for the RST_STREAM frame itself. At that point nothing else may be on the stream, and the check after the connection ends still asserts that nothing follows the reset. The "one RST_STREAM" case had the same PING barrier and now waits for the reset too.
  • The additionalHeaders() case no longer calls respond() in the same tick. Node still sends that respond(), because its reset comes one setImmediate later. Bun resets the stream at once and drops the respond(). On main that respond() would carry HPACK indexes the peer does not have, because the refused block already went through the encoder, and the peer ends the connection with COMPRESSION_ERROR. node:http2: encode each header block in one call, all fields or none #41520 removes that cause. After it lands, the reset for an informational block can wait one setImmediate like node.
  • A new case covers the same-tick sequence with a real client. It asserts what both runtimes agree on: no session error, the open request completes, and GOAWAY(NO_ERROR). It fails without the request() guard.

Result: all 8 cases pass on Node v26.3.0 and on the debug build, and all 8 fail with main's src/. I also rebased the branch onto main.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the five follow-up commits and found no bugs; the two concerns from my earlier pass (a second RST_STREAM after the entry was evicted, and the refused client request pinning its Stream/Strong context until session death) are both addressed in the code now. Because this changes stream lifecycle in the native h2 parser and relies on node/nghttp2 wire semantics I could not run node against here, a human look from someone who owns node:http2 would still be worthwhile.

What was reviewed:

  • rst_stream() unknown-id gating: on_stream_open always registers server streams and clients only request() with a fresh id, so the only entry-less ids left are client-side pushed streams — the is_pushed_to_client narrowing is sound.
  • New frameError -> setImmediate(session.close) alongside the existing trailer-size path (h2_frame_parser.rs ~5683) that already sends a native GOAWAY: send_go_away(emit_error=true) dispatches onEnd, which destroys the session, so close() is a no-op there — no duplicate GOAWAY.
  • Client branch ordering: identifier captured and ensure_still_alive before free_resources drops the Strong, dispatch_depth armed via GuardedStream across the JS dispatch; mirrors the existing end_stream shape.
  • Server-only CLOSED guard in request() runs before HPACK so a dropped block no longer desyncs the encoder table; the client is exempt since it only ever passes a new id.
Extended reasoning...

The change touches src/runtime/api/bun/h2_frame_parser.rs (over-limit header block path now resets on the wire for servers, frees the refused client stream, gates the raw RST_STREAM fallback to pushed streams, and adds CLOSED early-returns in request/send_trailers/no_trailers), src/js/node/http2.ts (frameError now schedules session.close()), and adds seven conformance tests. No auth, crypto, or injection surface; it is protocol-lifecycle code with raw *mut Stream borrows and JS re-entry. Deferring rather than approving because the correctness claims rest on measured node v26.3.0 wire behavior I could not reproduce in this sandbox, the native stream-lifecycle changes carry UAF risk if a caller shape was missed, and a maintainer (cirospaciari) commented before the latest commits with content I cannot see.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants