Conversation
…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.
|
Warning Review limit reached
Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
|
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 |
|
Updated 5:16 PM PT - Jul 20th, 2026
✅ @robobun, your commit 87d2e5de143defd11377c3ae16e3c55d27a53ac3 passed in 🧪 To try this PR locally: bunx bun-pr 34854That installs a local version of the PR into your bun-34854 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
|
Of the three suggested issues, only #18315 is addressed here (verified its repro now prints #17175 and #18654 are on the |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 matchesfromIterableCancelAlgorithm; VM-termination path (takeAbruptCompletion→ empty) falls through toreturn nullptrcorrectly.- Removed
controller->onClose()was already a no-op (early-returns onstate != ReadableafterreadableStreamClose), so no sink-teardown regression vs. before. ControllerKind::Nonearm: clearingm_directUnderlyingSource+ resettingm_bunModeleavesmaterializeIfNeeded()a no-op afterwards;underlyingSourcestays 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()ofcancel,StreamAsyncContextScope, catch-scope wrapping,promiseRejectedWith/promiseResolvedWith) mirrorsperformDefaultControllerCancelAlgorithmandfromIterableCancelAlgorithmin the same file. - The removed
onClose()call was demonstrably dead: it early-returns onstate != Readable, andreadableStreamCloseruns 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.
) #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 -->
|
Covered by #41757, which runs a direct stream's |
Cancelling a
type: "direct"ReadableStreamnever invoked the underlying source'scancel()callback. A producer that relies oncancel()for upstream teardown never learns the consumer went away.readableStreamCancel'sControllerKind::Directarm calledonClose(), which early-returned becausereadableStreamClosehad already moved the stream out ofReadable, and then resolved withundefined. 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:stream.cancel()on a not-yet-materialized direct stream (ControllerKind::Nonewith a pending direct source), andm_closedsocontroller.write()throws after cancellation instead of silently buffering.The previously
test.todoAsyncLocalStorage "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