Skip to content

node:http2: do not reset a stream the native layer already closed - #43539

Open
robobun wants to merge 5 commits into
mainfrom
robobun/4bae7ede/http2-no-rst-after-native-close
Open

robobun wants to merge 5 commits into
mainfrom
robobun/4bae7ede/http2-no-rst-after-native-close

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 client writes RST_STREAM twice when its inbound engine resets a server-pushed stream (node v26.3.0: once). It also answers the peer's RST_STREAM. Other streams do both when a read precedes the deferred reset.
  • Http2Stream#_destroy (src/js/node/http2.ts:2685) defers rstStream(id, rstCode) even after the native layer closed the stream. The native rst_stream (src/runtime/api/bun/h2_frame_parser.rs:5056) drops the call only while the stream has a stream table entry.
  • DATA on a pushed stream that is still reserved gets RST_STREAM(STREAM_CLOSED). RFC 9113 §5.1 makes it a connection error. Node sends GOAWAY(PROTOCOL_ERROR).

Fix

  • The streamError and aborted handlers set a new StreamState.NativeReset bit. Native dispatches them only after it closed the stream. _destroy and the callback that submits the reset skip a stream with that bit or NativeClosed, as node does (if (!this.closed)).
  • The engine (src/runtime/api/bun/h2/connection.rs) answers DATA on a reserved (remote) stream with GOAWAY(PROTOCOL_ERROR).
  • Verified: test/js/node/http2/h2-conformance.test.ts and h2-push-refusal-staged.test.ts (12 tests fail without the fix). Also test/js/node/http2/ and 279 vendored node tests.
  • Self-reviewed: 5 concerns raised, 4 addressed. Rejected: a guard in the native rst_stream (Notes).

Background

  • H2FrameParser has two layers. The inbound engine (src/runtime/api/bun/h2/) parses frames and raises stream and connection errors. The older stream table serves outbound calls such as rstStream.
  • The table drops a closed stream's entry at the start of the next read. Then rstStream cannot tell it from an unknown stream.
  • The server opens a pushed stream with PUSH_PROMISE. It is reserved (remote) until its response HEADERS arrive. Only the client's engine tracks it, so it has no table entry.
Notes

Wire output, bun 1.4.3-canary.1+367d939d9 against node v26.3.0. Raw TCP server unless marked. "mem" is an in-memory Duplex transport where two frames are two reads in the same turn.

scenario node bun before bun now
client, pushed stream, malformed response block RST(2)=1 RST(2)=1 twice RST(2)=1
client, DATA on a reserved pushed stream GOAWAY(PROTOCOL_ERROR) RST(2)=5 twice GOAWAY(PROTOCOL_ERROR)
client, peer sends RST(2)=2 on a pushed stream none RST(2)=2 none
client, peer sends RST(2)=8 on a pushed stream none RST(2)=8 none
client, request stream, malformed block, then one more read (mem) RST(1)=1 RST(1)=1 twice RST(1)=1
client, req.close(8) on an open POST, one read after the first RST_STREAM (mem) RST(1)=8 RST(1)=8 twice RST(1)=8
server, stream.close(2) on an open POST while the peer sends PING frames (TCP) RST(1)=2 RST(1)=2 twice RST(1)=2
client, peer cancels, 'aborted' listener calls close(8), one more read (mem) none RST(1)=8 twice none
client, response ended cleanly, later destroy(err) none RST(1)=2 none
server, peer cancels, then one more read (mem) none RST(1)=8 none
server, maxSessionRejectedStreams: 1, peer sends RST(1)=7, then PING PING ACK GOAWAY(ENHANCE_YOUR_CALM) PING ACK

DATA on a reserved (remote) stream. RFC 9113 §5.1: "Receiving any type of frame other than HEADERS, RST_STREAM, or PRIORITY on a stream in this state MUST be treated as a connection error of type PROTOCOL_ERROR." nghttp2 ends the session with the reason "DATA: stream in reserved" (session_on_data_received_fail_fast), and node reports ERR_HTTP2_ERROR with rstCode 2 on every stream. The engine now does the same in handle_data and in begin_streamed_data, the path for a DATA frame whose payload is not complete yet. Two tests pinned the old stream error: the push-state test in h2-conformance.test.ts, which now has a row for a whole frame and a row for the head of a frame, and its staged twin in h2-push-refusal-staged.test.ts. They assert what node does and bun now does: the first GOAWAY carries PROTOCOL_ERROR, no RST_STREAM goes out, the session error is ERR_HTTP2_ERROR, the request and the pushed stream close with rstCode 2, and the payload never reaches the pushed stream. Only the reserved (remote) state changes. #43494 covers DATA from a client on a server-pushed stream.

A client session's teardown does not reach a pushed stream (#42410 adds that). So before the GOAWAY the engine reports the promised stream to the embedder with on_stream_reset(id, INTERNAL_ERROR), and the pushed stream closes with rstCode 2. With #42410 merged those two engine lines can go.

Three differences from node remain in that scenario, and the tests do not assert them. The pushed stream is destroyed before the session error exists, so its error is ERR_HTTP2_STREAM_ERROR where node reports ERR_HTTP2_ERROR. After an engine connection error bun writes a second GOAWAY(INTERNAL_ERROR) from session.destroy(err). Over a JS Duplex transport the engine's GOAWAY does not reach the peer at all.

Why the native side cannot decide this. rst_stream finds a closed stream through its table entry (end_stream returns early on CLOSED). Without an entry it writes the frame for any nonzero code. That branch exists so that a client can refuse or cancel a pushed stream, which is never in the table. An evicted entry and a pushed stream look the same there. Every native site that dispatches onStreamError or onAborted sets the stream to CLOSED first and has already written the RST_STREAM when one was due (end_stream, the abort signal path, on_stream_reset, emit_error_to_all_streams, emit_abort_to_all_streams, and the request() validation exits, which never reach the wire). So the dispatch is the point where JS learns that nothing is left to send.

Why the setImmediate callback (rstNextTick) checks again. close() queues its reset before the native side has closed the stream. Two cases reach the callback with the bit set: a close() from an 'aborted' listener, and the reset that _destroy queues after close(code). In the second case the first reset goes through native end_stream, which writes the frame and dispatches streamError, so the handler sets the bit before the second callback runs.

Why a new bit and not NativeClosed. NativeClosed means that state 7 (both END_STREAM flags) arrived. destroyStreamForSessionDestroy reads it to let a cleanly closed stream with buffered data finish before the destroy. A request() that fails native validation dispatches streamError and keeps its table entry, so a session.destroy() in the same turn would take that branch for it. With NativeReset no reader of NativeClosed changes. The handlers set the bit before 'aborted' is emitted, so a destroy() from that listener already sees it.

Tests. The nine tests of the new block fail on the unfixed build, release and debug, with the frame counts from the table. They wait for the stream's 'close', then for one setImmediate (the deferred reset was queued before 'close'), then for a PING round trip, and only then count frames. RawH2 and RawH2Server can now run over memoryPair(), two linked Duplex streams, to make "one more read before the deferred reset" deterministic.

Suites run on the debug (ASAN) build. h2-conformance.test.ts 80 pass, and the new block in 25 more runs. test/js/node/http2/ 589 pass, 0 fail. All 279 test/js/node/test/{parallel,sequential}/test-http2-*.js and test-diagnostics-channel-http2-*.js. grpc-js: test-deadline, test-server-deadlines, test-server-errors, test-call-propagation, test-retry, test-idle-timer, test-server, test-server-interceptors. test-client fails on the debug build at its 100 ms waitForReady deadline (a connect does not fit in 100 ms there) and passes on a release build.

Self-review. Five concerns, four addressed.

  1. The reset that _destroy queues after close(code) had no test. The tenth test pins it.
  2. destroyStreamForSessionDestroy also reads NativeClosed, and a review comment showed one path where a shared flag changes it. The reset now has its own bit, NativeReset.
  3. Over the in-memory transport a PING ACK can arrive before the deferred reset runs. The tests wait one setImmediate after 'close' first.
  4. node:http2: send RST_STREAM when a server stream is reset #33380 also changes rstNextTick. This change gives it the same signature, so the two guards merge as one line each.
  5. Rejected: a guard in the native rst_stream in place of the JS flag. Native has no record that tells an evicted entry from a client's pushed stream, and the branch must keep writing for the second.

Seen and not changed here. WINDOW_UPDATE on a reserved (remote) stream is accepted, where node v26.3.0 writes RST_STREAM(1)=2 and GOAWAY code 2. pushed.close(code) on a client fails with Invalid stream id and never emits 'close' (the bug #33000 addressed before it went stale). A peer cancel of a pushed stream still emits 'aborted'. destroy(err) on a cleanly closed stream reports rstCode 2 where node keeps 0 (#43433 changes that line).

Nearby open PRs. #33380 adds a first-wins guard to rstNextTick for a reset that close() and _destroy both submit. #42410 tears a client's pushed streams down with the session. Both touch the same lines as this change and neither covers a stream that the native layer closed.


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

fails on main (without fix)
ASAN without fix: 12 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" "test/js/node/http2/h2-push-refusal-staged.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) [833.13ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [206.28ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [151.42ms]
(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) [77.04ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [64.64ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [64.69ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [66.31ms]
(pa
... (truncated)

release without fix: 12 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.66ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.79ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [2.33ms]
(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.24ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [0.98ms]
(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.98ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.81ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.93ms]
(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" "test/js/node/http2/h2-push-refusal-staged.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) [525.43ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [162.78ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [150.24ms]
(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) [75.86ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [57.70ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [51.50ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [57.37ms]
(pa
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     1123ac7437
  features     lto, baseline

23 deps, 136 codegen, 1176 objects in 1185ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
244 units: 172 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1499] mkdir stamps
[2/1499] mkdir codegen
[3/1499] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 26 installs across 65 packages (no changes) [52.00ms]
[4/1499] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [2.00ms]
[5/1499] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (367d939d9)

Checked 111 installs across 104 packages (no changes) [9.00ms]
[6/1499] gen bake.{client,server,error}.js
-> bake.clien
... (truncated)
diff hotspot
src/js/node/http2.ts                              |  25 +-
 src/runtime/api/bun/h2/connection.rs              |  23 +-
 test/js/node/http2/h2-conformance.test.ts         | 361 ++++++++++++++++++++--
 test/js/node/http2/h2-push-refusal-staged.test.ts |  20 +-
 4 files changed, 382 insertions(+), 47 deletions(-)

gate history · 3 passed · 0 rejected · iteration 1

evidence per changed file
file                                               reads  edits  tests
src/js/node/http2.ts                                  14     11     49
src/runtime/api/bun/h2/connection.rs                   6      3     50
test/js/node/http2/h2-conformance.test.ts              8     14     47
test/js/node/http2/h2-push-refusal-staged.test.ts      1      0      9

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary.1+367d939d9 against node v26.3.0 with a raw TCP HTTP/2 server.

  1. On the client's request HEADERS the server sends PUSH_PROMISE on stream 1 (promised id 2). Then it sends HEADERS on stream 2 with a malformed block (:status: 200 plus connection: close).
  2. The server logs each RST_STREAM it receives and then does a PING round trip.

Bun wrote RST_STREAM(2)=1 twice. Node wrote it once. The same server shows the peer-reset case and the maxSessionRejectedStreams case from the description. An in-memory Duplex transport reproduces the request-stream and server-stream cases, because it can place one read between the reset and the deferred rstStream call.

DATA on a reserved pushed stream (review request): the same server sends PUSH_PROMISE(1 -> 2) and then DATA on stream 2 before its HEADERS. Node answers GOAWAY code 1 and no RST_STREAM. Bun answered RST_STREAM(2)=5 and kept the connection. On this branch bun answers GOAWAY code 1.

Fail-before: USE_SYSTEM_BUN=1 bun test test/js/node/http2/h2-conformance.test.ts test/js/node/http2/h2-push-refusal-staged.test.ts fails 12 tests and passes the other 69. bun bd test on this branch passes all 81.

CI: build 119472 passed 180 of 181 jobs, and every test/js/node/http2/ test passed on every lane. The one red job is test/js/bun/s3/s3.test.ts on debian 13 x64, which failed with S3Error: ServiceUnavailable from Cloudflare R2. It is not related to this change. The diff is ready for review.

@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: 49bac9e7-59bd-47d4-8b79-8147ebb6ae75

📥 Commits

Reviewing files that changed from the base of the PR and between 2d2912e and 1123ac7.

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

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


Walkthrough

HTTP/2 reserved-stream DATA now causes a connection-level PROTOCOL_ERROR. Native reset tracking prevents duplicate RST_STREAM frames after native closure. The conformance tests add in-memory socket support and cover reset, push-stream, cleanup, and rejection-accounting behavior.

Changes

HTTP/2 protocol and reset handling

Layer / File(s) Summary
Reserved-stream DATA errors
src/runtime/api/bun/h2/connection.rs
DATA on remotely reserved streams now closes the stream, reports INTERNAL_ERROR, and sends GOAWAY(PROTOCOL_ERROR).
Native reset suppression
src/js/node/http2.ts
Reset scheduling and teardown now skip native RST_STREAM submission after NativeClosed or NativeReset. Server and client abort or stream-error handlers record NativeReset.
In-memory duplex test harness
test/js/node/http2/h2-conformance.test.ts
The conformance harness supports injected duplex sockets and servers attached without listeners.
Reset and protocol conformance coverage
test/js/node/http2/h2-conformance.test.ts, test/js/node/http2/h2-push-refusal-staged.test.ts
Tests cover reserved-stream protocol errors, duplicate resets, peer resets, pushed-stream violations, cleanup paths, clean termination, and rejected-stream limits.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

🚥 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 describes the primary change: preventing duplicate stream resets after the native layer has already closed a stream.
Description check ✅ Passed The description explains the problem, implementation, behavioral changes, test coverage, verification results, and review decisions. It does not use the exact template headings, but it provides all re…

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

@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 this PR and found no bugs, but since it changes which RST_STREAM frames reach the wire based on cross-layer (native/JS) timing, a human look at the flag-ordering choices would still be worthwhile.

What was reviewed:

  • Traced every native onStreamError/onAborted dispatch site in src/runtime/api/bun/h2_frame_parser.rs (end_stream, abort_stream, on_stream_reset, emit_error_to_all_streams, emit_abort_to_all_streams, the request() validation exits): each sets CLOSED first and writes the RST_STREAM (when one is due) after the dispatch, so NativeClosed is set only when nothing is left to send.
  • Checked the _destroy guard relaxation (rstCode !== 0 || dropped) against destroyStreamForSessionDestroy and emitStreamErrorNT: a session.destroy() with no error in the same turn as a peer reset now defers that stream's destroy to 'end', but emitStreamErrorNT still destroys it on the next tick.
  • Confirmed a synchronous destroy(err)/close(code) from an 'aborted' listener runs _destroy before the flag is set, and that the new rstNextTick early-return catches that case at setImmediate time; the RST written by the native side precedes it.
  • Test file: memoryPair destroy recursion terminates (second destroy() is a no-op on an already-destroyed Duplex), each new test asserts an exact frame list ([code] vs [code, code] / []), and resources are released in finally.
Extended reasoning...

Overview

The production change is ~30 lines in src/js/node/http2.ts: rstNextTick is rebound to the stream (session passed explicitly) and returns early when StreamState.NativeClosed is set; the four native aborted/streamError handlers (server and client) now set NativeClosed; and _destroy's deferred-reset guard drops the rstCode !== 0 || clause so any natively-closed stream skips the host call. All five rstNextTick.bind call sites are updated consistently. The test file adds an in-memory Duplex pair, generalizes RawH2/RawH2Server to accept any Duplex, and adds a describe block with nine tests that count RST_STREAM frames after a stream's 'close', one setImmediate, and a PING round trip.

Security risks

None specific to this change. The code path only decides whether to emit an additional RST_STREAM frame for a stream that is already closed at the native layer; it does not parse untrusted input or change resource limits. One positive side effect: a peer's RST_STREAM(REFUSED_STREAM) no longer feeds the server's maxSessionRejectedStreams budget via the JS-side echo, which removes a way for a client to trip GOAWAY(ENHANCE_YOUR_CALM) on itself.

Level of scrutiny

Medium. The diff is small but the correctness argument depends on ordering between native dispatches, setImmediate callbacks, process.nextTick(emitStreamErrorNT), and user code run from 'aborted'. I verified the native dispatch sites and the JS consumers of NativeClosed (rstNextTick, _destroy, destroyStreamForSessionDestroy) and found the reasoning holds, but the choice to set the flag after emit("aborted") and the interaction with two open PRs touching the same lines (rstNextTick first-wins guard, pushed-stream teardown) are judgment calls a maintainer familiar with the http2 layer should confirm. No debug build was available in this environment, so I did not execute the new tests.

Other factors

The PR description claims the ten new tests fail on the unfixed build and pass with the fix, and that the full test/js/node/http2/ directory plus 279 vendored node http2 tests pass; the test assertions are exact frame lists, so the before/after distinction is real rather than vacuous. No CODEOWNERS entry covers the changed files. The bug hunt exited on a dry streak with no findings, and the five candidate issues it examined were either pre-existing behavior (client GOAWAY sweep, close() from an 'aborted' listener on an open writable) or tick-level timing differences that resolve on the next tick.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:02 PM PT - Sep 21st, 2026

❌ @robobun, your commit 1123ac7 has 1 failures in Build #119472 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 43539

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

bun-43539 --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.

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

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

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Two changes came out of the review.

  1. The review found that a session.destroy() in the same turn could defer the destroy of a stream that the native side had reset. I traced one path where a shared flag causes that. A request() that fails native validation dispatches streamError and keeps its table entry, so forEachStream still reaches it, and destroyStreamForSessionDestroy reads NativeClosed. The reset now has its own bit, StreamState.NativeReset (baffde2). No reader of NativeClosed changes, and the handlers set the new bit before 'aborted' is emitted, so the order question is gone.
  2. The 'aborted' listener in the test is a jest.fn() now, and the test asserts toHaveBeenCalledTimes(1) (9b0040c).

The three code comments that were longer than one line are one line each now.

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

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

A second run on #43510 reached the same fix: the streamError and aborted handlers mark the stream native-closed and _destroy skips the deferred rstStream for it. That branch reuses NativeClosed and adds one conformance test: https://github.com/oven-sh/bun/tree/robobun/063e1f88/http2-single-rst-after-native-reset. This PR covers it, so no second PR.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun On DATA for a pushed stream that is still reserved, Node v26.3.0 treats it as a connection error (deps/nghttp2/lib/nghttp2_session.c:5262): it sends GOAWAY(PROTOCOL_ERROR) and the pushed stream reports rstCode 2. The test "a client resets a pushed stream once for DATA ahead of the response HEADERS" expects one RST_STREAM(STREAM_CLOSED) and a connection that survives, so it fails on Node. Node is the stricter side here, so please match Node so the test passes on Node and Bun.
Repro: a raw server sends PUSH_PROMISE(1->2) and then DATA on stream 2 before its HEADERS; Node's client answers GOAWAY code=1 and no RST_STREAM.

After the native layer closes a stream (its own reset, the peer's
RST_STREAM, or both END_STREAM flags), Http2Stream#_destroy and close()
still submitted a deferred rstStream. Native drops that call only while
the stream's table entry exists. A client's pushed stream has no entry
and any other stream loses its entry on the next read, so the call wrote
a second RST_STREAM, or answered the peer's RST_STREAM with one. On a
server a peer's RST_STREAM(REFUSED_STREAM) also counted against
maxSessionRejectedStreams.

The streamError and aborted handlers now mark the stream NativeClosed,
_destroy skips the deferred reset for such a stream whatever its
rstCode, and rstNextTick checks the mark again when it runs.
NativeClosed is also read by destroyStreamForSessionDestroy, which lets a
cleanly closed stream with buffered data finish before it is destroyed.
A request() that fails native validation keeps its table entry, so a
session.destroy() in the same turn took that branch for it once the
streamError handler set NativeClosed.

The streamError and aborted handlers now set NativeReset, and set it
before 'aborted' is emitted. _destroy and rstNextTick skip the reset for
either bit. NativeClosed keeps its meaning.
…error

RFC 9113 5.1 makes any frame other than HEADERS, RST_STREAM or PRIORITY
on a reserved (remote) stream a connection error of type PROTOCOL_ERROR,
and nghttp2 ends the session for it ("DATA: stream in reserved"). The
engine answered with RST_STREAM(STREAM_CLOSED) and kept the connection.
It now sends GOAWAY(PROTOCOL_ERROR), for a whole DATA frame and for one
that arrives incomplete.
@robobun
robobun force-pushed the robobun/4bae7ede/http2-no-rst-after-native-close branch from 6bdbe60 to 71c7679 Compare September 21, 2026 23:46
@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Done in 71c7679. I also rebased the branch onto main.

  • The engine now treats DATA on a reserved (remote) stream as a connection error: GOAWAY(PROTOCOL_ERROR) and no RST_STREAM. That is RFC 9113 §5.1 and nghttp2's "DATA: stream in reserved". The check is in handle_data and in begin_streamed_data, the path for a DATA frame whose payload is not complete yet.
  • Three tests pinned the old stream error. I removed the row from my new block. The push-state test in h2-conformance.test.ts and its staged twin in h2-push-refusal-staged.test.ts now assert node's result: the first GOAWAY has code 1, no RST_STREAM goes out, the session error is ERR_HTTP2_ERROR, the request closes with rstCode 2, and the payload never reaches the pushed stream. The conformance test has two rows: a whole DATA frame, and only the head of one.
  • I ran the scenario on node v26.3.0 and on this branch. Node: GOAWAY=1, req close rstCode=2, pushed close rstCode=2, session error ERR_HTTP2_ERROR. This branch gives the same wire result, session error and request result.

Two results in that scenario still differ from node. Both exist on main for every engine connection error, so the tests do not assert them.

  1. The pushed stream gets no 'error' or 'close' when the session goes away, so it cannot report rstCode 2 yet. node:http2: tear down a client's pushed streams with the session #42410 adds that teardown.
  2. After the engine's GOAWAY, session.destroy(err) writes a second GOAWAY(INTERNAL_ERROR). Node writes one GOAWAY.

The tests use TCP for this case. Over a JS Duplex transport the engine's GOAWAY does not reach the peer at all, also on 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.

Beyond the inline findings, two more things were checked and ruled out: the server-side twin of the new GOAWAY arm (a client sending DATA on a stream the server reserved with PUSH_PROMISE, State::ReservedLocal) still answers with RST_STREAM(STREAM_CLOSED) via the can_receive_data branch at src/runtime/api/bun/h2/connection.rs:1525 — that is pre-existing behavior this PR does not change, so it is not a regression here. And the _destroy guard change (dropping the rstCode !== 0 exception for a NativeClosed stream) matches node's if (!this.closed) rule: a stream that reached state 7 never gets a RST_STREAM in node either, while half-closed streams still do here since NativeClosed is not set for them.

Extended reasoning...

The push since the last review adds a Rust change in src/runtime/api/bun/h2/connection.rs (DATA on a ReservedRemote stream becomes GOAWAY(PROTOCOL_ERROR) on both the streamed and whole-frame paths) and a NativeReset bit in src/js/node/http2.ts that suppresses the deferred RST_STREAM from close()/destroy() once native has already closed or reset the stream. No security-sensitive surface beyond protocol-error handling of peer frames. The inline findings (a pushed stream left without 'close'/'error' after the new GOAWAY, and WINDOW_UPDATE on a reserved stream not being rejected) already signal that a human look is needed; the note records the server-side ReservedLocal sibling and the _destroy guard semantics as examined and not regressed.

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

  • 🟣 src/runtime/api/bun/h2/connection.rs — pre-existing, nit: a client still accepts WINDOW_UPDATE on a promised stream that is reserved (remote), where node/nghttp2 end the connection with GOAWAY(PROTOCOL_ERROR). This PR applies the RFC 9113 §5.1 reserved-remote rule only to DATA (connection.rs:1383, connection.rs:1524); handle_window_update at connection.rs:869 has no State::ReservedRemote check and just grows the window. Fix: apply the same reserved-remote connection error to every frame type §5.1 lists (WINDOW_UPDATE, both the zero-increment arm at connection.rs:852 and the normal arm at connection.rs:869), ideally through one shared predicate the DATA paths also use. nghttp2's reason text is "WINDOW_UPDATE to reserved stream".

    Why this was flagged

    A server sends PUSH_PROMISE reserving stream 2, so the client engine inserts an entry with State::ReservedRemote at src/runtime/api/bun/h2/connection.rs:1738. The server then sends WINDOW_UPDATE on stream 2 before the pushed HEADERS. handle_window_update at src/runtime/api/bun/h2/connection.rs:869 finds the entry, increases send_window, and calls sink.on_window_update; the session continues. RFC 9113 §5.1 says any frame other than HEADERS, RST_STREAM or PRIORITY on a reserved (remote) stream is a connection error of type PROTOCOL_ERROR, and nghttp2's session_on_stream_window_update_received returns a connection error "WINDOW_UPDATE to reserved stream", which node surfaces as a GOAWAY and session error. The base branch behaves the same as this PR here, so this is pre-existing; it is the sibling arm of the rule this PR adds for DATA with DATA_ON_RESERVED_STREAM at connection.rs:1383 and connection.rs:1524. No safeguard covers it: the only state check in handle_window_update is the send_window overflow at connection.rs:871.

    Verification: pre-existing (nit). Triggering condition: a server sends WINDOW_UPDATE on a promised stream after PUSH_PROMISE and before the pushed HEADERS. Mechanism verified: handle_push_promise inserts the promised stream with entry.state = State::ReservedRemote (src/runtime/api/bun/h2/connection.rs:1738); handle_window_update at connection.rs:869-878 does `else if let Some(s) =… | pre-existing (nit).…

Comment thread src/runtime/api/bun/h2/connection.rs
A client session's teardown does not reach a pushed stream, so after the
GOAWAY for DATA on a reserved (remote) stream the pushed stream never
closed. The engine now reports that stream to the embedder before the
connection error. It closes with rstCode 2, as in node.
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

On the two review findings for 71c7679:

  1. The pushed stream that never closed after the new GOAWAY is fixed in 1123ac7. The thread has the details.
  2. WINDOW_UPDATE on a reserved (remote) stream is not changed here. On main bun accepts it. I ran it on node v26.3.0: the client writes RST_STREAM(1)=2 and then GOAWAY with code 2, not GOAWAY(PROTOCOL_ERROR). That is a different wire result from the DATA case, so it needs its own change and its own check against node. The PR notes list it under "Seen and not changed here".

@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 after the latest push and found no new bugs; the newest commit addresses the pushed-stream teardown gap I flagged earlier, and because this changes HTTP/2 connection-error semantics across the native engine and the JS stream lifecycle, a maintainer look is still worthwhile.

What was reviewed:

  • data_on_reserved_stream in connection.rs: it now reports the promised stream via sink.on_stream_reset before GOAWAY; traced through the embedder, that dispatches onStreamError to JS without writing an RST_STREAM frame, so the test's "no RST_STREAM" assertion is consistent with the code.
  • The NativeReset bit and the rstNextTick rebinding: all four .bind(session, ...) call sites were updated to .bind(this, session, ...), and both client and server aborted/streamError handlers set the bit before _destroy can read it.
  • The Some(st) borrow in begin_streamed_data ends at the ReservedRemote comparison, mirroring the existing can_receive_data arm, so the &mut self call after it is fine.
  • I could not run the debug build here, so the test assertions in h2-conformance.test.ts were checked by reading, not by execution.
Extended reasoning...

The PR touches src/js/node/http2.ts (a new StreamState.NativeReset bit, rstNextTick rebound to the stream with an early return when native already closed or reset it) and src/runtime/api/bun/h2/connection.rs (DATA on a ReservedRemote stream becomes a connection PROTOCOL_ERROR via a new data_on_reserved_stream helper wired into both the whole-frame and streamed-data paths), plus conformance tests with an in-memory Duplex socket pair. It touches no auth, crypto, or injection surface; it changes wire-level HTTP/2 error handling on peer-controlled input. The latest commit resolves the inline finding from my previous run, and no CODEOWNER covers these files. I chose defer over approve because the change alters protocol-level teardown behavior across two layers, a human maintainer commented mid-review with content I cannot see, and I was unable to execute the debug build to run the tests in this environment.

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