Skip to content

node:http2: end the stream with an empty DATA frame when sendTrailers() encodes no fields - #37951

Open
robobun wants to merge 4 commits into
mainfrom
farm/7a5be308/h2-empty-encoded-trailers
Open

robobun wants to merge 4 commits into
mainfrom
farm/7a5be308/h2-empty-encoded-trailers

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • stream.sendTrailers({ "x-none": [] }) (any block whose fields all encode to nothing: empty-array values, or an empty name) writes a trailer HEADERS frame with END_STREAM | END_HEADERS and a 0-byte header block. The peer decodes an empty block and emits 'trailers' with no headers (rawHeaders is []). Same from a client stream's sendTrailers().
  • Node v26.3.0 sends an empty END_STREAM DATA frame instead, so the peer gets no 'trailers' event at all. A bun or node client talking to a bun server sees one 'trailers' event; talking to a node server it sees none.
  • Cause: H2FrameParser::send_trailers() in src/runtime/api/bun/h2_frame_parser.rs writes whatever it encoded as a HEADERS frame, even when that is nothing. The JS side (Http2Stream#sendTrailers in src/js/node/http2.ts) only routes to noTrailers() when the object has no keys, so a block with keys that encode to nothing reaches the native encoder. The comment there claimed strict peers reject a zero-length block; they do not (node's client accepts it and emits 'trailers'), the difference is only the spurious event.
  • Noticed while working on node:http2: an empty array value is not an occurrence of a single-value header #37945, which left this rule alone. Reproduces on the 1.4.0 release and on main.

Fix

  • send_trailers() checks the encoded block after the walk: if it is empty, it ends the stream the way no_trailers() does (clear wait_for_trailers, send an empty END_STREAM DATA frame) instead of writing a HEADERS frame. The two sites share a new send_no_trailers() helper so they cannot drift. The stale JS comment is removed; the JS {} shortcut itself is unchanged.
  • Matches node: Http2Stream::SubmitTrailers (src/node_http2.cc) submits DATA with END_STREAM when the header count is 0 and nghttp2_submit_trailer otherwise; the count comes from buildNgHeaderString, which emits no field for an empty array or an empty name. The check is on the encoded output, so a block with one real field next to an empty array still goes out as trailers, and the JS {} shortcut and the native path now produce the same frame.
  • The new path is the existing noTrailers() path, so its stream-state transition, onStreamEnd dispatch and stream release are the ones sendTrailers({}) has always used (the removed HEADERS tail did the same things); under backpressure the frame is queued like any other END_STREAM frame. sentTrailers is unchanged: it is set to the block before the native call and stays set, as node keeps kSentTrailers either way. Client and server streams share the native function, so one change covers both.
  • Overlap: node:http2: skip undefined-valued properties in outbound header blocks #37953 (skip undefined-valued properties), opened shortly after this PR, carries an inline copy of the same encoded_headers.is_empty() rule in send_trailers(), because skipping undefined adds another way to reach an empty block. The rules are the same; whichever lands second drops its copy (a few-line conflict at the same spot). This PR is the standalone fix with the wire-level tests.
  • Verification: test/js/node/http2/h2-conformance.test.ts, describe sendTrailers() with a block that encodes to no fields. A raw peer records the frames. Server with { "x-none": [] }, { "x-none": [], "content-type": [] } (a single-value field, whose bookkeeping must not count the empty array as a field), { "": "x" } and {}, and a client with { "x-none": [] }, must end the stream with DATA(END_STREAM, length 0) and send exactly one HEADERS frame (the response/request block); { "x-none": [], "x-sent": "1" } must still end with a non-empty trailer HEADERS frame. Four of the six fail on the release binary (they get HEADERS(0x5, length 0) and a second HEADERS frame; {} already took the JS shortcut and the control already passed), all pass with the fix. Every row was also run under node v26.3.0, which produces the asserted outcome.
  • test/js/node/http2/node-http2.test.js, sendTrailers() whose values are all empty arrays ends the stream without a 'trailers' event: bun server to bun client; the client must reach 'close' with rstCode 0 and no 'trailers' event, and the server stream's sentTrailers must still be the block. Fails on the release binary (one 'trailers' event with []), passes with the fix.
  • Also run with the fix: the rest of h2-conformance.test.ts (67 pass) and node-http2.test.js (356 pass; the two "DATA payload survives its ArrayBuffer being detached" cases only hit the 5 s default timeout when the whole file runs on this debug build, pass on their own, and do not involve trailers), node-http2-continuation.test.ts, and the 14 upstream test-http2-*trailers*, *multiheaders*, *single-headers-validation*, sent-headers, misused-pseudoheaders, exceeds-server-trailer-size and no-wanttrailers-listener files, all passing. The probes below (including the queued-frame path) were run against node v26.3.0 and the fixed build with the same output.

Background

  • Trailers: with waitForTrailers, end() does not finish the stream. The stream emits 'wantTrailers' and the app calls sendTrailers(block), which normally goes out as a final HEADERS frame carrying END_STREAM. A stream can equally be finished by a zero-length DATA frame carrying END_STREAM; that is what sendTrailers({}) (and a stream with no 'wantTrailers' listener) sends in both node and bun.
  • Array values: in the object form of a header block an array means one field per element, so an empty array contributes no field. A block can therefore have keys and still encode to nothing.
  • sentTrailers: the stream property holding the block passed to sendTrailers(); it also backs the ERR_HTTP2_TRAILERS_ALREADY_SENT guard, which is why it stays set even when the block sends no fields.
  • Two layers: Http2Stream#sendTrailers in http2.ts validates the argument and calls either noTrailers(id) or sendTrailers(id, block, sensitive) on the native H2FrameParser; the native send_trailers() walks the object, HPACK-encodes each field into a buffer and writes the frame. The fix is in that native function because it is the only place that knows how many fields the walk produced.
  • Outbound queue: when the socket has backpressure or earlier frames are waiting on flow control, send_data() queues the frame per stream and the drain path writes it later, completing the stream's close at that point. The noTrailers() path has always gone through this; the trailer HEADERS frame was written directly.
Probes: server sends each block, client reports what it observed (node v26.3.0 vs bun)

Server: respond({":status": 200}, {waitForTrailers: true}), sendTrailers(block) from 'wantTrailers', end("body"). Client: logs the 'trailers' event (if any) and rstCode at 'close'.

block bun 1.4.0 server this PR / node v26.3.0 server
{ "x-none": [] } 'trailers' with {} / [], rstCode 0 no 'trailers' event, rstCode 0
{ "x-none": [], "x-other": [] } 'trailers' with {} / [], rstCode 0 no 'trailers' event, rstCode 0
{ "": "x" } 'trailers' with {} / [], rstCode 0 no 'trailers' event, rstCode 0
{} no 'trailers' event no 'trailers' event
{ "x-real": "1" } 'trailers' with { "x-real": "1" } same
{ "x-none": [], "x-real": "1" } 'trailers' with { "x-real": "1" } same

stream.sentTrailers on the server is the passed block in every row on all three. The client direction (req.sendTrailers({ "x-none": [] }) against a same-runtime server) behaves the same way: the unfixed bun server logs a 'trailers' event with [], node and the fixed build log none.

Queued path: stream A's response exhausts the send windows while the client is not reading it, then stream B sends { "x-none": [] } as trailers, so B's terminating frame goes through the outbound queue. Node and the fixed build: B closes on both ends with rstCode 0 and no 'trailers' event, and A still delivers all 196605 bytes once the client reads it; bun 1.4.0: the same, except one 'trailers' event on B.

Review notes

A self-review of the diff found no problem introduced by it. Two pre-existing things it turned up, neither changed here: streams whose final END_STREAM frame goes out through the outbound queue are never released until the session ends (this predates the PR and applies to sendTrailers({}) today; reported separately), and a client request() with endStream plus waitForTrailers marks the stream half-closed up front (#37718 is changing that path). Review nits on test coverage and comments were applied in the follow-up commits.

…() encodes no fields

sendTrailers() with a block whose values are all empty arrays (for example
{ "x-none": [] }) encoded to zero fields and still went out as a HEADERS
frame with a zero-length block, which the peer reports as a 'trailers'
event with no headers. Node's Http2Stream::SubmitTrailers sends an empty
END_STREAM DATA frame when the encoded block has no fields, the same frame
sendTrailers({}) produces, so the peer sees no trailers at all.

The native sendTrailers() now takes the noTrailers() path when nothing was
encoded; both share one helper. sentTrailers still records the block that
was passed, as in node.
@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:26 AM PT - Aug 13th, 2026

❌ @robobun, your commit 0fa14c2 has some failures in Build #93984 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37951

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

bun-37951 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on the 1.4.0 release and on main with stream.sendTrailers({ "x-none": [] }) (and from a client stream): the peer gets a 'trailers' event with an empty header list, node v26.3.0 sends an empty END_STREAM DATA frame and the peer gets none. Fix and tests are in this PR; the new tests fail on the release binary and pass with the fix. Self-review done (notes in the description); all review threads addressed. Overlaps with one hunk of #37953 (noted on both PRs).

CI on the final revision (build 93984): every test lane that ran is green (177 jobs; the only annotations are retried-then-passed tests unrelated to http2). The build shows as failing only because the two darwin 26 aarch64 test jobs were canceled without ever being assigned an agent, which also happened on the previous build (93682, where the darwin 14 jobs did run and pass). Ready for a maintainer.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 17894d56-e519-430a-876f-82b599da5f08

📥 Commits

Reviewing files that changed from the base of the PR and between 165dc9f and 0fa14c2.

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

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

@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 — narrow Node-compat fix with comprehensive wire-level coverage.

Checked that send_no_trailers() is a byte-identical extraction of the old no_trailers() body, that &mut stream at the new call site coerces via GuardedStream: DerefMut<Target = Stream>, and that the empty-block early return skips the same state-transition/onStreamEnd tail that send_data(.., close=true, ..) already performs on the existing noTrailers() path. Tests cover server (three empty variants + one non-empty control) and client, plus an end-to-end bun↔bun case asserting no 'trailers' event and preserved sentTrailers; all wire error handlers to reject, use port: 0, and clean up in finally.

Extended reasoning...

Overview

The PR fixes a Node.js-compat divergence in node:http2: when stream.sendTrailers() is passed a block whose values all encode to nothing (e.g. { "x-none": [] }), Bun was writing a zero-length HEADERS(END_STREAM|END_HEADERS) frame, causing the peer to emit a spurious 'trailers' event with an empty header list. Node instead sends an empty DATA(END_STREAM) frame and no 'trailers' event fires. The fix is 4 new lines in H2FrameParser::send_trailers() checking encoded_headers.is_empty() after the walk, plus a 2-line helper send_no_trailers() extracted verbatim from the existing no_trailers() host fn so both paths share one implementation. src/js/node/http2.ts only has a comment rewrite (no logic change). Two test files gain 6 tests total.

Security risks

None. This is protocol framing on the outbound path — it decides between two already-legal ways to end a stream we control. No untrusted input parsing, no size arithmetic, no new allocation, no auth/crypto surface. The empty-block case previously wrote a 0-length HEADERS frame (accepted by peers); now it writes a 0-length DATA frame instead.

Level of scrutiny

Medium — native HTTP/2 code, but the change is mechanically small and reuses an existing shipped code path. I verified: (1) the helper is a pure extraction — no_trailers() previously did stream.wait_for_trailers = false; let _ = this.send_data(stream, b"", true, JSValue::UNDEFINED, false, false), which is exactly what send_no_trailers() now does; (2) the new call site passes &mut stream where stream: GuardedStream<'_>, which auto-derefs to &mut Stream via the existing DerefMut impl; (3) the early return skips the HEADERS-frame tail (state transition + onStreamEnd dispatch), but send_data(.., true, ..) performs the equivalent END_STREAM handling — this is the same tail behavior no_trailers() has always relied on. The dispatch guard held by GuardedStream remains armed through the helper call, and send_data with JSValue::UNDEFINED callback runs no user JS.

Other factors

Test coverage is thorough for a change this size: wire-level assertions on frame type/flags/length for three empty-encoding variants plus {}, a non-empty control ({ "x-none": [], "x-sent": "1" }) proving the fix does not swallow real trailers, both server→client and client→server directions, and an end-to-end bun↔bun test asserting rstCode: 0, no 'trailers' event, and sentTrailers still set. Tests wire 'error' to reject, use port: 0, await observable conditions (frame arrival) rather than sleeps, and clean up in finally. The PR description documents that three of the five wire tests fail on the release binary and cites the Node source (Http2Stream::SubmitTrailers). No CODEOWNERS entries match these files. No prior reviewer comments to address.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated

@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 routing an empty-encoded trailer block through the existing noTrailers path.

What was reviewed:

  • send_no_trailers helper is a straight extraction of the two lines no_trailers already ran; both call sites (no_trailers host fn and the new encoded_headers.is_empty() branch) now share it.
  • The empty check sits after the encode loop and before frame construction, so single-value bookkeeping and validation errors still fire first; &mut stream (a GuardedStream) deref-coerces to &mut Stream for the helper.
  • Test matrix covers server × {empty-array, single-value empty-array, empty-name, {} control, mixed real+empty control} and client, asserting the exact terminating frame at wire level plus sentTrailers retention; the end-to-end test wires every failure path to reject.
Extended reasoning...

Overview

Four files: a 4-line early-return in H2FrameParser::send_trailers that diverts to a new 3-line send_no_trailers helper when the HPACK-encoded trailer block is empty; the helper is the extracted body of the existing no_trailers host function (clear wait_for_trailers, send_data(b"", close=true)). A stale 6-line JS comment is deleted. Two test files gain a wire-level describe.concurrent block (raw H2 peer records frames, asserts DATA(END_STREAM, len 0) and exactly one HEADERS frame) and one bun-server↔bun-client end-to-end test (no 'trailers' event, rstCode 0, sentTrailers preserved).

Security risks

None. No user-controlled input reaches new parsing logic; the change only picks a different terminal frame type for an already-validated outbound block. No allocation, no bounds arithmetic, no new JS-visible surface.

Level of scrutiny

Moderate — HTTP/2 wire code, but the new branch is the exact path sendTrailers({}) and the no-'wantTrailers'-listener case have always taken, so its state transition, onStreamEnd dispatch, and backpressure queueing are already exercised in production. The check is on the encoder's own output buffer, so it cannot misfire on a block that produced any bytes.

Other factors

The PR description cites Node's Http2Stream::SubmitTrailers as the reference, and every asserted outcome was cross-run under Node v26.3.0. Tests follow the file's harness conventions (raw-peer helpers, Promise.withResolvers with error handlers wired to reject, test.each for the matrix, a positive control that must still send trailers). The comment-cop threads were addressed in the follow-up commits and are resolved. The noted overlap with #37953 is a few-line same-spot conflict, not a semantic disagreement.

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