Skip to content

streams: direct controller methods no-op (not throw) once closed - #36779

Merged
Jarred-Sumner merged 7 commits into
mainfrom
claude/afabb81b/direct-write-after-cancel-no-throw
Aug 2, 2026
Merged

Jarred-Sumner merged 7 commits into
mainfrom
claude/afabb81b/direct-write-after-cancel-no-throw

Conversation

@robobun

@robobun robobun commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

#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()).

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.


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

fails on main (without fix)
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 (c8be033d3)

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 (a6fb7a624)

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

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)
diff hotspot
.../webcore/streams/JSDirectStreamController.cpp   | 17 ++++--------
 ...eadable-stream-terminal-barrier-release.test.ts | 30 ++++++++++++++++++----
 2 files changed, 30 insertions(+), 17 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

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

….x write-after-cancel)

#36703 set m_closed=true in readableStreamCancel's Direct arm so the bound
controller methods would throw after cancel. That is a user-visible behavior
change from v1.3.x, where a producer's in-flight pull() could still call
write()/end() after the consumer cancelled and get the byte count back
without a throw.

Drop the m_closed assignment. The source-clearing call is kept, and the
handleError path #36703 was guarding is already safe:
callUnderlyingSourceClose and closeDirectSinkForError both early-return on a
cleared source, and readableStreamError is gated on stream state == Readable.

The test that asserted a throw is flipped to pin the non-throwing contract.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Direct stream cancellation

Layer / File(s) Summary
Preserve controller usability after cancellation
src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp, test/js/web/streams/readable-stream-terminal-barrier-release.test.ts
Direct-stream cancellation no longer sets m_closed before source cleanup. The test verifies that write() returns the byte length and end() does not throw after reader.cancel().

Possibly related PRs

  • oven-sh/bun#36337: Changes direct-stream cancellation behavior in ReadableStreamOperations.cpp.
  • oven-sh/bun#36358: Changes direct-stream cancellation and flush behavior.
  • oven-sh/bun#36703: Changes direct-stream cancellation and its terminal-barrier test.
🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title describes no-op behavior after closure, but the change removes the direct stream closed state on cancellation and preserves post-cancel controller operations. Rename the title to state that direct cancellation no longer marks the controller closed or that post-cancel controller calls retain their prior behavior.
Description check ⚠️ Warning The description is detailed, but it specifies a different fix and behavior: it keeps m_closed true and returns 0, while the changes remove that assignment and preserve writes. Update the description to match the diff: remove m_closed assignment during direct cancellation, retain source clearing, and verify post-cancel write and end behavior.
✅ Passed checks (2 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.

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

@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: 1

🤖 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 `@test/js/web/streams/readable-stream-terminal-barrier-release.test.ts`:
- Around line 119-120: Extend the regression test around ctrl.write and ctrl.end
to assert that ctrl.flush() and ctrl.error(...) do not throw after cancellation,
placing the ctrl.error(...) assertion last. Preserve the existing write
return-value and end no-throw 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: 2f9dd812-b7d0-421a-a129-8d98e720d6cf

📥 Commits

Reviewing files that changed from the base of the PR and between af119b8 and 35c9cd3.

📒 Files selected for processing (2)
  • src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp
  • test/js/web/streams/readable-stream-terminal-barrier-release.test.ts

Comment thread test/js/web/streams/readable-stream-terminal-barrier-release.test.ts Outdated
@robobun
robobun force-pushed the claude/afabb81b/direct-write-after-cancel-no-throw branch from ab67561 to c376c09 Compare August 2, 2026 12:45
Comment thread src/jsc/bindings/webcore/streams/ReadableStreamOperations.cpp Outdated
@robobun

robobun commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:44 AM PT - Aug 2nd, 2026

✅ @robobun, your commit c8be033d38e691e41a2fdce8f1690289f5612de4 passed in Build #87850! 🎉


🧪   To try this PR locally:

bunx bun-pr 36779

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

bun-36779 --bun

robobun added 2 commits August 2, 2026 13:06
…ink__doClose leak)

ctrl.error() after cancel reaches handleError -> closeDirectSinkForError ->
sink.close() -> ArrayBufferSink__doClose, which nulls m_sinkPtr before calling
ArrayBufferSink__close (end only). ~JSArrayBufferSink then skips __finalize and
the 48-byte native struct + its Vec<u8> leak under LSAN with
BUN_DESTRUCT_VM_ON_EXIT=1. That codegen bug is orthogonal to this PR; the
write()/end()/flush() assertions cover the v1.3.x contract being restored.
@robobun
robobun force-pushed the claude/afabb81b/direct-write-after-cancel-no-throw branch from 3879308 to a6fb7a6 Compare August 2, 2026 13:23

@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 safety claims check out (onClose early-returns on state != Readable, callUnderlyingSourceClose/closeDirectSinkForError null-guard, handleError gates readableStreamError on Readable). Deferring to a human because this flips the contract that #36703's test was explicitly pinning (throw → no-throw) and #34854 is cited as making the same choice; a maintainer should sign off on which direction the direct-controller post-cancel semantics land.

What was reviewed

  • Traced post-cancel write()/end()/flush()/error() through boundDirect* → onClose/onFlush/handleError with m_closed=false and a cleared source; each path early-returns or no-ops safely.
  • Confirmed onClose returns before setting m_closed (stream is Closed), so the test's end()-then-flush() ordering is sound.
  • Checked directStreamControllerClearSource does not clear m_arrayBufferSink, so write() still returns a byte count as asserted.
Extended reasoning...

Overview

One-line C++ change in readableStreamCancel's ControllerKind::Direct arm: the controller->m_closed = true assignment added by #36703 is replaced with a comment. The accompanying test in readable-stream-terminal-barrier-release.test.ts is flipped from asserting that ctrl.write() after reader.cancel() throws /closed/ to asserting it returns the byte count and that end()/flush() don't throw. directStreamControllerClearSource(controller) is retained.

Security risks

None. This is internal stream-controller state management; no untrusted-input parsing, auth, or resource-limit surfaces are touched.

Level of scrutiny

Medium-high. The mechanical change is trivial and I verified the paths the PR description names: after cancel, the stream is Closed, so onClose (via end()) early-returns at the state != Readable gate without touching m_closed; onFlush finds no pending read and no queued requests and returns; handleError's closeDirectSinkForError/callUnderlyingSourceClose both null-guard, and its readableStreamError call is gated on Readable. So the safety concern #36703 might have been guarding is indeed handled without m_closed.

What I can't sign off on unilaterally is the API decision. #36703 shipped with a test that explicitly asserted the throw, and this PR flips that test to assert the opposite. The description also flags open PR #34854 as making the same m_closed choice. That's design churn on a Bun-specific extension's user-visible contract — whether a producer's late write() after consumer cancel should throw or silently succeed — and a maintainer should confirm "restore v1.3.x" is the intended direction rather than #36703's tightening being the intended one.

Other factors

All inline review threads (comment-cop long-comment, CodeRabbit's flush()/error() coverage ask) are resolved; the ctrl.error() assertion was dropped for a documented pre-existing LSAN leak in ArrayBufferSink__doClose, which is tracked separately and unrelated to this change. The evidence block shows the test failing on main and passing with the fix under both debug/ASAN and release.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

closed should be true when closed. We just don't want to throw because of it.

…tead of throw

Per review: m_closed should accurately track the closed state; the fix is to
stop the bound write/end/close/flush/error handlers from throwing on it.
write() returns 0 (no bytes written), the rest return undefined. Applies
uniformly whether m_closed was reached via reader.cancel(), end(), or error().
@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 11d1a73: m_closed = true stays in the cancel arm, and the four bound handlers (write/end+close/flush/error) now return early instead of throwing when m_closed is set. write() returns 0, the rest return undefined. Applies uniformly whether closed via reader.cancel(), controller.end(), or controller.error().

ReadableStreamOperations.cpp is now unchanged from main. The unused directControllerClosedMessage constant is removed.

Comment thread src/jsc/bindings/webcore/streams/JSDirectStreamController.cpp Outdated
@robobun robobun changed the title streams: don't set m_closed on direct-controller cancel (restore v1.3.x write-after-cancel) streams: direct controller methods no-op (not throw) once closed Aug 2, 2026

@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 changes the user-visible contract of the type: "direct" controller (throw → no-op, with write() now returning 0 rather than v1.3.x's byte count), so a maintainer should sign off on the API choice.

What was reviewed:

  • Traced all four bound handlers — the m_closed early return is hit before any sink call, so the earlier LSAN concern on ctrl.error() no longer applies.
  • Confirmed ReadableStreamOperations.cpp's cancel arm still sets m_closed = true and clears the source; the barrier-release test's invariants are preserved.
  • Checked that onClose/onFlush/handleError already guard m_closed internally, so folding the check into the bound wrapper doesn't skip any cleanup.
Extended reasoning...

Overview

Two files: JSDirectStreamController.cpp swaps the throwVMTypeError on m_closed in the four bound direct-controller handlers (write/close+end/flush/error) for early no-op returns (write → jsNumber(0), the rest → jsUndefined()), and drops the now-unused directControllerClosedMessage constant. The regression test is rewritten from asserting a throw to asserting the no-op contract on both the reader.cancel() and controller.end() paths.

Security risks

None. No untrusted input parsing, allocation, or lifetime changes; the early returns strictly do less work than before.

Level of scrutiny

This is core streams C++ and a user-facing Bun-specific API (ReadableStream({ type: "direct" })). The mechanical change is small and clearly correct, but it embeds an API design decision: post-close controller calls now silently no-op instead of throwing. That is neither the pre-#36703 behavior (post-cancel write() returned the byte count because m_closed wasn't set; post-end() write() threw once the deferred close ran) nor the WHATWG default-controller convention (enqueue() on a closed stream throws). The uniform no-op is defensible — a late call from an in-flight async pull() after consumer cancel would otherwise surface as an unhandled rejection — but per the repo's review guidance, changes to user-facing API surface warrant maintainer sign-off rather than bot approval.

Other factors

The PR went through two designs (drop m_closed on cancel vs. keep it and no-op the handlers); the final one is cleaner and keeps ReadableStreamOperations.cpp untouched. All prior bot threads (comment-cop, CodeRabbit coverage nit, LSAN concern) are resolved — the ctrl.error() assertion is now safe because boundDirectError returns before ever reaching closeDirectSinkForError. The [[unlikely]] hint now also covers m_closed, which is reasonable for a post-terminal call. Test verification (fail-on-main / pass-on-PR, ASAN + release) is documented in the description.

@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 bugs found. Deferring because this defines a new user-visible contract for Bun's type: "direct" controller: post-close write() now returns 0 (neither the v1.3.x byte-count nor the post-#36703 throw), and end/close/flush/error silently return undefined. That's a reasonable choice, but it's an API-surface decision a maintainer should sign off on.

What was reviewed:

  • All four bound handlers change uniformly; directControllerClosedMessage has no remaining references anywhere (src or tests).
  • Post-cancel ctrl.error() in the test is safe now — m_closed short-circuits before handleError, so the ArrayBufferSink__doClose leak path discussed earlier is not reached.
  • The now-unused DECLARE_THROW_SCOPE in close/flush/error is still consumed by RETURN_IF_EXCEPTION, so no dead scope.
Extended reasoning...

Overview

Two files. In JSDirectStreamController.cpp, the four jsWebStreamsHandler_boundDirect{Write,Close,Flush,Error} host functions are changed so that when controller->m_closed is set they return early (write → jsNumber(0), the rest → jsUndefined()) instead of throwVMTypeError(..., directControllerClosedMessage). The now-unused error-message constant is deleted. The test file replaces the previous "throws /closed/" assertion with exact-value assertions on all four methods, for both the reader.cancel() path and the controller.end() path.

Security risks

None. No untrusted-input parsing, no allocation, no new code paths — the change strictly reduces the work done on the m_closed branch to an immediate return. Exception-scope discipline is preserved (each function still declares a throw scope and checks it after the underlying call on the not-closed branch).

Level of scrutiny

Medium. The C++ change itself is mechanical and low-risk. What raises it above rubber-stamp territory is that it fixes a regression by picking a third behavior rather than a straight revert: pre-#36703 release builds returned the written byte count (because m_closed was never set on cancel), post-#36703 throws, and this PR returns 0. Returning 0 is defensible (the bytes are discarded, and it matches the !controller fallback shape), but it is a new observable contract on a Bun-native API and per the repo's API-design guidance should get maintainer eyes.

Other factors

  • The PR was reworked mid-review from "drop m_closed = true on cancel" to the current "keep m_closed, make handlers no-op", which is the cleaner layering (fix at the layer that owns the invariant). ReadableStreamOperations.cpp is untouched in the final diff.
  • The earlier LSAN concern about ctrl.error() no longer applies: with m_closed set before the assertion runs, boundDirectError returns before handleError → closeDirectSinkForError, so re-adding the error() assertion is safe.
  • Grepped for other consumers of the removed directControllerClosedMessage string and constant — none in src/ or test/, so no test will break on the message change.
  • All bot review threads are resolved; comment-cop's length complaints were addressed by trimming to one-line comments.

@Jarred-Sumner
Jarred-Sumner merged commit 2882c6c into main Aug 2, 2026
54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the claude/afabb81b/direct-write-after-cancel-no-throw branch August 2, 2026 15:48
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