Skip to content

node:http2: close a pushed stream whose END_STREAM HEADERS and CONTINUATION arrive in separate reads - #43586

Open
robobun wants to merge 5 commits into
mainfrom
robobun/ede45f5e/h2-continuation-closed-evict
Open

robobun wants to merge 5 commits into
mainfrom
robobun/ede45f5e/h2-continuation-closed-evict

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On a node:http2 client, a pushed stream never emits 'close' when its response HEADERS carries END_STREAM but not END_HEADERS, and the CONTINUATION arrives in a later read. session.close() never completes. node v26.3.0 closes both. Fixes node:http2 client: a pushed stream never closes when its END_STREAM HEADERS and the CONTINUATION arrive in different reads #43493.
  • Cause: handle_headers (src/runtime/api/bun/h2/connection.rs:945) applied RecvHeadersEndStream when the HEADERS frame arrived. A pushed stream went from reserved (remote) to Closed at once. receive() ends every read with replenish_windows, which evicts every Closed entry. When the CONTINUATION completed the block, finish_header_block found no entry and never called on_stream_end.

Fix

  • handle_headers applies only RecvHeaders. finish_header_block applies RecvEndStream when the block is complete, next to on_stream_end. This is the shape finish_streamed_data already uses for a streamed DATA frame.
  • The end states do not change. In stream.rs, RecvHeaders followed by RecvEndStream reaches the same state as RecvHeadersEndStream from every start state, and both classify errors the same way. The state that on_headers_complete and on_stream_end see is the same as before.
  • Verified: test/js/node/http2/h2-conformance.test.ts (one new test, times out on 1.4.3) and a Rust unit test next to the existing push test. Also the rest of test/js/node/http2/.
  • Self-reviewed: 3 concerns raised, 3 addressed. The first version skipped the in-flight stream in the eviction loop. The review asked for the deferred transition instead, so the engine has one in-flight model for HEADERS and DATA.

Background

  • A header block can span a HEADERS frame and CONTINUATION frames (RFC 9113 section 4.3). The engine parks the block in header_block_in_flight until END_HEADERS. No other frame may arrive in between.
  • replenish_windows runs at the end of each receive(). It sends WINDOW_UPDATE frames and drops every stream entry in Closed so the map stays bounded.
  • on_stream_end is the sink callback that tells the JS layer a stream's receive side ended. The JS layer closes the stream and decrements the session's open-stream count from it.
Notes
  • Only a client's pushed stream reaches Closed through END_STREAM on HEADERS. A request or response stream reaches half-closed (remote), which the sweep does not evict. Split trailers on a pushed stream hit the same path.
  • The Rust unit test compiles. The bun_runtime lib test target does not link locally (undefined C++ symbols), and CI does not run it. Each assertion in it was checked against the code by hand.
  • node-http2.test.js has a few tests that time out at 5 s when the whole file runs on the debug ASAN build (the DATA payload detach cases, the goaway cases with a bun server). Each of them passes when run alone, at 3 to 4 s. They do not touch the inbound header path.
  • Related open PRs: node:http2: let a client close a pushed stream #43534 (client close of a pushed stream, which found this bug) and node:http2: refuse END_STREAM on a 1xx HEADERS block #43575 (1xx HEADERS with END_STREAM). node:http2: refuse END_STREAM on a 1xx HEADERS block #43575 touches the same tail of finish_header_block, so one of the two needs a small rebase.
  • Repro from the issue: bun prints ["push 200","end"] and node prints ["push 200","end","close","session close"]. With this change bun prints the node output.

[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 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) [896.46ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [202.88ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [227.95ms]
(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) [185.73ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [216.61ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [103.92ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [122.10ms]
(pass) PING (checklist §3.7) > a PING on a non-zer
... (truncated)

release without fix: 2 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) [10.94ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [3.62ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [3.89ms]
(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) [2.47ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [2.35ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [1.48ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [1.81ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [1.48ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [2.18ms]
(pass) WINDOW_UPDAT
... (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) [528.62ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [130.57ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [230.31ms]
(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) [110.22ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [130.11ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [34.79ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [95.38ms]
(pass) PING (checklist §3.7) > a PING on a non-zero 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 2474ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/128] gen ErrorCode+*.h
[2/128] 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/128] gen cpp.rs (cppbind)
[4/128] gen JS modules (bundle-modules)
Preprocess modules (12609ms)
Bundle modules (230ms)
Postprocesss modules (237ms)
Bundle Functions (731ms)
Generate Code (66ms)

[13.98s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/12] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`

... (truncated)
diff hotspot
src/runtime/api/bun/h2/connection.rs      |  68 ++++++++++++++++----
 test/js/node/http2/h2-conformance.test.ts | 103 ++++++++++++++++++++++++++++++
 2 files changed, 160 insertions(+), 11 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/runtime/api/bun/h2/connection.rs           9     12     13
test/js/node/http2/h2-conformance.test.ts      3      2     13

root cause · written by the author bot

The client applied the END_STREAM state transition in handle_headers as soon as the HEADERS frame arrived, so a pushed stream moved to Closed before its header block was complete, and the read-ending replenish_windows pass evicted the Closed entry while the block was still parked awaiting CONTINUATION. When the CONTINUATION later completed the block, finish_header_block could not find the stream entry, so on_stream_end never fired, JS never received 'close', and the session's open-stream count never dropped. The fix keeps the stream in RecvHeaders when the frame arrives and applies RecvEndS…

A pushed stream's response HEADERS with END_STREAM moved the entry to
Closed when the frame arrived, before its CONTINUATION. The end-of-read
eviction then removed the Closed entry, so finish_header_block could
not find it and never reported on_stream_end. The pushed stream never
emitted 'close' and the session never finished closing.

handle_headers now applies only RecvHeaders. finish_header_block
applies RecvEndStream next to on_stream_end, the same way
finish_streamed_data does for a streamed DATA frame. The end states are
unchanged: RecvHeaders followed by RecvEndStream reaches the state that
RecvHeadersEndStream reached, and both classify errors the same way.
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

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: 847b6f48-f262-4e36-b95c-4b3969058e42

📥 Commits

Reviewing files that changed from the base of the PR and between 9b7c982 and d9e093f.

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

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


Walkthrough

The HTTP/2 connection now defers END_STREAM processing until a complete header block is decoded. Regression and conformance tests cover pushed response headers and trailers split across HEADERS and CONTINUATION frames.

Changes

HTTP/2 header completion

Layer / File(s) Summary
Defer stream closure until header completion
src/runtime/api/bun/h2/connection.rs
handle_headers keeps streams in RecvHeaders until the header block completes. finish_header_block then applies RecvEndStream before emitting on_stream_end. A regression test covers separate reads.
Validate split pushed-stream headers
test/js/node/http2/h2-conformance.test.ts
Tests cover pushed response headers and trailers split across HEADERS and CONTINUATION frames. They verify event ordering, stream and session closure, delivered data, and trailers.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #43493 requires a pushed stream to close when an END_STREAM HEADERS block completes in a later CONTINUATION. The PR defers the END_STREAM state transition from handle_headers to `finish_header…
Out of Scope Changes check ✅ Passed The reviewed changes are limited to HTTP/2 header-block state handling and regression tests for pushed streams. These changes directly support issue #43493. No unrelated change is demonstrated.
Title check ✅ Passed The title clearly identifies the node:http2 bug fix and the specific split HEADERS/CONTINUATION scenario that the changes address.
Description check ✅ Passed The description explains the problem, root cause, fix, expected behavior, test coverage, and verification results. It does not use the exact template headings, but it provides the required information…

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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:39 PM PT - Sep 19th, 2026

✅ @robobun, your commit d9e093fd8be25392a14982413f3828c2c79034b3 passed in Build #118706! 🎉


🧪   To try this PR locally:

bunx bun-pr 43586

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

bun-43586 --bun

@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.

Code review found no issues

No high-confidence issues detected in this change.

Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated
Comment thread src/runtime/api/bun/h2/connection.rs Outdated

@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.

Beyond the inline finding, I also checked that RecvHeaders followed by RecvEndStream in stream.rs lands on the same state as the removed RecvHeadersEndStream from every start state (Idle, ReservedRemote, Open, HalfClosedLocal) and raises the same error class from HalfClosedRemote/Closed, so the state on_headers_complete/on_stream_end observe is unchanged. The ignored Err in the new finish_header_block block is only reachable if the stream was locally reset between the HEADERS and CONTINUATION reads, in which case the state stays Closed and eviction still runs at the end of the read.

Extended reasoning...

Findings were reported inline, so this is the brief ruled-out note only. The Rust diff is small: handle_headers now always applies RecvHeaders, and finish_header_block applies RecvEndStream after the block completes, mirroring the existing DATA-path sites at connection.rs:1441 and :1572. I traced the state machine in src/runtime/api/bun/h2/stream.rs for every start state and confirmed the composed transition is equivalent to the removed single-step event, including error classification, so no other caller of the sink sees a different state. The only new behavior is that a stream sits in Open/HalfClosedLocal (instead of HalfClosedRemote/Closed) between reads while a CONTINUATION is pending; since RFC 9113 §4.3 forbids any other peer frame in that window, the only way the deferred transition can fail is a local reset, which leaves the entry Closed and evicted as before. The PR is not approved because verified findings remain unposted and a variant-matrix gap was flagged inline.

Comment thread test/js/node/http2/h2-conformance.test.ts

@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.

LGTM — the END_STREAM transition now lands with the completed header block, and the latest commit covers the split-trailers variant raised last round.

What was reviewed:

  • Walked every start state in stream.rs: RecvHeaders followed by RecvEndStream reaches the same state and classifies errors identically to the removed RecvHeadersEndStream, so the state passed to on_headers_complete/on_stream_end is unchanged.
  • Checked the early-return paths in finish_header_block (Refused, StreamClosed, malformed, rejected): each sets Closed explicitly, so dropping the frame-time END_STREAM does not leave a stream stuck open there.
  • Checked that reading end_state before on_headers_complete is safe: the engine RefCell is mutably borrowed across receive(), so JS dispatched from the sink cannot alter engine stream state mid-block; only CONTINUATION frames are dispatched while a block is parked, and replenish_windows branches solely on Closed.
Extended reasoning...

Overview

The production change is small: handle_headers in /home/claude/bun/src/runtime/api/bun/h2/connection.rs now applies only RecvHeaders when a HEADERS frame arrives, and finish_header_block applies RecvEndStream once the block is complete, immediately before on_headers_complete/on_stream_end. This mirrors the shape finish_streamed_data already uses. A Rust unit test and two integration tests in /home/claude/bun/test/js/node/http2/h2-conformance.test.ts (initial response block and trailer block on a pushed stream, each split across reads at the SETTINGS ACK boundary) exercise the fix; the trailer variant was added in the most recent commit in response to the previous inline nit.

Security risks

This is peer-facing frame handling, so the main question is whether the deferral loosens any check. It does not: RecvHeaders errors on HalfClosedRemote and Closed exactly as RecvHeadersEndStream did, so the connection-level STREAM_CLOSED escalation and the stream-level refusal path are unchanged. While a block is parked, dispatch rejects any frame other than a CONTINUATION on the same stream with GOAWAY, so a peer cannot interleave DATA or RST to exploit the intermediate Open/HalfClosedLocal state. Bounds and HPACK handling are untouched.

Level of scrutiny

Moderate. The diff is ~15 lines of engine code, but it is a state-machine relocation in a protocol implementation, so I verified equivalence from stream.rs for every start state rather than trusting the PR description, and confirmed the early-return paths in finish_header_block (Refused, StreamClosed disposition, malformed, rejected) all set Closed explicitly so no stream is left open by the removed frame-time transition. I also checked that reading end_state before the on_headers_complete dispatch is equivalent to the previous read-after ordering: rewrite_read in h2_frame_parser.rs holds the engine RefCell mutably across receive(), so JS callbacks cannot mutate engine stream state mid-block, and close_stream from the pending-close queue runs only between reads (leaving map to return None, the same outcome as before). The silently ignored Err from the deferred RecvEndStream is unreachable from the two states a successful RecvHeaders can leave.

Other factors

No CODEOWNERS entry covers the changed files. The bug hunt exited on a dry streak with no findings. The new JS tests follow the existing conventions of the file (no-op error handlers, RawH2Server.waitFor with its bounded timeout) and assert exact event sequences. The Rust unit test mirrors the neighbouring push_promise_roundtrip_server_to_client test; I could not compile it locally (no build artifacts, and the author notes the lib test target does not link), so its correctness rests on reading it against the CaptureSink helpers, which match. The open related PR (#43575) touching the same tail of finish_header_block is a rebase concern, not a correctness one.

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.

node:http2 client: a pushed stream never closes when its END_STREAM HEADERS and the CONTINUATION arrive in different reads

1 participant