Skip to content

node:http2: refuse END_STREAM on a 1xx HEADERS block - #43575

Open
robobun wants to merge 2 commits into
mainfrom
robobun/f7af6987/h2-client-final-response-before-data
Open

robobun wants to merge 2 commits into
mainfrom
robobun/f7af6987/h2-client-final-response-before-data

Conversation

@robobun

@robobun robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • The node:http2 client accepts a response that ends on a 1xx header block. HEADERS :status: 100 with END_STREAM gives headers 100, end, rstCode=0: a clean end with no 'response' event. node v26.3.0 sends RST_STREAM(PROTOCOL_ERROR), and the stream fails with ERR_HTTP2_STREAM_ERROR.
  • Cause: finish_header_block in src/runtime/api/bun/h2/connection.rs uses its informational flag only to keep the next block from being read as trailers. Nothing rejects END_STREAM on the 1xx block.

Fix

  • finish_header_block marks a 1xx block that carries END_STREAM as malformed. The existing malformed path resets the stream with PROTOCOL_ERROR and does not deliver the block. HEADERS on a promised stream use the same code, so a pushed response follows the rule too.
  • Correct because RFC 9113 §8.1 requires a final HEADERS block, and §8.1.1 makes a malformed response a stream error. nghttp2 has the rule (nghttp2_http_on_remote_end_stream with NGHTTP2_HTTP_FLAG_EXPECT_FINAL_RESPONSE), and so does Bun's fetch HTTP/2 client (src/http/h2_client/dispatch.rs).
  • Verified: test/js/node/http2/h2-conformance.test.ts (4 new tests, 2 fail on 1.4.3). Also test/js/node/http2/ and the 261 test-http2-* node tests.
  • Self-reviewed: 10 concerns raised, 6 addressed. The other 4 are existing gaps that open PRs own, or nits (Notes).

Background

  • Connection is the inbound HTTP/2 engine. It parses frames and calls JS through a Sink.
  • A response is zero or more interim (1xx) HEADERS, one final HEADERS, DATA, then optional trailers.
  • END_STREAM is the frame flag that closes the sender's half of a stream.
  • A stream error resets one stream with RST_STREAM. The session continues.
Notes

Repro. A raw TCP HTTP/2 server answers stream 1, because respond() cannot produce this shape. Client events, then the frames the client sent:

response on the wire node v26.3.0 bun 1.4.3 this PR
HEADERS 100 + END_STREAM error ERR_HTTP2_STREAM_ERROR, rstCode=1, RST_STREAM(1) headers 100, end, rstCode=0 same as node
HEADERS 103 + END_STREAM same headers 103, end, rstCode=0 same as node
the same on a pushed stream (HEADERS on the promised id) same, the request on stream 1 completes headers 100, end, rstCode=0 same as node
HEADERS 100, HEADERS 200 + END_STREAM delivered delivered delivered
HEADERS 100, HEADERS 200, DATA + END_STREAM delivered delivered delivered

The other half of the report. The same report covered DATA that arrives before the final HEADERS (none yet, or only 1xx). #43558 owns that. It needs a server fix, because Bun's server writes an empty END_STREAM DATA frame when a stream closes before respond(). An earlier version of this branch had a DATA check too. A Bun client on a Bun server then got ERR_HTTP2_STREAM_ERROR from for await (const chunk of req) where 1.4.3 and node-to-node give a clean end. This PR does not touch the DATA path. The two PRs change different functions in connection.rs and add their test blocks at different places.

Can Bun's own server send this shape? Not through valid API use. additionalHeaders(), writeContinue(), writeEarlyHints() and the automatic 100 Continue send the 1xx block with no options, so it never carries END_STREAM. Bun.serve HTTP/2 sends its 100 Continue the same way. Two misuses can: respond() or writeHead() with a 1xx status (node throws ERR_HTTP2_STATUS_INVALID, #43453 adds that check), and a Bun.serve HTTP/2 Response with status 101. A node client rejects both. Bun-to-Bun flows that send a 1xx block and then respond, end, close or destroy give the same client events as 1.4.3 (checked with the core and the compat API, with and without 'error' listeners).

Invalid-frame budget. The malformed path counts the block against maxSessionInvalidFrames (default 1000) and maxSessionRejectedStreams (default 100), like every other malformed block on main. So the 100th such response on one session makes the client send GOAWAY(ENHANCE_YOUR_CALM), and the session ends. node counts only an invalid frame for it (OnInvalidFrame with NGHTTP2_ERR_HTTP_MESSAGING), so its limit is 1000. main has this difference for every malformed response block, and this PR does not change it.

Self-review. Addressed: the Bun-to-Bun regression and the overlap with #43558 (the DATA check was removed), three misleading comments, a test that hung instead of failing without the fix, an imprecise test comment, and a test without a session 'error' listener. Not addressed:

Seen during the work, not changed here.

Overlap. #33191 and a pending change to the :status value check edit finish_header_block near the informational lines. This PR adds four lines after the content-length block and does not change how informational is computed.

Tests. describe("END_STREAM on a 1xx HEADERS block (RFC 9113 §8.1)") drives the client with RawH2Server, on a request and on a pushed stream. Each case ends with a PING round trip, so the RST_STREAM is on the wire before the assertion and a dead session fails the test. The two control cases (1xx, then a final HEADERS with END_STREAM) pass with and without the fix.

Suites run with the debug build. h2-conformance.test.ts: 74 pass. test/js/node/http2/: 580 pass, 6 skip. The 261 vendored test-http2-* node tests pass.


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

fails on main (without fix)
ASAN without fix: 3 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) [623.17ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [218.69ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [147.87ms]
(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) [71.39ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [62.12ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [55.07ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [64.64ms]
(pass) PING (checklist §3.7) > a PING on a non-zero st
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (36ab0742b)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [12.73ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.91ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [2.99ms]
(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.31ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [1.07ms]
(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.96ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [0.84ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [0.91ms]
(pass) WINDOW_UPDAT
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.3 (367d939d9)

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

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 898ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/128] gen ErrorCode+*.h
[2/128] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 245 extern-C blocks audited
[3/128] gen cpp.rs (cppbind)
[4/128] gen JS modules (bundle-modules)
Preprocess modules (13112ms)
Bundle modules (71ms)
Postprocesss modules (163ms)
Bundle Functions (631ms)
Generate Code (47ms)

[14.03s] Bundled "src/js" for production
  2607 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/12] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  
... (truncated)
diff hotspot
src/runtime/api/bun/h2/connection.rs      |  4 ++
 test/js/node/http2/h2-conformance.test.ts | 92 +++++++++++++++++++++++++++++++
 2 files changed, 96 insertions(+)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                       reads  edits  tests
src/runtime/api/bun/h2/connection.rs          15     20     31
test/js/node/http2/h2-conformance.test.ts      7      4     31

An interim (1xx) header block is not the response, so a stream cannot end
on it. The client engine now treats a 1xx HEADERS frame that carries
END_STREAM as a malformed block: it resets the stream with PROTOCOL_ERROR
and does not deliver the block. nghttp2 applies the same rule
(nghttp2_http_on_remote_end_stream with EXPECT_FINAL_RESPONSE set).
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for one more day.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 19 minutes for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a332f936-6392-490c-bff4-25def6169ced

📥 Commits

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

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

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

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:53 PM PT - Sep 19th, 2026

✅ @robobun, your commit d3bdb07decd86a8b1171fbe71cc3f3f928c0d062 passed in Build #118594! 🎉


🧪   To try this PR locally:

bunx bun-pr 43575

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

bun-43575 --bun

@robobun

robobun commented Sep 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed in this PR (#43575).

How I reproduced it: a raw TCP HTTP/2 server answers the request on stream 1 with one HEADERS frame, :status: 100 with END_HEADERS and END_STREAM (HPACK bytes 08 03 31 30 30). The client is http2.connect() plus request({ ":path": "/" }) with listeners for headers, response, end, error and close.

  • node v26.3.0: error ERR_HTTP2_STREAM_ERROR, rstCode=1, and RST_STREAM(PROTOCOL_ERROR) on the wire.
  • bun 1.4.3: headers 100, end, rstCode=0, and no RST_STREAM.
  • this branch: same as node. The same holds for HEADERS on a promised (pushed) stream.

The new tests in test/js/node/http2/h2-conformance.test.ts run this shape on a request and on a pushed stream. Two of the four fail on 1.4.3. The other two are controls for a valid 1xx then final HEADERS sequence.

DATA that arrives before the final HEADERS is the other half of the same report. #43558 covers it.

CI (build #118594): 179 of 181 jobs passed, 2 are still queued, and no job failed. Four tests failed once and passed on a retry, all outside this diff: test/bake/deinitialization.test.ts (alpine aarch64), test/cli/hot/hot.test.ts (Windows x64), test/js/node/test/parallel/test-watch-mode-kill-signal-default.mjs (alpine x64) and test/integration/next-pages/test/dev-server-ssr-100.test.ts (Windows x64). h2-conformance.test.ts is not among them. The one review thread is answered and resolved. The PR is ready for a maintainer.

@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, I also checked two interactions of the new informational && end_stream check in finish_header_block (src/runtime/api/bun/h2/connection.rs:1279): on the server side an inbound :status already sets malformed via wrong_direction, so request handling is unchanged; and informational is only computed for Deliver blocks, so the Refused/StreamClosed early returns and the rejected ENHANCE_YOUR_CALM path keep their existing precedence over the new PROTOCOL_ERROR reset.

Extended reasoning...

The diff is a four-line control-flow addition plus a conformance test block. I traced the informational flag from its assignment at connection.rs:1180 (inside the disposition == Deliver branch of the decode loop) to the new check at :1279 and to the existing malformed && !rejected / rejected branches. Server-side inbound :status is already marked malformed by wrong_direction at :1157, so the change is effectively client-only. Header-list-size rejection (rejected) still wins at :1303 because the malformed branch is gated on !rejected. The Bun-to-Bun respond({':status': 100}, {endStream: true}) case was investigated in a prior pass and is consistent with node's behaviour. The three confirmed inline findings (DATA-after-1xx gap, invalid-frame budget parity, double RST_STREAM on pushed streams) mean a human should still weigh in on scope before merging.

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

  • 🟡 src/runtime/api/bun/h2/connection.rs — A long-lived client session to a server that repeatedly answers with a 1xx block plus END_STREAM is torn down with GOAWAY ENHANCE_YOUR_CALM after 100 such responses, failing every other in-flight stream on that session; node keeps the session alive until 1000 invalid frames. The new malformed route at connection.rs:1279 reaches sink.on_stream_rejected at connection.rs:1300, and h2_frame_parser.rs:4153-4165 counts it against maxSessionRejectedStreams (default 100) and sends GOAWAY at the limit. Fix: on the client, a malformed response block should count only against maxSessionInvalidFrames (connection.rs:1286) as node does, not against the rejected-streams budget, or the PR must document the session-level consequence.

    Extended reasoning...

    A client opens one session and issues many requests. The server answers each with HEADERS :status 100 carrying END_STREAM. Each block sets malformed at connection.rs:1279. connection.rs:1286-1288 increments invalid_frame_count (node parity). connection.rs:1295-1300 then sends RST_STREAM(PROTOCOL_ERROR) and calls sink.on_stream_rejected. h2_frame_parser.rs:4156 increments rejected_streams and h2_frame_parser.rs:4157 compares against max_rejected_streams; at 100 it calls send_go_away with ENHANCE_YOUR_CALM, destroying the whole session. In node, Http2Session::OnInvalidFrame only bumps the invalid-frames counter (limit 1000); maxSessionRejectedStreams counts only memory-refused streams from OnBeginHeaders. The same accounting exists on the base for other malformed blocks, but this PR adds a response shape that a misconfigured proxy can emit for every request, so a busy session reaches the cap at request rate. Remedy: skip on_stream_rejected for client-side malformed response blocks, or raise the check to match node's counters.

    Verification: nit (the budget divergence itself lives in untouched code; this PR adds one more input class to it) — acknowledged in diff: the PR description's "Invalid-frame budget" note says the block is counted against maxSessionInvalidFrames and maxSessionRejectedStreams "like every other malformed block" and that "node counts it as an invalid frame too"; the first half is accurate for Bun, but the…

  • 🟣 src/runtime/api/bun/h2/connection.rs — Every pushed response that ends on a 1xx block now makes the Bun client write two RST_STREAM frames for the promised stream where node writes one. The engine sends RST_STREAM at connection.rs:1295 and then dispatches on_stream_reset at connection.rs:1299, and the JS pushed-stream teardown submits its own RST_STREAM again. Fix: the engine-initiated reset must mark the stream so the JS close path does not resubmit RST_STREAM, for every malformed pushed block, not only this input.

    Extended reasoning...

    A server pushes stream 2 via PUSH_PROMISE and then answers it with HEADERS :status 100 carrying END_STREAM. finish_header_block runs with push_parent None for the HEADERS on stream 2, sets malformed at connection.rs:1279, sends RST_STREAM(PROTOCOL_ERROR) at connection.rs:1295, marks the stream Closed at 1297, and calls sink.on_stream_reset at 1299. h2_frame_parser.rs:4168 marks the legacy stream CLOSED with rst_code and dispatches onStreamError; the JS ClientHttp2Session streamError handler (http2.ts:5033) defers emitStreamErrorNT, which destroys the pushed stream, and the pushed-stream destroy path submits a second RST_STREAM for stream 2. The wire then carries two RST_STREAM frames for one stream; node sends exactly one. The PR text acknowledges this. A strict peer that treats RST_STREAM after its own END_STREAM as a stream error, or a conformance harness counting frames, observes the extra frame per malformed push. Rate: once per malformed pushed response. Remedy: have the JS reset path skip submitting RST_STREAM when the native stream already carries an engine-set rst_code.

    Verification: pre-existing (nit). acknowledged in diff: PR description "Seen during the work, not changed here: The client sends RST_STREAM twice when the engine resets a pushed stream. Any malformed pushed response does this on main. node sends one." — that bound is accurate; this PR routes a new input class (1xx + END_STREAM on a promised stream, which base delivered with 0 RSTs) into the same untouched…

Comment thread src/runtime/api/bun/h2/connection.rs
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Replies to the two findings outside the diff.

Rejected-streams budget. Correct. The malformed path calls on_stream_rejected, so each 1xx block with END_STREAM also counts against maxSessionRejectedStreams (default 100). The 100th one on a session makes the client send GOAWAY(ENHANCE_YOUR_CALM). node counts only an invalid frame there, so its limit is 1000. main does the same for every malformed response block, for example a response with a connection field, so I did not make this one shape a special case. The right change is a separate one: a malformed response on a client counts only against maxSessionInvalidFrames. It touches on_stream_rejected in h2_frame_parser.rs, which the server path shares. The PR notes now state the session-level consequence under "Invalid-frame budget".

Two RST_STREAM frames on a pushed stream. Correct. main does this for every engine reset of a pushed stream. #43539 fixes it in Http2Stream#_destroy: no deferred rstStream after the native layer closed the stream. The notes now link it.

The inline finding (DATA after only a 1xx block) has its reply in the thread. #43558 owns that half, with the server fix it needs.

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.

1 participant