Bun.write(path, fetch()): opt into BufferAll so a streaming body completes - #35531
Bun.write(path, fetch()): opt into BufferAll so a streaming body completes#35531robobun wants to merge 13 commits into
Conversation
|
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 (5)
Comment |
|
Updated 11:06 PM PT - Jul 25th, 2026
❌ @robobun, your commit cb1d02d has 1 failures in
🧪 To try this PR locally: bunx bun-pr 35531That installs a local version of the PR into your bun-35531 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Verified the suggested issues against this build:
Leaving both out of the description. |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
On the duplicate flags: #32906 does include this same #31739 takes a different approach (pipe the network body through a FileSink instead of buffering) and does not touch Happy to close this in favor of #32906 if that one is landing soon; leaving it open as the minimal regression patch otherwise. |
There was a problem hiding this comment.
LGTM — minimal regression fix that mirrors the existing ValueBufferer pattern.
What was reviewed:
- Verified the new
on_start_buffering(producer_task)call passes the original FetchTasklet ctx (not the new task) beforelocked.taskis overwritten, matchingBody.rs:2497. - Confirmed
FetchTaskletis the only producer that setson_start_buffering, and it always setstaskalongside it — theif let (Some, Some)guard is safe and the.take()side-effect whentaskisNoneis harmless. - Test follows repo conventions (
tempDir,port: 0,await using,Buffer.allocover.repeat, local server) and asserts exact byte count + content.
Extended reasoning...
Overview
This PR fixes a hang regression in Bun.write(path, await fetch(url)) introduced by #29831's BodyReceiveMode::AutoPause. The fix is a 6-line addition in src/runtime/webcore/Blob.rs (write_file_internal's BodyValue::Locked arm) that calls locked.on_start_buffering(producer_task) before overwriting locked.task with the WriteFileWaitFromLockedValueTask pointer. A new test in test/js/bun/io/bun-write.test.js fetches a 1 MB body from a local Bun.serve and writes it to disk, which times out on main and passes with the fix.
Correctness
The fix is a byte-for-byte copy of the pattern already used at Body.rs:2497-2501 (ValueBufferer). The critical detail — that on_start_buffering must be called with the original producer task (the FetchTasklet*) before locked.task is repurposed — is handled correctly: the tuple destructure reads locked.task before line 5252 overwrites it. on_start_buffering_callback in FetchTasklet.rs:1669 casts ctx back to FetchTasklet, sets BodyReceiveMode::BufferAll, re-refs the poll_ref, and schedules a receive resume — exactly what's needed to un-park the transport so resolve() eventually fires on_receive_value → then_wrap.
I checked whether .take() on on_start_buffering when locked.task is None could drop a needed callback: FetchTasklet::to_body_value is the only site that sets on_start_buffering, and it always sets task on the line before, so the guard is defensive rather than load-bearing. The other stale callbacks left on locked (on_start_streaming, on_stream_cancelled, etc.) that still expect a FetchTasklet* after task is overwritten are pre-existing and won't fire on this path once BufferAll is set — no new hazard introduced.
Security risks
None. No untrusted input parsing, no new allocation, no lifetime changes. The c_void pointer passed is the same one the producer registered and expects.
Level of scrutiny
Medium — this touches the fetch/body-locking machinery which has memory-safety implications (raw *mut c_void ctx pointers, task ownership), but the change is purely additive, follows an established in-tree pattern verbatim, and the mechanism is well-explained in the PR description with reference to the causing PR (#29831) and adjacent work (#32906, #31739).
Other factors
The test is well-constructed per REVIEW.md: hermetic local server, port: 0, using/await using for cleanup, Buffer.alloc(n, fill) instead of "x".repeat(n), asserts exact written byte count and file content (not just length). It sits alongside the existing "Bun.file -> Response" test which covers the small-body (single-buffer) case, so both variants are now covered.
…35534) Adds a self-contained GitHub Action that scans `claude`-labeled PRs for multi-line comment blocks added under `src/` and leaves a review line comment on each one asking for it to be deleted (and the thread resolved once done). ### Behavior - Triggers on `pull_request_target` (`opened` / `synchronize` / `reopened` / `labeled`), gated on `github.repository == 'oven-sh/bun'` and the `claude` label being present. The `labeled` trigger covers the normal case where `auto-label-claude-prs.yml` applies the label after open. - Fetches PR files via `pulls.listFiles` and parses the unified diff in `actions/github-script`. No repo checkout. - A group is **2+ consecutive added lines** that are pure comment lines (`//`, `///`, `//!`, `/* ... */`, `* ` continuation) in `.rs` / `.c` / `.cpp` / `.h` / `.ts` / `.js` and friends. Groups containing `SAFETY:` are skipped. - Posts one standalone review line comment per consecutive group via `pulls.createReviewComment` (no review summary). - Each comment carries `<!-- comment-cop:<path>:<sha12> -->` keyed on the group's content hash, so reruns (new pushes, relabels) skip groups that already have a marker. - On each run, any existing comment-cop thread whose flagged block is no longer present in the diff is auto-resolved via `resolveReviewThread`. ### Verification Dry-ran the parser against the five most recent open `claude` PRs via `gh api repos/oven-sh/bun/pulls/<n>/files`: ``` #35531 src/runtime/webcore/Blob.rs:5245-5246 #35529 src/http_jsc/websocket_client.rs:907-910 #35528 src/runtime/webcore/FileSink.rs:917-920 #35526 src/http_jsc/websocket_client.rs:2179-2182 #35532 (clean) ``` Cross-checked `#35531` line numbers against the fetched head ref; `Blob.rs:5245` is the first line of the flagged block. `actionlint` and prettier both pass. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · build/CI scripts only; test-proof not applicable <!-- robobun:evidence:end -->
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟣
src/runtime/webcore/Blob.rs:5254-5260— Pre-existing (not introduced here, don't block on it): afterlocked.taskis overwritten with theWriteFileWaitFromLockedValueTaskpointer at line 5261,on_start_streaming/on_readable_stream_available/on_stream_cancelled/on_stream_drainedstill hold FetchTasklet callbacks — a subsequentresp.bodyorresp.clone()reachesto_readable_stream/tee()and calls them with the wrong pointer, casting*mut WriteFileWaitFromLockedValueTaskto*mut FetchTasklet(type confusion). Since this PR already does partial hook cleanup here, consider also.take()-ing /None-ing those four hooks alongsideon_start_bufferingso a laterresp.bodydegrades to the known #13237 hang instead of UB — or leave it for #32906.Extended reasoning...
What
In the block this PR edits,
write_file_internaloverwriteslocked.taskwith a pointer to its ownWriteFileWaitFromLockedValueTask(Blob.rs:5261, unchanged context) and installson_receive_value = WriteFileWaitFromLockedValueTask::then_wrap(Blob.rs:5262). The PR now additionally.take()son_start_bufferingand calls it — but deliberately leaveson_start_streamingin place (only checks.is_some()), and neither the PR nor the surrounding code toucheson_readable_stream_available,on_stream_cancelled, oron_stream_drained. Those four hooks were installed by the producer atFetchTasklet.rs:1723-1726and each doesFetchTasklet::from_ctx(ctx)— an unchecked cast ofctxto*mut FetchTasklet.If user code touches
resp.bodyorresp.clone()afterBun.write(path, resp)has returned its promise, those stale hooks are invoked withlocked.task, which now points at aWriteFileWaitFromLockedValueTask. That's a type-confused raw-pointer cast — the callee reads/writesFetchTaskletfields (signal_store.aborted,response.body,poll_ref, …) at offsets that are actuallyWriteFileWaitFromLockedValueTaskfields → crash / heap corruption.Trigger path (step-by-step)
const resp = await fetch(url); // FetchTasklet installs on_start_streaming/on_readable_stream_available/ // on_stream_cancelled/on_stream_drained; locked.task = *mut FetchTasklet const p = Bun.write("/tmp/out", resp); // Blob.rs:5254-5262: .take()s on_start_buffering, then // locked.task = *mut WriteFileWaitFromLockedValueTask resp.body; // or resp.clone()
resp.body→get_body(Body.rs:1746).get_body_readable_streamreturnsNone(no JS-side cache,locked.readableempty), so it falls through toto_readable_stream.Value::to_readable_stream,Lockedarm (Body.rs:776):locked.readable.get()isNone(:777);locked.promiseisNoneandlocked.actionisNone(:780) —write_file_internalset neither — so the used-body early-return does not fire.- Line 785-786:
if let Some(drain) = locked.on_start_streaming.take() { drain_result = drain(locked.task.unwrap()); }. HeredrainisFetchTasklet::on_start_streaming_http_response_body_callback, butlocked.task.unwrap()is the*mut WriteFileWaitFromLockedValueTaskinstalled at Blob.rs:5261.from_ctxcasts it to*mut FetchTaskletand immediately readsthis.signal_store.abortedetc. from garbage → type confusion. - Lines 806-814 wire the same wrong
locked.taskinto the ByteStream'scancel_ctx/drain_ctx, and lines 843-848 callon_readable_stream_available(locked.task.unwrap(), …)with the wrong pointer as well.resp.clone()hits the same shape viatee()at Body.rs:1434 / 1476 / 1496.
Why nothing guards it
bodyUsedisfalsein this state:body_stream_check(Body.rs:1763) seesLockedwithaction == None, no cached JS stream, andlocked.readableempty, sothrow_if_body_unusabledoesn't intervene andget_body/cloneare reachable. ContrastValueBufferer::buffer_locked_body_value(Body.rs:2494-2516), which handles the same situation correctly by only overwritinglocked.taskwhen it was alreadyNone, otherwise routing throughto_readable_streamwhiletaskstill points at the real producer.Why this is pre-existing
The
locked.taskoverwrite at line 5261 and the failure to clear the sibling producer hooks both predate this PR — they appear as unchanged context in the diff. The trigger (const p = Bun.write(out, resp); resp.body;) was equally reachable before this PR (Bun.writereturned synchronously with a promise then too; if anything the pre-PR window was longer because the transport parked forever). The PR neither introduces nor widens the surface —to_readable_stream/teenever callon_start_buffering, which is the only hook the PR removes. It's surfaced here only because the PR edits the exact block and does partial hook cleanup, making the inconsistency visible.Suggested hardening (optional, non-blocking)
At the point
locked.taskis repurposed, alsoNone-out the remaining producer hooks:locked.on_start_streaming = None; locked.on_readable_stream_available = None; locked.on_stream_cancelled = None; locked.on_stream_drained = None; locked.task = Some(task.cast::<c_void>()); locked.on_receive_value = Some(WriteFileWaitFromLockedValueTask::then_wrap);
With those cleared, a subsequent
resp.bodyafterBun.write(path, resp)degrades to the known #13237 hang (empty ByteStream, never fed) rather than a type-confused pointer deref. That's still wrong, but it's the pre-existing wrong that #32906 is already tracked to fix — not UB. Fine to defer to #32906 if you'd rather keep this PR strictly minimal.
|
Applied the hook-clearing hardening in f5dbc18: verified the |
There was a problem hiding this comment.
Beyond the inline nit: checked whether on_start_buffering(producer_task) can synchronously re-enter and invalidate the locked borrow before line 5256 — for FetchTasklet it only does poll_ref.ref_() + an atomic receive-mode CAS + an http-thread post (FetchTasklet.rs:1669-1678), so no re-entry into body_value. The four-hook clearing from my earlier comment is in place at 5252-5255.
Extended reasoning...
The prior review concern (four producer hooks left pointing at FetchTasklet after locked.task is overwritten) was addressed in f5dbc18 and the thread is resolved. This run's remaining finding is the inline nit about on_start_buffering not being cleared unconditionally — a narrow, pre-existing edge case. Separately verified that the new on_start_buffering(producer_task) call cannot synchronously resolve or mutate *body_value while the &mut *body_value borrow at 5242 is live: FetchTasklet's callback (the only producer that installs both on_start_streaming and on_start_buffering on a fetch Response reaching this arm) only refs the poll, flips body_receive_mode atomically, and posts a resume to the HTTP thread. Not approving because this touches type-erased raw-pointer state in a memory-safety-adjacent path and overlaps with #32906/#31739, which is a maintainer call.
There was a problem hiding this comment.
All three earlier inline findings are addressed (hook clearing in f5dbc18, unconditional on_start_buffering = None in 46c7689, subprocess ASAN repro in 080a299). I didn't find anything new, but this touches fetch-body lifetime and the Response GC finalizer, and there's an open which-PR-lands question vs #32906/#31739 — worth a maintainer look.
Checked: the on_start_buffering(producer_task) call mirrors the existing ValueBufferer pattern at Body.rs:2497-2500; all five producer hooks are now cleared before locked.task is repurposed; the new on_receive_value early-return in on_response_finalize — verified the other setters (ValueBufferer, write_file.rs:1423) also represent live consumers, so no new over-retention.
Extended reasoning...
Overview
Three files: Blob.rs adds an on_start_buffering call plus clears all five producer hooks before overwriting locked.task in the Bun.write(path, Response) locked-body path (12 lines); FetchTasklet.rs makes on_response_finalize treat locked.on_receive_value as a live consumer so the body isn't dropped when the Response wrapper is GC'd mid-download (4 lines); two tests added to bun-write.test.js.
Security risks
None user-facing. The hook-clearing is a hardening that downgrades a pre-existing type-confusion (FetchTasklet callbacks invoked with a WriteFileWaitFromLockedValueTask*) from ASAN heap-buffer-overflow to the tracked #13237 hang. No new untrusted-input parsing.
Level of scrutiny
High. This is squarely in the memory-safety category REVIEW.md flags as most-blocked: it repurposes locked.task (a type-erased *mut c_void dispatched through function-pointer hooks), and it changes behavior inside a JSC Weak finalizer (on_response_finalize) that runs during sweep. The change itself is small and the mechanism is well-argued — the on_start_buffering call is copied from ValueBufferer at Body.rs:2497-2500, and I checked that every other on_receive_value setter (Body.rs:2515, write_file.rs:1423, RequestContext.rs:3261) also represents a consumer that should keep the body flowing — but finalizer-path edits and callback-context repurposing are exactly where a maintainer's eyes are worth having.
Other factors
- All three of my prior inline findings were addressed in follow-up commits; the last (subprocess ASAN repro) landed in 080a299 and matches the pattern used elsewhere in the file.
- The author explicitly flagged this as a strict subset of #32906 and an alternative to #31739; deciding which lands is a maintainer call, not something I should preempt with an approval.
- Bug hunter found nothing this run.
- No CI results visible for the latest commit yet (Build #80314 was building at last timeline update).
|
CI status:
Ready for review. |
…complete The receive-backpressure change in #29831 made fetch pause the transport after the first body chunk unless a consumer calls on_start_buffering. Bun.write's file-destination Locked arm registered on_receive_value and overwrote locked.task without ever signalling the producer, so the transport parked at ~128 KB and resolve() never fired. The awaited promise hung forever and no file was created. Call on_start_buffering with the original producer task before replacing locked.task, mirroring ValueBufferer.
The unguarded call dereferences a freed FetchTasklet when the body's ByteStream has already been materialised (e.g. after resp.clone().text()): once on_start_streaming is taken the producer delivers via the ByteStream, may have already dropped its last ref, and check_body_stream_ref can move the readable out of locked.readable, so neither locked.readable nor locked.task is a safe witness. on_start_streaming.is_some() is: it is take()'d exactly when the ByteStream is created, and while it is present the producer is guaranteed live and will resolve() this body.
After locked.task is overwritten with the WriteFileWaitFromLockedValueTask pointer, on_start_streaming / on_readable_stream_available / on_stream_cancelled / on_stream_drained still pointed at FetchTasklet callbacks. A subsequent resp.body or resp.clone() reached to_readable_stream / tee and invoked them with the wrong pointer type (ASAN: heap-buffer-overflow in on_start_streaming_http_response_body_callback). Pre-existing on main; clearing them here degrades that case to the known issue 13237 hang instead of UB. Covered by an ASAN-gated test.
When the Response JS wrapper is collected while the body is still Locked, on_response_finalize decides whether anything is waiting for it. It checked locked.promise but not locked.on_receive_value, so Bun.write's WriteFileWaitFromLockedValueTask (which registers on_receive_value without setting locked.promise) was treated as 'never started buffering': the body was dropped via ignore_remaining_response_body and the Bun.write promise never settled. This surfaced under BUN_GARBAGE_COLLECTOR_LEVEL=1 with describe.concurrent, where resp becomes unreachable after Bun.write(out, resp) returns and is collected before the body completes. resp.arrayBuffer() was unaffected because set_promise sets locked.promise. Also clear on_start_buffering unconditionally alongside the other four producer hooks, completing the set.
Isolates the crash from the concurrent describe block so a regression shows up as a single attributable failure, and the hung write promise is cleaned up by process exit.
080a299 to
5c11bbb
Compare
There was a problem hiding this comment.
No issues found this round — all three earlier inline concerns (stale-hook type confusion, unconditional on_start_buffering clearing, subprocess-spawned ASAN repro) are now applied. Deferring to a maintainer for the final look given this touches fetch body lifetime + the Response GC finalizer, and overlaps two open PRs (#32906, #31739) reworking the same Locked arm.
Checked: the on_start_buffering(producer_task) call matches the existing ValueBufferer/set_promise pattern in Body.rs and cannot synchronously replace *body_value under the caller's borrow. The on_response_finalize widening only affects the three on_receive_value setters (WriteFileWaitFromLockedValueTask ×2, ValueBufferer), all of which hold their own promise/task independent of the Response wrapper, so returning early there does not strand the transport.
Extended reasoning...
Overview
Two-file runtime change plus tests. Blob.rs (+12 lines in write_file_internal's BodyValue::Locked arm): call on_start_buffering(producer_task) while on_start_streaming is still set as the producer-liveness witness, then unconditionally null all five producer hooks before overwriting locked.task with the WriteFileWaitFromLockedValueTask*. FetchTasklet.rs (+4 lines in on_response_finalize): treat locked.on_receive_value.is_some() as a live consumer and return early instead of falling through to ignore_remaining_response_body. Three new tests in bun-write.test.js: the 300 KB streaming-body regression, an abort-propagation test, and an ASAN-gated subprocess repro for the resp.body-after-Bun.write type confusion.
Security risks
None identified. No user-controlled data reaches new parsing or allocation paths; the change tightens a pointer-type-confusion hazard by clearing stale callbacks rather than introducing one.
Level of scrutiny
High. This is memory-safety-adjacent: locked.task is a type-erased *mut c_void that different callbacks cast to different concrete types, and on_response_finalize runs inside a GC finalizer. The on_start_buffering invocation pattern is copied verbatim from Body.rs:2497-2500 (ValueBufferer) and Body.rs:421-424 (set_promise), and a verifier confirmed the call cannot synchronously mutate *body_value out from under the re-borrowed locked. The finalizer change was checked against every on_receive_value = Some(...) site in the tree — all three own an independent strong promise/task that outlives the Response wrapper, so keeping the transport alive is correct and does not leak (BufferAll was already opted into, so the body will resolve and then_wrap consumes the task).
Other factors
All three of my earlier inline comments were applied and the threads are resolved. CI on the latest push has bun-write.test.js passing on every lane; remaining reds are unrelated infra. The author explicitly flags this as the minimal regression patch subset of #32906, with #31739 taking a different (stream-to-disk) approach — a maintainer should decide sequencing between the three. Given the finalizer-path change and the overlapping-PR coordination question, deferring rather than approving.
…ter arm Introduce PendingValue::take_over_as_buffering_consumer(), which performs the on_start_buffering handshake with the producer's own task pointer (matching set_promise / ValueBufferer) and clears the five producer hooks. Call it from both the write_file_internal Locked arm and the WriteFileWaitFromLockedValueTask::then() Locked re-register arm. Expand the test coverage into a five-case acceptance suite: Content-Length body settles, chunked body settles, abort mid-transfer rejects with AbortError, abort mid-transfer rejects with the signal's TimeoutError reason, and abort after a fully received body still settles. The abort cases force GC on the Response wrapper before aborting so the on_response_finalize path is exercised deterministically instead of depending on timing.
5c11bbb to
e4a16b1
Compare
ccfade1 to
dc72278
Compare
dc72278 to
dd6a398
Compare
There was a problem hiding this comment.
All prior inline concerns are addressed and this run found nothing new, but the change reworks the locked.task ownership handshake and adds a live-consumer branch to the Response GC finalizer — worth a human pass, and someone should decide whether this lands independently of #32906.
What was reviewed:
take_over_as_buffering_consumermirrors theset_promise/ValueBuffererhandshake and clears every producer hook keyed on the oldtask— the earlier type-confusion path (resp.bodyafterBun.write) is closed and covered by the subprocess ASAN test.on_response_finalize's newon_receive_valueguard: the only other setter (ValueBufferer, Body.rs:2527) also setslocked.promise, so its behavior is unchanged.- The dead
on_start_buffering = Nonestore flagged last run is gone (969afa8).
Extended reasoning...
Overview
Fixes a 1.4.0 regression where Bun.write(path, await fetch(url)) hangs on bodies large enough to stream, caused by #29831's AutoPause backpressure never being released because write_file_internal's Locked arm registered on_receive_value without calling on_start_buffering. The fix extracts a shared PendingValue::take_over_as_buffering_consumer() (Body.rs) that performs the buffering opt-in with the producer's task pointer and then clears the five producer hooks, and calls it from both the initial registration (Blob.rs:5247) and the re-register arm (write_file.rs:1423) before overwriting locked.task with the consumer pointer. FetchTasklet::on_response_finalize gains an on_receive_value.is_some() early-return so GC of the Response wrapper doesn't tear down a body Bun.write is still waiting on. Five acceptance tests plus an ASAN-gated subprocess crash repro are added to bun-write.test.js.
Security risks
None identified. No untrusted-input parsing; the change is internal callback lifecycle.
Level of scrutiny
High. This is native code at the intersection of GC finalization, event-loop backpressure, and a repurposed type-erased *mut c_void context pointer — exactly the class REVIEW.md calls out as most-blocked. The prior revision had a real ASAN heap-buffer-overflow (FetchTasklet::from_ctx(WriteFileWaitFromLockedValueTask*)) that the hook-clearing now closes; the finalizer edit changes when a paused transport is torn down. It has been through several rounds of inline review here (type-confusion via stale hooks, unconditional on_start_buffering clear, subprocess isolation for the ASAN repro, dead-store cleanup) and each was addressed in a follow-up commit; the current diff looks correct to me.
Other factors
- The author notes this is a strict subset of #32906 and orthogonal to #31739; a maintainer should decide sequencing.
- I checked the other
on_receive_valuesetter (ValueBufferer at Body.rs:2527) — it also setslocked.promise, so the new finalizer guard changes behavior only for the Bun.write path. - Tests follow harness conventions (tempDir, port: 0, drained pipes, subprocess for the crash fixture); the abort tests use
Bun.gc(true)to force the finalizer path deterministically. Build #80414 was green on every lane that ran the file.
There was a problem hiding this comment.
No issues found, but deferring: this touches GC-finalizer logic (on_response_finalize runs inside sweep) and repurposes the type-erased locked.task pointer in the fetch body state machine — worth a maintainer's eyes, and there's a landing decision to make against the overlapping #32906 / #31739.
What was reviewed:
take_over_as_buffering_consumermirrors the existingset_promise/ValueBuffererhandshake; the re-register arm inthen()can't type-confuse becauseon_start_bufferingwas already.take()n on the first pass.- The new
on_receive_value.is_some()early-return inon_response_finalize— checked the other setters (ValueBufferer, serverRequestContext); keeping the tasklet alive is correct for all of them. - Prior inline findings (producer-hook clearing, unconditional
on_start_bufferingclear, subprocess ASAN fixture, dead store after.take()) are all applied in the current diff.
Extended reasoning...
Overview
Fixes a 1.4.0 regression where await Bun.write(path, await fetch(url)) never settles for bodies large enough to stream (~128 KB+). Root cause: #29831's AutoPause backpressure requires consumers to call on_start_buffering, and write_file_internal's Locked arm never did. The fix introduces PendingValue::take_over_as_buffering_consumer() (Body.rs) which performs the same handshake set_promise/ValueBufferer already do and clears the four remaining producer hooks so they can't be invoked with the repurposed locked.task. Called from Blob.rs:5247 and the then() re-register arm in write_file.rs:1423. on_response_finalize (FetchTasklet.rs) now treats on_receive_value as a live consumer alongside promise. Six new tests in bun-write.test.js cover Content-Length, chunked, mid-transfer abort (two error variants) with forced GC, post-completion abort, and a subprocess ASAN repro for the resp.body-after-Bun.write type confusion.
Security risks
None identified. The producer-hook clearing is a strict safety improvement — it closes a pre-existing type-confusion path (FetchTasklet::from_ctx(WriteFileWaitFromLockedValueTask*)) reachable from ordinary JS via resp.body after Bun.write(path, resp).
Level of scrutiny
High. The change is small and mechanically straightforward, but it sits at the intersection of three delicate subsystems: (1) on_response_finalize runs inside a JSC Weak finalizer during MutatorState::Sweeping, where the set of safe operations is narrow; (2) locked.task is a type-erased *mut c_void whose interpretation depends on which callback set is installed — clearing hooks and reassigning it must be atomic from the producer's point of view; (3) the fetch body Locked state machine has multiple consumers (.text(), ReadableStream, ValueBufferer, server RequestContext, Bun.write) that share these fields.
Other factors
- All four of my prior inline findings on this PR have been addressed in the current diff; the last one (dead
= Nonestore after.take()) landed in 969afa8. - I traced the re-register arm in
then(): on second entryon_start_bufferingis alreadyNone(cleared by.take()on first pass), so the tuple pattern fails and no callback is invoked with the wrongtask; ifthen()is handed a differentLockedbody, that body's own producer task is used, which is correct. - I checked every other setter of
on_receive_value(ValueBuffererat Body.rs:2527, serverRequestContextat RequestContext.rs:3261) against the new finalizer early-return — for each, keeping the fetch tasklet alive when the JS Response wrapper is collected is the desired behavior. - The scenario-comment relabeling in
on_response_finalize(2b→2a) matches the doc block above it; the unlabeled fall-through whenpromiseis non-empty still does nothing, preserving the pre-PR behavior. - Two open PRs (#32906, #31739) cover the same regression with broader scope / a different mechanism. Choosing which to land is a maintainer call.
- CI on build #80414 was green for bun-write.test.js on every lane per the author; I did not independently verify.
|
Issue #35854 reports this same hang (Bun.write of an in-flight fetch Response never settles, regression from 1.3). Verified its repro against the analysis here: a chunked body still streaming when Bun.write is called hangs forever on main. Added "Fixes #35854" to the body so the issue closes on merge. |
…35534) Adds a self-contained GitHub Action that scans `claude`-labeled PRs for multi-line comment blocks added under `src/` and leaves a review line comment on each one asking for it to be deleted (and the thread resolved once done). ### Behavior - Triggers on `pull_request_target` (`opened` / `synchronize` / `reopened` / `labeled`), gated on `github.repository == 'oven-sh/bun'` and the `claude` label being present. The `labeled` trigger covers the normal case where `auto-label-claude-prs.yml` applies the label after open. - Fetches PR files via `pulls.listFiles` and parses the unified diff in `actions/github-script`. No repo checkout. - A group is **2+ consecutive added lines** that are pure comment lines (`//`, `///`, `//!`, `/* ... */`, `* ` continuation) in `.rs` / `.c` / `.cpp` / `.h` / `.ts` / `.js` and friends. Groups containing `SAFETY:` are skipped. - Posts one standalone review line comment per consecutive group via `pulls.createReviewComment` (no review summary). - Each comment carries `<!-- comment-cop:<path>:<sha12> -->` keyed on the group's content hash, so reruns (new pushes, relabels) skip groups that already have a marker. - On each run, any existing comment-cop thread whose flagged block is no longer present in the diff is auto-resolved via `resolveReviewThread`. ### Verification Dry-ran the parser against the five most recent open `claude` PRs via `gh api repos/oven-sh/bun/pulls/<n>/files`: ``` #35531 src/runtime/webcore/Blob.rs:5245-5246 #35529 src/http_jsc/websocket_client.rs:907-910 #35528 src/runtime/webcore/FileSink.rs:917-920 #35526 src/http_jsc/websocket_client.rs:2179-2182 #35532 (clean) ``` Cross-checked `#35531` line numbers against the fetched head ref; `Blob.rs:5245` is the first line of the flagged block. `actionlint` and prettier both pass. <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · build/CI scripts only; test-proof not applicable <!-- robobun:evidence:end -->
|
Heads up: as written, |
…as a ByteStream (#36809) ### Repro ```js const server = Bun.serve({ port: 0, fetch: () => new Response("x".repeat(20000)) }); const resp = await fetch(`http://127.0.0.1:${server.port}/`); const rd = resp.body.getReader(); await rd.read(); rd.releaseLock(); await Bun.sleep(5); // fetch completes, FetchTasklet freed await Bun.write("/tmp/out.bin", resp); // heap-use-after-free under ASAN ``` ASAN (debug build, origin/main 54bbd5d and 5b7c3ca): ``` READ of size 1 in bun_io::keep_alive::KeepAlive::ref_ (keep_alive.rs:78) FetchTasklet::on_start_buffering_callback (FetchTasklet.rs:1825) blob::write_file_internal::{closure} (Blob.rs:5236) freed by: drop<Box<FetchTasklet>> <- FetchTasklet::deinit (:509) <- on_progress_update cleanup deref (:1006) ``` `resp.body;` alone (no reader) before the sleep also triggers it, as does `resp.clone()`. ### Cause `FetchTasklet::to_body_value` stores `pending.task = self` and `pending.on_start_buffering` / `on_start_streaming` / `on_readable_stream_available` / `producer` on the Response's `Locked` body. Touching `.body` runs `locked_to_native_stream`, which `.take()`s `on_start_streaming`, copies `producer` onto the new `NewSource<ByteStream>`, stores `locked.readable`, and calls `on_readable_stream_available`, but leaves `on_start_buffering` / `on_readable_stream_available` / `task` / `producer` on the PendingValue pointing at the tasklet. The clone/tee materialisation path does the same. Once the transfer completes, `on_progress_update`'s `is_done` cleanup derefs the tasklet to zero and frees it (`clear_stream_handlers` clears the ByteStream's `producer`, not the PendingValue's). The body stays `Locked{readable}` with the stale hooks. `write_file_internal`'s non-S3 `Locked` arm doesn't look at `locked.readable`; it calls `on_start_buffering(orig_task)` with the freed pointer, then parks on `on_receive_value`. `BodyValueBufferer` at `Body.rs:2593` has the same call for any body where `task.is_some()`. ### Fix Add `PendingValue::detach_producer()` and call it at both materialisation points after the ByteStream is wired up and `on_readable_stream_available` has fired. It clears `on_start_buffering` / `on_start_streaming` / `on_readable_stream_available` / `producer`, and clears `task` only when `on_receive_value` is unset: once a consumer has registered `on_receive_value`, `task` holds the consumer's ctx (read by `Value::resolve` / `Value::to_error_instance` via `.unwrap()`), not the producer being detached; every `on_receive_value` setter writes `task` alongside it so the stale producer pointer cannot be in `task` in that state. Delivery is then owned by the ByteStream's `NewSource.producer`, the single live back-reference already cleared by `clear_stream_handlers` on teardown. The `Bun.write` hang on a stream-backed body (`on_receive_value` never fires) is unchanged here; #32906 and #35531 cover it. Both of those still call `on_start_buffering(producer_task)` before checking for a readable, so neither removes this UAF on its own. ### Verification `test/js/bun/io/bun-write.test.js` gains: - an ASAN-gated `it.each` covering the three exposure shapes (`getReader().read()` then `releaseLock()`; bare `resp.body`; `resp.clone()`). Each spawns a subprocess that runs 8 iterations against a 200 KB loopback body and asserts `{stdout: "done", stderr: "", exitCode: 0}`. `ASAN_OPTIONS=symbolize=0:detect_leaks=0` keeps the crashing child's exit prompt (symbolize) and suppresses the known `WriteFileWaitFromLockedValueTask` leak from the #13237 hang (detect_leaks). - a test that drives `Bun.write(path, HTMLRewriter.transform(resp))` then `out.body` through an async input body and asserts the write resolves with the rewritten bytes, guarding the `on_receive_value` case. Without the `src/` change the three ASAN cases fail on the heap-use-after-free; with it all four pass. `body.test.ts` (448 pass), `body-stream.test.ts` (9086 pass), `html-rewriter.test.js` (69 pass) and `fetch-response-finalizer-sweep.test.ts` are unchanged. <!-- 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/bun/io/bun-write.test.js <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
|
Closing: the bug this PR was opened for is fixed on main, and the one piece of it that is still needed is carried by #32906. What landed on main since this was opened:
Verified on main at 165dc9f (debug build) with this PR's test hunk applied on its own: the Content-Length, chunked, late-abort and both mid-transfer abort tests pass when run one at a time, and the ASAN Still broken on main: const server = Bun.serve({ port: 0, fetch: () => new Response(new ReadableStream({ async start(c) {
c.enqueue(Buffer.alloc(65536)); await gate; for (let i = 0; i < 5; i++) { c.enqueue(Buffer.alloc(65536)); await Bun.sleep(10); } c.close();
} })) });
let release; const gate = new Promise(r => (release = r));
async function start() { const res = await fetch(server.url); return Bun.write("/tmp/out.bin", res); }
const p = start();
await Bun.sleep(20); Bun.gc(true); await Bun.sleep(20); Bun.gc(true);
release();
console.log(await p); // never printsThis PR's |
Fixes #35854.
Repro
On 1.4.0 (and main at
df6c7eed6),await Bun.write(path, await fetch(url))never resolves once the response body is large enough to arrive as a stream (observed at roughly 128 KB and above; bodies that fit in one buffer still work). No file is created, no error. 1.3.14 returns the byte count.The abort escape hatch is dead for the same reason:
fetch(url, { signal })followed byBun.write(path, res)hangs after the signal fires once the Response wrapper has been collected, because the finalizer drops the body before the abort can reach it.Cause
#29831 introduced
BodyReceiveMode::AutoPause: after the first body chunk,FetchTasklet::callbackCASesAutoPausetoPausedand the transport stops reading until a consumer opts intoBufferAllviaon_start_buffering(used by.text()/.arrayBuffer()/...andValueBufferer) or a ReadableStream drains it.write_file_internal's file-destinationBodyValue::Lockedarm only registerson_receive_valueand then overwriteslocked.taskwith theWriteFileWaitFromLockedValueTask*, never callingon_start_buffering. The transport parks after roughly one socket buffer,has_morestays true,BodyValue::resolvenever runs, and the task's promise is never settled.Separately,
on_response_finalizeonly recogniseslocked.promiseas a live consumer.Bun.writedoes not setlocked.promise, so when the Response JS wrapper is collected while the body is still arriving the finalizer callsignore_remaining_response_body, the tasklet is torn down, and a latersignal.abort()has no listener to deliver to.Fix
Introduce
PendingValue::take_over_as_buffering_consumer(), which performs theon_start_bufferinghandshake with the producer's owntaskpointer (the same handshakeset_promiseandValueBuffereralready do) and clears the remaining producer hooks so they cannot be invoked against the consumer'staskafter it has been repurposed. Call it from both thewrite_file_internalLocked arm and theWriteFileWaitFromLockedValueTask::then()Locked re-register arm before overwritinglocked.task.In
FetchTasklet::on_response_finalize, treatlocked.on_receive_valueas a live consumer alongsidelocked.promise, so collecting the Response wrapper does not drop a body thatBun.writeis still waiting on.Covers every non-S3 file destination (path,
Bun.file, fd blobs,Bun.stdout). The S3 destination pulls the stream itself and is unaffected.Bun.write(path, new Response(res.body))(a Locked value carrying a JS ReadableStream, #13237) is a separate arm and is not touched here.Verification
test/js/bun/io/bun-write.test.jsgains a five-case acceptance suite underBun.write(path, fetch()) with a streaming body:AbortErrorTimeoutErrorreason rejects withTimeoutErrorThe abort cases scope the Response to a helper and force GC before aborting so the
on_response_finalizepath is exercised deterministically. All five time out on main and pass here. An ASAN-gated test also checks that touchingresp.body/resp.clone()afterBun.write(path, resp)does not crash.Related: #31739 reworks this path to stream to disk instead of buffering; #32906 covers the JS-constructed ReadableStream body case. This PR is the minimal regression fix.
no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/io/bun-write.test.js