Skip to content

node:http2: put END_STREAM on the HEADERS frame of an endStream request even with waitForTrailers - #37718

Closed
robobun wants to merge 1 commit into
mainfrom
farm/48724c45/http2-client-endstream-wait-for-trailers
Closed

robobun wants to merge 1 commit into
mainfrom
farm/48724c45/http2-client-endstream-wait-for-trailers

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A node:http2 client request with endStream (explicit, or implied for GET/HEAD/DELETE/OPTIONS) plus waitForTrailers goes out as HEADERS(flags=0x4) and then a second terminating frame: a trailer HEADERS(flags=0x5), or an empty END_STREAM DATA frame with no 'wantTrailers' listener. Node sends one HEADERS(flags=0x5).
  • 'wantTrailers' fires on such a stream (node: never). A later sendTrailers() throws ERR_HTTP2_TRAILERS_ALREADY_SENT where node throws ERR_HTTP2_TRAILERS_NOT_READY; with no listener it does not throw and writes a trailer block after the stream was already ended.
  • Cause: on the client, endStream with waitForTrailers meant "end the stream after the trailers". The HEADERS frame went out without END_STREAM, the stream was marked half-closed although nothing on the wire had closed it, and JS was asked for trailers. The server side already had an exception.

Fix

  • endStream now always puts END_STREAM on the HEADERS frame, client and server alike, and a stream only remembers waitForTrailers when that frame did not end it. The deferred want-trailers branch is removed.
  • Property to check: a stream ended by its HEADERS frame can carry neither a body nor trailers, so trailers are never requested and sendTrailers() reports ERR_HTTP2_TRAILERS_NOT_READY. Node behaves the same, and its docs place trailers after the final DATA frame, which such a stream does not have.
  • Server respond() no longer strips waitForTrailers itself; the native rule covers it and its wire output is unchanged.
  • Verification: wire-level tests against a raw HTTP/2 peer. Four client cases fail on the unfixed binary with the frame lists above; a body-carrying POST and a server respond({ endStream: true, waitForTrailers: true }) pin the paths that must not change. Outcomes were checked against node v26.3.0; the existing bun, upstream node and grpc-js http2 suites still pass.

Background

  • Each side of an HTTP/2 stream ends its half by setting END_STREAM on a frame. HEADERS flags 0x4 is END_HEADERS alone, 0x5 adds END_STREAM. Anything a side sends on the stream after its END_STREAM is a protocol violation.
  • Trailers are a second HEADERS block sent after the body's last DATA frame, and that block is what carries END_STREAM. A stream ended on its first HEADERS frame has nowhere for them to go.
  • In the node API, waitForTrailers stops the stream from ending by itself after the body: 'wantTrailers' is emitted and the app calls sendTrailers() to send the closing block. endStream declares there is no body.
  • bun's http2 is split between src/js/node/http2.ts (the node-facing API) and a native frame parser that builds frames and tracks per-stream state. One native entry point serves both the client's request() and the server's respond(), so a rule there covers both sides.
Original description

What

A node:http2 client request submitted with endStream (explicitly, or by default for GET/HEAD/DELETE/OPTIONS...) together with waitForTrailers now goes out the way node sends it: END_STREAM on the request HEADERS frame, no trailers requested, no 'wantTrailers' event.

Repro

Raw net server that sends SETTINGS after the preface, ACKs the client's SETTINGS and logs every frame it receives on stream 1:

const req = client.request({ ":method": "GET", ":path": "/" }, { waitForTrailers: true }); // GET => endStream
req.on("wantTrailers", () => req.sendTrailers({ "x-t": "1" }));
// later:
try { req.sendTrailers({ "x-late": "1" }); } catch (e) { console.log(e.code); }

Same with { ":method": "POST" } and { endStream: true, waitForTrailers: true }.

node v26.3.0 bun 1.4.0
frames on stream 1 HEADERS(flags=0x5) HEADERS(flags=0x4), trailer HEADERS(flags=0x5)
same, no 'wantTrailers' listener HEADERS(flags=0x5) HEADERS(flags=0x4), DATA(len 0, END_STREAM)
'wantTrailers' emitted no yes
later sendTrailers() throws ERR_HTTP2_TRAILERS_NOT_READY throws ERR_HTTP2_TRAILERS_ALREADY_SENT (with a listener); with no listener it does not throw and writes a trailer HEADERS frame after the END_STREAM DATA frame

Cause

H2FrameParser::request() (src/runtime/api/bun/h2_frame_parser.rs) handled endStream as "end the stream after the trailers" on the client: it left END_STREAM off the HEADERS frame when waitForTrailers was set (the server already had an is_server exception), and after writing the frame it set the stream to HALF_CLOSED_LOCAL, although nothing had ended it on the wire yet, and dispatched onWantTrailers. The JS wantTrailers handler then terminated the stream with a trailer block or an empty END_STREAM DATA frame.

Fix

request() puts END_STREAM on the HEADERS frame whenever endStream is set, and only records waitForTrailers on the stream when that frame did not end it (stream.wait_for_trailers = wait_for_trailers && !end_stream). The deferred onWantTrailers branch is gone, so an endStream stream takes the same path as one without waitForTrailers: state transition plus onStreamEnd, the writable side finishes, and sendTrailers() reports ERR_HTTP2_TRAILERS_NOT_READY because trailers were never requested.

The rule now lives in one place and applies to both sides, so the server-side respond() in src/js/node/http2.ts no longer has to strip waitForTrailers itself before calling into native (its existing options?.waitForTrailers && !endStream guard still keeps the JS-side trailers flag off). The wire output of respond({ endStream: true, waitForTrailers: true }) is unchanged.

Why this is the right behavior

This is what node does for both request() and respond(): with endStream, Http2Session::SubmitRequest / Http2Stream::SubmitResponse pass STREAM_OPTION_EMPTY_PAYLOAD, which makes the nghttp2_data_provider null, so nghttp2 sets END_STREAM on the HEADERS frame and the trailers callback (which hangs off the data provider) can never run. waitForTrailers therefore has no effect on such a stream, which also matches its documentation: trailers are sent "after the final DATA frame", and an endStream stream has none. Requests that do carry a body keep requesting trailers exactly as before (stream.wait_for_trailers is still set for them; the upstream trailer tests cover that path).

Verification

New endStream with waitForTrailers block in test/js/node/http2/h2-conformance.test.ts, wire-level against the file's raw server/client. After the client's HEADERS frame arrives the raw server sends a PING; anything the client wrote while submitting the request is ahead of the PING ACK on the wire, so once the ACK arrives the stream-1 frame list is complete.

  • GET with waitForTrailers (implied endStream), POST with endStream: true, waitForTrailers: true, the same without a 'wantTrailers' listener, and a request submitted after 'connect' (direct submission rather than the queued-until-connect path): the stream-1 frames are exactly HEADERS(0x5), 'wantTrailers' never fires, 'finish' still fires, sendTrailers() throws ERR_HTTP2_TRAILERS_NOT_READY, sentTrailers is undefined. These four fail on the unfixed binary with the frame lists from the table above.
  • A POST that carries a body still gets 'wantTrailers' and sends its trailers (HEADERS, DATA, trailer HEADERS(0x5)), pinning the path that is still supposed to request trailers.
  • A server respond({ endStream: true, waitForTrailers: true }) still produces exactly HEADERS(0x5) with no 'wantTrailers', covering the removed JS strip.

Outcomes of every case were checked against node v26.3.0. Also passing with the fix: test/js/node/http2/ (all files), all 261 upstream test-http2-* tests in test/js/node/test/{parallel,sequential}, and the grpc-js test-server, test-end-to-end and test-metadata suites.

Related: #37712 fixes the empty DATA frame emitted on the body-carrying trailers path and lists this flow as out of its scope; #36389's "GET with waitForTrailers still half-closes its side" test accepts END_STREAM on either HEADERS or DATA, so it stays green with this change.

…st even with waitForTrailers

When a client request was submitted with endStream (explicitly, or by
default for GET/HEAD/DELETE...) together with waitForTrailers, native
request() left END_STREAM off the HEADERS frame, marked the stream
HALF_CLOSED_LOCAL anyway and dispatched onWantTrailers, so the stream was
terminated by a trailer HEADERS frame (or, without a 'wantTrailers'
listener, an empty END_STREAM DATA frame) and 'wantTrailers' fired for a
request that has no body. Node puts END_STREAM on the request HEADERS and
never asks for trailers: an endStream stream is submitted without the
nghttp2 data provider that trailers are requested through.

request() now sets END_STREAM whenever endStream is set and only records
waitForTrailers on the stream when the HEADERS frame did not already end
it, for client and server alike; the server-side respond() no longer needs
to strip waitForTrailers itself.
@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:31 PM PT - Aug 11th, 2026

❌ @robobun, your commit 768d0f3 has 2 failures in Build #92755 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37718

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

bun-37718 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed; builds and Linux test lanes (incl. ASAN/musl) green, Windows and darwin test lanes still running.

Reproduced with a raw net HTTP/2 server logging the client's frames: on bun 1.4.0 and main, a GET (or POST with endStream: true) requested with waitForTrailers: true went out as HEADERS(END_HEADERS) followed by a trailer HEADERS(END_STREAM) (or an empty DATA(END_STREAM) without a listener) and emitted 'wantTrailers'; node v26.3.0 sends a single HEADERS(END_STREAM | END_HEADERS) and never emits the event. The four new client tests in test/js/node/http2/h2-conformance.test.ts fail on the unfixed binary and pass with this branch.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 15696bcd-e64e-4527-b412-c8cd024003bb

📥 Commits

Reviewing files that changed from the base of the PR and between 0825a8b and 768d0f3.

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

Walkthrough

Changes

endStream now terminates HTTP/2 streams on the initial HEADERS frame. Trailer waiting applies only when the stream remains open. Conformance tests cover request and response trailer behavior.

HTTP/2 stream termination

Layer / File(s) Summary
Request END_STREAM handling
src/runtime/api/bun/h2_frame_parser.rs
Request parsing sets trailer waiting only for non-ended streams. END_STREAM HEADERS now finalize stream state and dispatch onStreamEnd.
No-payload response termination
src/js/node/http2.ts
Http2Stream.respond() forces endStream: true for 204, 205, 304, and HEAD responses while preserving other options.
Trailer and termination conformance
test/js/node/http2/h2-conformance.test.ts
Tests cover ended requests, valid trailer-bearing requests, ended responses, trailer events, frame ordering, and late trailer errors.

Suggested reviewers: cirospaciari, jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main HTTP/2 behavior change for endStream requests with waitForTrailers.
Description check ✅ Passed The description explains the problem, fix, behavior, rationale, and verification, including detailed test coverage and results.

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

One correction to the walkthrough above, for anyone skimming: respond() forcing endStream for 204/205/304/HEAD is pre-existing behavior. The only change in src/js/node/http2.ts is that this branch no longer also strips waitForTrailers from the options before the native call, since native request() now ignores it for any stream whose HEADERS frame carries END_STREAM (the fix in h2_frame_parser.rs). The server-side wire output is unchanged, which the new "server response with endStream and waitForTrailers" test pins.

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

LGTM — focused node-compat fix that moves the endStream/waitForTrailers rule into native request() where it belongs.

What was reviewed:

  • Traced the new stream.wait_for_trailers = wait_for_trailers && !end_stream assignment against every consumer (send_data, flush_queue, no_trailers) — the field is now set once, at the same or later point than the old code, so early-error returns (memory limit, header block length) are unaffected.
  • Confirmed removing the server-side JS waitForTrailers: false strip is safe: native now clears it, and the JS options?.waitForTrailers && !endStream guard at http2.ts:3841 still keeps bunHTTP2WaitForTrailers off.
  • Checked the removed onWantTrailers early-return: the endStream path now falls through to the existing HALF_CLOSED_LOCAL/CLOSED transition + onStreamEnd dispatch, which is what makes 'finish' fire (asserted by the new tests).
Extended reasoning...

Overview

This PR fixes a node:http2 client-side wire-format divergence: when a request is submitted with both endStream (explicit or defaulted for GET/HEAD/etc.) and waitForTrailers, Bun was leaving END_STREAM off the initial HEADERS frame and then emitting 'wantTrailers', resulting in a trailer HEADERS block or empty END_STREAM DATA frame following the request headers. Node puts END_STREAM directly on the HEADERS frame and never fires 'wantTrailers' for such a stream (nghttp2 has no data provider to request trailers through).

The Rust change in H2FrameParser::request() (h2_frame_parser.rs:9251-9620) is ~15 net lines: (1) always set the END_STREAM flag when endStream is true regardless of waitForTrailers, (2) record stream.wait_for_trailers only when the HEADERS frame did not end the stream, and (3) remove the special-case branch that dispatched onWantTrailers and returned early. The endStream path now falls through to the pre-existing state transition and onStreamEnd dispatch — the same code the server side already used via its is_server exception, which is now deleted.

The JS change in ServerHttp2Stream.respond() (http2.ts:3812-3818) removes the waitForTrailers: false strip that was working around the old native behavior; the native layer now enforces the rule for both client and server, and the existing options?.waitForTrailers && !endStream guard further down keeps the JS-side trailers flag off. Server wire output is unchanged (covered by the new server test).

Security risks

None. This is HTTP/2 frame-emission ordering on the outbound side; no untrusted-input parsing changed. The change makes Bun emit fewer frames (one HEADERS instead of HEADERS + trailer/DATA), reducing surface if anything.

Level of scrutiny

Medium. node:http2 is production networking code and protocol-level mistakes surface as interop failures with real servers (grpc, envoy, etc.). That said: the change is small and localized to one function, the invariant is moved to the layer that owns it (native request()) rather than patched at each caller, the old server-side is_server special case and the JS workaround both collapse into one rule, and the PR description documents empirical verification against node v26.3.0 plus passing runs of the full test/js/node/http2/ suite, all 261 upstream test-http2-* tests, and the grpc-js suites.

Other factors

The six new wire-level tests in h2-conformance.test.ts are strong: they use the file's existing raw net server/client harness to assert exact frame sequences on stream 1, use a PING/ACK barrier to prove the frame list is complete before asserting, cover the variant matrix (implicit vs explicit endStream, with/without listener, before/after connect, client and server), pin the still-working body-with-trailers path, and check the specific error codes (ERR_HTTP2_TRAILERS_NOT_READY / ERR_HTTP2_INVALID_STREAM / ERR_HTTP2_TRAILERS_ALREADY_SENT) that distinguish the fixed behavior from the old. The PR notes these fail on the unfixed binary. The related-PR cross-references (#37712, #36389) were checked for interaction and the description explains why both stay green.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. Nothing to change from it. CI so far: every build lane and all Linux test lanes (including the ASAN and musl ones) are green; the Windows and darwin test lanes are still running.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Nov 11, 2026
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