Skip to content

node:http2: fail only the push when a pushStream() header block is over the send limit - #43619

Open
robobun wants to merge 4 commits into
robobun/4e2adf3a/http2-oversized-header-fieldfrom
robobun/45c1534a/http2-push-promise-send-limit
Open

robobun wants to merge 4 commits into
robobun/4e2adf3a/http2-oversized-header-fieldfrom
robobun/45c1534a/http2-push-promise-send-limit

Conversation

@robobun

@robobun robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 server calls stream.pushStream() with a header field over 64 KiB. Bun ends the whole session: GOAWAY code 9, ERR_HTTP2_SESSION_ERROR: Session closed with error code 9 on every stream. Node fails only the push.
  • push_promise() (src/runtime/api/bun/h2_frame_parser.rs) encodes each field as it reads it, with no send limit check. The HPACK encoder refuses the field, and the session ends.
  • The fields encoded before the failure stay in the encoder table. The parent's response then uses an index the peer never received (ERR_HTTP2_ERROR: Protocol error on bun's own client).

Fix

Background

Notes

Where this comes from. No user reported it. It came out of differential tests of node:http2 against node v26.3.0, after #43474 listed pushStream() as not covered.

Measured. Raw frame client, plain TCP, default options. The handler calls pushStream() with a 90000-byte field, then respond() and end("x") in the same tick.

wire server events
node v26.3.0 1 HEADERS, 1 DATA END_STREAM, GOAWAY 0 last=1 (sent twice) callback (null, push 2), parent frameError 5 6 1, push ERR_HTTP2_STREAM_ERROR NGHTTP2_INTERNAL_ERROR with rstCode 2, parent rstCode 0, session close
main (367d939) and the #43474 head 1 HEADERS whose block starts 88 be (index 62, which the peer never received), 1 DATA END_STREAM, 2 HEADERS, 2 DATA END_STREAM for a stream that was never promised, GOAWAY 9 ERR_HTTP2_SESSION_ERROR code 9 on the session, the push and the parent
this PR 1 HEADERS (the same bytes as node), 1 DATA END_STREAM, GOAWAY 0 last=1 the same events as node. frameError has two arguments, as everywhere in bun

What is refused now that main sends. With the option unset: nothing. The rule refuses only a field over 65536 bytes, and that push ends the session on main. A block of many small fields over 64 KiB in total still goes out, although node refuses it (a test pins this, the same choice as #43474 for respond()). With the option set: main ignores it for pushes. A push over the user's limit is now refused, as in node. The bound for PUSH_PROMISE has no priority bytes: 12 + 12 * fields + names + values. Checked against node with an explicit and a default limit of 65536: a 65407-byte value is sent, 65408 is refused. Both sizes are tests, and so are a low limit that refuses a block and a higher limit that sends the same block.

Timing. The callback runs on process.nextTick, as before. frameError follows on the next tick, so it always comes before the parent's close. The reserved stream fails from setImmediate. nghttp2 closes it when the session next sends, which is after the ticks and microtasks of the turn. A callback that answers the push after an await of a settled promise must not throw. Measured on node: respond() from a microtask or a nextTick in the callback returns, respond() from a setImmediate throws ERR_HTTP2_INVALID_STREAM. Bun now does the same, and a test covers the microtask case. In the same-tick case the parent's close comes before the push's error in bun, and after it in node, because bun writes the parent's response at once.

Why the JS guard and not only the native state. The native side closes and frees the reserved stream. Its map entry goes away on a later read, and request() would create a new stream for the id. The reserved stream lives until setImmediate, so the ServerHttp2Stream paths skip the native side for a kNeverAnnounced stream: respond(), additionalHeaders(), _write, _writev, _final, the file response sink, and the state and endAfterHeaders getters. A dropped write calls its callback at once. _final keeps its callback until close() or the failure settles it, so there is no finish (node has none either) and an end() callback gets ERR_HTTP2_STREAM_ERROR, as in node. Http2Stream itself is unchanged: a refused client request is destroyed on the next tick and still emits finish, as in node. The raised limit case (-1) sets the same flag, so it no longer writes HEADERS and DATA for a stream that was never promised before its GOAWAY 9.

A refused push stays like a sent one until it fails. Measured on node: a HEAD or endStream push over the limit reaches the callback with writableEnded === true (and headRequest === true), and its failure emits no aborted. A callback that calls close() or close(NGHTTP2_CANCEL) gets aborted, finish and close with that code, and no error. Bun does the same, and four tests cover it. Node reports the frame error with or without a frameError listener: OnFrameNotSent in v26.3.0 has no listener check, and the wire is the same with no listener anywhere.

The graceful GOAWAY count. Node sends GOAWAY 0 twice: from session.close() after the frame error, and again from Http2Session::Close. Bun sends it once. The tests drop one repeat of the first GOAWAY, so they pass with one or two and still fail with a third. The same scenarios, with the same expected values, pass as a plain script on node v26.3.0 (two GOAWAY frames on the wire) and on bun (one). Two controls do not pass on node on purpose, and their titles say so: they pin the rule that an unset limit refuses only a field over 65536 bytes.

Other cases measured against node. A parent that never responds: RST_STREAM 6 on it, GOAWAY 0, and a stream in flight still completes (a test). A parent that responds later: ERR_HTTP2_STREAM_ERROR NGHTTP2_FRAME_SIZE_ERROR, rstCode 6. respond() now and end() later: HEADERS, an empty DATA END_STREAM, GOAWAY 0, the same wire as node.

Not in this PR

Self-review. A review of the first version (a standalone branch on main with its own HeaderList and a default limit of 65536 for pushes only) asked for a restack: one HeaderList, one limit rule and one frameError close for the three senders, no new cap, and the base named here. This version does that. It also adds the two tests the review asked for: a higher limit sends the block a lower limit refuses, and a real client decodes a second response on the same connection after the refused push.

Suites run with the debug build: test/js/node/http2/h2-conformance.test.ts (101 pass), test/js/node/http2/ (608 pass, 6 skip, and 3 subprocess tests that time out under the load of this container and pass alone), and 261 vendored test/js/node/test/{parallel,sequential}/test-http2-*.js files (all exit 0).


[human-review] gate passed · iteration 0 · 3 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"
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) [478.90ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [187.76ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [90.78ms]
(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) [76.15ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [49.97ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [31.21ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [49.45ms]
(pass) PING (checklist §3.7) > a PING on a non-zero str
... (truncated)

release without fix: 26 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) [6.89ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.25ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [2.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) [1.62ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [1.18ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [0.80ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [1.25ms]
(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 [1.20ms]
(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) [511.11ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [116.40ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [112.44ms]
(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) [92.29ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [110.47ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [34.51ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [76.72ms]
(pass) PING (checklist §3.7) > a PING on a non-zero s
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1406ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[2/126] gen JS modules (bundle-modules)
Preprocess modules (8457ms)
Bundle modules (140ms)
Postprocesss modules (328ms)
Bundle Functions (690ms)
Generate Code (46ms)

[9.68s] Bundled "src/js" for production
  2608 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/8] 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`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:
... (truncated)
diff hotspot
src/js/node/http2.ts                      |  51 ++++-
 src/runtime/api/bun/h2_frame_parser.rs    |  76 +++++---
 test/js/node/http2/h2-conformance.test.ts | 307 ++++++++++++++++++++++++++++++
 3 files changed, 395 insertions(+), 39 deletions(-)

gate history · 3 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/js/node/http2.ts                          21     19     42
src/runtime/api/bun/h2_frame_parser.rs        20     16     42
test/js/node/http2/h2-conformance.test.ts      7      7     42

…er the send limit

push_promise() encoded each field as it read it and had no send limit check.
A field over 64 KiB failed in the HPACK encoder and ended the whole session
with COMPRESSION_ERROR. The fields encoded before the failure stayed in the
encoder table, so the parent response could carry an index the peer never
received.

push_promise() now stages the block in HeaderList and checks it with
over_send_limit(), the rule request() already applies, before anything
reaches the encoder. For a refused block it closes the reserved stream
without an RST_STREAM and reports it to pushStream(). pushStream() then
does what node does: the callback still gets the reserved stream, the
parent gets 'frameError' (PUSH_PROMISE, FRAME_SIZE_ERROR) and the graceful
close, and the reserved stream fails with INTERNAL_ERROR from setImmediate.
A stream the peer never saw announced sends no frames.
@robobun

robobun commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the fix is pushed and waits for a maintainer review. This PR is stacked on #43474 (which is on #43440).

CI on 2e332fe (build 119447). The build is finished: 180 jobs passed, with the test lanes of every platform among them. The one red job is binary-size, and this diff does not cause it. It reports FreeBSD x64 88.41 MB and aarch64 89.79 MB against main's canary (84.91 MB and 85.94 MB). Main had 88.40 MB and 89.79 MB until #43650 landed (main builds 118825 and 119213), and the base of this stack is older than #43650. Every other target is within 0.32 MB of main. The check passes again when the stack is rebased on a main that has #43650. Four tests failed once and passed on the retry (bun-lock, cli/bun, webview-chrome, update_interactive_formatting). None of them touches node:http2.

How to reproduce. Start an http2.createServer() whose stream handler runs:

stream.pushStream({ ":path": "/pushed", "x-big": Buffer.alloc(90000, "A").toString() }, (err, push) => push.on("error", () => {}));
stream.respond({ ":status": 200 });
stream.end("x");

Send one GET with a raw frame client (or http2.connect()).

  • node v26.3.0: HEADERS and DATA END_STREAM on stream 1, then GOAWAY code 0. The parent stream gets frameError(5, 6, 1). The pushed stream closes with NGHTTP2_INTERNAL_ERROR.
  • bun main (367d939) and the node:http2: refuse a header block over the default send limit, like node #43474 head: GOAWAY code 9 and ERR_HTTP2_SESSION_ERROR: Session closed with error code 9 on every stream. A node:http2 client fails with ERR_HTTP2_ERROR: Protocol error.
  • this PR: the same wire and the same events as node.

The tests are in test/js/node/http2/h2-conformance.test.ts, describe a PUSH_PROMISE over the send limit.

@robobun

robobun commented Sep 20, 2026 •

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

❌ @robobun, your commit 2e332fe has 1 failures in Build #119447 (All Failures):

  • 📦 Binary size — 2 over 0.50 MB
  • targetthis build canary: main #119427
    sizeΔ
    bun-darwin-aarch6460.61 MB60.64 MB-32.4 KB
    bun-darwin-x6466.34 MB66.39 MB-48.4 KB
    bun-linux-aarch6476.55 MB76.55 MB+0.0 KB
    bun-linux-x6476.97 MB77.03 MB-60.0 KB
    bun-linux-aarch64-musl69.51 MB69.57 MB-64.0 KB
    bun-linux-x64-musl70.95 MB71.00 MB-56.0 KB
    bun-linux-aarch64-android83.22 MB82.91 MB+310.6 KB
    bun-linux-x64-android86.28 MB86.20 MB+88.4 KB
    ❌ bun-freebsd-x6488.41 MB84.91 MB+3.49 MB
    ❌ bun-freebsd-aarch6489.79 MB85.94 MB+3.85 MB
    bun-windows-x6483.62 MB83.69 MB-66.5 KB
    bun-windows-aarch6472.68 MB72.50 MB+189.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 43619

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

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

Beyond the inline findings (all in the http2.ts layer), I also checked the Rust side of src/runtime/api/bun/h2_frame_parser.rs: the over_send_limit extraction preserves the request() semantics exactly (same two match arms, StreamPriority::BYTE_SIZE still counted for HEADERS, 0 for PUSH_PROMISE), and the refusal path returns before any field reaches encode_header_into_list, so the HPACK dynamic table stays in sync with the peer; the enter_stream_dispatch guard is scoped to the if let block and drops before the early return.

Extended reasoning...

Inline findings were reported, so this is the brief ruled-out note only. The Rust diff was read in full: the staged HeaderList replaces the incremental encode loop, the new helper is a verbatim lift of the prior request() check, and the -1 compression-error path is unchanged in behaviour (now iterating staged.iter() instead of encoding inline). The confirmed findings concern the TS-side lifecycle of the kNeverAnnounced stream (respondWithFD sink bypassing nativeForStreamFrames, missing _final callback, rstCode overwrite on close, unconditional onServerFrameError, and the missing end() for endStream/HEAD pushes) and warrant human attention before merge.

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

  • 🟡 src/js/node/http2.ts — A server that answers a refused push with respondWithFD() can crash with an uncaught 'Invalid stream id' Error thrown from the file sink. The sink at src/js/node/http2.ts:3086 reads session[bunHTTP2Native] directly, not nativeForStreamFrames, and its only guard is stream.destroyed || stream.closed at http2.ts:3087. Between the callback's nextTick and failUnannouncedPush's setImmediate the native entry for the kNeverAnnounced id can be evicted by a socket read, and native.writeStream at http2.ts:3091 then throws. Fix: route the sink's write (and final) through nativeForStreamFrames so a never-announced stream drops the chunk, matching _write/_writev. [also at: src/js/node/http2.ts:2468 - User code that reads pushedStream.state (or endAfterHeaders) after an await inside the pushStream callback gets a bare Error 'Invalid stream id' instead of node's state object.]

    Extended reasoning...

    The finder settled this by argument (basis argued, 5 finders). push_promise refuses the block and calls free_resources:: on the promised stream (h2_frame_parser.rs:6284), which queues the id in pending_engine_stream_closes. The next rewrite_read at dispatch depth 0 removes the map entry (h2_frame_parser.rs:3695-3708). pushStream delivers the stream on nextTick (http2.ts:3307) and destroys it only on setImmediate (http2.ts:3305). In the callback the user calls push.respondWithFD(fd, headers). respond() is silently skipped (headersSent = true), closeWritableForFileResponse runs, then fs.fstat completes on the threadpool and doSendFileFD creates the read stream and pipes it into the sink (http2.ts:3112). If a client frame (WINDOW_UPDATE, PING, a pipelined request) arrives in that window the entry is evicted; a fast page-cache read then reaches sink.write before the immediate: stream.destroyed is false, native.writeStream(stream.id) hits the missing-entry branch (h2_frame_parser.rs:5970-5971) and throws 'Invalid…

    Verification: normal (narrow timing race, but the outcome is an uncaught exception that kills the server process). Trigger: a refused pushStream() whose callback answers with respondWithFD()/respondWithFile(), when a depth-0 socket read on that session lands before the failUnannouncedPush immediate runs (e.g. pushStream called from another socket's callback or from a setImmediate, so the client socket is…

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
A HEAD or endStream push over the send limit now reaches the callback with
its writable ended, as a sent push does, so its failure is not an abort.
A refused push that the callback closes keeps the code of that close()
and ends without an 'error'. Both match node v26.3.0.

The write guards for a never-announced stream move from Http2Stream into
the ServerHttp2Stream overrides. A refused client request is destroyed on
the next tick and needs none, and it emits 'finish' again as on the base.
The file response sink and the state getters also skip the native entry
of a never-announced stream, which can be gone before the stream fails.
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
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 20, 2026

Copy link
Copy Markdown
Collaborator Author

Reply to the review finding outside the diff (the respondWithFD() sink and the state getter), and a summary of the inline threads.

The file sink and the getters: fixed in 6f882df. The race is narrow. It needs two thread pool completions and a read on the same session before the setImmediate that fails the push. The guard costs one condition, so it is in:

  • The sink in doSendFileFD() now drops the chunk for a kNeverAnnounced stream, as it does for a destroyed or closed one. Its final goes through ServerHttp2Stream._final, which has the same guard.
  • stream.state returns {} and stream.endAfterHeaders returns false for a kNeverAnnounced stream, so neither asks the native side for an entry that can be gone.

I have no deterministic test for the race. respondWithFile() and respondWithFD() on a refused push normally reach doSendFileFD() after the stream is destroyed, so a test passes with and without the guard.

Inline threads. All measured against node v26.3.0 with a raw frame client.

  • HEAD and endStream pushes: correct, fixed, two tests.
  • close() in the callback: correct, fixed, two tests.
  • _final on a never-announced stream: correct for a refused client request (it lost finish), fixed by moving the guards into the ServerHttp2Stream overrides. Not correct for the push: node emits no finish there and calls the end() callback with ERR_HTTP2_STREAM_ERROR, and bun does the same.
  • The frameError listener gate: not in node v26.3.0. OnFrameNotSent has no listener check (node_http2.cc#L1219-L1260), and node closes the parent and the session with no listener anywhere. No change.

The multi-line comments that the comment check flagged are one line each now (c550f99).

@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 two follow-up commits and found no new bugs; because this changes the PUSH_PROMISE path in both the Rust frame parser and the node:http2 stream state machine, a human look is still worthwhile.

What was reviewed:

  • The follow-ups fold the refused push into the shared Delivered/HEAD/endStream path and keep a user-closed stream's rstCode in failUnannouncedPush; traced close()/destroy() through markStreamClosed -> markWritableDone, which is what drains the callback the new ServerHttp2Stream._final stashes for a never-announced stream.
  • Every remaining native entry point on a kNeverAnnounced server stream (respond, additionalHeaders, _write/_writev, the doSendFileFD sink, state/endAfterHeaders, _destroy's rstStream, sendTrailers via the WantTrailer gate) is guarded, so nothing is written for an id the peer never saw; setStreamContext(id, undefined) after native free_resources only drops the sctx root, so the double release is benign.
  • over_send_limit() is a faithful extraction of the request() rule (priority bytes for HEADERS, none for PUSH_PROMISE), and the staged block never reaches the HPACK encoder on refusal, which the client-decodes-after-refusal test pins.
  • The one design point from the earlier round the author chose to keep as-is is closing the parent and session regardless of whether a frameError listener exists; that divergence from node's listener-count gate is pre-existing at the HEADERS site and is a judgment call for a maintainer.
Extended reasoning...

Overview

The PR touches three files: src/runtime/api/bun/h2_frame_parser.rs (a new over_send_limit() helper shared by request() and push_promise(), staging of the PUSH_PROMISE block in HeaderList before encoding, and a -2 sentinel that closes the promised native stream without RST_STREAM), src/js/node/http2.ts (the kPushPromiseOverSendLimit branch in pushStream(), failUnannouncedPush scheduled on setImmediate, onServerFrameError shared with the native frameError handler, and kNeverAnnounced guards on every path that would otherwise write a frame for the reserved id), and ~300 lines of new tests in test/js/node/http2/h2-conformance.test.ts using the raw-frame client. Since my previous review, two follow-up commits removed the nativeForStreamFrames helper in favor of explicit guards, moved the refused branch so it shares the Delivered/HEAD/endStream handling with a sent push, made failUnannouncedPush respect a stream user code already closed, and added tests for the HEAD/endStream and close()/close(CANCEL) cases.

Security risks

No auth, crypto, or permission surface is involved. The input to the new native code is header data supplied by the server application, not by the peer; the staging path uses try_reserve and maps allocation failure to a thrown JS error rather than unwinding. The refusal path frees the promised stream's resources via the same free_resources::<false> used by the client-side refusal in request(), and the JS side's kReleaseUnannouncedStream only removes the sctx root, so there is no double free. The change reduces a peer-observable failure mode (a session-wide GOAWAY plus desynchronized HPACK tables) rather than adding one.

Level of scrutiny

High. This is protocol state-machine code split across Rust and TypeScript where a missed guard means writing a frame on a stream id the peer considers idle, which is a connection error. I traced every native call site reachable from a ServerHttp2Stream (respond, additionalHeaders, _write, _writev, _final, the doSendFileFD sink, sendTrailers, _destroy's deferred rstStream, the state and endAfterHeaders getters) and each is gated on kNeverAnnounced or on a state flag that a never-announced stream cannot reach. The _final override stashes the callback in bunHTTP2StreamFinal, which markStreamClosed drains from both close() and _destroy, so end() does settle once the stream is closed or destroyed. Because the timing semantics (nextTick callback, nextTick frameError, setImmediate failure) are asserted against node measurements the PR author reports rather than something I can reproduce here, a maintainer familiar with the http2 compat layer should still confirm the ordering is the one they want to pin.

Other factors

The bug hunt ran to a dry streak with no findings. Two of my four earlier inline points were addressed by the follow-ups (closed-stream rstCode; HEAD/endStream on the refused path), the _final point was reworked into the stash-and-drain shape, and the frameError listener-count gating point was resolved by the author without a code change; that last one is a compatibility judgment rather than a defect, and it applies equally to the pre-existing HEADERS refusal site, so I did not re-raise it inline. The new tests cover exact limit boundaries on both sides (65536/65537-byte field, at/one-over a user limit), both HEAD and endStream, close()/close(CANCEL), an unanswered parent with a stream in flight, and a real node:http2 client decoding responses after the refusal, which is the observable consequence of keeping the refused block out of the encoder. The tests use try/finally rather than using for server cleanup, which is consistent with the rest of that file.

@robobun

robobun commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the summary above, for the maintainer who reads it: the close with no frameError listener is not a divergence from node.

Node v26.3.0 has no listener-count gate. OnFrameNotSent returns early only for NGHTTP2_ERR_SESSION_CLOSING, NGHTTP2_ERR_STREAM_CLOSED and NGHTTP2_ERR_STREAM_CLOSING (node_http2.cc#L1219-L1260). I ran the refused push on node v26.3.0 with no frameError listener on the stream, the session or the server. Node still sends GOAWAY 0 and closes the session, and it still closes a parent that is open with NGHTTP2_FRAME_SIZE_ERROR. This PR does the same.

The review threads are resolved. The PR waits for CI and a maintainer review.

@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

A note for when this stack merges main. main (4af1842) now typechecks src/js in lint.yml (#43649). I tried the merge of the whole stack (the #43632 head) with main locally. There is one conflict, in #43440's part of http2.ts. With it resolved, bun x tsc --noEmit -p src/js/tsconfig.json reports one error, and it comes from 6f882df in this PR:

src/js/node/http2.ts(3388,26): error TS2345: Argument of type 'ClientHttp2Session | ServerHttp2Session' is not assignable to parameter of type 'ServerHttp2Session'.

The line is onServerFrameError(session, this, kFrameTypePushPromise, constants.NGHTTP2_FRAME_SIZE_ERROR); in pushStream(). The same check passes for the stack up to #43474, so this is the only one. To reproduce it: merge main, run bun install --frozen-lockfile and bun run build:types, then the tsc command above.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun Node v26.3.0 sends the graceful GOAWAY(NO_ERROR, last=3) twice here: once from session.close() after the frameError (lib/internal/http2/core.js:1639) and again from Http2Session::Close calling nghttp2_session_terminate_session (src/node_http2.cc:828). The test "an unanswered parent is reset and a stream in flight still completes" expects exactly one GOAWAY, so it fails on Node; please accept one or two so it passes on Node and Bun.
Repro: server pushStream() with a 90000-byte header while another stream is still in flight, raw client logs frames: Node prints 3 RST_STREAM 6, GOAWAY code=0 last=3, GOAWAY code=0 last=3.

Node sends the graceful GOAWAY twice: from session.close() after the frame
error, and again from Http2Session::Close. Bun sends it once. The tests now
drop one repeat of the first GOAWAY, so they pass with one or two and still
fail with a third. The control for a field of exactly 65536 bytes says in
its title that node refuses that block.
@robobun

robobun commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari Done in 2e332fe. The tests now accept one or two graceful GOAWAY frames.

What changed. The block has a withoutRepeatedGoaway() helper. It drops one repeat of the first GOAWAY, so a run with one GOAWAY (bun) and a run with two (node) give the same list, and a third GOAWAY still fails. Every raw frame test in the block gets its frames through it, not only the unanswered parent test. The node:http2 client test uses it for the goaway events, because a node client also sees the event twice.

Checked on both runtimes. I ran the same two scenarios, with the same helper and the same expected values, as a plain script on node v26.3.0 and on the debug build:

node: both scenarios match the test expectations (GOAWAY frames on the wire in the second: 2)
bun: both scenarios match the test expectations (GOAWAY frames on the wire in the second: 1)

The second scenario is the unanswered parent with a stream in flight. Node gives 3 RST_STREAM 6, GOAWAY 0 last=3, then the second GOAWAY 0 last=3 after stream 1 ends, as in your repro. The block passes on the debug build (16 pass), and so does the whole file (101 pass).

Two controls do not pass on node, on purpose. Their titles say so:

  • a field of exactly 65536 bytes, the most bun encodes (node refuses it) is pushed
  • a block of small fields over 64 KiB with the option unset (node refuses it) is pushed

They pin the limit rule from #43474: with maxSendHeaderBlockLength unset, bun refuses only a field that its encoder cannot emit, so no block that main sends today is refused. Node applies its default of 65536 to the whole block and refuses both. If you prefer node's default for the whole block, it is one arm of over_send_limit(), and it then applies to respond() and pushStream() together. I can change it here or in #43474.

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

Re-reviewed the latest push; no new bugs found, and the follow-up commits address what I raised earlier. Since this changes native stream teardown in the frame parser plus the server-side push state machine, a maintainer look is still worthwhile.

What was reviewed this round:

  • The refused-push path now falls through to the HEAD/endStream end() and Delivered marking, and failUnannouncedPush skips the rstCode overwrite for a stream user code already closed.
  • The _final callback a never-announced push parks in bunHTTP2StreamFinal is drained by markStreamClosed -> markWritableDone from both close() and _destroy, so finish/end(cb) settle.
  • Native: push_promise() closes and free_resources::<false>'s the promised stream the same way request()'s client refusal does; every JS path that could reach native for that id (respond, additionalHeaders, writeStream, rstStream, getStreamState, getEndAfterHeaders, the sendfile sink) is gated on kNeverAnnounced, and the redundant sctx remove in kReleaseUnannouncedStream is a no-op after the native free.
Extended reasoning...

The diff touches src/runtime/api/bun/h2_frame_parser.rs (a shared over_send_limit helper, push_promise staging headers into HeaderList before encoding, and a -2 sentinel returned after closing the reserved stream without RST_STREAM), src/js/node/http2.ts (pushStream handling of the sentinel, kNeverAnnounced gates on several ServerHttp2Stream paths, a shared onServerFrameError helper, and failUnannouncedPush), and 16 new tests in test/js/node/http2/h2-conformance.test.ts. No auth, crypto, or injection surface is involved; the security-relevant part is peer/user-sized header handling, which is bounds-checked before the HPACK encoder runs. The change is moderately complex, involves raw-pointer stream teardown in native code and cross-layer ordering (nextTick/setImmediate) that mirrors nghttp2, and a maintainer (cirospaciari) commented on the previous revision, so approval is left to a human even though this run found no defects and the prior inline findings appear addressed in commits 6f882df and c550f99.

@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

A heads-up for the next merge of the base branch (#43474, now at e7d15ae). A review asked for Node's default there, so two places that this PR also touches changed:

  1. over_send_limit() now exists in node:http2: refuse a header block over the default send limit, like node #43474 with the same name, signature and place as here, so the call sites stay as they are. Its body is different: an unset option means nghttp2's default of 65536 for the whole block, and HeaderList::has_field_over() is gone. In the conflict, take the body from node:http2: refuse a header block over the default send limit, like node #43474:
    let limit = match self.max_send_header_block_length.get() {
        0 => NGHTTP2_MAX_HEADERSLEN,
        limit => limit as usize,
    };
    staged.deflate_bound() + frame_bytes > limit
    For pushStream() and sendTrailers() this means the default limit also applies to the whole block, as in Node. Tests here that pin "a block of many small fields over 64 KiB is sent" need a new look.
  2. additionalHeaders() in http2.ts now calls request(this.id, undefined, headers, sensitiveNames, undefined, true). The last argument marks the block as informational (HeaderList::is_informational() is gone). This PR puts if (!this[kNeverAnnounced]) in front of the same line, so keep both.

The closed-stream check that 1efae9f added to the native request() is removed again in e7d15ae. The JS fence on kNeverAnnounced here does not depend on it, as far as I can read.

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