fetch: hold FetchTasklet mutex through deref_from_thread so HTTP thread is never the final deref - #32707
fetch: hold FetchTasklet mutex through deref_from_thread so HTTP thread is never the final deref#32707robobun wants to merge 7 commits into
Conversation
…back FetchTasklet::callback (HTTP thread) enqueues on_progress_update, then unlocks the mutex, then calls deref_from_thread. In the gap between unlock and deref, the JS thread can run on_progress_update (which needs the mutex) entirely and drop the JS-side initial ref. The HTTP-side deref then becomes the 1->0 transition, and deref_from_thread enqueues a deinit_callback ManagedTask to the JS thread. By the time that task runs, the refcount can have been bumped again (or the allocation reused), tripping assert_no_refs() in FetchTasklet::deinit. Holding the mutex through deref_from_thread closes the race structurally: on_progress_update (the only releaser of the initial ref) cannot have run yet, so at least one other ref is always live and the HTTP-side deref is never the final one. deinit_callback is therefore never scheduled from this path. A debug_assert in deref_from_thread documents the invariant. The dead ignore_data early-return deref (unreachable since is_done is always false there) is dropped. Rust-port equivalent of the approach in #29453.
|
Warning Review limit reached
More reviews will be available in 5 minutes and 18 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?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 credits. 🚦 How do rate 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 see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughFetchTasklet now asserts the 1→0 dereference path only happens during JavaScript VM shutdown and keeps the HTTP-thread mutex held across selected dereference calls. A new stress fixture and test run concurrent fetch/abort loops against an in-process server and verify clean process completion. ChangesFetchTasklet deref ordering
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 7:19 PM PT - Jun 25th, 2026
❌ @robobun, your commit 40ca09d has 4 failures in
🧪 To try this PR locally: bunx bun-pr 32707That installs a local version of the PR into your bun-32707 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
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-tasklet-deref-race-fixture.ts`:
- Around line 25-46: Make the fetch mix deterministic and stop swallowing
unexpected failures in one(): replace the Math.random()-driven abort choice with
a fixed success/abort pattern based on iteration or worker index, and let the
non-abort fetch path surface errors instead of using a blanket catch. Use the
one and worker helpers to locate the fixture logic, and ensure the test only
tolerates the intended abort case while failing loudly if the normal completion
path starts rejecting.
🪄 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: aeaab5d4-129c-4f8d-bd9a-1e98e7b2d663
📒 Files selected for processing (3)
src/runtime/webcore/fetch/FetchTasklet.rstest/js/web/fetch/fetch-tasklet-deref-race-fixture.tstest/js/web/fetch/fetch-tasklet-deref-race.test.ts
… fallback comment
There was a problem hiding this comment.
All my earlier feedback is addressed (including the test timeout in 40ca09d) and I didn't find any remaining issues — but the fix's correctness rests on the non-local invariant that on_progress_update is the sole releaser of the JS-side initial ref and always takes this mutex, plus the post-deref from_raw_ref(task).mutex.unlock() relying on that ref still being live; that's the kind of cross-thread refcount/lifetime reasoning a human owner of this code should sign off on.
Extended reasoning...
Overview
This PR reorders FetchTasklet::deref_from_thread to run before mutex.unlock() at the two reachable non-shutdown exits of FetchTasklet::callback (the HTTP-thread result callback), so the HTTP thread's deref can never be the 1→0 refcount transition. It also drops a dead if is_done { deref } inside the ignore_data && has_more branch, adds a debug_assert!(is_shutting_down()) after a 1→0 in deref_from_thread to document the new invariant, updates three comments to match, and adds a subprocess stress test (2000 fetch/abort iterations at concurrency 64).
The actual code delta is small (~10 lines of reordering in src/runtime/webcore/fetch/FetchTasklet.rs plus comments), and the four 🟡 nits I raised across earlier revisions (two stale comments, one overstated comment, missing test timeout) plus CodeRabbit's deterministic-fixture feedback have all been applied in 95d6550 / 660a330 / 8dd36dd / 40ca09d.
Security risks
None identified. This is internal refcount/mutex lifecycle code with no auth, crypto, parsing, or external-input handling changes. The test fixture binds a server on port 0 (ephemeral) in a subprocess.
Level of scrutiny
High. This is cross-thread refcount + mutex ordering in the core fetch() path — every fetch in Bun goes through FetchTasklet::callback. The fix is argued correct-by-construction rather than empirically: the PR description itself notes the race window is too narrow to reproduce reliably (10× at 5000 iterations against an unfixed build never hit it), so the new test is best-effort and the debug_assert is the real guard. Correctness hinges on two non-local claims that I can't fully verify from the diff alone:
on_progress_updateis the only path that releases the JS-side initial ref, and it always acquires this mutex first — so while the HTTP thread holds the mutex, refcount ≥ 2.- After
deref_from_thread(task)drops the HTTP-side ref, dereferencingtaskagain viaSelf::from_raw_ref(task).mutex.unlock()is safe because the JS-side initial ref is still pinned by (1).
Both look right from the surrounding code and the PR's analysis (which mirrors the unmerged Zig-era #29453), but they depend on the full ~2400-line FetchTasklet lifecycle and its interaction with #32704 / #32071. A human who owns this code should confirm the invariant holds.
Other factors
- No CODEOWNERS entry covers this path.
- Bug-hunting system found nothing.
- All prior review threads are resolved/addressed; the only unresolved inline thread (test timeout) is now fixed in the diff (
}, 30_000);). - CI build #64771 was triggered on the latest commit.
|
CI status: the remaining red is Windows-only and unrelated to this diff. Build 64747 and 64771 both failed on:
None of these touch fetch, As noted in the PR body, the race is not fail-before provable without Ready for review. |
|
Sentry crash cross-reference for the "Related" section: Covered by this PR
Not covered by this PR
|
|
Status check against current main (bdb7382), leaving this open.
It has not landed: |
Crash
deinit_callbackis aManagedTaskenqueued byderef_from_threadon the HTTP thread whenrelease()observes the 1→0 transition. By the time the JS thread runs it, the refcount is nonzero, so theassert_no_refsindeinitfires.Cause
FetchTasklet::callbackruns on the HTTP thread and ends withBetween
mutex.unlock()andderef_from_thread(), the JS thread can:on_progress_updateatself.mutex.lock()),on_progress_updateto completion withis_done = true,cleanupwhich drops the JS-side initial ref (FetchTasklet::deref(this)→ N→1).The HTTP thread then resumes at
deref_from_thread→ 1→0, and since the VM is not shutting down it enqueuesdeinit_callback. That task is the only path todeinitthat is not synchronous with the 1→0 transition, so anything that touches the refcount (or the allocation) before it runs trips the assert instead of being blocked by a live ref.The CAS-fail early return has the same
unlock → deref_from_threadordering. Theignore_dataearly return had anif is_done { deref_from_thread }that is unreachable (it is insideif has_more { .. }).Fix
Move
deref_from_threadbefore the unlock in both reachable paths.on_progress_updateis the only releaser of the initial ref, and it needs the mutex, so while the HTTP thread holds the mutex the initial ref is guaranteed live and the HTTP-side deref is never the 1→0 transition.release()therefore always returns false fromcallback, anddeinit_callbackis never scheduled from this path. The only remaining 1→0 callers ofderef_from_threadare the shutdown paths (callback'sis_shutting_downbranch andrelease_at_shutdown), which route throughdealloc_for_shutdownrather thandeinit_callback.A
debug_assert!(is_shutting_down())inderef_from_threadafter a 1→0 transition documents the invariant. The deadignore_dataderef is dropped.After the deref,
taskis still live (the initial ref is still held), so the subsequent unlock is safe.Verification
test/js/web/fetch/fetch-tasklet-deref-race.test.tsruns a subprocess fixture with 2000 concurrent fetch/abort cycles against a local server. On the fixed debug+ASAN build: passes in ~4s, 5/5 runs, and the newdebug_assertnever fires.Existing
fetch.test.ts,fetch-leak.test.ts,fetch-abort-stream-body.test.ts,fetch-abort-queued.test.ts,abort-signal-leak.test.tson the fixed debug build have identical pass/fail counts to an unfixed baseline build in the same container (the failures are pre-existing debug-timeout and RSS-threshold issues).Note on fail-before: the race window is the handful of instructions between
mutex.unlock()andderef_from_thread(). The JS thread has to acquire the mutex, run all ofon_progress_update, and reach its finalderefin that gap. A 10× run of the fixture at 5000 iterations against the unfixed release build did not hit the panic once; the same conclusion was reached in #29453 (Zig-era, ~500 attempts). The gate's fail-before check will not be satisfiable without instrumentation insrc/. The fix is correct by construction: with the mutex held throughderef_from_thread, the HTTP thread categorically cannot observecount == 1.Related
Option::unwrap() on Nonecrash attask_ref.http.as_mut().unwrap()in the same callback, which is the UAF side of this lifecycle race (tasklet already freed when a stalecallbackruns). That change and this one do not conflict.