Skip to content

node:http2: record empty trailers as sent when the stream ends without a 'wantTrailers' listener - #37722

Open
robobun wants to merge 7 commits into
mainfrom
farm/769446ad/http2-no-listener-trailers-already-sent
Open

robobun wants to merge 7 commits into
mainfrom
farm/769446ad/http2-no-listener-trailers-already-sent

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A waitForTrailers stream that ends its body with no 'wantTrailers' listener still accepts a later sendTrailers(): a trailer HEADERS frame goes out on a stream bun already half-closed with an END_STREAM DATA frame (RFC 9113 5.1 / 8.1). Node throws ERR_HTTP2_TRAILERS_ALREADY_SENT and leaves sentTrailers as {}.
  • Cause, client side (the path hit in practice): with no listener the session handler ended the stream without recording that trailers were sent, so nothing later refused the sendTrailers(), and the native send has no stream-state check.
  • Cause, server side: the trailers were recorded, but only after the stream was ended, so a sendTrailers() issued from 'prefinish' (which fires inside that call) still reached the wire.
  • Also, the session handlers still emitted 'wantTrailers' on a stream that had been close()d before its last chunk went out. Node does not ask a closed stream for trailers.

Fix

  • The three places that ask a stream for its trailer block now share one stream-level helper with the shape of node's onStreamTrailers: a closed stream is left alone; with no listener, record sentTrailers = {} and then end the stream; otherwise emit 'wantTrailers'.
  • Why that holds: the trailers are recorded as sent before the stream is ended, so every sendTrailers() after that point, including one from inside the ending call, hits the existing already-sent guard and writes nothing. No native change.
  • Behavior change: a stream that is already closed when its trailer block is due now gets neither the event nor an empty END_STREAM frame; the body is followed by close()'s RST_STREAM(NO_ERROR) alone and sentTrailers stays undefined, as in node. The server path used to send an END_STREAM frame right before the reset.
  • Verification: five wire-level tests against a raw peer. Three fail on main (client with no listener, server with no listener, stream closed before it was asked); two pass on both and pin the listener-calls-close() contract the helper now routes. The upstream test-http2-* suite was also run on a debug build.

Background

  • waitForTrailers (an option to request() / respond()) stops the stream from ending after the last body chunk. Instead it emits 'wantTrailers' and the app calls sendTrailers(headers), a final HEADERS frame carrying END_STREAM, or close(). With no listener, the stream is expected to end itself with empty trailers.
  • Half-closed: once a side has sent a frame with END_STREAM, the only thing it may still send on that stream is RST_STREAM. A trailer HEADERS frame after an END_STREAM DATA frame is a protocol violation.
  • sentTrailers is the stream property recording the trailers that went out. It doubles as the guard behind ERR_HTTP2_TRAILERS_ALREADY_SENT; undefined means the stream was never completed with trailers.
  • The trailer block is requested from two places: client streams are asked from a session-level native callback, server streams from the stream's own end-of-writable hook. Before this PR each had its own copy of the no-listener logic (three copies in all).
  • close() on an Http2Stream sends RST_STREAM(NO_ERROR) once the writable finishes, so a closed stream needs no END_STREAM frame to be torn down.

[review] gate passed · iteration 4 · 2 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.0 (e8c7c682d)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [385.71ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [103.55ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [78.63ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [44.31ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [37.35ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [33.91ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [41.94ms]
(pass) PING (checklist §3.7) > a PING on a non-zero st
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [11.66ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [2.86ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [2.52ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [1.36ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [1.16ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [1.15ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [1.29ms]
(pass) PING (checklist §3.7) > a PING on a non-zero stream id is a PROTOCOL_ERROR [1.02ms]
(pass) WINDOW_UPDATE (checklist §6) > a connection-level WINDOW_UPDATE with a 0 increment is a PROTOCOL_ERROR [1.17ms]
(pass) WINDOW_UPDAT
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/http2/h2-conformance.test.ts"
bun test v1.4.0 (e8c7c682d)

test/js/node/http2/h2-conformance.test.ts:
(pass) connection preface & SETTINGS handshake (checklist §1) > server sends a SETTINGS frame first (§1.4) [386.29ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > server ACKs the client's SETTINGS frame (§3.5) [103.69ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame with a non-zero stream id is a PROTOCOL_ERROR (§3.5) [78.93ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS frame whose length is not a multiple of 6 is a FRAME_SIZE_ERROR (§3.5) [43.93ms]
(pass) connection preface & SETTINGS handshake (checklist §1) > a SETTINGS ACK that carries a payload is a FRAME_SIZE_ERROR (§3.5) [38.25ms]
(pass) PING (checklist §3.7) > server replies to PING with a PING ACK echoing the payload [35.22ms]
(pass) PING (checklist §3.7) > a PING with length != 8 is a FRAME_SIZE_ERROR [83.03ms]
(pass) PING (checklist §3.7) > a PING on a non-zero st
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e8c7c682dc
  features     baseline

22 deps, 107 codegen, 1176 objects in 1041ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (da3851e57)

Checked 107 installs across 153 packages (no changes) [11.00ms]
[2/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (da3851e57)

Checked 1 install across 2 packages (no changes) [6.00ms]
[3/1238] gen bindgenv2
[4/1238] gen ErrorCode+*.h
[5/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (da3851e57)

Checked 129 installs across 147 packages (no changes) [6.00ms]
[6/1238] fetch picohttpparser
[picohttpparser] up to date
[7/1238] fetch zlib
[zlib] up to date
[8/1238] fetch tinycc
[tinycc] up to date
[9/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1237] gen .bind.ts → GeneratedBindings.cpp
[11/1237] subst deps/zlib/zlib.h
[12/1237] subst deps/zlib/zconf.h
[13/1188] fetch nodejs (prebuilt)
[nodejs] up to date
[14/11
... (truncated)
diff hotspot
src/js/node/http2.ts                      |  47 +++----
 test/js/node/http2/h2-conformance.test.ts | 207 ++++++++++++++++++++++++++++++
 2 files changed, 226 insertions(+), 28 deletions(-)

gate history · 2 passed · 0 rejected · iteration 4

evidence per changed file
file                                       reads  edits  tests
src/js/node/http2.ts                          26     10      0
test/js/node/http2/h2-conformance.test.ts      8      9      0
Original description

Repro

A waitForTrailers stream whose body finishes with no 'wantTrailers' listener, followed by a late sendTrailers() (the peer here is a raw server that only completes the SETTINGS handshake, so the stream stays half-closed):

const req = client.request({ ":method": "POST", ":path": "/" }, { waitForTrailers: true });
req.end("hello"); // no 'wantTrailers' listener
setTimeout(() => {
  try {
    req.sendTrailers({ "x-late": "1" });
    console.log("no throw", req.sentTrailers);
  } catch (e) {
    console.log(e.code, req.sentTrailers);
  }
}, 300);
wire (stream 1) late sendTrailers() sentTrailers
node v26.3.0 HEADERS, DATA(5), DATA(0, END_STREAM) throws ERR_HTTP2_TRAILERS_ALREADY_SENT {}
bun 1.4.0 / main same three frames, then an extra HEADERS(END_STREAM) returns { "x-late": "1" }

That extra trailer HEADERS frame is written on a stream we already half-closed with the END_STREAM DATA frame (RFC 9113 5.1 / 8.1).

Cause

Node's onStreamTrailers handles the no-listener case by calling stream.sendTrailers({}) itself, which both ends the stream (empty END_STREAM DATA frame) and records the trailers as sent. Bun's session-level wantTrailers handlers (ClientHttp2Session and ServerHttp2Session in src/js/node/http2.ts) called native noTrailers() directly without recording anything, so a later sendTrailers() passed both the sentTrailers and WantTrailer guards and native send_trailers() (which has no stream-state check) wrote the frame. Http2Stream#_final had its own copy of this logic that did record the trailers, but only after the native call returned, so a sendTrailers() issued from inside that call (the writable's 'prefinish' fires there) still went through. The client path is the one hit in practice: client streams always get their wantTrailers through the native dispatch, server streams through _final.

Fix

One Http2Stream#[kWantTrailers] helper now backs all three sites (both session handlers and _final) and has the shape of node's onStreamTrailers: a closed stream is left alone; otherwise, with nothing listening for 'wantTrailers', it records sentTrailers = {} and then calls noTrailers(); otherwise it emits the event.

The closed-stream rule is a small behavior change in its own right. The session handlers previously had no such rule, so a stream close()d before its last chunk went out still got 'wantTrailers' emitted, and a listener's sendTrailers() could only throw ERR_HTTP2_INVALID_STREAM from inside the native dispatch (the compat layer's onStreamTrailersReady is such a listener, attached to every Http2ServerResponse). _final did have the rule, but ended the stream with an empty END_STREAM DATA frame instead of asking; that frame was not needed for anything (the writable finishes through _final's own callback, or through the EndStreamSent short-circuit on the dispatch path, and close() has already arranged for the RST_STREAM), and it told the peer the message was complete right before resetting it. Node sends nothing there: the body frames are followed by RST_STREAM(NO_ERROR) alone and sentTrailers stays undefined, which is what both paths do now. The same holds when a listener close()s the stream instead of sending trailers (the other half of the documented 'wantTrailers' contract), which is unchanged here but was untested.

No native change: the JS bookkeeping is the layer node enforces this at, and it is what makes the error code (ERR_HTTP2_TRAILERS_ALREADY_SENT rather than a native throw) match.

Related but separate: #37718 (endStream + waitForTrailers on request()) and #37712 (empty DATA frames / frameError stream id) change what happens before the trailer block is requested; this PR is about what happens after the no-listener path has ended the stream, and the handler both of them route through is the one fixed here.

Verification

Five wire-level tests in test/js/node/http2/h2-conformance.test.ts (raw peer on the other side of a node:http2 client or server). The first three fail on main and pass with the fix; the last two pass on both and pin the listener-calls-close() contract the helper now routes:

  • client, no listener (the native dispatch path): sentTrailers is {}, the late sendTrailers() throws ERR_HTTP2_TRAILERS_ALREADY_SENT, and a PING round trip confirms nothing else reached the wire after the END_STREAM DATA frame. On main: sentTrailers is undefined.
  • server, no listener (the _final path): sendTrailers() from 'prefinish' and from 'finish' both throw ERR_HTTP2_TRAILERS_ALREADY_SENT and the wire ends with the END_STREAM DATA frame. On main the 'prefinish' call succeeds and a trailer HEADERS frame follows the END_STREAM frame.
  • client with a listener, close()d while the chunk passed to end() was still buffered behind an in-flight write: 'wantTrailers' is not emitted, sentTrailers stays undefined, and DATA, DATA, RST_STREAM(NO_ERROR) go out with no END_STREAM frame, as in node. On main the event fires on the closed stream. (An earlier version of this test issued the request before the session had connected; that variant never saw the RST_STREAM on the darwin-14-aarch64 lane, deterministically, while the linux lanes passed. The RST for a queued stream is sent by the pre-existing sendRstOnReady path, which this PR does not touch; the current test uses a connected stream and does not depend on it.)
  • client and server, a listener that calls close(): events are 'wantTrailers', 'finish', 'close', sentTrailers is undefined, and the body DATA is followed by RST_STREAM(NO_ERROR) alone.

Also run with the debug build: the rest of h2-conformance.test.ts, node-http2.test.js, and all 256 upstream test-http2-*.js files in test/js/node/test/parallel (one of them, test-http2-forget-closed-streams.js, a 10,000-request loop, timed out once on a heavily loaded box and passed on rerun; it does not involve trailers).

@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: 8d9906b8-a8d1-4316-81f3-ccc8023b3bc2

📥 Commits

Reviewing files that changed from the base of the PR and between 6596bf1 and e8c7c68.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/js/node/http2/h2-conformance.test.ts

Walkthrough

The HTTP/2 implementation centralizes trailer handling for client and server streams. The conformance suite verifies trailer submission errors, automatic empty completion, stream closure, event ordering, and emitted frames.

Changes

HTTP/2 trailer lifecycle

Layer / File(s) Summary
Centralize trailer completion handling
src/js/node/http2.ts
Http2Stream[kWantTrailers] now handles closed streams, empty trailer submission, and wantTrailers emission. _final, server handling, and client handling use the shared helper.
Validate trailer lifecycle behavior
test/js/node/http2/h2-conformance.test.ts
Tests cover late sendTrailers() errors, empty END_STREAM DATA, suppressed wantTrailers, stream closure, event ordering, trailer state, and client/server frame sequences.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 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 primary HTTP/2 trailer bookkeeping fix.
Description check ✅ Passed The description explains the problem, fix, behavior changes, and verification results, although it does not use the template headings.

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:04 PM PT - Aug 12th, 2026

⏳ @robobun, your commit e8c7c68 is still building in Build #93539, but has 2 failures so far (All Failures):

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed; the diff is green, and this is ready for a maintainer.

Reproduced with the raw-peer script in the description against bun 1.4.0 and current main: after the no-listener wantTrailers path ended the stream, a late sendTrailers() returned normally, sentTrailers became the late headers, and a trailer HEADERS(END_STREAM) frame went out after the END_STREAM DATA frame. Node v26.3.0 throws ERR_HTTP2_TRAILERS_ALREADY_SENT and sends nothing.

The first three of the five tests added to test/js/node/http2/h2-conformance.test.ts fail on main and pass with this branch; the other two pin the listener-calls-close() contract on both sides.

CI: the code has been unchanged since 73280c1. Build 93354 for that commit passed 191 jobs (darwin-14-aarch64 included, which runs these tests); its only red job was test/js/third_party/grpc-js/test-tonic.test.ts failing to download protoc from github.com before reaching any http2 code (reported separately), and its darwin-26-aarch64 jobs never got an agent. The re-run for the empty commit e8c7c68 (build 93539) did not get as far as compiling: every build agent failed to download c-ares, mimalloc and WebKit from github.com ("Failed to download after 5 attempts"), so it says nothing about this change. I am not going to keep re-triggering; a maintainer can re-run CI once the fleet can reach github.com again.

Reviews: claude[bot] and CodeRabbit both reviewed the final code with no findings; all review threads are resolved.

@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 consolidates the three wantTrailers dispatch sites into one helper and records sentTrailers = {} before the native noTrailers() call.

What was reviewed:

  • Verified the new [kWantTrailers] helper is inside the Http2Stream class body so #sentTrailers/#id private-field access is valid, and that all three former call sites (client session handler, server session handler, _final) now route through it.
  • Checked the if (native) guard vs. the old ?.noTrailers() — the emit-with-null-native case is unreachable (the callback is invoked from native) and a listener's sendTrailers() would no-op there anyway.
  • Confirmed sendTrailers() checks destroyed || closed before #sentTrailers, so the closed-stream path still reports ERR_HTTP2_INVALID_STREAM as node does.
  • Tests await wire frames / PING-ACK barriers (no sleeps), assert exact frame sequences, and cover all three paths.
Extended reasoning...

Overview

The PR fixes a Node.js compat gap in src/js/node/http2.ts: when a waitForTrailers stream ends without a 'wantTrailers' listener, Bun's session-level handlers called native noTrailers() without recording #sentTrailers, so a later sendTrailers() would write a trailer HEADERS frame on an already half-closed stream (RFC 9113 §5.1/§8.1 violation). The _final path did record it, but after the native call, leaving a 'prefinish' window. The fix extracts one [kWantTrailers](native) helper on Http2Stream that (a) sets #sentTrailers = {} before noTrailers(), (b) applies the StreamState.Closed guard uniformly (previously only _final had it), and (c) is called from all three sites. Net production diff is ~16 lines added and ~24 removed — mostly deduplication.

Security risks

None. This is JS-side bookkeeping in the node:http2 compat layer that tightens behavior toward the spec (rejecting a late sendTrailers() instead of writing an extra frame). No new user input is parsed; no auth/crypto/permission surface.

Level of scrutiny

Medium. node:http2 is production-facing, but the change is a narrow refactor-plus-ordering-fix at the JS bookkeeping layer with no native changes. I verified: private-field access from the symbol-keyed method works because it's declared in the class body; the sendTrailers() guard order (closed → #sentTrailers → WantTrailer) means the closed-stream branch still throws ERR_HTTP2_INVALID_STREAM; and the one behavioral difference in the null-native edge (previously would still emit, now skips) is unreachable since these callbacks are dispatched from native, and even if reached the listener's sendTrailers() would no-op on session[bunHTTP2Native]?..

Other factors

Three new wire-level tests in h2-conformance.test.ts cover each path (client via native dispatch, server via _final/'prefinish', closed stream not emitting), all awaiting real frames with a PING-ACK barrier before asserting the exact stream-1 frame sequence — no sleeps, try/finally cleanup, matches the file's existing harness conventions. The PR description confirms they fail on main and pass with the fix, and that the existing test-http2-trailers* / test-http2-no-wanttrailers-listener upstream tests still pass. No prior reviewer comments; bug-hunting system found nothing.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up on verification: the full debug build has since completed here, and bun bd test test/js/node/http2/h2-conformance.test.ts passes with it (64 tests, including the three added in this PR). No review threads to address.

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts 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 that consolidates three copies of the wantTrailers no-listener path into one helper and records sentTrailers = {} before the native noTrailers() call.

What was reviewed:

  • Verified the helper's ordering change (set #sentTrailers before noTrailers()) is what makes the 'prefinish' case throw ERR_HTTP2_TRAILERS_ALREADY_SENT, and that sendTrailers() checks closed first so the closed-stream branch setting #sentTrailers is not observably wrong.
  • Checked the if (native) guard at both session call sites: previously the listener branch emitted regardless of native, but a listener's sendTrailers() no-ops on a null native anyway, so no behavior lost.
  • Confirmed the auto-close hook at line ~3364 (WantTrailer && sentTrailers === undefined) is unaffected — the session handlers already set WantTrailer before calling the helper, and the helper now sets sentTrailers, which is the state that lets auto-close proceed.
  • Tests cover all three call sites (client native dispatch, server _final, closed-stream) with wire-level frame assertions and a PING barrier.
Extended reasoning...

Overview

This PR fixes a Node.js compatibility gap in node:http2: when a waitForTrailers stream ends without a 'wantTrailers' listener, Bun's session-level handlers called native noTrailers() without recording #sentTrailers, so a later sendTrailers() passed the guard and wrote a trailer HEADERS frame on an already half-closed stream (RFC 9113 §8.1 violation). Node throws ERR_HTTP2_TRAILERS_ALREADY_SENT in this case.

The fix extracts one Http2Stream#[kWantTrailers] helper backing all three sites (ClientHttp2Session, ServerHttp2Session, and _final). The helper sets #sentTrailers = {} before calling native.noTrailers() (fixing the _final path's ordering bug where 'prefinish' fires inside that call), and applies _final's existing closed-stream rule to the session handlers (matching Node's onStreamTrailers early return). Net: ~15 lines removed from src, one 8-line helper added, three wire-level tests added.

Security risks

None. This is JS-side bookkeeping in the http2 stream lifecycle — no parsing of untrusted input, no auth/crypto, no new external surface. The change makes behavior stricter (rejects a late sendTrailers() that previously succeeded and wrote a spec-violating frame).

Level of scrutiny

Medium. HTTP/2 stream lifecycle is subtle, but this change is a consolidation + ordering fix, not new logic. Each of the three call sites already had a variant of this code; the helper is the union of their correct behaviors. I traced:

  • sendTrailers() checks destroyed || closed before #sentTrailers, so setting #sentTrailers = {} on the closed-stream branch doesn't change which error a caller sees there.
  • The if (native) guard replaces ?. — previously the listener branch would emit even with a null native, but the listener's sendTrailers() is itself a no-op on null native, so nothing observable is lost.
  • Private-field access (this.#sentTrailers, this.#id) from a computed-name method on the same class is valid.
  • The auto-close hook that gates on sentTrailers === undefined now correctly proceeds on the client no-listener path (previously it would have held indefinitely there, though the closed-stream teardown masks that in practice).

Other factors

  • Three new tests in h2-conformance.test.ts cover each call site with raw-peer wire assertions (exact frame sequence + PING barrier to prove nothing else was sent). The PR description confirms all three fail on main.
  • The full h2-conformance suite and the upstream Node trailer tests were run against the debug build.
  • No CODEOWNERS entry for this path.
  • The comment-cop bot flagged earlier revisions of the helper's comment; the author iteratively shortened it to two lines, which is reasonable documentation of non-obvious Node-matching behavior (not a workaround justification). No human reviewer comments are outstanding.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed since the last update:

  • 988da84 rewrites the third test. The previous version created the request before the session had connected, and on the darwin-14-aarch64 lane the stream's RST_STREAM never showed up (4 of 4 attempts in build 92773; the linux lanes passed). The RST for a not-yet-connected stream comes from the pre-existing queued-stream path, which this PR does not change, so the test now exercises the closed-stream case on a connected stream instead: a write in flight, the chunk passed to end() buffered behind it, close() before that chunk goes out. It still fails on main (the event fires on the closed stream) and passes here.
  • 11e08f4 and 9e1bb6b shorten the helper's comment; the review threads about it are answered and resolved.

The PR description's verification section is updated to match. With the current build: h2-conformance.test.ts 64/64 and node-http2.test.js 356 pass, 0 fail.

…ke node

close() already arranges for the RST_STREAM, and the writable finishes through
_final / the EndStreamSent short-circuit, so ending the stream with an empty
END_STREAM frame first only told the peer the message was complete right before
resetting it. Covers the listener-calls-close() contract on both sides.
Comment thread src/js/node/http2.ts
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 73280c1 after another self-review pass over the closed-stream branch of the helper.

The previous revision still ended a closed stream with an empty END_STREAM DATA frame before close()'s RST_STREAM went out (carried over from the old _final code, and newly applied to the session handlers). That frame turned out to be unnecessary: the writable finishes through _final's own callback (server path) or the EndStreamSent short-circuit (client dispatch path) either way, and close() has already scheduled the RST. Node's onStreamTrailers simply returns for a closed stream, so the body frames are followed by RST_STREAM(NO_ERROR) alone and sentTrailers stays undefined. The helper now does the same on both paths; the no-listener fix itself is unchanged. The third test's expectations follow node's wire, and two tests were added for a listener that calls close() instead of sendTrailers() (client and server), which had no coverage.

Verified with the debug build: h2-conformance.test.ts (66 tests), node-http2.test.js, and the 256 upstream test-http2-*.js files. The description is updated accordingly.

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

I reviewed this PR and didn't find any bugs. Because it changes HTTP/2 stream-state handling (the closed-stream branch in _final no longer calls noTrailers() and now relies on close()'s RST_STREAM alone), a human look at that transition would still be worthwhile.

What was reviewed:

  • Traced all three call sites into [kWantTrailers]: #sentTrailers = {} now precedes noTrailers(), closing the 'prefinish' window; the WantTrailer flag still guards double-dispatch.
  • Verified the closed-stream early return still lets _final's callback run so 'finish' fires and the deferred rstNextTick sends the RST_STREAM; sendTrailers() on that stream hits ERR_HTTP2_INVALID_STREAM via the this.closed guard, so leaving #sentTrailers unset there is fine.
  • Checked the if (native) guard in the session handlers: previously a null native still emitted 'wantTrailers'; now it does not, but the handler is only reached from a native dispatch so native is present.
  • Confirmed the five wire-level tests await real frame arrivals (PING barrier / RST) and assert exact frame sequences with no sleeps.
Extended reasoning...

Overview

The PR consolidates three near-identical wantTrailers handling sites in src/js/node/http2.ts — Http2Stream#_final, and the ClientHttp2Session / ServerHttp2Session native-dispatch handlers — into a single Http2Stream#[kWantTrailers] helper. The functional changes are: (1) #sentTrailers = {} is recorded before the native noTrailers() call rather than after (or not at all in the session handlers), so a sendTrailers() fired from inside that call (e.g. from 'prefinish') hits ERR_HTTP2_TRAILERS_ALREADY_SENT; and (2) a stream already marked Closed returns early without calling noTrailers() or emitting the event, matching node's onStreamTrailers. Five new wire-level tests in h2-conformance.test.ts assert exact frame sequences against a raw peer.

Security risks

None identified. This is bookkeeping in the JS layer of node:http2; no untrusted-input parsing, no auth/crypto, no resource-limit changes. The fix removes a way to write a trailer HEADERS frame onto an already half-closed stream, which is a protocol-conformance improvement.

Level of scrutiny

Moderate-to-high. node:http2 is a production Node-compat module and HTTP/2 stream state transitions are subtle. The change is small (~20 net runtime lines) and is a deduplicating refactor plus an ordering fix, but the closed-stream branch changes observable wire behavior in _final: previously a closed waitForTrailers stream reaching _final sent an empty DATA(END_STREAM) and recorded sentTrailers = {}; now it sends nothing and leaves sentTrailers undefined, relying on close()'s RST_STREAM to terminate. This matches node and is covered by the third and fourth new tests, but it is a real behavior change that a maintainer familiar with the http2 stream lifecycle should confirm.

Other factors

  • The PR description's "such a stream is still ended with the empty END_STREAM DATA frame" line is stale relative to the tip commit 73280c1, which changed the closed-stream branch to skip that frame entirely; the tests and the code comment reflect the current behavior.
  • The unresolved comment-cop bot comment on line 2637 flags the 3-line helper comment; the author already justified keeping a shortened version in earlier (resolved) threads, and the current comment is a one-sentence node source reference, not a workaround justification.
  • CI on 9e1bb6b passed on all lanes that ran; h2-conformance.test.ts, node-http2.test.js, and the upstream test-http2-trailers* / test-http2-no-wanttrailers-listener suites were reported passing with the debug build.
  • No prior review from me on this PR.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

The description was refreshed for 73280c1 a few minutes before that review landed, so the sentence it quotes about still sending the END_STREAM frame is gone; the Fix section now describes the early return and why the frame was not needed. Build 93354 for 73280c1 has 190 jobs green so far with the remaining 4 waiting for agents; no review threads are open.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Build 93539 (the re-run for the empty commit e8c7c68) failed before compiling anything: every build agent got "Failed to download after 5 attempts" fetching c-ares, mimalloc and WebKit from github.com, the same connectivity problem that broke the protoc download in build 93354. The code is unchanged since 73280c1, which built and passed on all lanes that ran in build 93354. The status comment at the top has the full picture; nothing further to push from my side.

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

I reviewed this PR and didn't find any bugs. The [kWantTrailers] consolidation and the #sentTrailers = {}-before-noTrailers() ordering fix look correct and match node's onStreamTrailers. Because the closed-stream early-return in _final changes wire output (drops the noTrailers() call that previously wrote the END_STREAM DATA frame), a maintainer look at the HTTP/2 stream-state interaction would still be worthwhile.

What was reviewed:

  • The three call sites (both session wantTrailers handlers and _final) now route through one helper; verified _final's callback still fires via line 2940 when the helper early-returns on a closed stream.
  • The if (native) guard in the session handlers replacing ?. — native dispatches these, so self[bunHTTP2Native] being null there is not reachable in practice.
  • The file-response auto-close guard at sentTrailers === undefined (line ~3366) — the new ordering means it now passes through on the no-listener client path where it previously held; this looks like an incidental fix rather than a regression.
Extended reasoning...

Overview

The PR consolidates three copies of the wantTrailers dispatch (in Http2Stream#_final, ServerHttp2Session's handler, and ClientHttp2Session's handler) into one Http2Stream#[kWantTrailers] helper that mirrors node's onStreamTrailers. Two behavior changes: (1) #sentTrailers = {} is now recorded before the native noTrailers() call rather than after (closing the 'prefinish' race), and applied on the session-handler path where it was previously missing entirely; (2) a stream already marked StreamState.Closed is left alone — no noTrailers() call, no event, no sentTrailers — matching node's early return. Five wire-level tests are added covering client/server × no-listener/listener-close.

Security risks

None. This is JS-side bookkeeping for the trailer-sent flag and event emission; no parsing of untrusted input, no auth/crypto.

Level of scrutiny

Medium-high. The runtime diff is small (~19 net lines) and directly ports node's shape with a source citation, but it sits inside _final's synchronous-reentry maze (the surrounding code has extensive comments about markWritableDone racing the callback stash) and changes what goes on the wire for a close()d stream. The PR itself went through a self-review iteration (73280c1) that revised exactly this closed-stream branch, which suggests the interaction is subtle enough to merit a second reader.

Other factors

The verification is unusually thorough: fails-on-main/passes-on-PR evidence for both ASAN and release builds, wire-level frame-sequence assertions with PING barriers, and a full run of the 256 upstream test-http2-*.js files plus node-http2.test.js. All comment-cop threads are resolved. CI is green on 190+ jobs. I traced the _final closed-stream path and confirmed the writable's callback still fires at line 2940 after the helper's early return, and checked that the sentTrailers === undefined guard in maybeScheduleFileResponseClose now passes through (rather than holding) on the client no-listener path — that reads as a fix, not a break. No concrete issue found; deferring only because HTTP/2 stream-state changes benefit from a maintainer familiar with the native noTrailers/writeStream contract confirming the dropped call is safe on every _final entry shape.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the one open question in that review (is skipping noTrailers() safe on every way _final can reach the trailers branch with the stream already closed): StreamState.Closed is only ever set by four things, and each one already covers the wire and the native stream.

  • close(): sends the RST_STREAM itself, immediately for a non-zero code or an already finished writable, otherwise from its 'finish' listener. After the helper returns, _final still invokes its callback (or finds that markWritableDone already did), so 'finish' fires and that RST goes out. Native end_stream then frees the stream and dispatches state 7, so the connection count and destroy bookkeeping run as usual.
  • _destroy(): marks the stream closed after its own end() and schedules its RST with setImmediate; same outcome.
  • The state 7 streamEnd handlers and emitStreamErrorNT: the native stream is already closed or reset there, so there is nothing left to send. On these shapes the old code's noTrailers() would have been a host call on a stream id the native side had already dropped; returning is the safer behavior.
  • _final's own even-id push branch and onEndStreamSettled: END_STREAM has already been sent on those paths and they return before the trailers branch is reached.

The native side is also not put into a new state by this: a stream left open with wait_for_trailers set and then reset is exactly what happened before whenever a 'wantTrailers' listener chose close() over sendTrailers(), which the fourth and fifth tests pin on both the client and the server.

On the auto-close guard (maybeCloseUnreadServerStream): it only runs for server streams, whose trailer block request goes through _final, which recorded sentTrailers = {} before this PR too, and its first line returns for a closed stream anyway, so its behavior is unchanged here.

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