Skip to content

fetch: do not touch JSCells in the Response Weak finalizer during GC sweep#32729

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/ea9d7a26/fetch-weak-finalizer-sweep
Jun 26, 2026
Merged

fetch: do not touch JSCells in the Response Weak finalizer during GC sweep#32729
Jarred-Sumner merged 3 commits into
mainfrom
farm/ea9d7a26/fetch-weak-finalizer-sweep

Conversation

@robobun

@robobun robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Crash

ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping
vendor/WebKit/Source/JavaScriptCore/runtime/JSCell.cpp(179) : bool JSC::JSCell::validateIsNotSweeping() const

Backtrace (from a release-asan build with asserts):

#3  JSC::JSCell::validateIsNotSweeping()
#4  JSC::JSCell::classInfo() const
#5  WTF::uncheckedDowncast<WebCore::JSResumableFetchSink>(JSValue const&)
#6  ResumableFetchSinkPrototype__ondrainSetCachedValue
#7  bun_runtime::webcore::fetch::fetch_tasklet::FetchTasklet::ignore_remaining_response_body
#8  JSC::WeakBlock::sweep()          <- inside GC sweep (Weak finalizer)
#9  JSC::WeakSet::sweep()
#10 JSC::PreciseAllocation::sweep()
#12 JSC::Heap::finalize()
#21 JSC::LocalAllocator::allocateSlowCase
#23 JSC::ErrorInstance::create        <- ordinary allocation kicked off GC

Found by the syscall fault-injection fuzzer's client-side grammar scenario (fetch/node:http with abort + transient errno on the client socket). Reproduces ~4/5 under BUN_JSC_collectContinuously=1.

Cause

FetchTasklet::on_response_finalize is the WeakRefOwner<FetchResponse>::finalize callback and runs inside WeakBlock::sweep while MutatorState == Sweeping. When the response body is Locked without a pending promise or stream it calls ignore_remaining_response_body(), which called:

  • ResumableSink::detach_js(): writes the sink wrapper's cached ondrain / oncancel / stream slots via the generated ResumableFetchSinkPrototype__*SetCachedValue helpers. Each does uncheckedDowncast<JSResumableFetchSink>(thisValue), which reaches JSCell::classInfo() and then issues a write barrier on the wrapper cell.
  • clear_stream_handlers(): reaches ReadableStreamTag__tagged -> object->inherits<JSReadableStream>() (guarded today, but one boolean away).

Calling classInfo() on any cell while the mutator is sweeping is forbidden: the cell's Structure may already have been swept. Assert builds catch it; release builds corrupt the heap.

Fix

Thread a from_finalizer flag through ignore_remaining_response_body. When true (the on_response_finalize caller) skip detach_js() and clear_stream_handlers(); only native state is touched. The sink's JS-side detach still happens from clear_sink() in FetchTasklet::deinit(), which runs as an event-loop ConcurrentTask outside any sweep, so nothing leaks.

The on_stream_cancelled_callback caller (reader .cancel(), runs from JS on the event loop) passes false and keeps the immediate detach.

Also corrects the ResumableSink::detach_js doc comment that claimed finalizer safety.

Verification

New test at test/js/web/fetch/fetch-response-finalizer-sweep.test.ts: a child process under BUN_JSC_collectContinuously=1 does 12 iterations of fetch() with a user-constructed ReadableStream body (so the sink takes the JS route with a Strong js_this) against a raw TCP server that sends headers + a partial chunked body and never terminates it, then drops the Response unconsumed and runs Bun.gc(true).

Without the fix (bun bd, src/ stashed):

exitCode: 134
stderr: ASSERTION FAILED: vm().currentThreadIsHoldingAPILock() => vm().heap.mutatorState() != MutatorState::Sweeping

With the fix: stdout: "ok", exitCode: 0.

test/js/web/fetch/fetch-backpressure.test.ts (exercises the on_stream_cancelled_callback path) passes unchanged.

…sweep

FetchTasklet::on_response_finalize runs from WeakRefOwner::finalize
inside WeakBlock::sweep (MutatorState::Sweeping). It called
ignore_remaining_response_body, which in turn called
ResumableSink::detach_js() to clear the sink wrapper's cached
ondrain/oncancel/stream slots. Those generated *SetCachedValue helpers
uncheckedDowncast<JSResumableFetchSink>(...) -> JSCell::classInfo(),
and any classInfo() call while the mutator is sweeping trips the
validateIsNotSweeping assertion in assert builds (and is latent heap
corruption otherwise, since the cell's structure may already have been
swept).

Gate detach_js() and clear_stream_handlers() on a from_finalizer flag.
When called from the Weak finalizer only native state is touched; the
JS-side detach still happens later from clear_sink() in deinit(),
which runs as an event-loop task outside the sweep.

Also correct the detach_js() doc comment that claimed it was safe from
finalizers.
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:21 PM PT - Jun 25th, 2026

@robobun, your commit 0889b05 has 3 failures in Build #64829 (All Failures):

  • test/js/bun/http/fetch-file-upload.test.ts - timeout on 🍎 14 aarch64
  • test/js/bun/http/bun-serve-file.test.ts - timeout on 🍎 14 aarch64
  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_enter_wide32  [AVX]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.


🧪   To try this PR locally:

bunx bun-pr 32729

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

bun-32729 --bun

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 56e5116b-dcdb-4c32-a1eb-9e5299557372

📥 Commits

Reviewing files that changed from the base of the PR and between c23cd3a and 458ebda.

📒 Files selected for processing (1)
  • test/js/web/fetch/fetch-response-finalizer-sweep.test.ts

Walkthrough

Fetch response cleanup now distinguishes normal cancel paths from GC-finalizer paths. A new regression test exercises repeated GC sweeps on an unconsumed streaming-upload response and checks that the process completes successfully.

Changes

Finalizer-safe fetch response cleanup

Layer / File(s) Summary
Cleanup gating and safety note
src/runtime/webcore/ResumableSink.rs, src/runtime/webcore/fetch/FetchTasklet.rs
detach_js documents GC-sweep unsafety, and ignore_remaining_response_body now accepts from_finalizer so the normal cancel path keeps detaching the sink and clearing stream handlers while finalizer paths can skip that cleanup.
Finalize path and GC test
src/runtime/webcore/fetch/FetchTasklet.rs, test/js/web/fetch/fetch-response-finalizer-sweep.test.ts
on_response_finalize now passes true into the remaining-body cleanup calls, and the new Bun test runs a fixture that leaves a streamed response pending across repeated GC sweeps and checks that the process exits with ok.

Possibly related PRs

  • oven-sh/bun#31963: Updates fetch-stream GC/finalizer lifecycle handling around FetchTasklet and stream error protection.
  • oven-sh/bun#32120: Changes stream detachment behavior in fetch/stream finalization paths.
  • oven-sh/bun#32130: Adjusts fetch body cleanup ordering for streaming request/response paths.

Suggested reviewers

  • Jarred-Sumner
  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: avoiding JSCell access in the Response weak finalizer during GC sweep.
Description check ✅ Passed The PR explains the crash, root cause, fix, and verification, though it uses custom headings instead of the template's.
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/fetch/fetch-response-finalizer-sweep.test.ts`:
- Line 113: Remove the custom per-test timeout from the fetch response finalizer
sweep test; the issue is the explicit timeout on the test wrapper in
fetch-response-finalizer-sweep.test.ts. Update the test definition around the
collectContinuously-driven case to rely on Bun’s default timeout, and if the
test is too slow, reduce the iteration workload inside the test instead of
extending the timeout.
🪄 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: 4dffdd43-37e9-424c-8315-2cacc4cad6d9

📥 Commits

Reviewing files that changed from the base of the PR and between 9fed8df and c23cd3a.

📒 Files selected for processing (3)
  • src/runtime/webcore/ResumableSink.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/web/fetch/fetch-response-finalizer-sweep.test.ts

Comment thread test/js/web/fetch/fetch-response-finalizer-sweep.test.ts

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

The fix looks correct and well-tested, but this touches GC-finalizer lifecycle code in the fetch path with subtle reasoning about deferred cleanup (skipping detach_js/clear_stream_handlers during sweep and relying on clear_sink() in deinit() to catch up), so it's worth a human pass from someone familiar with the FetchTasklet/ResumableSink ownership model.

Extended reasoning...

Overview

This PR fixes a JSC assertion crash (validateIsNotSweeping) found by the syscall fault-injection fuzzer. FetchTasklet::on_response_finalize runs as a WeakRefOwner::finalize callback inside WeakBlock::sweep, and previously called ignore_remaining_response_body() which in turn invoked ResumableSink::detach_js() (writes cached-value slots → uncheckedDowncastJSCell::classInfo() + write barrier) and clear_stream_handlers() (reaches ReadableStreamTag__tagged). Touching any JSCell while MutatorState == Sweeping is forbidden.

The fix threads a from_finalizer: bool through ignore_remaining_response_body. The two finalizer call sites in on_response_finalize pass true (skip the cell-touching cleanup); the event-loop call site on_stream_cancelled_callback passes false (unchanged). A doc comment on ResumableSink::detach_js is corrected to drop the prior claim of finalizer safety. A new regression test reproduces the crash under BUN_JSC_collectContinuously=1.

Security risks

None. This is an internal GC-lifecycle correctness fix; no auth, crypto, input parsing, or trust-boundary changes.

Level of scrutiny

High. The diff is small (~30 LOC of logic plus comments and a test), but it changes behavior inside a JSC Weak finalizer in a hot runtime path. The correctness argument depends on non-local invariants:

  • Skipping clear_stream_handlers() is safe in the finalizer path because the guard at on_response_finalize (line 2460) already returns early when readable_stream_ref.has() is true, so clear_stream_handlers() would have been a no-op anyway (readable_stream_ref.get() → None). The PR description acknowledges this is defensive ("guarded today, but one boolean away").
  • Skipping detach_js() relies on clear_sink() in deinit() (line 492, scheduled as an event-loop ConcurrentTask) eventually running outside sweep to perform the deferred detach so the sink wrapper's cached ondrain closure becomes collectible. This holds as long as the tasklet's normal teardown path is reached, which the PR description asserts.

These look correct to me, but the reasoning spans the FetchTasklet refcount/teardown lifecycle and the ResumableSink ownership model — exactly the kind of thing where a maintainer who knows this code (Jarred/Ciro per coderabbit's suggestion) should confirm there's no path where the deferred cleanup is skipped, leaking the reader/stream graph.

Other factors

  • The regression test is solid: it was verified to fail (assertion abort, exit 134) without the fix and pass with it; fetch-backpressure.test.ts (the from_finalizer=false path) passes unchanged.
  • The CodeRabbit timeout nit was reasonably rebutted with precedent and resolved.
  • The only finding from the bug-hunting pass is a pre-existing inverted comment label (Scenario 2b vs 2a) on a line adjacent to the change — cosmetic only.
  • All three call sites of ignore_remaining_response_body are accounted for in the diff.

Comment thread src/runtime/webcore/fetch/FetchTasklet.rs
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status on #64829: every test lane that has completed is green, including all 20 debian 13 x64-asan shards (the only build config where validateIsNotSweeping fires), and the new fetch-response-finalizer-sweep.test.ts passes everywhere it runs.

The one hard failure is linux x64-musl-baseline / verify-baseline: the static instruction scan flags a single AVX Vcvtss2sd at llint_op_enter_wide32 (JavaScriptCore LLInt, generated by offlineasm from LowLevelInterpreter.asm). The sibling linux x64-baseline / verify-baseline job on the same commit passes, and this diff only touches src/runtime/webcore/{ResumableSink.rs, fetch/FetchTasklet.rs} and a new test file, none of which feed into the LLInt. That points at a musl build-cache / toolchain artifact rather than anything introduced here.

The remaining annotation is the [flaky] block for three Windows install/spawn tests that retried and are unrelated to fetch.

Ready for review; needs a maintainer to either retry the musl verify-baseline job or confirm it's a known infra issue.

@Jarred-Sumner Jarred-Sumner merged commit f58fd03 into main Jun 26, 2026
73 of 76 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/ea9d7a26/fetch-weak-finalizer-sweep branch June 26, 2026 03:52
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