Skip to content

node:http2: don't emit 'aborted' when respond() ends the stream - #33375

Closed
robobun wants to merge 4 commits into
mainfrom
farm/03b4e13f/http2-respond-endstream-aborted
Closed

robobun wants to merge 4 commits into
mainfrom
farm/03b4e13f/http2-respond-endstream-aborted

Conversation

@robobun

@robobun robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

A server stream that answers with a body-less status emits a 'aborted' event and flips stream.aborted to true, even though nothing aborted and no RST_STREAM is exchanged. The reported shape is the documented statCheck veto (reply 304 from a cache validator, cancel the file send):

const server = http2.createServer();
server.on("stream", stream => {
  stream.on("aborted", () => console.log("aborted!"));
  stream.on("close", () => console.log("stream.aborted =", stream.aborted));
  stream.respondWithFile(file, { "content-type": "text/plain" }, {
    statCheck() {
      stream.respond({ ":status": 304 });
      stream.end();
      return false; // documented veto: the handler took over
    },
  });
});
node: events = ["finish", "end", "close"]   stream.aborted = false
bun:  events = ["end", "aborted", "finish", "close"]   stream.aborted = true

It is not specific to statCheck. Any respond() whose HEADERS frame carries END_STREAM hits it as long as the request body has already been received:

server.on("stream", stream => stream.on("end", () => stream.respond({ ":status": 304 })));

Same for 204, 205, a HEAD request, and respond(headers, { endStream: true }).

Cause

ServerHttp2Stream.respond() ended the writable side after the native request() call:

session[bunHTTP2Native]?.request(this.id, undefined, wireHeaders, sensitiveNames, options);
// ...
if (endStream) this.end();

When the peer has already half-closed, submitting HEADERS with END_STREAM moves the stream to CLOSED inside request(), which dispatches onStreamEnd(7) synchronously. That handler calls stream.destroy(), and _destroy() reads _writableState.ending to decide whether the response was cut short:

const { ending } = this._writableState;
if (!ending && !this[kPush]) {
  this[kAborted] = true;
  this.emit("aborted");
  ...
}

this.end() had not run yet, so ending was false and the clean close was recorded as a client abort. The synchronous respond() case never showed it because there the stream is still OPEN when the handler runs, so the CLOSED transition lands after respond() returns.

Node ends the writable side before submitting, in lib/internal/http2/core.js:

if (!!options.endStream || statusCode === HTTP_STATUS_NO_CONTENT || ... ) {
  options.endStream = true;
  this.end();
}
const ret = this[kHandle].respond(headersList, streamOptions);

Fix

Copying that ordering does not hold in bun. Node validates headers in JS (mapToHeaders) before it ends the writable side, so a bad header throws with the stream untouched. Bun validates inside the native request(), so ending first means a throwing respond() leaves the writable side already ended, and the documented catch-and-retry recovery silently hangs the client:

try {
  stream.respond({ ":status": 304, ":path": "/" }); // throws ERR_HTTP2_INVALID_PSEUDOHEADER
} catch {
  stream.respond({ ":status": 500 });
  stream.end("err"); // no-ops on the EndedCalled guard: END_STREAM never goes out
}

So this.end() stays where it was. Instead respond() records, on the stream status, that a HEADERS frame carrying END_STREAM is in flight, and clears it once the native call returns. _destroy reads that bit to tell a completed response apart from a client abort. A request() that threw submitted nothing, so the bit is cleared, the writable side is still genuinely open, and the retry works.

EndStreamOnHeaders needs to be its own bit rather than reusing NativeClosed or WritableClosed. Both of those are also set when the peer cancels with RST_STREAM(NO_ERROR), which dispatches onStreamEnd(CLOSED) as well (h2_frame_parser.rs:4335) - keying off either would swallow a real client cancel.

Verification

New tests in test/js/node/http2/node-http2.test.js:

  • the reported statCheck veto, and the wider class (304, 204, 205, HEAD, endStream: true responding once the request body has been received). Both fail on main, asserting stream.aborted === false and that no 'aborted' fires while 'finish' still does.
  • a throwing respond() leaves the writable side open, so respond() + end() recovery still reaches the client.

Genuine aborts were checked to still fire: a client close(NGHTTP2_CANCEL) mid-body, a client close(NGHTTP2_NO_ERROR) mid-body (the onStreamEnd(CLOSED) path), and a server-side destroy() mid-body all still emit 'aborted' with stream.aborted === true, matching node.

Before / after, and no regressions
$ git checkout main -- src/js/node/http2.ts
$ bun bd test test/js/node/http2/node-http2.test.js
(fail) http2 respondWithFile statCheck returning false does not emit a spurious 'aborted'
(fail) http2 server respond() with END_STREAM after the request body does not emit a spurious 'aborted'
 295 pass, 2 fail

$ git checkout HEAD -- src/js/node/http2.ts
$ bun bd test test/js/node/http2/node-http2.test.js
 297 pass, 6 skip, 0 fail

All 293 test-http2-* / test-diagnostics-channel-http2-* files under test/js/node/test/parallel/ run against the debug build: 234 pass, 56 fail, an identical failure set before and after the change.

This also shrinks the maxSessionMemory stress test to 1k sequential requests under debug+ASAN. At 10k it took ~88s against its own 150s timeout, close enough to fail on a loaded machine; release still runs the full 10k. The file now runs in 45s instead of 140s.

A respond() whose HEADERS frame carries END_STREAM (204, 205, 304, a HEAD
request, or endStream: true) ended the writable side only after the native
request() call. When the peer had already half-closed, that call drives the
stream straight to closed and dispatches onStreamEnd synchronously, so
_destroy ran with the writable side still open and treated the teardown as a
client abort: a spurious 'aborted' event and stream.aborted === true.

End the writable side before submitting the frame, as node does. _final parks
its callback instead of writing an empty DATA frame, and the onStreamEnd
dispatch settles it.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 4 minutes

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

How can I continue?

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

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7ce1666b-a576-403c-afcd-e1215f223489

📥 Commits

Reviewing files that changed from the base of the PR and between ed6a9c5 and 69433a5.

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

Walkthrough

This PR fixes HTTP/2 stream shutdown sequencing for cases where respond() submits HEADERS with END_STREAM. A new StreamState.EndStreamOnHeaders flag prevents an extra empty DATA frame from _final, and respond() logic is reordered to end the writable side before native request() dispatch. Regression tests validate correct lifecycle event ordering.

Changes

HTTP/2 END_STREAM shutdown sequencing

Layer / File(s) Summary
StreamState flag and _final handling
src/js/node/http2.ts
Adds EndStreamOnHeaders bit to StreamState and updates Http2Stream._final to park its callback when the flag is set, deferring completion to the native onStreamEnd path.
respond() end-stream sequencing
src/js/node/http2.ts
ServerHttp2Stream.respond() sets EndStreamOnHeaders and calls this.end() before native request(); after request() returns, calls markWritableDone(this) idempotently and finalizes bookkeeping (headersSent, finish channel, bunHTTP2Headers) without a deferred this.end().
Regression tests for END_STREAM lifecycle
test/js/node/http2/node-http2.test.js
Adds serverStreamLifecycle test helper and new tests verifying no spurious aborted event occurs for respondWithFile statCheck 304 and for respond() END_STREAM scenarios (304/204/205, explicit endStream: true, HEAD).

Possibly related PRs

  • oven-sh/bun#29075: Modifies the same src/js/node/http2.ts stream finalization/half-close logic, adding related state bits and adjusting _final() and respond() shutdown sequencing.
🚥 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 states the main HTTP/2 fix and matches the reported behavior change.
Description check ✅ Passed The description includes repro, cause, fix, and verification details, covering the required information.

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

@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:09 PM PT - Jul 5th, 2026

✅ @robobun, your commit 69433a5af677fe10fdbefefcbd1457b4ea75fde2 passed in Build #68601! 🎉


🧪   To try this PR locally:

bunx bun-pr 33375

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

bun-33375 --bun

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

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

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

@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, but this reorders writable-side teardown against a synchronous native dispatch in the HTTP/2 stream state machine — worth a human sanity-check on the _final parking path.

Extended reasoning...

Overview

This PR fixes a spurious 'aborted' event on ServerHttp2Stream when respond() sends a HEADERS frame carrying END_STREAM (status 204/205/304, HEAD requests, or { endStream: true }) after the peer has already half-closed. The fix moves this.end() to before the native request() call (matching Node's ordering in lib/internal/http2/core.js), and introduces a new StreamState.EndStreamOnHeaders bit so that _final parks its callback rather than writing an empty DATA frame ahead of the HEADERS. A follow-up markWritableDone(this) after request() settles the parked callback idempotently in case the native layer didn't dispatch.

Two files touched: ~25 net lines in src/js/node/http2.ts (new enum bit, a guard in _final, reordered end() in respond()), and ~90 lines of new tests in test/js/node/http2/node-http2.test.js covering the reported statCheck veto plus the full class of end-stream response shapes.

Security risks

None identified. This is stream-lifecycle event ordering in the Node compat layer; no untrusted input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Medium-high. HTTP/2 stream state is notoriously order-sensitive, and this change threads a new state bit through three interacting sites (respond(), _final, markWritableDone) while relying on whether end() → _final runs synchronously relative to a native call that can itself synchronously destroy the stream. The author's analysis is thorough and the fix mirrors upstream Node, but the interaction between the parked _final callback and the various markWritableDone call sites (in markStreamClosed, in the new post-request() call, and in the destroy path) is subtle enough that a maintainer familiar with this file's history should confirm no path leaves the callback unsettled.

Other factors

The PR description demonstrates before/after test failures, a clean full-file run (296 pass / 0 fail), and identical Node parallel-test failure sets pre/post. The bug-hunting pass found nothing. The tests look solid — they assert exact event sequences and stream.aborted, cover five end-stream variants, and use port: 0 with proper cleanup. Still, per the approval guidelines this is not a simple/mechanical change: it's a state-machine ordering fix in a Node-compat hot path, so I'm deferring rather than auto-approving.

markWritableDone only needs to run from the native onStreamEnd dispatch, so
_final can call its callback straight back instead of parking it: 'finish' is
queued on the next tick either way, after request() has put the HEADERS frame
on the wire. This is what node's _final does here (handle.shutdown() reports a
synchronous finish and the callback runs inline), and it means no path can
leave the callback unsettled if request() throws.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Good call on the _final parking path - I dug into it and simplified it away in 4f7749c.

Parking the callback bought nothing. onFinish in internal/streams/writable queues finish with a bare process.nextTick(finish, ...), not a needFinish-guarded one, so it fires regardless of the destroy() that onStreamEnd(CLOSED) triggers right after. Calling the callback inline from _final produces the same event sequence and removes the question entirely: nothing is left unsettled if request() throws before it can dispatch.

That also matches node more closely. Its _final calls handle.shutdown(req), Http2Stream::DoShutdown returns 1 (synchronous finish), and afterShutdown runs the callback inline - so node emits prefinish from inside this.end() before handle.respond() too.

The new markWritableDone(this) follow-up in respond() is gone with it; markWritableDone is back to being called only from the native onStreamEnd dispatch and markStreamClosed, as before.

Same verification as before on the simplified version: bun bd test test/js/node/http2/node-http2.test.js is 296 pass / 0 fail, and the 293 test-http2-* files under test/js/node/test/parallel/ have an identical failure set to main.

For the record on the earlier red build (#68558): both failures were agent infra, neither ran the tests. darwin 26 aarch64 hit buildkite-agent artifact download timed out after 120s, and windows 2019 x64-baseline failed all six checkout attempts with Could not resolve host: github.com.

Comment thread src/js/node/http2.ts Outdated
…nd()

Ending the writable side before the native request() call, the way node does,
does not hold in bun: node validates headers in JS before ending, while bun
validates them inside request(). A respond() that threw on a bad header left
the writable side already ended, so the documented catch-and-retry recovery
(respond again with a body-carrying status, then end) silently dropped its
END_STREAM and hung the client.

Keep this.end() where it was and record the in-flight END_STREAM on the stream
status instead, cleared once the frame is out. _destroy reads it to tell a
completed response apart from a client abort. A request() that threw submitted
nothing, so the writable side stays genuinely open and the retry works.

EndStreamOnHeaders has to be its own bit: NativeClosed and WritableClosed are
also set when the peer cancels with RST_STREAM(NO_ERROR), which dispatches
onStreamEnd(CLOSED) too, and suppressing 'aborted' there would swallow a real
client cancel.

Also shrink the maxSessionMemory stress test to 1k requests under debug+ASAN
(88s -> 13s); at 10k it sat close enough to its own timeout to fail on a
loaded machine.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

You're right, and thanks - that's a real regression. Confirmed it with your exact scenario:

node:            completed, status = 500 body = "err"
bun pre-PR:      completed, status = 500 body = "err"
bun + reorder:   HUNG (no 'end'), status = 500 body = ""

ending was already true when the throw surfaced, so the retry's stream.end("err") no-opped on EndedCalled and END_STREAM never went out. Reworked in 6f98db0.

I went with neither of your two suggestions though. Pre-validating in JS would duplicate the native header encoder's validation on every respond() (two sources of truth, and a second pass over every header), and it wouldn't fully close the class anyway - request() can still throw on things like a failed header-buffer allocation.

Instead this.end() stays exactly where it was, and respond() records on the stream status that a HEADERS frame carrying END_STREAM is in flight, clearing it once the native call returns. _destroy reads that bit to tell a completed response apart from a client abort. A request() that threw submitted nothing, the bit is cleared in a finally, the writable side is untouched, and the retry works. The _final parking you flagged in the earlier review is gone too - _final is back to its original form.

One thing worth recording, since it's the trap in this area: the bit has to be its own flag rather than reusing NativeClosed or WritableClosed. Both of those also get set when the peer cancels with RST_STREAM(NO_ERROR), which dispatches onStreamEnd(CLOSED) just like a clean close does (h2_frame_parser.rs:4335). Keying off either would have suppressed a genuine client cancel. I checked the three abort shapes explicitly - client close(NGHTTP2_CANCEL) mid-body, client close(NGHTTP2_NO_ERROR) mid-body, and a server-side destroy() mid-body - and all three still emit 'aborted' with stream.aborted === true, matching node.

Added a regression test for your retry path so the reorder can't come back.

Also shrank the maxSessionMemory stress test to 1k sequential requests under debug+ASAN. At 10k it took ~88s against its own 150s timeout, which is what was tripping on loaded machines; release still runs the full 10k. The file now runs in 45s instead of 140s.

Verification on the reworked version: 297 pass / 0 fail for the file, 2 fail on main, and the 293 test-http2-* files under test/js/node/test/parallel/ have an identical failure set to main.

@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 flag-based redesign in 6f98db0 addresses my earlier concern (writable side stays open when request() throws), and the new test covers that exact case.

Extended reasoning...

Overview

This PR fixes a spurious 'aborted' event on ServerHttp2Stream when respond() sends a HEADERS frame carrying END_STREAM (204/205/304, HEAD, or endStream: true) after the peer has already half-closed. The final implementation is minimal: a new StreamState.EndStreamOnHeaders bit is set immediately before the native request() call and cleared in a finally, and _destroy checks that bit to skip the 'aborted' emission. Nothing else changes — this.end() still runs after request() exactly as before, and _final is untouched in the cumulative diff.

Response to prior review

My earlier comment flagged that the previous revision (which moved this.end() before native request()) would leave the writable side ended if native header validation threw, breaking catch-and-retry. Commit 6f98db0 abandoned the reordering entirely in favor of the flag-only approach, so this.end() no longer precedes validation. The try/finally guarantees the flag is cleared even on throw, and a new test ("a respond() that throws on invalid headers leaves the writable side open") asserts exactly the recovery scenario I described (ERR_HTTP2_INVALID_PSEUDOHEADER → respond({':status':500}); end('err') → client receives 500 + body). That fully resolves the concern.

Security risks

None. This is stream-lifecycle event bookkeeping in the Node.js http2 compat layer; no auth, crypto, parsing of untrusted data, or resource limits are touched. The try/finally is defensive only — it doesn't swallow the exception.

Level of scrutiny

Moderate. HTTP/2 stream state machines are subtle, but this change is deliberately narrow: one bit set/checked/cleared around an existing call, with no reordering of side effects relative to pre-PR behavior. The pre-PR code path already ran this.end() after a synchronous destroy; this PR only stops _destroy from mislabeling that as an abort.

Other factors

Test coverage is thorough: the reported statCheck veto, the wider class (304/204/205/HEAD/endStream:true after request-body 'end'), and the throw-then-recover edge case. The author verified 296/296 pass on the http2 test file and an identical parallel-test failure set vs main. The unrelated MAX_SESSION_MEMORY_REQUESTS change shrinks a stress-test workload under debug/ASAN rather than raising its timeout, which matches the repo's test guidance. No CODEOWNERS apply. The PR description is stale (it describes the earlier reordering approach), but the code and commit message are accurate.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks - and the description is current now, it just got rewritten a few minutes after 6f98db0 was pushed, so the review snapshotted the old one. It describes the flag-based approach and the throw-then-recover case.

For anyone looking at the red check: the only failing job on build #68587 is darwin 26 aarch64 - test-bun, and it never ran a test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

Same agent-side artifact-download timeout took out that lane on the previous build too. The rest of the build is still running.

Build #68587 was green on 280 jobs; the three reds never touched this diff:
darwin aarch64 (both shards) failed with 'buildkite-agent artifact download
timed out after 120s' before running any test, ubuntu x64 OOM-killed
v8-heap-snapshot.test.ts, and the windows 2019 agent failed to provision.

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

The rework addresses my earlier concern — this.end() stays after native request() and the new EndStreamOnHeaders bit is scoped tightly with try/finally, so the throw-then-recover path is preserved (and now covered by a test). The fix is small and well-reasoned, but the HTTP/2 stream state-machine interaction with synchronous native dispatch is subtle enough that someone familiar with h2_frame_parser.rs should give it a quick look.

Extended reasoning...

Overview

This PR fixes a spurious 'aborted' event on ServerHttp2Stream when respond() sends a HEADERS frame with END_STREAM (204/205/304, HEAD, or endStream: true) after the peer has half-closed. Two files touched: src/js/node/http2.ts (~30 lines net) and test/js/node/http2/node-http2.test.js (+135 lines of tests, plus a debug-build workload reduction for an unrelated stress test).

The fix adds a StreamState.EndStreamOnHeaders bit that respond() sets immediately before calling native request() and clears in a finally. _destroy checks that bit alongside _writableState.ending to distinguish a completed body-less response from a genuine client abort. this.end() remains after the native call, unchanged from pre-PR — so a request() that throws on header validation leaves the writable side untouched and catch-and-retry recovery still works.

Prior review

I previously flagged (now-resolved inline comment) that an earlier revision moved this.end() before native request(), which regressed the throw-then-recover path: a validation error from native request() would surface with the writable side already ended, and a retry respond() + end() would silently hang the client. The author confirmed the regression empirically, reworked to the flag-based approach in 6f98db0, and added a regression test for that exact scenario. The current diff no longer has that problem — verified by reading respond() at lines 3124-3157: the flag is cleared in finally, and if (endStream) this.end() runs only after request() returns successfully.

Security risks

None identified. This is event-emission timing in the Node-compat HTTP/2 server layer; no auth, crypto, permissions, or untrusted-input parsing is touched. The change narrows when 'aborted' fires — it does not relax any validation or resource limit.

Level of scrutiny

Medium. The mechanical change is small (one enum bit, one && clause, one try/finally), but correctness depends on understanding when native request() synchronously dispatches onStreamEnd(CLOSED) → stream.destroy(), and why the new bit must be distinct from NativeClosed/WritableClosed (both are also set on RST_STREAM(NO_ERROR), which is a real abort). The author's reasoning is sound and well-documented in the PR description and code comments, and the Node parallel-test-suite comparison (identical failure set) is reassuring — but this is exactly the kind of state-machine subtlety where a second pair of eyes from someone who owns h2_frame_parser.rs is worth the cost.

Other factors

  • Test coverage is thorough: the reported statCheck veto, five END_STREAM variants responding after request-body 'end', and the throw-then-recover regression I flagged. All wire failure events to reject; no sleeps.
  • The author verified genuine aborts (client close(CANCEL), client close(NO_ERROR), server destroy() mid-body) still fire 'aborted', matching Node.
  • No CODEOWNERS entry for src/js/node/http2.ts.
  • The unrelated maxSessionMemory stress-test workload reduction (10k → 1k under debug/ASAN) is a sensible flake mitigation and doesn't weaken the assertion.
  • CI red on the last two builds is agent-side (artifact download timeout on darwin-aarch64, DNS failure on windows-x64-baseline) per the author's note; neither ran tests.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-05 and it conflicts with main. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant