node:http2: end a pushed response on its HEADERS frame when the stream was ended before respond() - #38104
node:http2: end a pushed response on its HEADERS frame when the stream was ended before respond()#38104robobun wants to merge 1 commit into
Conversation
…m was ended before respond()
pushStream({ endStream: true }) and an explicit push.end() before
push.respond() park the stream's _final callback and rely on respond()
to put END_STREAM on the response HEADERS frame, but respond() only did
so for options.endStream, 204/205/304 and HEAD pushes. The pushed
HEADERS went out without END_STREAM and nothing followed, so the
client's pushed stream never ended and the parked _final never
completed. respond() now also ends the stream when _final parked its
callback, which is what node does once a stream's writable side is
shut before the response is submitted.
|
Warning Review limit reached
Next review available in: 9 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 7:01 AM PT - Aug 13th, 2026
❌ @robobun, your commit 10d7edd has 1 failures in
🧪 To try this PR locally: bunx bun-pr 38104That installs a local version of the PR into your bun-38104 --bun |
|
Status: reproduced on main ( Reproduced with a frame-level sniff of the server's output for New cases in |
There was a problem hiding this comment.
LGTM — narrowly scoped one-condition fix with thorough coverage.
What was reviewed:
- Traced every
_finalpath that parksbunHTTP2StreamFinal; only the even-id push-before-respond branch (:2892) can leave it set whenrespond()runs (guarded byheadersSentat :3713), so the new condition cannot fire on odd-id or already-responded streams. - Confirmed the branch already strips
waitForTrailersand the parked callback is completed viaonStreamEnd→markWritableDone, matching the existing HEAD-push path. - Tests: END_STREAM flag asserted directly from the client
'push'event before any wait; negative case pins that a still-writable push keeps END_STREAM on the DATA frame; all failure events wired to reject.
Extended reasoning...
Overview
The PR adds a single OR clause to ServerHttp2Stream#respond()'s endStream-forcing condition (src/js/node/http2.ts:3819): when _final has parked its callback in bunHTTP2StreamFinal, the response HEADERS frame carries END_STREAM. This closes the gap where a pushed stream ended before respond() (via pushStream({ endStream: true }) or an explicit push.end()) never had its response terminated on the wire, leaving both peers hung. Five new test cases in node-http2.test.js cover both entry points, the waitForTrailers interaction, the HEAD baseline, and a negative case proving still-writable pushes are unaffected.
Security risks
None. This is HTTP/2 server-push frame-flag placement; no parsing of untrusted input, no allocation, no auth/crypto surface.
Level of scrutiny
Moderate — HTTP/2 stream state is subtle, but the change is one boolean clause added to an existing well-commented branch. I audited every site that writes bunHTTP2StreamFinal (:2879, :2892, :2912, :2967) against respond()'s entry guards (destroyed, assertSession, headersSent). The :2879 path requires EndStreamSent (implies data was written → implicit respond() already set headersSent); :2912 requires bunHTTP2WaitForTrailers (only set at :3855 after respond()); :2967 requires a dead session (fails assertSession). Only :2892 — the even-id push ended before respond — can reach the new clause, which is exactly the intended target. The PR's choice of the parked callback over writableEnded as the signal is correct: writableEnded flips before buffered cork'd writes flush, and forcing END_STREAM there would half-close before the body goes out.
Other factors
The tests follow the repo's harness conventions closely: every error/sessionError/stream error is wired to a shared reject, each wait is Promise.raced against it, the END_STREAM flag is asserted from the client's 'push' event flags byte before awaiting close so the pre-fix hang surfaces as an assertion failure rather than a timeout, and cleanup is in finally. The negative test ("still writable at respond()") pins that the fix does not over-apply. The PR reports the full node-http2.test.js (362), h2-conformance.test.ts (61), and all 261 upstream test-http2-* tests pass. The interaction with the independent #38082 is documented and orthogonal (that PR touches native even-id lifecycle, not respond()).
Problem
node:http2server, a pushed stream whose writable side is ended beforerespond()never gets its response ended on the wire. Two ways to get there:stream.pushStream(headers, { endStream: true }, (err, push) => push.respond(...))stream.pushStream(headers, (err, push) => { push.end(); push.respond(...); })0x4= END_HEADERS,0x5= END_HEADERS | END_STREAM):'end'/'close', the server push stream's writable never finishes ('finish'never fires,writableFinishedstays false), and aclient.close()waiting on the pushed stream never completes. The:method HEADvariant of the same thing works.Http2Stream#_final(src/js/node/http2.ts:2887) handles an ended-but-unresponded push stream by parking its callback inbunHTTP2StreamFinalinstead of writing an emptyDATAframe ahead of the responseHEADERS, and relies onrespond()to put END_STREAM on theHEADERSframe.ServerHttp2Stream#respond()(:3807) only did that foroptions.endStream, status 204/205/304 andheadRequest, so for a plain push it sentHEADERSwithout END_STREAM and the parked callback was never completed.Fix
respond()also forcesendStreamwhen_finalhas parked its callback. TheHEADERSframe then carries END_STREAM, the nativerequest()dispatchesonStreamEnd, and the existing handler completes the parked callback throughmarkWritableDone(the path HEAD pushes already take today).Http2Stream::SubmitResponsesubmits the response without a data provider whenever the stream's writable half has been shut (!is_writable()), which is the statepushStream({ endStream })andpush.end()leave the stream in, so nghttp2 sets END_STREAM on theHEADERSframe.writableEnded): it is set from_final, i.e. only onceend()was called and every buffered chunk has been written.writableEndedis already true during the implicitrespond()that_write/_writevissue for data buffered behind acork()whenend()was called before uncorking, and forcing END_STREAM there would put the body on a half-closed stream.respond(headers, { waitForTrailers: true })on such a stream goes through the same branch, which already stripswaitForTrailers, so no'wantTrailers'is emitted; node also never asks for trailers on a response that has no body.HALF_CLOSED_REMOTEso a completed push is released and emits'close'. It keeps the parked-callback branch in_finaland does not touchrespond(), so this bug is present with or without it; with both, anendStreampush closes exactly like node's. node:http2: send RST_STREAM when a server stream is reset #33380 covers the neighbouringclose()/destroy()-before-respond()paths.test/js/node/http2/node-http2.test.js,describe("http2 pushStream: response ended before respond()"). One real client/server session per case; the flags argument of the client's'push'event is the flags byte of the pushed response'sHEADERSframe, so END_STREAM placement is asserted directly, then the pushed stream's'end'/'close'(rstCode0) and the server push stream's'finish'. Cases:pushStream({ endStream: true }),push.end()beforerespond(), the same withwaitForTrailers, a HEAD push (passes before too; pins that the two paths agree), and a push that is still writable atrespond()time (END_STREAM must stay on its DATA frame). The first three fail on main at the flags assertion and pass with the fix.node-http2.test.js(362 pass),h2-conformance.test.ts(61 pass), and all 261 upstreamtest-http2-*tests undertest/js/node/test(all pass).Background
PUSH_PROMISEframe on the client's stream, reserving a new even-numbered stream id, and then sends the response on that stream. Innode:http2that isstream.pushStream(), whose callback receives aServerHttp2Streamfor the reserved id; the response is sent with the normalrespond()/end()API.DATAframe or, for a response with no body, theHEADERSframe itself. A response whoseHEADERSlacks it is, as far as the peer is concerned, still in progress until a frame carrying it arrives._finaland the parked callback:_finalis thestream.Writablehook that runs onceend()was called and every buffered write has been flushed; the writable emits'finish'when its callback is invoked. For a push stream that was ended beforerespond(), bun's_finalcannot send anything yet (a response has to start withHEADERS), so it stores the callback in thebunHTTP2StreamFinalslot;markWritableDone, run from theonStreamEnddispatch, invokes it once the END_STREAM has gone out.