Skip to content

node:http2: error pending write callbacks when a stream is torn down - #33606

Closed
robobun wants to merge 5 commits into
mainfrom
farm/1f76c4eb/http2-drain-after-destroy
Closed

robobun wants to merge 5 commits into
mainfrom
farm/1f76c4eb/http2-drain-after-destroy

Conversation

@robobun

@robobun robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator

What

A ClientHttp2Stream whose upload is blocked on flow control (the peer withholds WINDOW_UPDATE) has a DATA frame queued in native with its Writable _write callback held. When session.destroy() tears the stream down, that callback was invoked with no error. The Writable state machine treats that as a successful write, so it emits 'drain'. And once the session's native handle is gone, _write/_writev fell through to callback() on every call, so write() kept returning true forever.

A producer following the canonical backpressure idiom:

function more() {
  while (source.hasMore()) {
    if (!stream.write(source.next())) {
      stream.once('drain', more);
      return;
    }
  }
}

is woken by the teardown itself and then never sees write() return false again, so it drains its entire source into a destroyed stream.

Reproduction

Before: 'drain' fires after destroy, 32 subsequent write() calls all return true:

{"backpressured":true,"drainsAfterDestroy":1,"writeOkAfterDestroy":32,"writeCbErrorCode":"none"}

After: no 'drain', no further accepted writes, the write callback receives ERR_HTTP2_INVALID_STREAM:

{"backpressured":true,"drainsAfterDestroy":0,"writeOkAfterDestroy":0,"writeCbErrorCode":"ERR_HTTP2_INVALID_STREAM"}

Node.js never emits 'drain' here and settles the pending write callbacks with ECANCELED.

Cause

session.destroy() calls native emitErrorToAllStreams, which drops each stream's queued DATA frames (clean_queue) and dispatches onStreamError. The JS streamError handler defers the actual stream destroy via process.nextTick(emitStreamErrorNT, ...), so when clean_queue invokes the held _write callback the stream is still neither ended nor destroyed: afterWrite sees kNeedDrain with no kEnding/kDestroyed and emits 'drain'.

Http2Stream._write/_writev also fell through to callback() when session[bunHTTP2Native] is null, which is the state after session.destroy() returns.

Fix

  • session.destroy() (client and server) now runs emitStreamErrorNT synchronously for each still-open stream (via parser.forEachStream) before emitErrorToAllStreams, so when clean_queue settles the held callback the stream is already destroyed and afterWrite skips 'drain' via kDestroyed. This matches Node.js, which destroys each Http2Stream before ClearOutgoing(UV_ECANCELED) settles the write reqs. Streams already marked closed (completed normally) are skipped, matching native emitErrorToAllStreams.
  • _write/_writev wrap the callback handed to native writeStream with onWriteStreamDone: when the callback fires after the stream is destroyed, it reports ERR_HTTP2_INVALID_STREAM to the user's write callback (Node.js reports ECANCELED). errorOrDestroy is a no-op on a destroyed stream, so no 'error' event is emitted and no 'drain'.
  • The _write/_writev fallthrough when the session/native handle is gone now passes ERR_HTTP2_INVALID_STREAM instead of reporting success.

The other teardown paths that reach clean_queue (stream.close(), received RST_STREAM, socket abort) already call end() or destroy() before the callback fires, so they are unchanged.

Verification

test/js/node/http2/node-http2-session-destroy-backpressure.test.ts: a raw TCP peer that withholds WINDOW_UPDATE, one write larger than the 65535-byte initial window, then session.destroy(). Asserts drainsAfterDestroy === 0, writeOkAfterDestroy === 0, and the write callback received an error. Fails on main with drainsAfterDestroy: 1, writeOkAfterDestroy: 32.

When session.destroy() (or any stream teardown) drops DATA frames still
queued behind flow control, the Writable _write callback was invoked
with no error. That looks like a successful write to the Writable state
machine, so it emits 'drain', and since _write/_writev fell through to
callback() once the native handle was gone, every subsequent write()
also reported success. A producer following the canonical
'if (!write(chunk)) once("drain", more)' idiom is woken by the
teardown and then never sees backpressure again, buffering its entire
source into a dead stream.

clean_queue now settles each dropped frame's callback with
ERR_HTTP2_INVALID_STREAM (node settles them with ECANCELED), and the
_write/_writev fallthrough does the same when the session/native handle
is gone. The Writable error path takes over: no 'drain', the stream is
marked errored so further write() returns false, and the user's write
callback receives the error.
@coderabbitai

coderabbitai Bot commented Jul 7, 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: cd73949c-8c6f-47ea-99f2-a56f1bfc510e

📥 Commits

Reviewing files that changed from the base of the PR and between 0345254 and 5eb3eef.

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

Walkthrough

This PR adjusts HTTP/2 stream write completion handling and session destroy teardown in src/js/node/http2.ts to more closely mirror Node.js semantics, introducing onWriteStreamDone and sessionDestroyStream helpers, and adds a new test verifying backpressure behavior during session destroy.

Changes

HTTP/2 Write and Destroy Semantics

Layer / File(s) Summary
Write completion error handling
src/js/node/http2.ts
Adds onWriteStreamDone wrapper converting completion on an already-destroyed stream into ERR_HTTP2_INVALID_STREAM; _writev and _write use it for native writeStream completion and invoke ERR_HTTP2_INVALID_STREAM when no native session/writer is available.
Session destroy stream teardown
src/js/node/http2.ts
Introduces sessionDestroyStream helper performing per-stream error plumbing; ServerHttp2Session.destroy and ClientHttp2Session.destroy compute streamRstCode and conditionally run per-stream teardown via forEachStream before calling parser.emitErrorToAllStreams.
Backpressure destroy test
test/js/node/http2/node-http2-session-destroy-backpressure.test.ts
Adds a test with a raw TCP HTTP/2 peer withholding WINDOW_UPDATE that verifies session.destroy() blocks further writes, avoids emitting 'drain', and surfaces a stream-destruction error to the write callback.

Sequence Diagram(s)

sequenceDiagram
  participant Test
  participant ClientSession
  participant Stream
  participant sessionDestroyStream
  participant Callback

  Test->>Stream: write large payload (backpressured)
  Test->>ClientSession: destroy() on next tick
  ClientSession->>ClientSession: compute streamRstCode
  ClientSession->>sessionDestroyStream: forEachStream(sessionDestroyStream)
  sessionDestroyStream->>Stream: emitStreamErrorNT
  Stream->>Callback: writeStream completion via onWriteStreamDone
  Callback-->>Test: error code (stream destroyed)
Loading

Compact Metadata

  • Estimated review effort: High
  • Lines changed: +159/-6 across 2 files

Related Issues: Not specified in provided information.

Related PRs: Not specified in provided information.

Suggested Labels: node:http2, bug, needs-tests

Suggested Reviewers: Not specified in provided information.

🐰 A stream once blocked, now destroyed with care,
No drain sneaks through when the session's not there,
Errors propagate as Node intends,
Backpressure tested from bitter ends,
Hop along, reviewer — the frames compare!

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: pending write callbacks now error when HTTP/2 streams are torn down.
Description check ✅ Passed The PR description covers the fix, cause, reproduction, and verification, matching the required sections.
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.

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

@robobun

robobun commented Jul 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:14 AM PT - Jul 7th, 2026

❌ @robobun, your commit 5eb3eef has some failures in Build #69734 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33606

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

bun-33606 --bun

@github-actions github-actions Bot added the claude label Jul 7, 2026
node-http2.test.js has a pre-existing borderline-timeout test
(maxSessionMemory, ~100s on debug+ASAN against a 150s limit) that flakes
independently of this change. Moving the new test into its own file keeps
the fail-before/pass-after proof deterministic.
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread test/js/node/http2/node-http2-session-destroy-backpressure.test.ts Outdated
robobun added 2 commits July 7, 2026 05:36
…ed writes

The previous approach (native clean_queue passes an error to onwrite)
reached errorOrDestroy on a not-yet-destroyed stream, emitting 'error'
on streams without a listener (test-http2-cancel-while-client-reading,
test-http2-respond-with-file-connection-abort). clean_queue is shared by
every teardown path (stream.close(), received RST, socket abort), not
only session.destroy(), so scoping it there is wrong.

Instead: session.destroy() now runs emitStreamErrorNT synchronously for
each still-open stream (via forEachStream) before emitErrorToAllStreams,
so the dropped-frame Writable callback sees kDestroyed and afterWrite
skips 'drain'. This is the same ordering node uses (destroy the
Http2Stream before ClearOutgoing(UV_ECANCELED) settles the write req).

onWriteStreamDone wraps the callback so a write that settles after the
stream is destroyed is reported as ERR_HTTP2_INVALID_STREAM to the
user's write callback (errorOrDestroy is a no-op on a destroyed stream).
The _write/_writev fallthrough to callback() when native is gone is kept
as an error for the same reason.

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/http2.ts`:
- Around line 2086-2090: The comment near the Writable onwrite wrapper in
http2.ts is too long for the repository’s 3-line limit. Shorten the explanatory
block around the writeStream/onwrite callback so it keeps only the durable,
non-obvious point about treating dropped writes during stream teardown as an
error and that onwriteError does not emit drain. Preserve the relevant context
in the existing comment, but remove the detailed bug narrative and extra
teardown mechanics.
- Around line 4111-4118: Condense the teardown comment near session.destroy() in
http2.ts to fit the 3-line limit and keep only durable behavior notes. Remove
the PR-history/style explanation and retain just the essential non-obvious
teardown semantics around destroying still-open streams, skipping already-closed
streams, and preserving the same error/rstCode flow used by emitStreamErrorNT.

In `@test/js/node/http2/node-http2-session-destroy-backpressure.test.ts`:
- Around line 4-10: Shorten the explanatory comment in the
node-http2-session-destroy-backpressure test to a brief, durable summary of the
behavior under test. Keep only the non-obvious point that session.destroy() must
fail the held write callback so the ClientHttp2Stream never emits drain and
later write() calls do not succeed; remove the historical regression story while
keeping the comment around the existing assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f4a7ab11-fba7-4d75-94a3-df55a5bdf8b1

📥 Commits

Reviewing files that changed from the base of the PR and between 3f67971 and 0345254.

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

Comment thread src/js/node/http2.ts Outdated
Comment thread src/js/node/http2.ts Outdated
Comment thread test/js/node/http2/node-http2-session-destroy-backpressure.test.ts Outdated
@robobun

robobun commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green. The remaining red on build 69734 is unrelated infra:

  • :darwin: 26 aarch64 - test-bun (both shards): buildkite-agent artifact download timed out after 120s before any tests ran
  • flaky lane retries: bun-install.test.ts (Windows EBADF fstat) and spawn-pipe-leak.test.ts (Windows aarch64)

No http2 failures anywhere. The previous build (69690) was green on every lane except a napi GC flake on Windows x64-baseline. All 194 expected-passing test-http2-* node parallel tests pass locally, as do node-http2.test.js (293/300, 6 skip, 1 pre-existing debug+ASAN timeout) and h2-conformance.test.ts (37/37).

Ready for review.

@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-07, it conflicts with main, and its last CI run failed. 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 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Replaced by #42717, rebased on current main. Main already had the server half of this fix (#32488), so the new change is smaller and also covers the server socket-close and error-GOAWAY paths.

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