Repository navigation
Conversation
The inbound engine has no entry for a stream that the embedder opened. When the first DATA frame arrives on such a stream, a shim enters it as open. On a server the only such streams are pushed streams. A pushed stream is reserved (local), then half-closed (remote), for the server, so it never receives DATA. The shim now enters the stream as half-closed (remote) on a server. The engine then answers with RST_STREAM(STREAM_CLOSED), as it does for DATA after END_STREAM on a request stream.
|
Warning Review paused — included plan limit reachedKeep your review moving with free on-demand reviews.
On-demand reviews are free for one more day.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Promotion and pricing detailsOn-demand reviews are free for one more day. After that, they cost $0.25 per reviewed file. Review limit detailsOr wait 53 seconds for your next included review. Limit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Repository: oven-sh/bun/.coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (3)
Comment |
|
Status: the fix is pushed. CI is running. How I reproduced it: a raw TCP HTTP/2 client connects to
The 4 new cases in |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/runtime/api/bun/h2/connection.rs— A server whose pushStream() failed header validation can have its session stream count driven below zero by a client DATA frame, so session.close() never completes. On the base the DATA was delivered and dropped; after this change the shim at connection.rs:1502-1508 enters the never-announced even id as HalfClosedRemote and connection.rs:1536 calls on_stream_reset, which reaches streamError in http2.ts:4034-4039 and decrements #connections a second time after kReleaseUnannouncedStream at http2.ts:4416-4420 already did. Fix: kReleaseUnannouncedStream must also free the legacy stream entry (or on_stream_reset must skip streams already released) so no second streamError can fire for that id.Extended reasoning...
Server code calls pushStream with a header the encoder rejects. http2.ts:3278 calls kReleaseUnannouncedStream, which decrements #connections and clears only sctx via set_stream_context at h2_frame_parser.rs:6305-6309. The legacy entry in the embedder map stays, with its js identifier. A client sends DATA on that even id. is_local_stream at h2_frame_parser.rs:3945-3949 returns true because the legacy entry exists. The shim inserts HalfClosedRemote. can_receive_data fails. connection.rs:1536 calls on_stream_reset. rewrite_stream_ctx at h2_frame_parser.rs:3511-3513 falls back to the legacy entry's identifier, the destroyed JS stream. streamError at http2.ts:4036 decrements #connections again. Net effect: #connections is one lower than the number of live streams. session.close() waits for #connections === 0 (http2.ts:4085, 4178, 4265); with the count at -1 after all streams end, it never fires, so graceful close hangs until the socket dies. The dismissing finders noted RST_STREAM and WINDOW_UPDATE(0) already reach this on the base. DATA is a new route added by this diff, and a…
Verification: pre-existing / nit — acknowledged in diff: PR self-review note 4 ("RST_STREAM and a WINDOW_UPDATE with a zero increment do this today. DATA now does it too ... The cause is the release path, not the shim") is accurate; the root cause and an equivalent attacker route already exist on the base. Mechanism verified. Trigger: server app calls
pushStream()with a headerpushPromiserejects… | nit… -
🟡
src/runtime/api/bun/h2/connection.rs— Clients that send DATA on a pushed stream can now receive two RST_STREAM(2, STREAM_CLOSED) frames from a Bun server, where the base sent none. The engine writes the first at connection.rs:1381/:1531 and calls on_stream_reset (:1385/:1536); the JS pushed stream's _destroy schedules rstNextTick via setImmediate (http2.ts:2639), and if the next read batch drains the legacy entry first (h2_frame_parser.rs:3618) the rst_stream host fn finds no entry and writes a second frame (h2_frame_parser.rs:5062-5073). Fix: mark the legacy stream so the deferred rstNextTick is a no-op after an engine-initiated reset, for request and pushed streams alike. [also at: src/runtime/api/bun/h2/connection.rs:1531 - Windows servers that push can now send two RST_STREAM frames for the same pushed stream when a client sends DATA on it; on the base no reset was sent at all.]Extended reasoning...
Server pushes stream 2; client sends DATA(2). The shim enters HalfClosedRemote (:1366/:1506), the Rst arm sends RST_STREAM(2,5) and calls sink.on_stream_reset. on_stream_reset (h2_frame_parser.rs:4168-4215) sets the legacy entry CLOSED, dispatches onStreamError, then free_resources queues the id in pending_engine_stream_closes. JS destroys the pushed stream; _destroy (http2.ts:2639) does setImmediate(rstNextTick). Normally the immediate runs before the next socket read and rst_stream host fn finds the CLOSED entry so end_stream returns at h2_frame_parser.rs:2150. But if another read batch for the same socket runs first (Windows probe recv after the callback, packages/bun-usockets/src/loop.c Windows branch; Linux repeat-recv when a read fills the buffer, loop.c:764; or a JS-fed Duplex calling parser.read() inside the same tick), the drain at h2_frame_parser.rs:3614-3631 removes the legacy entry, and rst_stream then takes the no-entry branch at :5052-5075 and hand-writes a second RST_STREAM(2, 5). The base delivered the DATA and…
Verification: nit — acknowledged in diff: the PR description's self-review item 1 names exactly this race ("On Windows, uSockets does one more recv() after each read callback... That call finds no native entry and writes a second RST_STREAM") and its bound ("not new with this PR: each reset that the engine starts can produce it, for request streams too") is accurate; this PR routes a new input class (DATA…
|
Both findings are correct. Both root causes exist on main, and this PR does not change them. I filed a tracker for each. Stream count after a push that failed validation: #43509. I confirmed it on bun 1.4.3-canary (367d939) with the two triggers that exist today. After a Second RST_STREAM: #43510. It is deterministic on main for a pushed stream on a client: a raw server that sends DATA on a reserved stream gets |
|
Updated 12:25 PM PT - Sep 19th, 2026
✅ @robobun, your commit 3a671da6d616552d19130c02a79175086e405744 passed in 🧪 To try this PR locally: bunx bun-pr 43494That installs a local version of the PR into your bun-43494 --bun |
Problem
node:http2server accepts DATA that a client sends on a server-pushed stream. The pushedServerHttp2Streamemits'error'ERR_STREAM_PUSH_AFTER_EOF, then never sends END_STREAM or emits'close'. node v26.3.0 refuses the frame.Connection::handle_dataandbegin_streamed_data(src/runtime/api/bun/h2/connection.rs:1502,:1362) enter a locally opened stream asState::Open. That is correct for a client's request stream. On a server such a stream is always a pushed stream.Fix
local_stream_state()gives the state to enter:Openon a client,HalfClosedRemoteon a server. The existing path then sendsRST_STREAM(STREAM_CLOSED)and resets the pushed stream (ERR_HTTP2_STREAM_ERROR,rstCode5).GOAWAY(STREAM_CLOSED). The engine answers DATA in each other non-receiving state with a stream error, which RFC 9113 §5.1 asks for in half-closed (remote). This PR keeps that level.test/js/node/http2/h2-conformance.test.ts(4 new cases, all fail on bun 1.4.3-canary 367d939). Alsotest/js/node/http2/and the 256test-http2-*node tests.Background
Connectionis the inbound HTTP/2 engine.H2FrameParserembeds it, encodes all outbound frames itself, and keeps its own stream map.Notes
Repro. A raw TCP client sends the preface, SETTINGS and
HEADERS(1, END_STREAM). The'stream'handler callsstream.pushStream({ ":path": "/pushed" }, (err, pushed) => { pushed.respond({ ":status": 200 }); pushed.write("p"); setTimeout(() => pushed.end("done"), 750); }), thenstream.respond(); stream.end("main"). When the client sees the pushed response HEADERS on stream 2, it sendsDATA(2)with 28 bytes.DATA(2)GOAWAY(code=5, "DATA: stream in half-closed(remote)"), socket closes'error'ERR_HTTP2_ERROR, pushed'close'rstCode2,'sessionError'ERR_HTTP2_ERRORDATA(2, END_STREAM)never leaves'error'ERR_STREAM_PUSH_AFTER_EOF, no'close'RST_STREAM(2, code=5)'error'ERR_HTTP2_STREAM_ERROR, pushed'close'rstCode5The same three results occur when the client sends the DATA after PUSH_PROMISE and before the pushed response HEADERS (reserved (local)). node prints the same reason for both states.
Why
ERR_STREAM_PUSH_AFTER_EOF.pushStream()ends the readable side of the pushed stream (endInboundHalf(pushedStream)insrc/js/node/http2.ts). The engine then delivers the client's DATA throughon_data, andpush()after EOF errors the stream. The stream is not destroyed, so the laterpushed.end()sends nothing.The request stream case today. The same client sends
DATA(1)afterHEADERS(1, END_STREAM), while the response is still open. node:GOAWAY(code=5, "DATA: stream in half-closed(remote)"). bun 1.4.3-canary and this branch:RST_STREAM(1, code=5), stream'error'ERR_HTTP2_STREAM_ERROR,rstCode5. The pushed stream now takes this path.nghttp2.
session_on_data_received_fail_fastinlib/nghttp2_session.cends the session with STREAM_CLOSED when the stream is shut for reading ("DATA: stream in half-closed(remote)"). The engine already differs from nghttp2 for DATA in this way. The existing test "DATA on a promised stream before its response HEADERS is refused, not delivered" assertsRST_STREAM(STREAM_CLOSED)and a live connection. node v26.3.0 as the client ends the session there:GOAWAY(code=1, "DATA: stream in reserved"). To end the session for DATA in half-closed (remote) is a change for request streams too, so it is not part of this PR.handle_rst_stream. It has the same shim. It enters the stream asClosed, which is correct for both roles, so it does not change.No stream on a server needs the old
Open. A stream that can receive DATA on a server is a request stream, andhandle_headersenters it before any DATA. The engine removes an entry only together with the embedder's entry (close_streamin thepending_engine_stream_closesdrain), or after a reset that also frees the embedder's entry. A request stream whose engine entry is gone is closed, so half-closed (remote) refuses DATA there too, whereOpendelivered it.Connection window.
handle_dataandbegin_streamed_datacount the frame toward the connection window before they look at the stream, andreplenish_windowsgives the credit back. That does not change. The stream window is no longer charged.Related. #38082 gives a server's pushed stream the state
HALF_CLOSED_REMOTEin the embedder's own stream map, for the outbound side. This PR is the inbound engine's half. #42467 changes what the engine does for DATA on a closed stream. It keeps the shim, so it does not change this case.Tests. Each new case starts an
http2.createServer()that pushes, and drives it with the raw clientRawH2. The matrix is the state of the pushed stream (half-closed (remote), reserved (local)) times the delivery of the DATA frame (one read forhandle_data, header plus 10 payload bytes first forbegin_streamed_data). A PING travels with the first read and a second PING follows the rest of the frame. After the second PING ACK each case asserts that the only RST_STREAM or GOAWAY on the wire isRST_STREAM(2, STREAM_CLOSED), then the error code,rstCode, and that no data reached the stream. On the released build each case fails on that assertion, not on a timeout.Self-review.
'error'event, which runs inside the server's read callback. On Windows, uSockets does one morerecv()after each read callback (packages/bun-usockets/src/loop.c). The server could then read those bytes before the deferredrstStreamcall from the stream's_destroyran. That call finds no native entry and writes a second RST_STREAM, and the exact assertion fails. The test now writes only after a PING ACK arrives from the wire. The second RST_STREAM is not new with this PR: each reset that the engine starts can produce it, for request streams too. node:http2 sends RST_STREAM twice for a stream that the inbound engine reset #43510 tracks it.'error'with no failure path. It now records the code and asserts it after the PING ACK.pushStream()failure rejects the awaited promise at once.kReleaseUnannouncedStream. That leaves the native stream entry and its JS context in place. A client frame on that id that resets the stream then reachesstreamErrorfor the destroyed stream, and the session's stream count goes down a second time. RST_STREAM and a WINDOW_UPDATE with a zero increment do this today. DATA now does it too, where before it was delivered to the destroyed stream. The cause is the release path, not the shim. node:http2 server: a client frame on the id of a push that failed validation makes session.close() cut open streams #43509 tracks it.waitForguard than the 2 s default: the raw-client tests in this file use the default.Suites run on the debug build.
test/js/node/http2/h2-conformance.test.ts,h2-push-refusal-staged,h2-late-rst-staged,node-http2-continuation,node-http2-invalid-padding,node-http2-settings-ack-ordering,node-http2-client-close, and all 256test/js/node/test/parallel/test-http2-*.jspass. Two kinds of failure occur on this machine and do not involve a pushed stream. The "stream release after a queued END_STREAM" cases in the conformance file fail in some runs, also when run alone (#42357 covers them). Innode-http2.test.js, tests that spawn a debug-build server take 3 to 4 s against the 5 s default and some time out under load. They pass when run alone.