Skip to content

ByteStream: take the pending buffer action before signal_drained() in on_data - #37165

Merged
Jarred-Sumner merged 7 commits into
mainfrom
farm/61dabeff/bytestream-ondata-reentrancy
Aug 8, 2026
Merged

ByteStream: take the pending buffer action before signal_drained() in on_data#37165
Jarred-Sumner merged 7 commits into
mainfrom
farm/61dabeff/bytestream-ondata-reentrancy

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Symptom

Aborting or losing an in-flight fetch whose streaming response body has a parked consumer can abort the whole process:

Body::Value::to_error_instance (or FetchTasklet::on_body_received)
  -> ByteStream::on_data(Err) -> panic (core::option::unwrap_failed)

Observed in the wild when Claude Code shuts down its MCP streamable-HTTP transports: abort() on a fetch whose SSE response body has a parked consumer.

Cause

In ByteStream::on_data, the branch guarded by self.buffer_action.get().is_some() called self.signal_drained() before moving the action out of the cell. signal_drained() dispatches to the producer (SourceHandle::ready), and a producer that re-enters on_data or on_cancel from that signal consumes buffer_action, so the self.buffer_action.replace(None).unwrap() that followed (on both the Err path and the has_received_last_chunk path) could observe None and panic. The existing R-2 comment already states this rule for reject(); it was not applied to signal_drained().

Fix

  • Err path: move the action out of the cell first, then signal_drained(), then reject.
  • Non-Err path: keep signal_drained() before the fulfill (a backpressure-gated producer must observe the installed action), but re-take the action with let-else and return if a re-entrant consumer already settled it.

The non-last-chunk tail only appends to the buffer and never takes the action, so it needs no guard.

Verification

The in-tree producers do not re-enter synchronously from the drain signal (fetch and server request bodies schedule socket resumes, the rewriter defers behind its driving flag), so the interleaving is not reachable from plain JS. The deterministic test therefore installs a stub producer through bun:internal-for-testing (SourceHandle::TestingCancelOnDrain, whose ready() re-enters on_cancel), parks body.text() on a streaming fetch response, and severs the connection. The severed connection matters: the tasklet then delivers Err straight to on_data with the action still in its cell, while abort() first errors the JS stream, whose done() path cancels the native source and consumes the action before on_data runs.

On the unfixed ordering the fixture panics on every run:

panic: called `Option::unwrap()` on a `None` value
  core::option::unwrap_failed
  <bun_runtime::webcore::byte_stream::ByteStream>::on_data   src/runtime/webcore/ByteStream.rs:317
  <bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet>::on_body_received

With the fix it prints rejected:TypeError and exits 0.

bun bd test test/js/web/fetch/fetch.stream.test.ts -t "buffer action consumed re-entrantly"  # deterministic red/green
bun bd test test/js/web/fetch/fetch.stream.test.ts -t "parked body consumers"               # abort-path stress coverage

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

on_data called signal_drained() while buffer_action was still in its
cell. signal_drained() dispatches to the producer, which can run JS or
synchronously deliver more data, re-entering on_data/on_cancel; either
consumes the action, so the buffer_action.replace(None).unwrap() that
followed hit None and panicked (core::option::unwrap_failed), aborting
the process. Seen in the wild when aborting an in-flight fetch whose
streaming response body had a parked read.

Move the action out of the cell before signal_drained() on the Err
path, and re-take it with let-else instead of unwrap() after the
non-Err signal_drained(), bailing out when a re-entrant consumer
already settled it. This is the same rule the existing R-2 comment
states for reject().

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

ByteStream::on_data now handles producer signaling safely during re-entrant processing. Fetch regression coverage exercises repeated aborts with parked direct, decoded, and native text() consumers in a subprocess.

Changes

Fetch abort re-entrancy

Layer / File(s) Summary
Coordinate buffered actions during producer signaling
src/runtime/webcore/ByteStream.rs
ByteStream::on_data extracts buffered actions before error signaling and safely handles actions consumed by re-entrant processing.
Exercise parked consumers during fetch aborts
test/js/web/fetch/fetch-abort-parked-reads-fixture.ts, test/js/web/fetch/fetch.stream.test.ts
The fixture tests direct readers, decoded readers, native text() buffering, nested aborts, promise settlement, delayed callbacks, and process completion. The stream test runs the fixture as a subprocess and checks its output, stderr, and exit code.

Possibly related PRs

  • oven-sh/bun#35998: Both changes update fetch stream tests, but this PR covers re-entrant abort handling rather than network failure shaping.
  • oven-sh/bun#36588: Both changes modify ByteStream::on_data producer signaling, but this PR covers re-entrant abort handling rather than spilled-buffer draining.
  • oven-sh/bun#36736: Both changes cover ByteStream re-entrancy and cancellation, but this PR covers parked abort callbacks rather than owned-buffer handoff.

Suggested reviewers: jarred-sumner

🚥 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 identifies the primary ByteStream re-entrancy fix in on_data.
Description check ✅ Passed The description explains the symptom, cause, fix, and verification with relevant test commands and results.

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: 2

🤖 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/runtime/webcore/ByteStream.rs`:
- Around line 334-345: In the on_data flow around signal_drained, store or move
the current stream chunk into self.buffer before signaling the producer, while
leaving buffer_action installed during the signal. After signal_drained returns,
re-take buffer_action with the existing replace/let-else pattern and return when
re-entry consumed it, preserving chunk order for terminal and non-terminal
re-entry. Add a regression case using at least two distinct chunks and assert
the resulting buffered body order.

In `@test/js/web/fetch/fetch.stream.test.ts`:
- Around line 1432-1449: Remove the explicit 30_000 timeout argument from the
test.concurrent call for “aborting streaming fetches with parked body consumers
settles them without crashing”; retain the test body and runner-managed timeout
behavior unchanged.
🪄 Autofix

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: cbb377f0-6666-409a-a763-549a099171d1

📥 Commits

Reviewing files that changed from the base of the PR and between 392726b and 91404bd.

📒 Files selected for processing (3)
  • src/runtime/webcore/ByteStream.rs
  • test/js/web/fetch/fetch-abort-parked-reads-fixture.ts
  • test/js/web/fetch/fetch.stream.test.ts

Comment thread src/runtime/webcore/ByteStream.rs Outdated
Comment thread test/js/web/fetch/fetch.stream.test.ts
Comment thread src/runtime/webcore/ByteStream.rs Outdated
Comment thread src/runtime/webcore/ByteStream.rs Outdated
Comment thread src/runtime/webcore/ByteStream.rs Outdated
Comment thread src/runtime/webcore/ByteStream.rs
Comment thread src/runtime/webcore/ByteStream.rs
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:34 PM PT - Aug 7th, 2026

@robobun, your commit 5d5f7fd95fa702dc0a82c050d0167586210aaaac passed in Build #90447! 🎉


🧪   To try this PR locally:

bunx bun-pr 37165

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

bun-37165 --bun

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Review sweep is done: removed the per-test timeout (the runner default is the bound), tightened the re-entrancy comments, and kept the on_data structure as is. The chunk-ordering suggestion was withdrawn after verification that no in-tree producer delivers synchronously from the drain signal, and the interleaving it described predates this change, which only replaces the panic with a clean return. Details are in the resolved threads. Current head is 859129f; tests pass locally with the debug build.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

I checked whether the new test reproduces the bug on an unfixed build: it doesn't.

$ bun --revision            # system bun, no fix
1.4.0-canary.1+392726bc7
$ for i in $(seq 10); do bun test/js/web/fetch/fetch-abort-parked-reads-fixture.ts | tail -1; done | sort | uniq -c
  10 done 12

So fetch.stream.test.ts › aborting fetches with parked reads… passes with USE_SYSTEM_BUN=1, which per test/CLAUDE.md means it isn't validating the change (the fixture's own header comment says the timing "is not deterministically reachable from JS").

The code change itself matches the analysis (take the action out of the cell before signal_drained() on the Err path; re-take with let Some(..) else on the other paths) and I believe it fixes the crash I hit — but the PR needs a test that actually fails before / passes after. Two ways to get a deterministic repro:

  1. Make the producer re-enter synchronously. The panic needs signal_drained()SourceHandle::ready → producer synchronously calling back into on_data/on_cancel while buffer_action is still Some. From JS, a ReadableStream with a pull() that, when pulled after the drain, synchronously controller.error()s (or enqueues the final chunk + closes) — consumed through a path that installs a native BufferAction (res.body.text() / Response(new ReadableStream(...)).text() over a ByteStream) — should hit the unwrap() on the unfixed build every time. The current fixture's server stream only enqueues once in start() and never reacts to the drain, so nothing re-enters.
  2. Or a native unit test on ByteStream::on_data with a SourceHandle stub whose ready() calls on_cancel().

For reference, the in-the-wild trigger was: streaming text/event-stream fetch response, a parked reader.read(), then AbortController.abort() fired from another signal's abort listener during process shutdown → BodyAbortListener::on_abortto_error_instanceByteStream::on_data(Err)core::option::unwrap_failed.

…-for-testing producer

No in-tree producer re-enters ByteStream::on_data synchronously from the
drain signal (fetch and server bodies schedule socket resumes, the
rewriter defers behind its driving flag), so the stress fixture could not
turn the panic red. Add SourceHandle::TestingCancelOnDrain, installed
through bun:internal-for-testing, whose ready() re-enters on_cancel, and
a fixture that parks body.text() on a streaming fetch response and then
severs the connection: the tasklet delivers Err straight to on_data with
the action still in its cell (unlike abort, which errors the JS stream
first and consumes the action via done() -> cancel() before on_data
runs).

On the old ordering this panics every run (Option::unwrap() on None in
ByteStream::on_data, via FetchTasklet::on_body_received); with the fix
the text() promise rejects with the network error and the process exits
cleanly.
Comment thread src/js/internal-for-testing.ts
Comment thread src/runtime/webcore/ByteStream.rs
Comment thread src/runtime/webcore/ByteStream.rs
Comment thread src/runtime/webcore/streams.rs
@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Deterministic red/green is in now, via your option 2: a stub producer installed through bun:internal-for-testing (SourceHandle::TestingCancelOnDrain, whose ready() re-enters on_cancel).

Two findings from making it deterministic:

  1. Option 1 (a JS pull() that re-enters from the drain) is not reachable in this codebase: no ByteStream producer runs JS or pumps synchronously from ready(). FetchResponseBody and ServerRequestBody schedule socket resumes, RewriterPipe::resume defers behind its driving flag, and the JSController arm only ever fronts JSSink sources (fetch/S3 upload pumps), never a ByteStream's producer slot. That is why the stress fixture could not turn red.

  2. The delivery that reaches on_data(Err) with the action still parked is connection loss, not abort. BodyAbortListener::on_abort runs readable.error() first, and ReadableStream::error calls done(), which cancels the native source; on_cancel consumes (rejects) the buffer action before to_error_instance ever calls on_data(Err), so a plain abort settles benignly. FetchTasklet::on_body_received(success=false) delivers Err straight to on_data with no prior JS-stream error, so the fixture parks body.text(), installs the stub producer, and severs the connection. Claude Code's shutdown tears down transports as well as aborting, which fits the crash arriving via that window.

On the old ordering the fixture panics every run:

panic: called `Option::unwrap()` on a `None` value
  <bun_runtime::webcore::byte_stream::ByteStream>::on_data   src/runtime/webcore/ByteStream.rs:317
  <bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet>::on_body_received

With the fix it prints rejected:TypeError and exits 0 (the action is rejected with the tasklet's network error). The earlier stress fixture stays as abort-path settle coverage; happy to drop it if you would rather keep the PR to the deterministic test.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix clippy

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

The clippy failure was the transient main break from #35472 (uses of PartTag::JsxImport landing without the variant), fixed on main by #37174. Merged main to pick that up; cargo clippy --workspace is clean locally and both regression tests still pass.

@Jarred-Sumner
Jarred-Sumner merged commit 3ce0052 into main Aug 8, 2026
54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/61dabeff/bytestream-ondata-reentrancy branch August 8, 2026 07:29
springmin pushed a commit to springmin/bun that referenced this pull request Aug 8, 2026
… on_data (oven-sh#37165)

### Symptom

Aborting or losing an in-flight `fetch` whose streaming response body
has a parked consumer can abort the whole process:

```
Body::Value::to_error_instance (or FetchTasklet::on_body_received)
  -> ByteStream::on_data(Err) -> panic (core::option::unwrap_failed)
```

Observed in the wild when Claude Code shuts down its MCP streamable-HTTP
transports: `abort()` on a fetch whose SSE response body has a parked
consumer.

### Cause

In `ByteStream::on_data`, the branch guarded by
`self.buffer_action.get().is_some()` called `self.signal_drained()`
before moving the action out of the cell. `signal_drained()` dispatches
to the producer (`SourceHandle::ready`), and a producer that re-enters
`on_data` or `on_cancel` from that signal consumes `buffer_action`, so
the `self.buffer_action.replace(None).unwrap()` that followed (on both
the `Err` path and the `has_received_last_chunk` path) could observe
`None` and panic. The existing R-2 comment already states this rule for
`reject()`; it was not applied to `signal_drained()`.

### Fix

- `Err` path: move the action out of the cell first, then
`signal_drained()`, then `reject`.
- Non-`Err` path: keep `signal_drained()` before the fulfill (a
backpressure-gated producer must observe the installed action), but
re-take the action with `let`-`else` and return if a re-entrant consumer
already settled it.

The non-last-chunk tail only appends to the buffer and never takes the
action, so it needs no guard.

### Verification

The in-tree producers do not re-enter synchronously from the drain
signal (fetch and server request bodies schedule socket resumes, the
rewriter defers behind its `driving` flag), so the interleaving is not
reachable from plain JS. The deterministic test therefore installs a
stub producer through `bun:internal-for-testing`
(`SourceHandle::TestingCancelOnDrain`, whose `ready()` re-enters
`on_cancel`), parks `body.text()` on a streaming fetch response, and
severs the connection. The severed connection matters: the tasklet then
delivers `Err` straight to `on_data` with the action still in its cell,
while `abort()` first errors the JS stream, whose `done()` path cancels
the native source and consumes the action before `on_data` runs.

On the unfixed ordering the fixture panics on every run:

```
panic: called `Option::unwrap()` on a `None` value
  core::option::unwrap_failed
  <bun_runtime::webcore::byte_stream::ByteStream>::on_data   src/runtime/webcore/ByteStream.rs:317
  <bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet>::on_body_received
```

With the fix it prints `rejected:TypeError` and exits 0.

```
bun bd test test/js/web/fetch/fetch.stream.test.ts -t "buffer action consumed re-entrantly"  # deterministic red/green
bun bd test test/js/web/fetch/fetch.stream.test.ts -t "parked body consumers"               # abort-path stress coverage
```

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

---

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

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

---------

Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Jarred-Sumner added a commit that referenced this pull request Aug 8, 2026
Takes main's ByteStream fix (#37165) over the branch's variant, and moves the
process.argv passthrough-offset decision to the list the VM actually receives:
deciding it in the argument parser broke `bun -e code a b` (positionals are
merged back in later), which test/cli/run/run-eval.test.ts and the streaming
fetch tests caught. The snapshot request's termination unwind/clear go through
two HeapImage.cpp exports now that main removed the Rust wrappers.
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