Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions src/runtime/webcore/fetch/FetchTasklet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,15 @@
if !unsafe { bun_ptr::ThreadSafeRefCount::<Self>::release(this) } {
return;
}
// The 1→0 transition should only be reachable at shutdown. The
// `callback` paths that call this hold the tasklet mutex, which
// blocks on_progress_update from releasing the JS-side initial ref,
// so at least one other ref is always live there.
let self_ = Self::from_raw_ref(this);
debug_assert!(
self_.javascript_vm.is_shutting_down(),
"FetchTasklet::deref_from_thread reached 1->0 outside shutdown",
);
Comment thread
robobun marked this conversation as resolved.
if self_.javascript_vm.is_shutting_down() {
// SAFETY: last ref; exclusive access. `deinit()` would run
// `clear_data()` + `Drop` for the JSC `Strong`/`Weak` fields, which
Expand Down Expand Up @@ -2261,11 +2269,8 @@
}
if success && task_ref.result.has_more {
// we are ignoring the body so we should not receive more data, so will only signal when result.has_more = true
// `has_more` is true here so `is_done` is always false; unlock only.
task_ref.mutex.unlock();
if is_done {
// SAFETY: `task` is the live heap tasklet; HTTP-thread ref held.
FetchTasklet::deref_from_thread(task);
}
return;
}
} else {
Expand All @@ -2287,11 +2292,16 @@
Ordering::Relaxed,
) {
if has_schedule_callback {
task_ref.mutex.unlock();
// Deref while still holding the mutex. on_progress_update
// (the only releaser of the JS-side initial ref) needs this
// mutex, so the initial ref is still held here and this
// deref is never the 1→0 transition.
if is_done {
// SAFETY: `task` is the live heap tasklet; HTTP-thread ref held.
FetchTasklet::deref_from_thread(task);
}
// SAFETY: `task` is still live (initial ref still held).
Self::from_raw_ref(task).mutex.unlock();
return;
}
}
Expand Down Expand Up @@ -2337,13 +2347,17 @@
// queue takes ownership of its `next` link.
Self::enqueue_concurrent(task_ref.javascript_vm, ct);

task_ref.mutex.unlock();
// we are done with the http client so we can deref our side
// this is a atomic operation and will enqueue a task to deinit on the main thread
// Deref while still holding the mutex. on_progress_update (the only
// releaser of the JS-side initial ref) needs this mutex, so the
// initial ref is still held here and this deref is never the 1→0
// transition — deref_from_thread therefore never schedules
// deinit_callback from this path.
if is_done {
// SAFETY: `task` is the live heap tasklet; HTTP-thread ref held.
FetchTasklet::deref_from_thread(task);
}
// SAFETY: `task` is still live (initial ref still held).
Self::from_raw_ref(task).mutex.unlock();

Check warning on line 2360 in src/runtime/webcore/fetch/FetchTasklet.rs

View check run for this annotation

Claude / Claude Code Review

Stale comment at line 2193 contradicts the new deref/unlock ordering

The comment at lines 2193-2194 ("we need to unlock before task.deref(); explicit unlock + deref at end instead of nested defers") now states the exact opposite of what this PR implements — `deref_from_thread` is now intentionally called *before* `mutex.unlock()` in both reachable paths, and the new comments at the deref sites explicitly say "Deref while still holding the mutex." This stale comment documents the buggy ordering as a requirement and could mislead a future reader into reverting the
Comment thread
robobun marked this conversation as resolved.
}
}

Expand Down
54 changes: 54 additions & 0 deletions test/js/web/fetch/fetch-tasklet-deref-race-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions test/js/web/fetch/fetch-tasklet-deref-race.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import path from "node:path";

// The HTTP thread's result callback must hold the tasklet mutex through its
// deref_from_thread call so it is never the 1->0 transition. When it was
// not, the deref could schedule a deinit_callback task that later observed
// a nonzero refcount and panicked with
// "assertion failed: self.raw_count.load(Ordering::SeqCst) == 0".
//
// The race window is a handful of instructions between mutex.unlock() and
// deref_from_thread() on the HTTP thread, so this test is best-effort: it
// exercises many concurrent fetch + abort cycles under load and asserts
// the process completes. It does not deterministically reproduce the crash
// on an unfixed build; a debug_assert in deref_from_thread documents the
// invariant the mutex ordering enforces.
test("FetchTasklet HTTP-thread deref is never the final ref", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), path.join(import.meta.dir, "fetch-tasklet-deref-race-fixture.ts")],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.text(),
proc.stderr.text(),
proc.exited,
]);

// Include stderr in the failure message for diagnostics without asserting
// it is exactly empty (debug/ASAN builds may emit benign warnings).
expect({ stdout: stdout.trim(), exitCode, stderr }).toMatchObject({
stdout: "ok",
exitCode: 0,
});
});
Loading