Skip to content

streams: invoke underlyingSource.cancel() when a type:"direct" ReadableStream is cancelled - #34854

Closed
robobun wants to merge 2 commits into
mainfrom
farm/69ae4ab8/direct-stream-cancel-callback
Closed

robobun wants to merge 2 commits into
mainfrom
farm/69ae4ab8/direct-stream-cancel-callback

Conversation

@robobun

@robobun robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator

Cancelling a type: "direct" ReadableStream never invoked the underlying source's cancel() callback. A producer that relies on cancel() for upstream teardown never learns the consumer went away.

let called = false;
const stream = new ReadableStream({
  type: "direct",
  pull(c) { c.write("hello"); c.flush(); },
  cancel() { called = true; },
});
const r = stream.getReader();
await r.read();
await r.cancel("bye");
console.log(called); // false (should be true; default streams print true)

readableStreamCancel's ControllerKind::Direct arm called onClose(), which early-returned because readableStreamClose had already moved the stream out of Readable, and then resolved with undefined. The source callback was never reached.

This calls underlyingSource.cancel(reason) under the stream's captured async context and chains its completion into the cancel promise, matching the default/byte controller behaviour. It also:

  • handles stream.cancel() on a not-yet-materialized direct stream (ControllerKind::None with a pending direct source), and
  • sets m_closed so controller.write() throws after cancellation instead of silently buffering.

The previously test.todo AsyncLocalStorage "readable stream direct .cancel" case is now enabled.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/streams/streams.test.js

Fixes #18315

…leStream is cancelled

readableStreamCancel's ControllerKind::Direct arm called onClose() (which
early-returned because readableStreamClose had already moved the stream
out of Readable) and then resolved with undefined, so the source's
cancel(reason) callback never ran. Producers keyed on cancel() for
upstream teardown silently leaked.

Call underlyingSource.cancel(reason) under the stream's captured async
context and chain its completion into the cancel promise, matching the
default/byte controller behavior. Also handle the unmaterialized
(ControllerKind::None / DirectPending) case and set m_closed so
controller.write() throws after cancellation.

Un-todo the AsyncLocalStorage direct .cancel test now that cancel()
actually runs.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 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: 3f504df8-2e07-4032-a2d1-72cd2d018208

📥 Commits

Reviewing files that changed from the base of the PR and between b18d5df and 87d2e5d.

📒 Files selected for processing (3)
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • test/js/node/async_hooks/AsyncLocalStorage.test.ts
  • test/js/web/streams/streams.test.js

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

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with:

bun bd test test/js/web/streams/streams.test.js -t "canceling a direct stream invokes"
bun bd test test/js/node/async_hooks/AsyncLocalStorage.test.ts -t "readable stream direct .cancel"

Both fail on main (the first on the expect(reason).toBe("bye") assertion, the second by timing out waiting for the cancel callback to fire) and pass with this change.

@robobun

robobun commented Jul 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:16 PM PT - Jul 20th, 2026

✅ @robobun, your commit 87d2e5de143defd11377c3ae16e3c55d27a53ac3 passed in Build #76444! 🎉


🧪   To try this PR locally:

bunx bun-pr 34854

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

bun-34854 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. cancel callback of "direct" readable stream doesn't get called #18315 - Exact bug: cancel callback of type: "direct" ReadableStream is never called when the stream is cancelled
  2. ReadableStream with new Response produces cancel reason: undefined #17175 - Direct stream cancel fires spuriously with reason: undefined after controller.close(); the reworked cancel routing and m_closed flag address this
  3. Bun crashed while trying to run an sse example #18654 - SSE crash in JSReadableHTTPResponseSinkController::destroy after cancel; proper cancellation lifecycle (setting m_closed, preventing writes after cancel) addresses the use-after-cancel state

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #18315
Fixes #17175
Fixes #18654

🤖 Generated with Claude Code

@robobun

robobun commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Of the three suggested issues, only #18315 is addressed here (verified its repro now prints cancelled: true).

#17175 and #18654 are on the Bun.serve native-sink path (JSReadableHTTPResponseSinkController / readDirectStreamCloseImpl), not the JS-consumption JSDirectStreamController this PR touches, so they are left open.

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

Beyond the inline nit, I also checked: (1) GC safety of underlyingSource after m_directUnderlyingSource.clear() in the ControllerKind::None arm — it's stack-rooted and the pattern matches fromIterableCancelAlgorithm; (2) whether dropping the onClose() call loses sink teardown or the Bun-only .close() callback — it was already a dead call (early-returned on the Readable check), and setting m_closed = true now gates onClose/handleError against later double-invocation; (3) exception-scope discipline in directUnderlyingSourceCancel — the TOP_EXCEPTION_SCOPE + takeAbruptCompletion + result.isEmpty() termination path mirrors fromIterableCancelAlgorithm exactly.

Extended reasoning...

This is a ~40-line C++ change to JSC stream bindings that adds a new user-JS invocation point on the direct-stream cancel path. It follows the file's established patterns closely (StreamAsyncContextScope, TOP_EXCEPTION_SCOPE/takeAbruptCompletion, promiseResolvedWith), and the removed onClose() was provably dead. Deferring to human review given it's JSC bindings with observable semantic changes; the note above records the non-obvious concerns I traced so they aren't re-derived.

Comment thread test/js/web/streams/streams.test.js Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found; the previous .toThrow() nit is addressed in 87d2e5d. Deferring to a human because this reworks the ControllerKind::Direct/None cancellation lifecycle in C++ JSC bindings — worth a maintainer glance at the sink-teardown / m_deferClose interactions.

What was reviewed:

  • directUnderlyingSourceCancel — exception-scope layering matches fromIterableCancelAlgorithm; VM-termination path (takeAbruptCompletion → empty) falls through to return nullptr correctly.
  • Removed controller->onClose() was already a no-op (early-returns on state != Readable after readableStreamClose), so no sink-teardown regression vs. before.
  • ControllerKind::None arm: clearing m_directUnderlyingSource + resetting m_bunMode leaves materializeIfNeeded() a no-op afterwards; underlyingSource stays stack-rooted across the cleared WriteBarrier.
Extended reasoning...

Overview

Adds a directUnderlyingSourceCancel helper in ReadableStreamOperations.cpp that invokes underlyingSource.cancel(reason) under the stream's captured async context and wraps the result in a promise. Wires it into readableStreamCancel for both ControllerKind::Direct (materialized) and ControllerKind::None with a pending m_directUnderlyingSource (un-materialized). Also sets controller->m_closed = true so post-cancel write() throws. Adds tests in streams.test.js covering all three sub-cases and un-todos the AsyncLocalStorage direct-cancel test.

Security risks

None. No untrusted-input parsing, auth, or network-facing surface. The user-JS callback invocation follows the same TOP_EXCEPTION_SCOPE + takeAbruptCompletion pattern as sibling algorithms.

Level of scrutiny

Moderate-to-high. This is C++ JSC bindings code in the streams cancellation lifecycle — REVIEW.md flags memory safety and "anything that can run user JS can synchronously free your state" as the most-blocked category. The helper calls a user-provided cancel() while holding raw pointers to stream/controller/underlyingSource; I verified these remain stack-rooted (conservative scan) and that m_closed/m_pendingRead are settled before the callout, but a maintainer familiar with JSDirectStreamController should confirm the removed onClose() doesn't leave the ArrayBufferSink or m_deferClose state in a shape later paths don't expect.

Other factors

  • The new helper's shape (dynamic ->get() of cancel, StreamAsyncContextScope, catch-scope wrapping, promiseRejectedWith/promiseResolvedWith) mirrors performDefaultControllerCancelAlgorithm and fromIterableCancelAlgorithm in the same file.
  • The removed onClose() call was demonstrably dead: it early-returns on state != Readable, and readableStreamClose runs first.
  • Test coverage is good: reader.cancel with reason, stream.cancel before materialization, promise-chaining onto the source's returned promise, write-after-cancel throws the specific TypeError, and async-context propagation via the un-todo'd ALS test.
  • The prior review's bare-.toThrow() nit was fixed in 87d2e5d and the thread is resolved.

Jarred-Sumner pushed a commit that referenced this pull request Aug 2, 2026
)

#36703 set `m_closed = true` in `readableStreamCancel`'s
`ControllerKind::Direct` arm, and the bound
`write`/`end`/`close`/`flush`/`error` handlers throw `TypeError:
ReadableStreamDirectController is now closed` whenever `m_closed` is
set. That is a user-visible change from v1.3.x for a producer whose
in-flight `pull()` keeps calling the controller after the consumer has
cancelled (or after its own `end()`/`error()`).

```js
const rs = new ReadableStream({
  type: "direct",
  async pull(c) {
    ctrl = c;
    c.write("first");
    await new Promise(() => {});
  },
});
const r = rs.getReader();
r.read().catch(() => {});
// ...after pull has started...
await r.cancel();
ctrl.write("after-cancel");
// v1.3.x: byte count
// after #36703: TypeError: ReadableStreamDirectController is now closed
```

## Fix

Keep `m_closed = true` on cancel (the state is accurate) and change the
bound handlers to no-op instead of throw once `m_closed` is set:
`write()` returns `0`, `end()`/`close()`/`flush()`/`error()` return
`undefined`. `ReadableStreamOperations.cpp` is unchanged from main; the
now-unused `directControllerClosedMessage` constant is removed.

## Verification

```
# without src/ change
TypeError: ReadableStreamDirectController is now closed
(fail) direct controller methods no-op once closed

# with src/ change (incl. BUN_DESTRUCT_VM_ON_EXIT=1 + detect_leaks=1)
(pass) ReadableStream releases source-only WriteBarriers once terminal
(pass) direct controller methods no-op once closed
```

The test covers both the `reader.cancel()` path and the
`controller.end()` path. Open PR #34854 also throws on `m_closed` after
cancel and would need the same treatment if it lands.

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

---

**[stamp-90s]** gate passed · iteration 2 · 2 files touched

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

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/streams/readable-stream-terminal-barrier-release.test.ts
bun test v1.4.0 (c8be033)

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:
(pass) ReadableStream releases source-only WriteBarriers once terminal [2645.80ms]
113 |   });
114 |   const reader = rs.getReader();
115 |   reader.read().catch(() => {});
116 |   await pullStarted.promise;
117 |   await reader.cancel();
118 |   expect(ctrl.write("after-cancel")).toBe(0);
                    ^
TypeError: ReadableStreamDirectController is now closed
      at <anonymous> (/workspace/bun/test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:118:15)
(fail) direct controller methods no-op once closed [47.15ms]

 1 pass
 1 fail
 3 expect() calls
Ran 2 tests across 1 file. [4.73s]
error: script "bd" exited with code 1
__F:1:S:0

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

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:
(pass) ReadableStream releases source-only WriteBarriers once terminal [28.31ms]
113 |   });
114 |   const reader = rs.getReader();
115 |   reader.read().catch(() => {});
116 |   await pullStarted.promise;
117 |   await reader.cancel();
118 |   expect(ctrl.write("after-cancel")).toBe(0);
                                           ^
error: expect(received).toBe(expected)

Expected: 0
Received: 12

      at <anonymous> (/workspace/bun/test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:118:38)
(fail) direct controller methods no-op once closed [1.54ms]

 1 pass
 1 fail
 4 expect() calls
Ran 2 tests across 1 file. [175.00ms]
__F:1:S:0
```

</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/mechgate.xml" test/js/web/streams/readable-stream-terminal-barrier-release.test.ts
bun test v1.4.0 (c8be033)

test/js/web/streams/readable-stream-terminal-barrier-release.test.ts:
(pass) ReadableStream releases source-only WriteBarriers once terminal [2656.43ms]
(pass) direct controller methods no-op once closed [51.20ms]

 2 pass
 0 fail
 11 expect() calls
Ran 2 tests across 1 file. [4.74s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 677ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/41] gen cpp.rs (cppbind)
[2/41] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[3/41] gen JS modules (bundle-modules)
Preprocess modules (8808ms)
Bundle modules (32ms)
Postprocesss modules (250ms)
Bundle Functions (698ms)
Generate Code (29ms)

[9.83s] Bundled "src/js" for production
  2559 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[3/30] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92
... (truncated)
```

</details>

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

```
.../webcore/streams/JSDirectStreamController.cpp   | 17 ++++--------
 ...eadable-stream-terminal-barrier-release.test.ts | 30 ++++++++++++++++++----
 2 files changed, 30 insertions(+), 17 deletions(-)
```

</details>

**gate history** · 3 passed · 0 rejected · iteration 2

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

```
file                                                      reads  edits  tests
…c/bindings/webcore/streams/JSDirectStreamController.cpp     10      2      0
…treams/readable-stream-terminal-barrier-release.test.ts      3      5      0
```

</details>

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

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Covered by #41757, which runs a direct stream's cancel(reason) on reader.cancel() / stream.cancel() / for await ... break through the new cancelSteps path (and directly on the source for a never-pulled stream), in the stream's async context. All of this PR's test cases pass on that branch: it already tests cancel(reason) after a read and before the first read and un-todos the same AsyncLocalStorage test, and the async-hook ordering case was added there in 46036f8. The write-after-cancel TypeError assertion no longer applies: on main a closed direct controller's write() returns 0 instead of throwing. Closing in favor of #41757.

@robobun robobun closed this Sep 8, 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.

cancel callback of "direct" readable stream doesn't get called

1 participant