Skip to content

node:http2: defer the callback in Http2Stream#end() on an already-ended stream - #33489

Open
robobun wants to merge 2 commits into
mainfrom
farm/1fa1d94a/http2-endstream-aborted
Open

robobun wants to merge 2 commits into
mainfrom
farm/1fa1d94a/http2-endstream-aborted

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

Since #32488, request() closes the writable side of every body-less request itself. A user's first .end(cb) on that stream now lands on Http2Stream#end()'s already-ended short-circuit, which invokes the callback synchronously. Node's Writable#end never does.

const req = client.request({ ":path": "/" });   // GET, endStream defaults true
let stillSync = true;
req.end(() => console.log({ calledSynchronously: stillSync }));
stillSync = false;
node v26.3.0            { calledSynchronously: false }
bun main (59242d6)      { calledSynchronously: true }
bun release (pre-#32488)  { calledSynchronously: false }

The same short-circuit also swallows the ERR_STREAM_ALREADY_FINISHED / ERR_STREAM_DESTROYED that Writable#end reports to the callback once the stream is past those states.

Cause

The already-ended path hand-rolled half the Writable#end contract (the return value) and dropped the other half:

if ((status & StreamState.EndedCalled) !== 0) {
  typeof callback == "function" && callback();   // synchronous, no error
  return this;
}

Before #32488 nothing inside request() set EndedCalled, so a user's first .end(cb) fell through to super.end(cb) and stayed async. Now every GET/HEAD/DELETE, and every explicit { endStream: true }, routes the first user .end(cb) into this branch.

Fix

Forward to Writable#end:

if ((status & StreamState.EndedCalled) !== 0) {
  return super.end(undefined, undefined, callback);
}

kEnding is already set, so super.end() skips the finishMaybe branch and cannot re-enter _final; all it does is route the callback, through the kOnFinished queue, with ERR_STREAM_ALREADY_FINISHED / ERR_STREAM_DESTROYED where node reports them, and it returns the stream. Passing undefined for the chunk keeps the existing silent-drop of a repeat end(chunk) (a separate pre-existing divergence).

History

This PR originally fixed the spurious 'aborted' on endStream requests (see the thread). #32488 landed the same fix independently and now covers that whole area. Running this PR's 15-test file against current main showed the headline bug is gone, but the end(cb) timing is not, for the same reason review caught it in the earlier version of this PR. So the PR now carries only that remaining piece.

Verification

New tests in test/js/node/http2/node-http2-end-stream.test.ts (15 tests). Two fail on main:

  • end() on an already-ended request stays chainable and defers the callback
  • end(callback) once the writable side finished reports ERR_STREAM_ALREADY_FINISHED

The other 13 pass on main (#32488 fixed them) and pin the contract main now claims: no 'aborted' on the four endStream shapes, explicit endStream: false delivering a body on GET/DELETE, close() on a body-less request, the queued-behind-maxConcurrentStreams path, the wire shape (no DATA frame on a stream the HEADERS frame already half-closed), and the negative contract (an AbortController firing mid-body and a peer RST_STREAM(CANCEL) still emit 'aborted' on a request whose writable half is open, and stay quiet on a body-less one).

Before / after, and no regressions
$ git stash push -- src/
$ bun bd test test/js/node/http2/node-http2-end-stream.test.ts
(fail) end() on an already-ended request stays chainable and defers the callback
(fail) end(callback) once the writable side finished reports ERR_STREAM_ALREADY_FINISHED
 13 pass, 2 fail

$ git stash pop
$ bun bd test test/js/node/http2/node-http2-end-stream.test.ts
 15 pass, 0 fail

Three consecutive runs of the new file: 15 pass each time.

bun bd test test/js/node/http2/: 423 pass / 1 fail, versus 421 pass / 3 fail on main with this test file in place. The one remaining failure is the pre-existing does not hold *Stream across user-controlled options getters debug+ASAN timeout.

All test-http2-* files under test/js/node/test/parallel/ against the debug build: 1 failure before and after, identical.


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

fails on main (without fix)
ASAN without fix: 2 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/node-http2-end-stream.test.ts"
bun test v1.4.0 (860820a15)

test/js/node/http2/node-http2-end-stream.test.ts:
(pass) http2 client request() endStream > explicit endStream does not emit 'aborted' [1150.77ms]
(pass) http2 client request() endStream > implicit GET does not emit 'aborted' [254.13ms]
(pass) http2 client request() endStream > implicit DELETE does not emit 'aborted' [148.89ms]
(pass) http2 client request() endStream > POST with endStream does not emit 'aborted' [108.34ms]
(pass) http2 client request() endStream > GET with an explicit endStream: false keeps the writable side open [145.65ms]
(pass) http2 client request() endStream > DELETE with an explicit endStream: false keeps the writable side open [156.98ms]
(pass) http2 client request() endStream > close() on a body-less request does not emit 'aborted' [185.37ms]
(pass) http2 client request() endStream > a request whose writable side is still open > still emits 'aborted' on an AbortController firing mid-body [209.34ms]
(pass) http2 client request() endS
... (truncated)

release without fix: 12 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/http2/node-http2-end-stream.test.ts:
51 |     ["implicit GET", { ":path": "/b" }, undefined],
52 |     ["implicit DELETE", { ":path": "/c", ":method": "DELETE" }, undefined],
53 |     ["POST with endStream", { ":path": "/d", ":method": "POST" }, { endStream: true }],
54 |   ])("%s does not emit 'aborted'", async (_name, headers, options) => {
55 |     await withEchoServer(async client => {
56 |       expect(await roundtrip(client, headers, options)).toEqual({
                                                             ^
error: expect(received).toEqual(expected)

  {
-   "aborted": false,
+   "aborted": true,
    "events": [
      "response",
      "end",
+     "aborted",
    ],
    "received": "",
-   "writableEndedAtRequest": true,
+   "writableEndedAtRequest": false,
  }

- Expected  - 2
+ Received  + 3

      at <anonymous> (/workspace/bun/test/js/node/http2/node-http2-end-stream.test.ts:56:57)
      at async withEchoServer (/workspace/bun/test/js/node/http2/node-http2-end-stream.test.ts:24:11)
      at async <anonymous> (/workspace/bun/test/js/node/http2/node-http2-end-stream.test.ts:55:11)
(fail) http2 client 
... (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/node-http2-end-stream.test.ts"
bun test v1.4.0 (860820a15)

test/js/node/http2/node-http2-end-stream.test.ts:
(pass) http2 client request() endStream > explicit endStream does not emit 'aborted' [772.45ms]
(pass) http2 client request() endStream > implicit GET does not emit 'aborted' [233.07ms]
(pass) http2 client request() endStream > implicit DELETE does not emit 'aborted' [112.78ms]
(pass) http2 client request() endStream > POST with endStream does not emit 'aborted' [108.36ms]
(pass) http2 client request() endStream > GET with an explicit endStream: false keeps the writable side open [137.47ms]
(pass) http2 client request() endStream > DELETE with an explicit endStream: false keeps the writable side open [129.30ms]
(pass) http2 client request() endStream > close() on a body-less request does not emit 'aborted' [118.13ms]
(pass) http2 client request() endStream > a request whose writable side is still open > still emits 'aborted' on an AbortController firing mid-body [186.67ms]
(pass) http2 client request() endSt
... (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     860820a15a
  features     baseline

22 deps, 108 codegen, 1171 objects in 993ms

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

Checked 124 installs across 170 packages (no changes) [16.00ms]
[2/1234] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1234] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [10.00ms]
[4/1234] gen ErrorCode+*.h
[5/1234] gen bindgenv2
[6/1234] fetch tinycc
[tinycc] up to date
[7/1234] fetch picohttpparser
[picohttpparser] up to date
[8/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1234] fetch zlib
[zlib] up to date
[10/1234] gen .bind.ts → GeneratedBindings.cpp
[11/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from 
... (truncated)
diff hotspot
src/js/node/http2.ts                             |   5 +-
 test/js/node/http2/node-http2-end-stream.test.ts | 316 +++++++++++++++++++++++
 2 files changed, 318 insertions(+), 3 deletions(-)

gate history · 1 passed · 0 rejected · iteration 4

evidence per changed file
file                                              reads  edits  tests
src/js/node/http2.ts                                  9     12      0
test/js/node/http2/node-http2-end-stream.test.ts      3      4      0

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR modifies node:http2 client behavior: Http2Stream.end() on an already-ended stream now delegates to Writable#end for correct callback semantics, and ClientHttp2Session.request() refines endStream defaulting for no-payload methods while calling req.end() consistently across multiple code paths. A comprehensive test file is added.

Changes

HTTP/2 endStream fix

Layer / File(s) Summary
Http2Stream.end() callback fix
src/js/node/http2.ts
Already-ended stream end() now routes through super.end() instead of invoking the callback synchronously.
endStream computation and req.end() wiring
src/js/node/http2.ts
endStream defaults to true only when unset by caller; content-length rejection gated on computed endStream; req.end() now called across queued, invalid-path, rejected-content-length, aborted-signal, out-of-stream, and successful request paths.
Tests for endStream and end() semantics
test/js/node/http2/node-http2-end-stream.test.ts
New test suite covering echo server roundtrips, body-less vs explicit endStream behavior, abort/RST_STREAM scenarios, chained end() callback ordering, ERR_STREAM_ALREADY_FINISHED, queued concurrency-limited streams, and raw HTTP/2 frame-level DATA frame validation.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ClientHttp2Session
  participant Http2Stream

  Caller->>ClientHttp2Session: request(options)
  ClientHttp2Session->>ClientHttp2Session: compute endStream (respect explicit false)
  alt endStream true
    ClientHttp2Session->>Http2Stream: req.end()
  end
  ClientHttp2Session-->>Caller: return req
Loading

Estimated code review effort: Medium

Related issues: None specified.

Related PRs: None specified.

Suggested labels: node.js, http2, bug

Suggested reviewers: None specified.


A stream once thought done, now ends its dance with care,
No payload, no body, but end() still marks it there.
Through queues and aborts, the callback finds its place,
Frames without extra DATA — a tidy HTTP/2 grace. 🐇

🚥 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 is specific and accurately reflects the main change: deferring Http2Stream#end() callback timing on already-ended streams.
Description check ✅ Passed The description covers the bug, cause, fix, and verification well, though it uses custom headings instead of the template wording.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:02 AM PT - Jul 29th, 2026

❌ @robobun, your commit 860820a has 1 failures in Build #85069 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33489

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

bun-33489 --bun

@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 didn't find any issues, but this threads req.end() through six distinct return paths in request() with ordering that depends on native stream state and _final timing — worth a human sanity check on the placement relative to #parser.request() and cork().

Extended reasoning...

Overview

This PR fixes two Node.js compat divergences in ClientHttp2Session#request() (src/js/node/http2.ts): (1) requests created with endStream: true (explicit or the GET/HEAD/DELETE default) spuriously emit 'aborted' because the JS Duplex's writable side was never closed, and (2) an explicit endStream: false on those methods was being overridden back to true. The fix defaults endStream only when unset, computes the effective value once, and calls req.end() on every return path where it's true — mirroring Node's if (options.endStream) stream.end(). It also makes Http2Stream#end() return this on its idempotent short-circuit, and gates the preemptive content-length rejection on the effective endStream rather than the method alone. Eight new tests cover the variant matrix.

Security risks

None. This is stream lifecycle bookkeeping in the Node compat layer; no auth, crypto, input parsing, or trust boundary is touched.

Level of scrutiny

Medium-high. The change itself is small and well-motivated (with cited Node source), but it inserts req.end() at six different points in request() — before/after native #parser.request(), before cork(), on error paths, and on the queued-behind-concurrency-limit path — and correctness depends on subtle interactions: _final parking on 'ready' for pending streams, the native side suppressing an empty DATA frame because can_send_data() is false in HALF_CLOSED_LOCAL, and _writableState.ending being set synchronously so _destroy/close no longer take the aborted branch. This is the kind of ordering that's easy to get wrong in one path and only show up under load or with specific peers.

Other factors

The PR description is thorough, the author ran the full test/js/node/http2/ suite and all 272 test-http2-* node parallel tests with no new failures, and the bug hunter found nothing. The tests look solid (they assert exact event sequences, writableEnded at request time, and body echo). I'm deferring only because the http2 client request path is load-bearing for grpc-js/got/http2-wrapper and the placement of end() relative to native registration and corking is subtle enough that a maintainer familiar with the native side should confirm the HALF_CLOSED_LOCAL suppression claim.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Fair concern: the HALF_CLOSED_LOCAL suppression was the one load-bearing claim in the description that wasn't pinned by a test. It is now, at the wire level, and the queued path has coverage too (c65ac4c).

The suppression claim, verified on the wire

A raw TCP server that decodes every frame the client sends, against node v26.3.0 and bun (debug) before and after the change:

                              node v26.3.0                        bun, before                       bun, after
GET (implicit endStream)      HEADERS(END_STREAM)                 HEADERS(END_STREAM)               HEADERS(END_STREAM)
explicit endStream: true      HEADERS(END_STREAM)                 HEADERS(END_STREAM)               HEADERS(END_STREAM)
GET endStream: false + body   HEADERS, DATA("hello",END_STREAM)   HEADERS(END_STREAM)   << dropped  HEADERS, DATA("hello"), DATA(0,END_STREAM)
POST + explicit end()         HEADERS, DATA("hi",END_STREAM)      HEADERS, DATA("hi"), DATA(0,EOS)  HEADERS, DATA("hi"), DATA(0,EOS)

Two things fall out of that:

  • The endStream rows are byte-identical before and after, and identical to node. req.end() adds nothing to the wire: _final calls writeStream(..., close = true), can_send_data() is false for HALF_CLOSED_LOCAL, the native returns early after invoking the write callback, and no DATA frame is emitted. The writable half finishes in JS without the stream ever sending a byte it shouldn't.
  • The trailing empty DATA(0, END_STREAM) on the body rows is pre-existing. The POST + explicit end() row shows bun doing it on main, on a path this PR doesn't touch. The new endStream: false row inherits that same write path, it isn't a new wart.

This is now the test puts a DATA frame on the wire only when the request can carry a body: it asserts exactly one HEADERS(END_STREAM) and zero DATA frames for endStream: true, and that the body actually reaches the wire for endStream: false. It deliberately doesn't assert the empty-DATA quirk, only that the concatenated DATA payloads are "hello" and the last one carries END_STREAM.

The queued path

The path where _final can't run yet (the stream has no id, so it parks on 'ready') is the SETTINGS_MAX_CONCURRENT_STREAMS queue. New test: maxConcurrentStreams: 1, three GETs, so the first submits immediately and the other two queue. All three come back with writableEnded === true the instant request() returns, aborted === false, writableFinished === true, rstCode === 0, so the parked _final does run once the HEADERS frame is actually submitted.

On the placement

Two constraints, and together they're why this isn't a single req.end() at the top of request() the way node has it:

  • It must follow #parser.request() on the submit path. The stream is only registered with the native parser there. Any earlier and _final -> writeStream(id, ...) throws Invalid stream id. Node doesn't hit this because its stream.end() runs while kID is still undefined, so node's _final parks on 'ready' exactly like bun's pending path does.
  • cork() is irrelevant to it. needFinish() doesn't look at state.corked, only at buffered.length, which is 0 for an end() with no chunk. Corking before or after doesn't change whether _final runs.

The other four sites are streams that never reach the wire (bad :path, content-length rejection, already-aborted signal, stream ids exhausted). They have no id, so _final parks and the only effect is _writableState.ending, which is precisely what keeps _destroy off the aborted branch. Node ends the writable side before any of those checks too.

All 10 tests in the endStream describe block fail on main and pass with the fix. bun bd test test/js/node/http2/: 379 pass, 2 fail, the same two pre-existing debug+ASAN timeouts as main (minimal maxSessionMemory and does not hold *Stream across user-controlled options getters).

@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 didn't find any bugs — the fix is well-reasoned and the wire-level frame test is a nice proof — but this threads req.end() into six different return paths of the client request() hot path with ordering that depends on native-side state (_final → writeStream suppressed by HALF_CLOSED_LOCAL), so it's worth a human familiar with the http2 native layer confirming those assumptions.

Extended reasoning...

Overview

This PR fixes a Node.js compatibility bug in src/js/node/http2.ts where every body-less HTTP/2 client request (explicit { endStream: true } or implicit via GET/HEAD/DELETE) spuriously emitted 'aborted' and set stream.aborted = true on normal completion. The root cause is that ClientHttp2Session#request() never closed the JS Duplex's writable half after the native layer sent END_STREAM on the HEADERS frame, so _destroy() misread the still-open writable side as "peer cut us off."

The fix has three parts, all in request():

  • Default endStream from the method only when the caller left it unset (an explicit endStream: false on GET/HEAD/DELETE now reaches native and can carry a body).
  • Call req.end() on every return path (6 sites: invalid-path reject, content-length reject, pre-aborted signal, queued-behind-concurrency-limit, out-of-streams, and the main submit path) when the effective endStream is true.
  • Gate the preemptive content-length rejection on the effective endStream rather than the method alone.

Plus a one-line fix: Http2Stream#end() now returns this on its idempotent short-circuit (was undefined), matching the Writable#end() contract.

~200 lines of new tests cover: no-aborted for all four endStream shapes, explicit endStream: false delivering a body on GET/DELETE, close() on body-less requests, end() chainability, the queued-behind-maxConcurrentStreams path, and a raw-socket frame decoder proving no extra DATA frame goes on the wire.

Security risks

None. This is client-side stream state and event emission for Node.js compat. No auth, crypto, input parsing, or trust-boundary changes.

Level of scrutiny

Medium-high. This is not a mechanical change:

  • Every HTTP/2 client request flows through request(); the change fans out across 6 return paths, each with its own ordering constraint (e.g., on the submit path req.end() must follow the native #parser.request() so the stream is registered before _final runs; on the queued path it precedes cork() and _final parks on 'ready').
  • Correctness depends on a native-side invariant the diff can't show: that writeStream(..., close=true) is suppressed when the stream is already HALF_CLOSED_LOCAL, so end() doesn't emit a stray empty DATA frame. The wire-level test verifies this empirically, which is reassuring, but a reviewer familiar with src/runtime/node/h2_frame_parser.rs (or equivalent) should confirm the assumption holds generally.
  • There's a behavioral change beyond the aborted fix: endStream: false on GET/HEAD/DELETE now sends a body where it was previously silently dropped. This matches Node but is a user-visible behavior change.

Other factors

  • The PR description is exceptionally thorough: root cause traced to Node's own lib/internal/http2/core.js, before/after verification, full http2 suite run (377 tests, same 2 pre-existing failures), and all 272 test-http2-* node parallel tests with identical pass/fail sets.
  • The wire-level test that decodes raw frames from a net.Server directly addresses the main risk (extra DATA frame).
  • No CODEOWNERS entry for this path.
  • No bugs found by the automated review.
  • CI is still building at time of review.

Given the breadth of code paths touched in a hot path and the reliance on native-side state machine behavior, I'm deferring rather than auto-approving.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

a reviewer familiar with h2_frame_parser.rs should confirm the assumption holds generally

The wire test shows it empirically; here is the static version, so a reviewer doesn't have to take the test's word for it. Nothing in this PR touches src/runtime/api/bun/h2_frame_parser.rs, so these are all on main:

  1. A fresh stream is OPEN. Stream::init (h2_frame_parser.rs:2062) sets state: StreamState::OPEN.
  2. request() with end_stream lands on HALF_CLOSED_LOCAL, always, for a client. (h2_frame_parser.rs:8995-9023)
    if end_stream {
        stream.end_after_headers = true;
        if wait_for_trailers {
            stream.state = StreamState::HALF_CLOSED_LOCAL;
            ...
        }
        if stream.state == StreamState::HALF_CLOSED_REMOTE {
            stream.state = StreamState::CLOSED;
            stream.free_resources::<false>(this);
        } else {
            stream.state = StreamState::HALF_CLOSED_LOCAL;
        }
    The HALF_CLOSED_REMOTE arm is unreachable from a client request(): the stream was just created as OPEN and the peer hasn't seen it yet, so it cannot have half-closed. That arm exists for the server's respond(), where the client's request body already arrived. This matters twice over: the stream never hits free_resources, so it stays registered and the later writeStream(id, ...) cannot throw Invalid stream id.
  3. HALF_CLOSED_LOCAL is not a sendable state. (h2_frame_parser.rs:2099)
    pub fn can_send_data(&self) -> bool {
        matches!(self.state, StreamState::IDLE | StreamState::OPEN | StreamState::HALF_CLOSED_REMOTE)
    }
  4. write_stream bails before it builds a frame. (h2_frame_parser.rs:7631)
    let mut stream = this.enter_stream_dispatch(stream_ptr);
    if !stream.can_send_data() {
        this.dispatch_write_callback(callback_arg);
        return Ok(JSValue::FALSE);
    }
    The only thing it does is invoke the JS write callback, which is _final's callback, which is what makes 'finish' fire.

So req.end() on an endStream request walks _final -> writeStream -> early return, and no byte reaches the wire. The wait_for_trailers branch lands on the same state, so endStream + waitForTrailers behaves identically (checked against node, matches).

On the intentional behaviour change

Right that endStream: false on GET/HEAD/DELETE now sends a body where it previously didn't. Worth being precise about the blast radius: the old behaviour wasn't "no body is sent", it was "the body is silently dropped" (bun put END_STREAM on the HEADERS frame anyway, then quietly discarded every write()). So the only code this can change is code that explicitly passed endStream: false and then wrote a body, which was already broken and silently losing data. Callers that never pass endStream are untouched: it still defaults to true for those three methods.

@robobun
robobun force-pushed the farm/1fa1d94a/http2-endstream-aborted branch from c65ac4c to 8e58cf9 Compare July 6, 2026 14:13
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto 48ff9eb and moved the tests into their own file (8e58cf9). Two housekeeping things, no change to the fix itself:

  • The red CI was my stale base. The only failure was test/js/bun/cookie/cookie-map.test.ts on three platforms, a Set-Cookie Expires format mismatch. main had already fixed it in cookie: update remaining cookie-map Expires assertions to IMF-fixdate #33425, which was the single commit my branch was behind. Rebasing clears it; nothing in this PR touches cookies.
  • The tests moved to test/js/node/http2/node-http2-end-stream.test.ts. They were in node-http2.test.js, which carries a 10k-request maxSessionMemory stress test sitting right on its 150s timeout under debug+ASAN (~128s on an idle box, over the line on a loaded one). Running the whole file to verify a 10-test behaviour change meant inheriting that flake. The new file runs in ~6s, five consecutive runs clean, and it matches the other focused files already in that directory. node-http2.test.js is untouched now, which also keeps this from conflicting with node:http2: don't emit 'aborted' when respond() ends the stream #33375.

Earlier comments in this thread reference c65ac4c and the old file path; the content they describe is unchanged, just relocated.

Comment thread src/js/node/http2.ts Outdated
@robobun
robobun force-pushed the farm/1fa1d94a/http2-endstream-aborted branch from 8e58cf9 to 5d5020d Compare July 6, 2026 16:17

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

Thanks for addressing the synchronous-callback issue — forwarding to super.end() is a cleaner fix than what I suggested, and the two new tests pin it. I didn't find anything further, but the coupled changes to the client request() path (six req.end() sites, the endStream defaulting, and the content-length gate) interact closely enough with the native stream state machine that someone familiar with h2_frame_parser.rs should give this a look.

Extended reasoning...

Overview

This PR fixes a Node.js compatibility bug in src/js/node/http2.ts: client requests created with endStream: true (explicit or the GET/HEAD/DELETE default) were emitting a spurious 'aborted' event on normal completion because the JS writable half was never closed. It also fixes a secondary divergence where an explicit endStream: false on those methods was overridden back to true, silently dropping the body. The fix has three coupled parts in ClientHttp2Session#request(): (1) only default endStream from the method when the caller left it unset, (2) call req.end() at six sites when the effective endStream is true so _writableState.ending is set before _destroy/close inspect it, and (3) gate the preemptive content-length rejection on the effective endStream rather than the method. A fourth change rewrites Http2Stream#end()'s already-ended short-circuit to delegate to super.end() — this addresses the synchronous-callback issue I flagged in a prior review, which the author confirmed and fixed. A new 241-line test file with 11 tests covers all variants including a raw-TCP wire-level frame decoder and a maxConcurrentStreams queued-path test.

Security risks

None identified. This is stream-lifecycle bookkeeping in the Node compat layer; no auth, crypto, permissions, or untrusted-input parsing is touched. The endStream: false behaviour change only affects callers who explicitly opted into sending a body on GET/HEAD/DELETE (matching Node), and the previous behaviour was silent data loss rather than a security boundary.

Level of scrutiny

This warrants human review. The HTTP/2 client request path is production-critical (used by grpc-js, got, http2-wrapper per the description), and the correctness of the fix rests on a specific interaction with the native layer: req.end() after an endStream HEADERS frame must be a no-op on the wire because can_send_data() returns false for HALF_CLOSED_LOCAL. The author has verified this both empirically (wire-level frame decoder test) and by static trace through h2_frame_parser.rs, and the reasoning looks sound to me — but the six req.end() insertions are placed at specific points relative to #parser.request(), cork(), and error-path nextTicks for stated ordering reasons, and a maintainer who owns that native code should confirm the placement and the state-machine assumptions hold generally (including under waitForTrailers and abort-signal races).

Other factors

My earlier inline comment about the synchronous end(cb) callback was addressed (the fix delegates to Writable#end, which is stronger than my suggestion — it also restores ERR_STREAM_ALREADY_FINISHED/ERR_STREAM_DESTROYED reporting and the return value), and two tests now pin that behaviour. The bug-hunting pass on the current revision found nothing. Test coverage is thorough (11 tests, all fail on main, wire-level and queued-path variants included), and the author reports no regressions across 272 test-http2-* Node parallel tests and test/js/node/http2/. The change is well-reasoned and well-verified — I'm deferring purely because it's a multi-part behavioural change on a hot Node-compat path, not because I see a problem.

@robobun
robobun force-pushed the farm/1fa1d94a/http2-endstream-aborted branch from 04ee69b to 80df27b Compare July 6, 2026 17:26
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

You named two scenarios worth checking, waitForTrailers and abort-signal races. I'd only hand-verified the first, so I went and probed both against node v26.3.0. The abort one turned up a genuine gap in my test coverage, now fixed in 80df27b.

Why the abort case was worth worrying about

The native aborted handler only emits the event when the stream wasn't already half-closed:

aborted(self, stream, error, old_state) {
  stream.rstCode = constants.NGHTTP2_CANCEL;
  // if writable and not closed emit aborted
  if (old_state != 5 && old_state != 7) {   // 5 = HALF_CLOSED_LOCAL
    stream[kAborted] = true;
    stream.emit("aborted");
  }

Every endStream request sits in HALF_CLOSED_LOCAL, so for those streams 'aborted' historically came only from _destroy's !ending branch, which is exactly the branch this PR stops taking. So a real question: does this swallow 'aborted' on a body-less request that genuinely was cut short?

Answer: no, and node agrees, because node skips the same branch for the same reason.

                                   node v26.3.0            bun on main             bun with this PR
GET  + AbortController mid-body    aborted=false           aborted=TRUE            aborted=false
GET  + peer RST_STREAM(CANCEL)     aborted=false           aborted=TRUE            aborted=false
POST + AbortController mid-body    aborted=true            aborted=true            aborted=true
POST + peer RST_STREAM(CANCEL)     aborted=true            aborted=true            aborted=true

The PR flips exactly the two rows where main was wrong and leaves the two where it was right. The writable-side-ended check is what tells a cut-short request from a completed one, in node and now here.

What I added

My tests only asserted 'aborted' is not emitted. Nothing guarded that it still is for a real abort, so a future change that suppressed the event outright would have passed all 11. Four new tests close that:

  • a POST with its writable half open still emits 'aborted' (and sets stream.aborted) for an AbortController firing mid-body, and for a peer RST_STREAM(CANCEL)
  • the same two cases on a body-less request stay quiet

The first pair passes on main too, by construction: it's the negative contract, and it only fails if someone breaks aborts. 13 of the 15 fail on main. The peer-reset case sends RST_STREAM from the server's write() completion callback rather than a timer, so the client always observes it after 'response', deterministically.

waitForTrailers

Re-checked with the super.end() change in place, since that was the one path where _final could have taken the trailers branch:

                             node v26.3.0                        bun with this PR
endStream + waitForTrailers  ["finish","response","end"]         ["finish","response","end"]
GET + waitForTrailers        ["finish","response","end"]         ["finish","response","end"]

Both match. endStream + waitForTrailers lands on HALF_CLOSED_LOCAL via the wait_for_trailers arm of request() rather than the normal one, so can_send_data() is false either way and _final's writeStream is suppressed identically. bunHTTP2WaitForTrailers is only ever set on server streams, so the client's _final never takes the trailers path at all.

test/js/node/http2/: 384 pass / 2 fail, the same two pre-existing debug+ASAN timeouts as main. New file: 15 tests, ~7s, three consecutive clean runs.

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready to merge, CI green

  • Gate: pass (robobun/evidence: "test fails without the fix and passes with it on ASAN and release builds").
  • CI: build #85069, 50 checks pass, 0 fail. (The darwin lanes that had Expired waiting for agents were retried and passed.)
  • Node parity: all 15 tests in the new file pass unmodified on Node.js v26.3.0 and on Bun with this fix (report).
  • Review: claude[bot] LGTM; CodeRabbit no actionable comments, 4/4 pre-merge checks; 0 unresolved threads.

What is verified

  • The 15-test file: 2 fail on main, 15 pass with the fix, three consecutive runs clean on each runtime.
  • test/js/node/http2/: 423 pass / 1 fail vs main's 421/3, the 1 being the pre-existing *Stream across user-controlled options getters debug+ASAN timeout.
  • All test-http2-* under test/js/node/test/parallel/: 1 failure before and after, identical set.

Comment thread src/js/node/http2.ts Outdated
@robobun
robobun force-pushed the farm/1fa1d94a/http2-endstream-aborted branch from aac9304 to acd46ff Compare July 29, 2026 09:35
robobun added 2 commits July 29, 2026 09:35
Since request() closes the writable side of every body-less request itself
(setupRequestEndAndSignal, added in #32488), a user's first .end(cb) lands on
the already-ended short-circuit, which invoked the callback synchronously and
returned. Writable#end never does: the callback goes through the kOnFinished
queue or a deferred tick with ERR_STREAM_ALREADY_FINISHED/ERR_STREAM_DESTROYED.

Forward to super.end(undefined, undefined, callback) instead. kEnding is
already set, so super.end() skips finishMaybe and cannot re-enter _final; it
only routes the callback.
The two end() callback cases fail on main and pass with the fix:
- end() on an already-ended request stays chainable and defers the callback
- end(callback) once the writable side finished reports ERR_STREAM_ALREADY_FINISHED

The rest pass on main (via #32488) and pin that contract: no spurious 'aborted'
on the four endStream shapes, endStream: false delivering a body, close() on a
body-less request, the queued-behind-maxConcurrentStreams path, the wire shape
(no DATA frame on a stream the HEADERS frame already half-closed), and the
negative (an AbortController firing mid-body and a peer RST_STREAM(CANCEL)
still emit 'aborted' on a request whose writable half is open, and stay quiet
on a body-less one).
@robobun
robobun force-pushed the farm/1fa1d94a/http2-endstream-aborted branch from acd46ff to 860820a Compare July 29, 2026 09:35
@robobun robobun changed the title node:http2: don't emit 'aborted' on a client request created with endStream node:http2: defer the callback in Http2Stream#end() on an already-ended stream Jul 29, 2026
@robobun

robobun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Retargeted

The spurious-'aborted' bug this PR originally fixed was done independently by #32488 (the big http2 compat PR). Running this PR's 15-test file against current main confirms it: 12 of them pass.

What #32488 did not cover is the end(cb) callback timing: it routed request() through req.end() the same way this PR's earlier revision did, so Http2Stream#end()'s already-ended short-circuit is now reached on every body-less request, and it invokes the callback synchronously. That's the one finding review caught in this PR's own earlier revision, and it's the same trap.

node v26.3.0              GET .end(cb) -> calledSynchronously: false
bun main (59242d6)        GET .end(cb) -> calledSynchronously: true
bun release (pre-#32488)  GET .end(cb) -> calledSynchronously: false

So the PR now carries only that: the super.end() delegation (a 3-line src diff), plus the test file. Two of its tests fail on main, the other 13 pin the endStream contract main now implements. Title and description are updated; the thread above refers to the earlier, broader diff.

@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 — the retargeted diff is a focused 3-line delegation to Writable#end, which is the layer that owns this contract.

What was reviewed:

  • Traced super.end(undefined, undefined, cb) through writable.ts:770-826: with kEnding set it skips finishMaybe (no _final re-entry), routes cb via nextTick/kOnFinished, reports ERR_STREAM_ALREADY_FINISHED/ERR_STREAM_DESTROYED, and returns this.
  • Checked the dropped-chunk and non-function-callback cases match old behavior; the uncork side effect is idempotent (first end() already uncorked).
  • Test file: no timers, awaits events, listen(0), error handlers wired to reject, cleanup in finally, ./helpers exports the frame utilities used.
Extended reasoning...

Overview

After retargeting, the source diff is 3 lines in src/js/node/http2.ts: the EndedCalled short-circuit in Http2Stream#end() now calls return super.end(undefined, undefined, callback) instead of invoking callback() synchronously and returning this. The 316-line test file covers the end(cb) timing/error contract plus 13 tests pinning the endStream behavior that #32488 already fixed on main.

Security risks

None. This is Node-compat callback ordering in a builtin JS module; no untrusted-input parsing, no auth/crypto/permissions, no native memory.

Level of scrutiny

Low-to-medium. The src change replaces hand-rolled behavior with delegation to the parent class that owns the contract — the "fix at the layer that owns the invariant" pattern the review guide asks for. I verified against src/js/internal/streams/writable.ts:799-826 that with kEnding set (guaranteed, since the first end() set it before setting EndedCalled), Writable#end skips finishMaybe entirely and only routes the callback. Edge cases: chunk is passed as undefined so a repeat end(chunk) stays a silent drop (pre-existing, called out in the description); a non-function callback is a no-op in both old and new code; uncork is idempotent.

Other factors

My prior inline finding on this PR (the synchronous callback) is exactly what the retargeted PR now fixes, and the thread is resolved. The comment-cop note about the long comment was addressed (tightened to one line naming the non-obvious invariant). Two of the 15 tests fail on main and pass with the fix; the other 13 pin #32488's contract. Tests follow harness conventions: dynamic ports, event-driven awaits (no sleep), error handlers reject the awaited promise, resources released in finally, it.each for the variant matrix. The wire-level test reuses the existing test/js/node/http2/helpers.js frame utilities. CI's only red was napi.test.ts on Windows, unrelated to node:http2.

@cirospaciari

Copy link
Copy Markdown
Member

Tests added or changed in this PR must pass on both Bun and Node.js v26.3.0, so they only assert behavior Node actually has.
Please run the new tests under Node v26.3.0 and fix any case that only passes on Bun.

@cirospaciari

Copy link
Copy Markdown
Member

@robobun please run the tests this PR adds or changes on Node.js v26.3.0 and report whether each one passes or fails.
They must pass on both Bun and Node v26.3.0, so fix any case that only passes on Bun.

@robobun

robobun commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

@cirospaciari ran the one test file this PR adds, unmodified, under Node.js v26.3.0 and under bun bd with the fix. All 15 pass on both. Nothing needed changing.

test Node v26.3.0 Bun (this PR)
explicit endStream does not emit 'aborted' pass pass
implicit GET does not emit 'aborted' pass pass
implicit DELETE does not emit 'aborted' pass pass
POST with endStream does not emit 'aborted' pass pass
GET with an explicit endStream: false keeps the writable side open pass pass
DELETE with an explicit endStream: false keeps the writable side open pass pass
close() on a body-less request does not emit 'aborted' pass pass
still emits 'aborted' on an AbortController firing mid-body pass pass
still emits 'aborted' on the peer sending RST_STREAM(CANCEL) pass pass
a body-less request stays quiet on an AbortController firing mid-body pass pass
a body-less request stays quiet on the peer sending RST_STREAM(CANCEL) pass pass
end() on an already-ended request stays chainable and defers the callback pass pass
end(callback) once the writable side finished reports ERR_STREAM_ALREADY_FINISHED pass pass
requests queued behind maxConcurrentStreams end their writable side once submitted pass pass
puts a DATA frame on the wire only when the request can carry a body pass pass

Three consecutive Node runs: tests 15, pass 15, fail 0 each time.

How it was run

The file imports bun:test, so under Node it went through a small loader hook that maps bun:test onto node:test + node:assert/strict (describe, it, it.each with %s titles, expect().toBe/.toEqual/.toMatchObject), and resolves the extensionless ./helpers import that Node's ESM resolver rejects. The test file itself was not touched. Node 26 strips the TypeScript natively.

$ node --version
v26.3.0
$ cd test/js/node/http2
$ node --import /tmp/buntest-shim/register.mjs --test --test-reporter=spec node-http2-end-stream.test.ts
▶ http2 client request() endStream
  ✔ explicit endStream does not emit 'aborted' (17.9ms)
  ✔ implicit GET does not emit 'aborted' (3.2ms)
  ✔ implicit DELETE does not emit 'aborted' (2.0ms)
  ✔ POST with endStream does not emit 'aborted' (2.5ms)
  ✔ GET with an explicit endStream: false keeps the writable side open (2.3ms)
  ✔ DELETE with an explicit endStream: false keeps the writable side open (1.6ms)
  ✔ close() on a body-less request does not emit 'aborted' (1.5ms)
  ▶ a request whose writable side is still open
    ✔ still emits 'aborted' on an AbortController firing mid-body (3.0ms)
    ✔ still emits 'aborted' on the peer sending RST_STREAM(CANCEL) (2.0ms)
    ✔ a body-less request stays quiet on an AbortController firing mid-body (1.5ms)
    ✔ a body-less request stays quiet on the peer sending RST_STREAM(CANCEL) (1.3ms)
  ✔ end() on an already-ended request stays chainable and defers the callback (2.3ms)
  ✔ end(callback) once the writable side finished reports ERR_STREAM_ALREADY_FINISHED (1.3ms)
  ✔ requests queued behind maxConcurrentStreams end their writable side once submitted (2.0ms)
  ✔ puts a DATA frame on the wire only when the request can carry a body (43.6ms)
ℹ tests 15
ℹ pass 15
ℹ fail 0

This was the expectation going in: every assertion in the file was written against Node's observed behaviour first (the probes are in the thread above), including the exact event orderings, the kOnFinished callback timing, ERR_STREAM_ALREADY_FINISHED, and the all-four abort/RST matrix. This run confirms it end to end rather than probe by probe.

The shim, for reproducibility (3 files, ~60 lines)

register.mjs

import { register } from "node:module";
import { pathToFileURL } from "node:url";
register(pathToFileURL("/tmp/buntest-shim/hooks.mjs").href);

hooks.mjs

export async function resolve(specifier, context, nextResolve) {
  if (specifier === "bun:test") {
    return { url: "file:///tmp/buntest-shim/bun-test.mjs", shortCircuit: true };
  }
  try {
    return await nextResolve(specifier, context);
  } catch (err) {
    // bun resolves extensionless relative imports; node's ESM resolver does not.
    if (err?.code === "ERR_MODULE_NOT_FOUND" && specifier.startsWith(".")) {
      for (const ext of [".js", ".ts", ".mjs", ".cjs"]) {
        try { return await nextResolve(specifier + ext, context); } catch {}
      }
    }
    throw err;
  }
}

bun-test.mjs

import { describe as nDescribe, it as nIt } from "node:test";
import assert from "node:assert/strict";
import { format } from "node:util";

export const describe = (name, fn) => nDescribe(name, fn);
function makeIt(base) {
  const it = (name, fn) => base(name, fn);
  it.each = table => (nameFmt, fn) => {
    for (const row of table) {
      const args = Array.isArray(row) ? row : [row];
      let i = 0;
      const title = nameFmt.replace(/%[sdifjo%]/g, m => (m === "%%" ? "%" : format("%s", args[i++])));
      base(title, () => fn(...args));
    }
  };
  return it;
}
export const it = makeIt(nIt);
export const test = it;

function matchObject(actual, expected, path = "") {
  for (const key of Object.keys(expected)) {
    const e = expected[key], a = actual?.[key];
    if (e !== null && typeof e === "object" && !Array.isArray(e) && !Buffer.isBuffer(e)) {
      matchObject(a, e, `${path}.${key}`);
    } else {
      assert.deepStrictEqual(a, e, `toMatchObject mismatch at ${path}.${key}`);
    }
  }
}
export function expect(actual) {
  return {
    toBe: expected => assert.strictEqual(actual, expected),
    toEqual: expected => assert.deepStrictEqual(actual, expected),
    toMatchObject: expected => matchObject(actual, expected),
  };
}

cirospaciari added a commit that referenced this pull request Sep 11, 2026
## What does this PR do?

Fixes the event sequence emitted by `ClientHttp2Stream.close(code)` to
match Node.js:

| code | Node.js | Bun before | Bun after |
| --- | --- | --- | --- |
| `NGHTTP2_NO_ERROR` (0) | `end`, `close` | `end`, `close` | `end`,
`close` |
| `NGHTTP2_CANCEL` (8) | `end`, `close` | `end`, **`error`**, `close` |
`end`, `close` |
| any other (e.g. 2, 11) | `error`, `close` | **`end`**, `error`,
`close` | `error`, `close` |

### Reproduction

A raw h2c server sends `200` + one DATA frame and never sends
`END_STREAM`. The client calls `req.close(code)` from inside the first
`data` handler:

```js
st.on("data", () => { st.close(code); });
```

<details><summary>Full repro (verified against Node v26.3.0)</summary>

```
close( 0): DIFF got=[resp,data,aborted,end,close:0]                              node=[resp,data,end,close:0]
close( 8): DIFF got=[resp,data,aborted,end,err:ERR_HTTP2_STREAM_ERROR,close:8]   node=[resp,data,end,close:8]
close( 2): DIFF got=[resp,data,aborted,end,err:ERR_HTTP2_STREAM_ERROR,close:2]   node=[resp,data,err:...,close:2]
close(11): DIFF got=[resp,data,aborted,end,err:ERR_HTTP2_STREAM_ERROR,close:11]  node=[resp,data,err:...,close:11]
```

(The extra `aborted` on every row is a separate issue, already tracked
in #33489.)

</details>

### Cause

1. `Http2Stream.prototype.close()` called `this.push(null)`
unconditionally before scheduling the RST, so `'end'` fired on the next
tick for every code, before `destroy()` could set the errored state. For
error codes that meant both `'end'` and `'error'` on a readable the
stream itself had just killed with `RST_STREAM`.
2. `emitStreamErrorNT` (the stream teardown path reached from
`native.rstStream(id, code)`) synthesized `ERR_HTTP2_STREAM_ERROR` for
any nonzero code, with no exemption for `NGHTTP2_CANCEL`. Node documents
CANCEL as the silent "abort this fetch" idiom: `_destroy` already
exempts it, but this path bypassed that.

### Fix

- In `close()`, only `push(null)` when `this.pending || code ===
NGHTTP2_NO_ERROR || code === NGHTTP2_CANCEL`, matching Node's
`finishCloseStream`: a pending stream (no id yet) ends its readable
cleanly regardless of code, and a non-pending stream closed with an
error code lets `_destroy` end the readable so `'end'` is suppressed
once the error is set.
- Drop the `push(null)` from `pushToStream`'s closed-stream branch: data
arriving after `close()` is discarded, and `_destroy` ends the readable
with the right event.
- Exempt `NGHTTP2_CANCEL` in `emitStreamErrorNT`, matching the exemption
`_destroy` already applies.

### Verification

New test matrix over `{0, 8, 2, 11}` in
`test/js/node/http2/node-http2-client-close.test.ts` asserts the exact
event sequence in three situations: `close(code)` after data has started
(raw h2c server that never sends END_STREAM), `close(code)` on a
non-pending stream before any response, and `close(code)` on a pending
stream (no id yet, `request()` before the session connects). The file
uses `node:test` + `node:assert` so it runs unchanged under `node
--test`, and it spawns Node on itself from `bun test`: all 12 cases pass
on Node v26.3.0 and on this branch, 7 of 12 fail on main. Full
`node-http2.test.js` suite (379 tests),
`test-http2-client-rststream-before-connect.js`, and the other
rst/cancel `test-http2-*` Node parallel tests pass.

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 9 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 7 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2-client-close.test.ts"
bun test v1.4.3 (4ff9193)

test/js/node/http2/node-http2-client-close.test.ts:
(pass) ClientHttp2Stream.close(code) event sequence after data > close(0) [846.54ms]
107 |   ] as const) {
108 |     test(`close(${code})`, async () => {
109 |       const srv = rawH2Server();
110 |       const port = await listen(srv);
111 |       try {
112 |         assert.deepStrictEqual(await collectEvents(port, code), expected);
                     ^
AssertionError: Expected values to be strictly deep-equal:
+ actual - expected

  [
    'response',
    'data',
    'end',
+   'error:ERR_HTTP2_STREAM_ERROR',
    'close:8'
  ]

 generatedMessage: true,
     actual: [ "response", "data", "end", "error:ERR_HTTP2_STREAM_ERROR", "close:8" ],
   expected: [ "response",
  "data", "end", "close:8"
],
   operator: "deepStrictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/http2/node-http2-client-close.test.ts:112:16
      at node:test:1781:26
      at executeTe
... (truncated)

release without fix: 7 FAILED
bun test v1.4.3-canary.1 (4ff9193)

test/js/node/http2/node-http2-client-close.test.ts:
(pass) ClientHttp2Stream.close(code) event sequence after data > close(0) [12.37ms]
107 |   ] as const) {
108 |     test(`close(${code})`, async () => {
109 |       const srv = rawH2Server();
110 |       const port = await listen(srv);
111 |       try {
112 |         assert.deepStrictEqual(await collectEvents(port, code), expected);
                     ^
AssertionError: Expected values to be strictly deep-equal:
+ actual - expected

  [
    'response',
    'data',
    'end',
+   'error:ERR_HTTP2_STREAM_ERROR',
    'close:8'
  ]

 generatedMessage: true,
     actual: [ "response", "data", "end", "error:ERR_HTTP2_STREAM_ERROR", "close:8" ],
   expected: [ "response",
  "data", "end", "close:8"
],
   operator: "deepStrictEqual",
       diff: "simple",
       code: "ERR_ASSERTION"

      at /workspace/bun/test/js/node/http2/node-http2-client-close.test.ts:112:16
      at node:test:1445:26
      at executeTestNode (node:test:1448:63)
      at processTicksAndRejections (native:7:39)
(fail) ClientHttp2Stream.close(code) event sequence after data > close(8) [4.85ms]
107 |   ] as const
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/node/http2/node-http2-client-close.test.ts"
bun test v1.4.3 (4ff9193)

test/js/node/http2/node-http2-client-close.test.ts:
(pass) ClientHttp2Stream.close(code) event sequence after data > close(0) [669.27ms]
(pass) ClientHttp2Stream.close(code) event sequence after data > close(8) [116.17ms]
(pass) ClientHttp2Stream.close(code) event sequence after data > close(2) [73.51ms]
(pass) ClientHttp2Stream.close(code) event sequence after data > close(11) [80.02ms]
(pass) ClientHttp2Stream.close(code) before response > close(0) [178.78ms]
(pass) ClientHttp2Stream.close(code) before response > close(8) [76.89ms]
(pass) ClientHttp2Stream.close(code) before response > close(2) [61.56ms]
(pass) ClientHttp2Stream.close(code) before response > close(11) [56.12ms]
(pass) ClientHttp2Stream.close(code) while pending > close(0) [57.72ms]
(pass) ClientHttp2Stream.close(code) while pending > close(8) [50.58ms]
(pass) ClientHttp2Stream.close(code) while pending > close(2) [51.49ms]
(pass) ClientHttp2Stream.close(code) while pending > close(11) [4
... (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     830ec5f
  features     baseline

23 deps, 131 codegen, 1172 objects in 686ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.3-canary.1 (4ff9193)

Checked 22 installs across 61 packages (no changes) [13.00ms]
[2/1244] gen ErrorCode+*.h
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (4ff9193)

Checked 1 install across 2 packages (no changes) [2.00ms]
[4/1244] gen bindgenv2
[5/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (4ff9193)

Checked 111 installs across 104 packages (no changes) [12.00ms]
[6/1244] gen node-fallbacks/react-refresh.js
Bundled 1 module in 5ms

  react-refresh.js  4.81 KB  (entry point)

[7/1244] fetch tinycc
[tinycc] up to date
[8/1243] gen .bind.ts → GeneratedBindings.cpp
[9/1243] fetch zlib
[zlib] up to date
[10/1243] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[11/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.serve
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js/node/http2.ts                               |  16 +-
 test/js/node/http2/node-http2-client-close.test.ts | 199 +++++++++++++++++++++
 2 files changed, 205 insertions(+), 10 deletions(-)
```

</details>

**gate history** · 4 passed · 1 rejected · iteration 9

<details><summary>evidence per changed file</summary>

```
file                                                reads  edits  tests
src/js/node/http2.ts                                   15     16     36
test/js/node/http2/node-http2-client-close.test.ts      4      8     33
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
@robobun

robobun commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

#43566 changes the same block in Http2Stream#end(). It removes the EndedCalled early return, so a repeat end() always goes to Writable#end. That contains this PR's source change (the callback of a repeat end(cb) is deferred). It also reports a repeat end(chunk) as ERR_STREAM_WRITE_AFTER_END, which this PR leaves as a silent drop.

The 15 tests in test/js/node/http2/node-http2-end-stream.test.ts from this PR pass on the #43566 branch. The two PRs conflict in end(). The PR that merges second needs a rebase: keep the end() from #43566 and the tests from this PR.

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.

2 participants