node:http2: end a server response on its HEADERS frame when the stream was ended before respond() - #38170
node:http2: end a server response on its HEADERS frame when the stream was ended before respond()#38170robobun wants to merge 1 commit into
Conversation
…m was ended before respond() Http2Stream#_final on a client-initiated server stream that has not sent headers yet wrote an empty END_STREAM DATA frame, ahead of any HEADERS. It now just finishes the writable and marks FinalCalled, and respond() puts END_STREAM on the HEADERS frame for such a stream, which is what node sends. With that frame gone, close()/destroy() before respond() put a RST_STREAM(NO_ERROR) on the wire like node does, so the inbound engine bridge now routes a peer RST_STREAM(NO_ERROR) to onStreamEnd (as the local end_stream path already did) instead of onStreamError, so the receiving stream still emits 'end'.
|
Status: reproduced on bun 1.4.0 and a debug build of main with a frame-logging TCP proxy ( |
|
Warning Review limit reached
Next review available in: 58 seconds 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 (3)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes HTTP/2 wire-level frame semantics (both the server _final/respond() state machine and inbound RST_STREAM(NO_ERROR) routing) and loosens an existing snapshot, a human look would still be worthwhile.
What was reviewed:
_finalno longer writes an empty END_STREAM DATA before HEADERS on client-initiated server streams;FinalCalledgatesendStreaminrespond()— checked that the cork() test pins whywritableEndedwould be wrong.on_stream_resetnow routes NO_ERROR throughonStreamEnd(CLOSED)— confirmed this matches the legacyhandle_rst_stream_frame(line ~4645) andend_stream(line ~2524) paths.- The dropped
req error ERR_HTTP2_STREAM_CANCELsnapshot line — the fixture feeds RST_STREAM(NO_ERROR), so the clean-close routing legitimately removes that error; the test's actual invariant (exit 0, no signal) is preserved.
Extended reasoning...
Overview
This PR fixes RFC 9113 §8.1 conformance for node:http2 server streams that are end()ed before respond(). Three files change: src/js/node/http2.ts (Http2Stream#_final gains a client-initiated-stream branch that sets FinalCalled and completes the callback without writing; ServerHttp2Stream#respond forces endStream when FinalCalled is set), src/runtime/api/bun/h2_frame_parser.rs (on_stream_reset routes RST_STREAM(NO_ERROR) to onStreamEnd(CLOSED) instead of onStreamError), and test/js/node/http2/h2-conformance.test.ts (8 new wire-level tests plus one updated inline snapshot).
Security risks
None identified. This is protocol-conformance work in the Node.js compatibility layer; no auth, crypto, or untrusted-input parsing surface changes. The on_stream_reset change narrows an error path to a clean-close path for one specific code, which if anything reduces error surface.
Level of scrutiny
Medium-high. HTTP/2 frame ordering and stream-state transitions are a critical, subtle code path with many interacting variants (push vs client-initiated, half-close ordering, cork buffering, trailers). The change is small and well-argued, but it alters wire semantics observable by any peer, changes inbound RST_STREAM handling for all clients, and removes a line from an existing snapshot. The PR description is unusually thorough (frame tables measured against node v26.3.0, explicit reasoning for the FinalCalled vs writableEnded choice with a pinning test), and 261 vendored upstream http2 tests plus grpc-js/undici-h2 were run — but the interaction surface is large enough that a maintainer familiar with the h2 rewrite should confirm the state-machine reasoning.
Other factors
I verified the Rust change is consistent with the two existing paths it cites: handle_rst_stream_frame at line ~4645 and end_stream at line ~2524 both already dispatch onStreamEnd for NO_ERROR, so on_stream_reset was the odd one out. The streamEnd handler (line ~4263) handles state === 7 (CLOSED) by pushing null to the readable and destroying, which produces the 'end' then 'close' sequence the test asserts. The removed snapshot line is explained: the fixture pushes RST_STREAM(NO_ERROR) into the client during a re-entrant toString, and with the new routing that stream closes cleanly before client.destroy() cancels it — the test's purpose (no UAF, clean exit) is intact. Two related open PRs (#38104, #33380) overlap with parts of this change, which the description flags for whoever lands them.
|
Updated 10:28 AM PT - Aug 13th, 2026
✅ @robobun, your commit 8538bf55703061d742328c8930e697e348bada12 passed in 🧪 To try this PR locally: bunx bun-pr 38170That installs a local version of the PR into your bun-38170 --bun |
|
Closing: #43558 carries this fix, rebased on main. It also adds the client check that needs it (the client now refuses DATA before the response HEADERS), and it ends the writable side when the peer sends RST_STREAM(NO_ERROR). |
Problem
node:http2server,stream.end()beforestream.respond()on a regular (client-initiated) stream puts an empty END_STREAMDATAframe on the wire for a stream that has not sent anyHEADERS. Frames on stream 1 for a GET, against node v26.3.0 (0x4= END_HEADERS,0x5= END_HEADERS | END_STREAM):'end'/'close'but never'response'; arespond()a tick later throwsERR_HTTP2_INVALID_STREAM(the frame already closed the stream); on 1.4.0 the same-tickrespond()also raises an uncaughtERR_HTTP2_SESSION_ERRORon the server once the client rejects the lateHEADERS. For a strict peer,DATAbefore the responseHEADERSis a protocol error (RFC 9113 8.1).Http2Stream#_final(src/js/node/http2.ts) handled ended-before-respond only for pushed (even-id) streams; everything else fell through tonative.writeStream(id, "", ..., true), i.e. the empty END_STREAMDATAframe. Node's_final(Http2Stream::DoShutdown) only marks the stream unwritable, andSubmitResponsethen submits the response without a body, so END_STREAM rides on theHEADERSframe.close()/destroy()beforerespond()use the same_final, so the server now sends justRST_STREAM(NO_ERROR)for them (as node does), but a bun client receivingRST_STREAM(NO_ERROR)emitted'close'without'end'.on_stream_resetinsrc/runtime/api/bun/h2_frame_parser.rs(the inbound engine's bridge) dispatched every non-CANCEL code, NO_ERROR included, asonStreamError, which destroys the stream before its readable can end. Reproducible on main against a node server doingstream.close()(node's client:end, close; bun's:close), and it made the vendoredtest-http2-compat-write-head-after-close.jshang once the server side was fixed.Fix
_finalon a server stream without headers sent: a client-initiated stream setsFinalCalledand completes its callback without writing anything, so the writable finishes right away like node's, whether or not arespond()follows. The pushed-stream branch (parked callback) is unchanged.respond()forcesendStreamwhenFinalCalledis set, so END_STREAM goes on theHEADERSframe; that branch already stripswaitForTrailers, and node likewise never asks for trailers on a response submitted without a body.FinalCalledis the right signal: it is the analog of node's!is_writable(). Data written beforerespond()triggers the implicitrespond()in_write/_writev, so whenrespond()is still allowed to run,_finalhaving run meansend()was called with nothing written at all.writableEndedwould be wrong: it is already true during that implicitrespond()for chunks buffered behindcork()whenend()is called before uncorking, and END_STREAM on theHEADERSwould then put the body on a half-closed stream.on_stream_reset: a peerRST_STREAM(NO_ERROR)dispatchesonStreamEnd(CLOSED), the routing the localend_stream(and the dead legacy inboundhandle_rst_stream_frame) already use; thestreamEndhandlers push EOF and read, so the stream emits'end'then'close'withrstCode0, matching node. Other codes unchanged.end()/close()/destroy()/session.destroy()-before-respond()variant measured now sends the same frames as node (table below). Streams whose headers were already sent are untouched.test/js/node/http2/h2-conformance.test.ts(raw-frame client against a real server):end(); respond()sends exactly oneHEADERScarrying END_STREAM and the stream finishes and closes;respond()from'finish'works;end()alone sends nothing and still finishes;waitForTrailersvariant emits no'wantTrailers'; request body still open (END_STREAM onHEADERShalf-closes, the request's END_STREAM closes); body buffered behindcork()keeps END_STREAM offHEADERS(pins the signal choice, passes before and after); bun's client seesresponse(flags0x5),end,close; a client fedRST_STREAM(NO_ERROR)by a raw server emitsend, close rstCode=0. 7 of the 8 new cases fail on main.sendTrailersinline snapshot in the same file loses itsreq error ERR_HTTP2_STREAM_CANCELline. That fixture feeds the client aRST_STREAM(NO_ERROR)beforeclient.destroy(); the error was the deferred error dispatch of that reset picking up the session's cancel error. The stream now closes cleanly, as node's does for a stream the peer already reset with NO_ERROR. The test's point (exit 0, no signal) is unchanged.test-http2-*files pass (compat-write-head-after-closeneeds theon_stream_resethunk);node-http2.test.js(357 pass) and the othertest/js/node/http2/*files; the http2 regression tests;grpc-jstest-server/test-metadata;undici-h2(11/11).end()path: node:http2: end a pushed response on its HEADERS frame when the stream was ended before respond() #38104 does the same END_STREAM-on-HEADERSfor pushed streams, keyed on the parked callback (the conditions are additive; one-line rebase for whichever lands second). node:http2: send RST_STREAM when a server stream is reset #33380 owns theclose()/destroy()reset semantics (including the missingclosedguard inrespond()); it independently carries the sameRST_STREAM(NO_ERROR)routing hunk and a bare-end()test, which this PR needs in order to stand on its own.Background
HEADERSframe; END_STREAM is a flag on the last frame the sender emits for the stream, either aDATAframe or, for a body-less response, theHEADERSframe itself (RFC 9113 8.1)._finalis thestream.Writablehook run onceend()was called and all buffered chunks were written;'finish'is emitted (on a later tick) after its callback is invoked.ServerHttp2Stream#_write/_writevcallrespond()implicitly when data is written before it.StreamStateinhttp2.tsis a per-stream bit set.FinalCalledwas already set by the other_finalpaths; this is its first reader. The pushed-stream path instead parks the callback inbunHTTP2StreamFinaland completes it from the native END_STREAM dispatch (markWritableDone).RST_STREAMcloses a stream abruptly; with code NO_ERROR it is the normal way to drop a stream without a complete response (node'sstream.close()defaults to it). Inbound frames are parsed by the rewritten engine (h2/connection.rs), which calls into theSinkbridge inh2_frame_parser.rs;on_stream_resetis the bridge for a receivedRST_STREAM.onStreamEndandonStreamErrorare the two JS dispatches it can choose between, handled by thestreamEnd(end-of-stream bookkeeping, destroy once both halves are done) andstreamError(destroy with an error) handlers inhttp2.ts.Frames the server sends on stream 1 (GET unless noted): node v26.3.0, bun 1.4.0, this branch
Measured with a TCP proxy logging frame type/flags between bun's client and the server.
end(); respond()HEADERS 0x5DATA 0x1, HEADERS 0x4(+ session error)HEADERS 0x5end(); respond()(POST, body open)HEADERS 0x5DATA 0x1, HEADERS 0x4HEADERS 0x5end()only'finish'DATA 0x1'finish'end(), thenrespond()on'finish'/ laterHEADERS 0x5DATA 0x1, HEADERS 0x5/ throwsHEADERS 0x5end(); respond({ waitForTrailers })HEADERS 0x5, no wantTrailersDATA 0x1, HEADERS 0x4HEADERS 0x5, no wantTrailersadditionalHeaders(102); end(); respond()HEADERS 0x4, HEADERS 0x5DATA 0x1HEADERS 0x4, HEADERS 0x5end(); respondWithFD(fd)HEADERS 0x5DATA 0x1HEADERS 0x5respondWithFD(fd); end()HEADERS 0x4, DATA, DATA 0x1destroy()RST_STREAM(0)DATA 0x1RST_STREAM(0)close()RST_STREAM(0)DATA 0x1, RST_STREAM(0)RST_STREAM(0)close(REFUSED_STREAM)RST_STREAM(7), client errorsDATA 0x1(reset lost, client sees a clean end)RST_STREAM(7), client errorsclose(); respond()RST_STREAM(0)DATA 0x1, HEADERS 0x4, RST_STREAM(0), session errorsHEADERS 0x5, RST_STREAM(0)(the throw is #33380)end(); session.destroy()DATA 0x1cork(); write("hello"); end()HEADERS 0x4, DATA(5) 0x1HEADERS 0x4, DATA(5) 0x0, DATA(0) 0x1respond(); close(code)(headers sent)