Skip to content

node:http2: don't track client streams opened by inbound HEADERS - #42369

Open
robobun wants to merge 5 commits into
mainfrom
robobun/9b9a993b/http2-client-unpromised-even-stream
Open

robobun wants to merge 5 commits into
mainfrom
robobun/9b9a993b/http2-client-unpromised-even-stream

Conversation

@robobun

@robobun robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A server sends HEADERS on an even stream that no PUSH_PROMISE reserved. The next session-level error raises uncaughtException on the http2.connect() client: error: Protocol error, code: "ERR_HTTP2_ERROR" (or ERR_HTTP2_GOAWAY_SESSION). Every stream that user code holds has an 'error' listener. Bun exits 1, node v26.3.0 exits 0.
  • That frame, or HEADERS on a finished request stream, makes session.close() wait forever. The second case needs no hostile server: a response that was in flight when the client cancelled the request is enough.
  • Cause: the client streamStart handler (src/js/node/http2.ts:4972). The native parser calls it for every stream id it registers, also one that the peer opens with HEADERS. The handler counted that stream in #connections, the count of open streams that close() waits on. For an even id it built a ClientHttp2Stream that user code never receives. Teardown destroys it with the session error.

Fix

  • Remove the handler. request() counts a stream where it takes the id. streamPush already creates and counts a real push.
  • Correct because a real push never reached the removed branch: the parser reports PUSH_PROMISE through streamPush. Every client handler already returns early for a stream with no object.
  • Verified: test/js/node/http2/h2-conformance.test.ts (5 new tests, all fail on 1.4.3). Also test/js/node/http2/ and the 261 test-http2-* node tests.
  • Self-reviewed: 12 concerns raised, none asks for a code change. The description changes they ask for are applied.

Background

Notes

Repro (from the report, bun repro.js):

const http2 = require('http2'), net = require('net');
const frame = (type, flags, sid, payload) => { const h = Buffer.alloc(9); h.writeUIntBE(payload.length, 0, 3); h[3] = type; h[4] = flags; h.writeUInt32BE(sid, 5); return Buffer.concat([h, payload]); };
const ev = [];
process.on('uncaughtException', e => ev.push('UNCAUGHT ' + e.code)); // remove this line: bun exits 1, node exits 0
const srv = net.createServer(s => {
  s.on('error', () => {}); let rx = 0, sent = false;
  s.write(frame(4, 0, 0, Buffer.alloc(0)));                          // SETTINGS
  s.on('data', d => { rx += d.length; if (!sent && rx > 42) { sent = true; s.write(Buffer.concat([
    frame(4, 1, 0, Buffer.alloc(0)),                                 // SETTINGS ack
    frame(1, 0, 2, Buffer.alloc(0)),                                 // HEADERS on stream 2 (even, never promised), no END_HEADERS
    frame(6, 0, 0, Buffer.alloc(8)),                                 // PING where a CONTINUATION is required -> connection error
  ])); } });
});
srv.listen(0, '127.0.0.1', () => {
  const c = http2.connect('http://127.0.0.1:' + srv.address().port);
  c.on('error', e => ev.push('session error ' + e.code)); c.on('close', () => ev.push('session close'));
  c.on('stream', s => { ev.push('stream event'); s.on('error', () => {}); });
  const rq = c.request({ ':path': '/' }); rq.on('error', e => ev.push('request error ' + e.code)); rq.on('close', () => ev.push('request close')); rq.resume();
  setTimeout(() => { console.log(ev.join(' | ')); process.exit(0); }, 500);
});
output
bun 1.4.3 canary / main 6b394bf session error ERR_HTTP2_ERROR | session close | UNCAUGHT ERR_HTTP2_ERROR | request error ERR_HTTP2_ERROR | request close
node v26.3.0 request error ERR_HTTP2_ERROR | request close | session error ERR_HTTP2_ERROR | session close
this PR session error ERR_HTTP2_ERROR | session close | request error ERR_HTTP2_ERROR | request close

The two paths to the uncaught error. A connection error or destroy(err) runs emitErrorToAllStreams. The streamError handler then reaches emitStreamErrorNT and Http2Stream._destroy, which takes the error from session[kSessionDestroyError]. A GOAWAY with an error code runs forEachStream(rejectStreamAboveGoawayLastId), which calls stream.destroy(err) with ERR_HTTP2_GOAWAY_SESSION. Both reached the object that streamStart built. Neither reaches a stream that has no object: for_each_stream skips it, and every client handler returns early when its stream argument is not an object.

Why a real push never reached the removed branch. The parser's inbound engine (src/runtime/api/bun/h2/connection.rs) reserves the promised id in handle_push_promise and does not call on_stream_open. When the block completes, on_headers_complete dispatches streamPush, which builds the ClientHttp2Stream, counts it and stores it with setStreamContext. The later response HEADERS find the reserved stream, so is_new is false and on_stream_open does not run. handle_received_stream_id (the only caller of streamStart) runs on a client from getNextStream() (odd ids) and from on_stream_open, which handle_headers calls only for a stream the engine does not know: never promised, or closed and evicted.

close() hang. Before this change (1.4.3): after HEADERS on stream 2 below a promised stream 4, or HEADERS on a finished stream 1 in a later read, client.close() never emits 'close'. Node finishes both. nghttp2 treats both streams as closed and ignores the frame (session_on_request_headers_received returns NGHTTP2_ERR_IGN_HEADER_BLOCK).

Tests. Two child-process tests cover the uncaughtException. Three in-process tests cover close(): an even stream below a promised one, HEADERS on a request stream that finished, and response HEADERS that arrive after req.close(NGHTTP2_CANCEL). The two child-process tests print the events without error codes. Node raises its connection error at the HEADERS frame itself, so the codes of the GOAWAY case differ from bun until the engine rejects the frame (#36343). The event set is the same in node and bun. The two close() tests use streams that are not idle, so a later engine-level idle check does not change them.

Other suites on the debug build. test/js/third_party/grpc-js/test-server, test-end-to-end, test-idle-timer, test-retry, test-deadline, test-server-errors pass. test-client.test.ts has 3 failures that are identical with main's http2.ts (a 100 ms connect deadline under debug+ASAN).

Source. Found by frame-mutation fuzzing of a raw h2 server against http2.connect(). There is no user report.

Not in this PR (same result on 1.4.3 and on this branch, node differs):


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

fails on main (without fix)
ASAN without fix: 5 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 (4ff919377)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [555.12ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [138.81ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [170.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) [99.93ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [76.57ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [67.16ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [69.88ms]
(pass) PING (checklist §3.7) > a PING on a non-zero st
... (truncated)

release without fix: 5 FAILED
bun test v1.4.3-canary.1 (4ff919377)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [8.17ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.35ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [3.16ms]
(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.15ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [0.91ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [0.89ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [0.93ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.73ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.83ms]
(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 (4ff919377)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [407.72ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [123.07ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [96.05ms]
(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) [49.15ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [43.49ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [40.72ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [80.80ms]
(pass) PING (checklist §3.7) > a PING on a non-zero str
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 701ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/650] cc obj/vendor/tinycc/tccrun.c.o
[2/650] cc obj/vendor/tinycc/tccasm.c.o
[3/650] cc obj/vendor/tinycc/x86_64-link.c.o
[4/650] cc obj/vendor/tinycc/libtcc.c.o
[5/650] cc obj/vendor/tinycc/tccdbg.c.o
[6/650] cc obj/vendor/tinycc/tccelf.c.o
[7/650] cc obj/vendor/tinycc/tccgen.c.o
[8/650] cc obj/vendor/tinycc/x86_64-gen.c.o
[9/650] cc obj/vendor/tinycc/tccpp.c.o
[10/650] cc obj/vendor/tinycc/i386-asm.c.o
[11/650] fetch boringssl
[boringssl] up to date
[12/228] fetch lsquic
[lsquic] up to date
[13/159] fetch mimalloc
[mimalloc] up to date
[14/158] fetch WebKit (prebuilt)
[WebKit] up to date
[15/158] fetch lolhtml
[lolhtml] up to date
[16/158] fetch rust-argon2
[rust-argon2] up to date
[17/158] gen cpp.rs (cppbind)
[18/158] check undefined symbols in boringssl
[19/158] gen JS modules (bundle-modules)
Preprocess modules (8236ms)
Bundle modules (54ms)
Postprocesss modules (200ms)
Bundle Functions (510ms)
Generate Code (42ms)

[9.05s] Bundled "src/js" for production
  2599 kb
  197 internal modules
  13 nati
... (truncated)
diff hotspot
src/js/node/http2.ts                      |  18 +--
 test/js/node/http2/h2-conformance.test.ts | 200 ++++++++++++++++++++++++++++++
 2 files changed, 205 insertions(+), 13 deletions(-)

gate history · 1 passed · 2 rejected · iteration 2

evidence per changed file
file                                       reads  edits  tests
src/js/node/http2.ts                          10      9     25
test/js/node/http2/h2-conformance.test.ts      6      8     23

The client streamStart handler ran for every stream id the native parser
registered. That includes a stream the peer opens with HEADERS that this
side neither opened nor reserved with PUSH_PROMISE. For an even id the
handler built a ClientHttp2Stream that user code is never handed, so
session teardown raised its 'error' as an uncaughtException. For every
id it counted the stream in #connections, so close() waited forever on a
stream nothing can close.

Remove the handler. request() counts a stream where it takes the stream
id, and streamPush already creates and counts a real pushed stream.
@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:51 AM PT - Sep 12th, 2026

✅ @robobun, your commit 608bf8cbb4c60840b4ee1aec2968d259d1f8cee1 passed in Build #114621! 🎉


🧪   To try this PR locally:

bunx bun-pr 42369

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

bun-42369 --bun

@coderabbitai

coderabbitai Bot commented Sep 11, 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: af18d781-4683-467b-9fdb-f44a4c528169

📥 Commits

Reviewing files that changed from the base of the PR and between 7686202 and fa0d813.

📒 Files selected for processing (1)
  • src/js/node/http2.ts

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


Walkthrough

The client HTTP/2 session now counts only allocated, user-visible streams. Conformance tests cover unsolicited streams, malformed headers, GOAWAY handling, and graceful session closure.

Changes

HTTP/2 client stream accounting

Layer / File(s) Summary
Stream allocation and connection tracking
src/js/node/http2.ts
Client connection counts now update after successful stream ID allocation. Queued requests increment tracking when assigned an ID. Parser-created unowned streams are excluded.
Unsolicited stream and closure validation
test/js/node/http2/h2-conformance.test.ts
Tests cover malformed and completed unsolicited streams, GOAWAY handling, and graceful closure with open or completed streams.

Suggested reviewers: cirospaciari

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to fa0d8

The HTTP/2 stream-accounting change has no unresolved merge-blocking risk.

🚥 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: client HTTP/2 streams opened by inbound HEADERS are no longer tracked.
Description check ✅ Passed The description explains the problem, fix, background, scope, and verification results. It does not use the template headings exactly, but it contains the required information and is complete.

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

@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status at 608bf8c (fa0d813 plus a merge of main and one more test)

Reproduced on 1.4.3-canary.1+4ff919377 and on main 6b394bf (Linux x64) with the script in the PR description. Bun prints session error ERR_HTTP2_ERROR | session close | UNCAUGHT ERR_HTTP2_ERROR | request error ERR_HTTP2_ERROR | request close. Without the process-level handler it exits 1. Node v26.3.0 prints no UNCAUGHT entry and exits 0.

New tests: test/js/node/http2/h2-conformance.test.ts -t "neither opened nor reserved".

  • USE_SYSTEM_BUN=1 bun test (1.4.3-canary.1): 0 pass, 5 fail. The two child-process tests report the extra uncaughtException entry. The three close() tests time out.
  • bun bd test (this branch, debug build): 5 pass, 0 fail.

CI at fa0d813: the diff is green. Build 114476 passes 180 of 181 jobs. The one red test is test/js/bun/http/serve-pending-promise-abort-leak.test.ts on debian 13 x64-asan. It is a Bun.serve + fetch GC check that does not load node:http2, and it also fails on main. The earlier run (build 114403) failed on the same test only. h2-conformance.test.ts passes on every lane. The PR needs a maintainer to review and merge.

PR: #42369

@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 didn't find any bugs. The #connections bookkeeping move looks correct end-to-end, but since it touches the client session's graceful-close gate across several lifecycle paths, a human look from someone familiar with the h2 parser dispatch would still be worthwhile.

What was reviewed

  • Native side tolerates a missing streamStart handler: handler_pair! skips storing it and handle_received_stream_id early-returns at the onStreamStart.get() guard, so getNextStream() on the client no longer touches JS.
  • Counter balance: increments now at request() post-getNextStream(), #flushPendingRequests(), and streamPush; every decrement site (aborted, streamError, streamEnd state 7, both catch blocks) either already guards on typeof stream !== "object" or is paired with a JS-side increment. The connectionsCounted = true move to after the id check matches the new increment point.
  • Tests: the subprocess fixtures assert an exact sorted event list (positive check — an uncaughtException would appear in stdout and fail the match); the 30_000 timeout matches five other tests in the same file.
Extended reasoning...

Overview

The PR removes ClientHttp2Session's streamStart handler and relocates its #connections++ into the two JS call sites that allocate a client stream id (request() and #flushPendingRequests()). Previously the native parser invoked streamStart for any newly-registered stream id — including one opened by hostile inbound HEADERS on a stream the client never opened or reserved — which (a) inflated the graceful-close counter with nothing to decrement it, hanging session.close(), and (b) constructed an orphaned ClientHttp2Stream that later received the session teardown error with no user listener, raising uncaughtException. Four new tests in test/js/node/http2/h2-conformance.test.ts cover both failure modes against Node v26.3.0's observed behavior.

Security risks

None identified. The change narrows what the client tracks in response to peer-initiated frames (the peer could previously inflate #connections and hang close(); now it cannot). No new parsing, no auth/crypto/permission surface, no user-controlled input reaching a new sink.

Level of scrutiny

Medium-high. The source change is small and mechanical, but #connections gates graceful close in roughly fifteen places across the client session (goaway handling, settings-ack grace, ping drain, close(), streamEnd, aborted, streamError). I traced each: every decrement site is either behind a typeof stream !== "object" guard (so a stream with no JS context — the hostile case — never decrements) or is paired with a JS-side increment in the same function. The native handler_pair! macro (h2_frame_parser.rs:757) uses get_truthy and simply skips absent handlers; handle_received_stream_id early-returns when onStreamStart.get() is None, so removing the JS handler is safe. The connectionsCounted flag move in request() correctly follows the increment to its new location.

Other factors

The tests follow file conventions (subprocess -e fixtures with Promise.all drain and a combined-object assertion, RawH2Server/once() for the in-process cases, describe.concurrent, port: 0 via the existing helper). The 30_000 per-test timeout matches five existing tests in the same file, so it's the local convention rather than a new deviation from test/CLAUDE.md. The subprocess tests assert an exact sorted event list on stdout — a positive check where an uncaughtException would appear and fail the match — so they don't rely solely on the weak stderr: not.stringContaining assertion. No CODEOWNERS entry covers these paths. Deferring rather than approving because this is bot-authored connection-lifecycle bookkeeping in a protocol-heavy Node compat module, and the PR notes its own self-review workflow had not completed; a maintainer who knows the h2 dispatch paths should confirm no other client-side path relied on streamStart running.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts 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.

Code review found no issues

No high-confidence issues detected in this change.

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

@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 19, 2026

Copy link
Copy Markdown
Collaborator Author

#43534 changes the engine side of this. On a client, handle_headers (src/runtime/api/bun/h2/connection.rs) no longer opens a stream for HEADERS on an id that neither request() registered nor a PUSH_PROMISE reserved. Such a block takes the closed-stream path, so streamStart is not called for it and no table entry stays behind.

I ran the five tests that this PR adds on the #43534 branch, without the src/ change of this PR. All five pass.

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