From dccb8b90690454b5ea7e94ac791674e7af36907c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:08:36 +0000 Subject: [PATCH 1/3] JSCTaskScheduler: drop deferred-work tasks scheduled after the event loop's last tick Worker teardown runs release_queued_tasks_for_shutdown, then WebWorker__teardownJSCVM which ends in ~VM(). ~VM() calls WaiterListManager::unregister for every Atomics.waitAsync ticket still pending on that VM, and each one reaches DeferredWorkTimer::scheduleWorkSoon -> JSCTaskScheduler::onScheduleWorkSoon, which allocates a JSCDeferredWorkTask and a ConcurrentTask and enqueues into the worker's concurrent queue. That queue was already drained for the last time, so the nodes become unreachable when the worker's VirtualMachine box is dealloc'd and LSan reports them. Gate onScheduleWorkSoon and onAddPendingWork on a new m_isShuttingDown atomic, set at the start of WebWorker__teardownJSCVM and Zig__GlobalObject__destructOnExit so work scheduled from the final collectNow or ~VM() is dropped instead of enqueued. A JSCDeferredWorkTask can also land in the queue before the flag is set (cross-thread Atomics.notify from another VM while this one is between its last tick and teardown). release_queued_tasks_for_shutdown forwards it into self.tasks where __bun_release_task_at_shutdown didn't recognise the tag, so it was re-queued into a freshly allocated LinearFifo buffer that leaked on worker dealloc. Add a JSCDeferredWorkTask arm that deletes the job via a new Bun__deleteDeferredWorkTask FFI. Surfaced by test/js/web/timers/timer-heap-race.test.ts on the x64-asan lane (build 73570) after #33131 added the cross-thread Atomics.waitAsync fixture. --- src/jsc/bindings/JSCTaskScheduler.cpp | 22 +++++++++++++ src/jsc/bindings/JSCTaskScheduler.h | 7 ++++ src/jsc/bindings/ZigGlobalObject.cpp | 2 ++ src/jsc/bindings/webcore/Worker.cpp | 5 +++ src/runtime/dispatch.rs | 15 +++++++++ .../timer-heap-atomics-teardown-fixture.ts | 32 +++++++++++++++++++ test/js/web/timers/timer-heap-race.test.ts | 25 +++++++++++++-- 7 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 test/js/web/timers/timer-heap-atomics-teardown-fixture.ts diff --git a/src/jsc/bindings/JSCTaskScheduler.cpp b/src/jsc/bindings/JSCTaskScheduler.cpp index 171b5c4edc19..8400ecab660d 100644 --- a/src/jsc/bindings/JSCTaskScheduler.cpp +++ b/src/jsc/bindings/JSCTaskScheduler.cpp @@ -40,6 +40,8 @@ static JSC::VM& getVM(Ticket& ticket) void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref&& ticket, JSC::DeferredWorkTimer::WorkType kind) { auto& scheduler = clientData->deferredWorkTimer; + if (scheduler.m_isShuttingDown.load(std::memory_order_acquire)) [[unlikely]] + return; Locker holder { scheduler.m_lock }; if (kind == DeferredWorkTimer::WorkType::ImminentlyScheduled) { Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, 1); @@ -50,6 +52,18 @@ void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref } void JSCTaskScheduler::onScheduleWorkSoon(WebCore::JSVMClientData* clientData, Ticket ticket, Task&& task) { + auto& scheduler = clientData->deferredWorkTimer; + // The event loop is past its last tick; a JSCDeferredWorkTask enqueued now + // would never run and its ConcurrentTask wrapper would leak once the Bun + // VirtualMachine box is dealloc'd. Reached from ~VM -> WaiterListManager:: + // unregister -> Waiter::cancelAndClear for every outstanding + // Atomics.waitAsync on a terminating worker, and from collectNow -> + // JSFinalizationRegistry::finalizeUnconditionally. Balance onAddPendingWork + // so the ticket-set entry and event-loop ref are released. + if (scheduler.m_isShuttingDown.load(std::memory_order_acquire)) [[unlikely]] { + onCancelPendingWork(clientData, ticket); + return; + } auto* job = new JSCDeferredWorkTask(*ticket, WTF::move(task)); Bun__queueJSCDeferredWorkTaskConcurrently(clientData->bunVM, job); } @@ -101,4 +115,12 @@ extern "C" void Bun__runDeferredWork(Bun::JSCDeferredWorkTask* job) runPendingWork(clientData->bunVM, clientData->deferredWorkTimer, job); } +// Reclaim a queued-but-never-dispatched job during shutdown. Called while the +// JSC VM is still alive, so ~Ref and the captured Task lambda may +// safely touch TZone-allocated / JSC-owned state. +extern "C" void Bun__deleteDeferredWorkTask(Bun::JSCDeferredWorkTask* job) +{ + delete job; +} + } diff --git a/src/jsc/bindings/JSCTaskScheduler.h b/src/jsc/bindings/JSCTaskScheduler.h index 24e8eb56e3f6..674da732d638 100644 --- a/src/jsc/bindings/JSCTaskScheduler.h +++ b/src/jsc/bindings/JSCTaskScheduler.h @@ -20,8 +20,15 @@ class JSCTaskScheduler { static void onScheduleWorkSoon(WebCore::JSVMClientData* clientData, JSC::DeferredWorkTimer::Ticket ticket, JSC::DeferredWorkTimer::Task&& task); static void onCancelPendingWork(WebCore::JSVMClientData* clientData, JSC::DeferredWorkTimer::Ticket ticket); + // Set once the owning VM's event loop has taken its last tick. After this, + // onScheduleWorkSoon drops the task instead of enqueueing a ConcurrentTask + // that can never be drained (~VM -> WaiterListManager::unregister reaches + // it for every still-pending Atomics.waitAsync ticket). + void markShuttingDown() { m_isShuttingDown.store(true, std::memory_order_release); } + public: Lock m_lock; + std::atomic m_isShuttingDown { false }; UncheckedKeyHashSet> m_pendingTicketsKeepingEventLoopAlive; UncheckedKeyHashSet> m_pendingTicketsOther; }; diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 1df82470bfe0..9ea2a62326f2 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -4024,6 +4024,8 @@ extern "C" void Zig__GlobalObject__destructOnExit(Zig::GlobalObject* globalObjec // of enqueueing a ConcurrentTask that leaks past the last drain. if (auto* ctx = globalObject->scriptExecutionContext()) ctx->markTerminating(); + if (auto* clientData = WebCore::clientData(vm)) + clientData->deferredWorkTimer.markShuttingDown(); Bun__InspectorConnection__disconnectAllOnExit(globalObject); // Hold a Ref so the RunLoop is guaranteed to outlive the VM teardown below. Ref runLoop = vm.runLoop(); diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 142bd56baf62..1a97f67c2571 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -647,6 +647,11 @@ extern "C" void WebWorker__teardownJSCVM(Zig::GlobalObject* globalObject) // can never run (e.g. notifyPeerClosed posted during the final collectNow). if (auto* ctx = globalObject->scriptExecutionContext()) ctx->markTerminating(); + // Same for DeferredWorkTimer: collectNow -> finalizers and ~VM -> + // WaiterListManager::unregister both reach scheduleWorkSoon; past this + // point those calls must not enqueue into our drained concurrent queue. + if (auto* clientData = WebCore::clientData(vm)) + clientData->deferredWorkTimer.markShuttingDown(); { auto scope = DECLARE_THROW_SCOPE(vm); diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index cd8f29b32f70..649928a78729 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -1198,6 +1198,21 @@ pub(crate) fn __bun_release_task_at_shutdown(task: bun_event_loop::Task) -> bool for_each_fs_async_op!(__fs_destroy); true } + // A cross-thread Atomics.notify (or Wasm/FinalizationRegistry + // completion) enqueued this after the event loop's last tick. The + // dispatch arm above would have `delete`d it; mirror that here so the + // re-queue path doesn't keep it alive past worker VM dealloc. Runs + // before JSC teardown, so ~Ref is safe. + task_tag::JSCDeferredWorkTask => { + unsafe extern "C" { + fn Bun__deleteDeferredWorkTask(task: *mut JSCDeferredWorkTask); + } + // SAFETY: every JSCDeferredWorkTask payload is heap-allocated by + // `new JSCDeferredWorkTask` in JSCTaskScheduler::onScheduleWorkSoon; + // we own it once popped. + unsafe { Bun__deleteDeferredWorkTask(task.ptr.cast::()) }; + true + } // Same reclaim `drop_concurrent_cpp_tasks` performs, but for tasks // that were already batch-moved into `self.tasks`. Must run before // JSC teardown: a Worker `dispatchExit` lambda's `~Ref` walks diff --git a/test/js/web/timers/timer-heap-atomics-teardown-fixture.ts b/test/js/web/timers/timer-heap-atomics-teardown-fixture.ts new file mode 100644 index 000000000000..bb5a5572f6e2 --- /dev/null +++ b/test/js/web/timers/timer-heap-atomics-teardown-fixture.ts @@ -0,0 +1,32 @@ +// Terminate a worker that still has pending Atomics.waitAsync tickets on a +// SharedArrayBuffer the parent keeps alive. ~VM() -> WaiterListManager:: +// unregister reaches DeferredWorkTimer::scheduleWorkSoon for every such ticket; +// the resulting task must be dropped, not enqueued into the dead event loop. +declare var self: Worker; + +if (!Bun.isMainThread) { + self.onmessage = (e: MessageEvent) => { + const i32 = new Int32Array(e.data as SharedArrayBuffer); + for (let i = 0; i < 32; i++) { + const r = Atomics.waitAsync(i32, 0, 0, 60_000); + if (r.async) r.value.then(() => {}); + } + postMessage("ready"); + }; +} else { + const sab = new SharedArrayBuffer(4); + const worker = new Worker(import.meta.url); + worker.onerror = (e: ErrorEvent) => { + console.error("worker error:", e.message); + process.exit(1); + }; + worker.onmessage = async () => { + // Drive a few notifies so some tickets are settled cross-thread before + // teardown; the rest reach ~VM() with a live ticket. + Atomics.notify(new Int32Array(sab), 0, 4); + await worker.terminate(); + console.log("OK"); + process.exit(0); + }; + worker.postMessage(sab); +} diff --git a/test/js/web/timers/timer-heap-race.test.ts b/test/js/web/timers/timer-heap-race.test.ts index e87b1d89ae0c..e1d912a843d0 100644 --- a/test/js/web/timers/timer-heap-race.test.ts +++ b/test/js/web/timers/timer-heap-race.test.ts @@ -1,8 +1,8 @@ import { expect, it } from "bun:test"; -import { bunEnv, bunExe, isDebug } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug } from "harness"; import path from "node:path"; -async function runFixture(fixture: string) { +async function runFixture(fixture: string, env: Record = {}) { await using proc = Bun.spawn({ cmd: [bunExe(), path.join(import.meta.dir, fixture)], env: { @@ -10,6 +10,7 @@ async function runFixture(fixture: string) { // These make the debug build an order of magnitude slower; the fixtures need real wall time. BUN_JSC_validateExceptionChecks: undefined, BUN_JSC_dumpSimulatedThrows: undefined, + ...env, }, stdout: "pipe", stderr: "pipe", @@ -41,3 +42,23 @@ it.skipIf(!isDebug)( }, 20_000, ); + +it.skipIf(!isASAN)( + "terminating a worker with pending Atomics.waitAsync tickets does not leak deferred-work tasks", + async () => { + const { stdout, stderr, signal, exitCode } = await runFixture("timer-heap-atomics-teardown-fixture.ts", { + BUN_DESTRUCT_VM_ON_EXIT: "1", + ASAN_OPTIONS: "allow_user_segv_handler=1:disable_coredump=0:detect_leaks=1:abort_on_error=1", + LSAN_OPTIONS: `malloc_context_size=30:print_suppressions=0:suppressions=${path.join(import.meta.dir, "..", "..", "..", "leaksan.supp")}`, + }); + // LSan writes its leak report to stderr and SIGABRTs; stdout holds the + // fixture's own OK line either way, so assert exitCode/signal explicitly. + expect({ stdout, stderr, signal, exitCode }).toEqual({ + stdout: "OK\n", + stderr: "", + signal: null, + exitCode: 0, + }); + }, + 20_000, +); From a7c0da06cde27d45449725ba49762d964408a3ac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:30:25 +0000 Subject: [PATCH 2/3] JSCTaskScheduler: serialize the shutdown transition with m_lock Address review: the atomic flag alone leaves a gap where a cross-thread Atomics.notify that reads m_isShuttingDown == false before the worker thread sets it can still enqueue after release_queued_tasks_for_shutdown has drained. Hold m_lock across the check and the enqueue in onScheduleWorkSoon (and across the check in onAddPendingWork, matching the existing lock there), and take the same lock in markShuttingDown. Introduce Bun__JSCTaskScheduler__markShuttingDown so worker shutdown can flip the flag before the drain: a notifier that enqueues under the lock before the flip is visible to the drain via the queue's own release/acquire, and a notifier that acquires the lock after the flip sees the flag and drops. Factor the pending-ticket removal into dropPendingTicketLocked so the shutdown paths (onScheduleWorkSoon's early return and Bun__deleteDeferredWorkTask) balance the event-loop ref and ticket-set entry the same way runPendingWork would have. Relax the new test's stderr assertion to not.stringContaining per the file convention; abort_on_error=1 already turns a leak into SIGABRT. --- src/jsc/bindings/JSCTaskScheduler.cpp | 67 ++++++++++++++++------ src/jsc/bindings/JSCTaskScheduler.h | 12 +++- src/jsc/web_worker.rs | 8 +++ test/js/web/timers/timer-heap-race.test.ts | 2 +- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/jsc/bindings/JSCTaskScheduler.cpp b/src/jsc/bindings/JSCTaskScheduler.cpp index 8400ecab660d..f87ee0d1eb04 100644 --- a/src/jsc/bindings/JSCTaskScheduler.cpp +++ b/src/jsc/bindings/JSCTaskScheduler.cpp @@ -37,12 +37,28 @@ static JSC::VM& getVM(Ticket& ticket) return ticket->scriptExecutionOwner()->vm(); } +// Drop `ticket` from whichever pending set holds it. Caller holds m_lock; the +// event-loop ref is balanced after the caller releases the lock. +static bool dropPendingTicketLocked(Bun::JSCTaskScheduler& scheduler, Ticket ticket) WTF_REQUIRES_LOCK(scheduler.m_lock) +{ + bool isKeepingEventLoopAlive = scheduler.m_pendingTicketsKeepingEventLoopAlive.removeIf([ticket](auto pendingTicket) { + return pendingTicket.ptr() == ticket; + }); + // -- At this point, ticket may be an invalid pointer. + if (!isKeepingEventLoopAlive) { + scheduler.m_pendingTicketsOther.removeIf([ticket](auto pendingTicket) { + return pendingTicket.ptr() == ticket; + }); + } + return isKeepingEventLoopAlive; +} + void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref&& ticket, JSC::DeferredWorkTimer::WorkType kind) { auto& scheduler = clientData->deferredWorkTimer; - if (scheduler.m_isShuttingDown.load(std::memory_order_acquire)) [[unlikely]] - return; Locker holder { scheduler.m_lock }; + if (scheduler.m_isShuttingDown) [[unlikely]] + return; if (kind == DeferredWorkTimer::WorkType::ImminentlyScheduled) { Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, 1); scheduler.m_pendingTicketsKeepingEventLoopAlive.add(WTF::move(ticket)); @@ -53,15 +69,21 @@ void JSCTaskScheduler::onAddPendingWork(WebCore::JSVMClientData* clientData, Ref void JSCTaskScheduler::onScheduleWorkSoon(WebCore::JSVMClientData* clientData, Ticket ticket, Task&& task) { auto& scheduler = clientData->deferredWorkTimer; + Locker holder { scheduler.m_lock }; // The event loop is past its last tick; a JSCDeferredWorkTask enqueued now // would never run and its ConcurrentTask wrapper would leak once the Bun // VirtualMachine box is dealloc'd. Reached from ~VM -> WaiterListManager:: // unregister -> Waiter::cancelAndClear for every outstanding // Atomics.waitAsync on a terminating worker, and from collectNow -> // JSFinalizationRegistry::finalizeUnconditionally. Balance onAddPendingWork - // so the ticket-set entry and event-loop ref are released. - if (scheduler.m_isShuttingDown.load(std::memory_order_acquire)) [[unlikely]] { - onCancelPendingWork(clientData, ticket); + // so the ticket-set entry and event-loop ref are released. The lock is held + // across the check and the enqueue so the transition in markShuttingDown + // cannot race a cross-thread Atomics.notify. + if (scheduler.m_isShuttingDown) [[unlikely]] { + bool wasKeepingAlive = dropPendingTicketLocked(scheduler, ticket); + holder.unlockEarly(); + if (wasKeepingAlive) + Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, -1); return; } auto* job = new JSCDeferredWorkTask(*ticket, WTF::move(task)); @@ -74,19 +96,10 @@ void JSCTaskScheduler::onCancelPendingWork(WebCore::JSVMClientData* clientData, auto& scheduler = clientData->deferredWorkTimer; Locker holder { scheduler.m_lock }; - bool isKeepingEventLoopAlive = scheduler.m_pendingTicketsKeepingEventLoopAlive.removeIf([ticket](auto pendingTicket) { - return pendingTicket.ptr() == ticket; - }); - // -- At this point, ticket may be an invalid pointer. - - if (isKeepingEventLoopAlive) { - holder.unlockEarly(); + bool wasKeepingAlive = dropPendingTicketLocked(scheduler, ticket); + holder.unlockEarly(); + if (wasKeepingAlive) Bun__eventLoop__incrementRefConcurrently(bunVM, -1); - } else { - scheduler.m_pendingTicketsOther.removeIf([ticket](auto pendingTicket) { - return pendingTicket.ptr() == ticket; - }); - } } static void runPendingWork(void* bunVM, Bun::JSCTaskScheduler& scheduler, JSCDeferredWorkTask* job) @@ -115,11 +128,29 @@ extern "C" void Bun__runDeferredWork(Bun::JSCDeferredWorkTask* job) runPendingWork(clientData->bunVM, clientData->deferredWorkTimer, job); } +// Flip m_isShuttingDown from the owning JS thread before the final concurrent- +// task drain. Any onScheduleWorkSoon that serializes before this under m_lock +// has its enqueue visible to the drain; any that serializes after drops. +extern "C" void Bun__JSCTaskScheduler__markShuttingDown(JSC::JSGlobalObject* globalObject) +{ + if (auto* clientData = WebCore::clientData(JSC::getVM(globalObject))) + clientData->deferredWorkTimer.markShuttingDown(); +} + // Reclaim a queued-but-never-dispatched job during shutdown. Called while the // JSC VM is still alive, so ~Ref and the captured Task lambda may -// safely touch TZone-allocated / JSC-owned state. +// safely touch TZone-allocated / JSC-owned state. Mirrors runPendingWork's +// ticket take() so the pending set and event-loop ref stay balanced. extern "C" void Bun__deleteDeferredWorkTask(Bun::JSCDeferredWorkTask* job) { + if (auto* clientData = WebCore::clientData(job->vm())) { + auto& scheduler = clientData->deferredWorkTimer; + Locker holder { scheduler.m_lock }; + bool wasKeepingAlive = dropPendingTicketLocked(scheduler, job->ticket.ptr()); + holder.unlockEarly(); + if (wasKeepingAlive) + Bun__eventLoop__incrementRefConcurrently(clientData->bunVM, -1); + } delete job; } diff --git a/src/jsc/bindings/JSCTaskScheduler.h b/src/jsc/bindings/JSCTaskScheduler.h index 674da732d638..488c7bec16b1 100644 --- a/src/jsc/bindings/JSCTaskScheduler.h +++ b/src/jsc/bindings/JSCTaskScheduler.h @@ -23,12 +23,18 @@ class JSCTaskScheduler { // Set once the owning VM's event loop has taken its last tick. After this, // onScheduleWorkSoon drops the task instead of enqueueing a ConcurrentTask // that can never be drained (~VM -> WaiterListManager::unregister reaches - // it for every still-pending Atomics.waitAsync ticket). - void markShuttingDown() { m_isShuttingDown.store(true, std::memory_order_release); } + // it for every still-pending Atomics.waitAsync ticket). Guarded by m_lock + // so the check+enqueue in onScheduleWorkSoon is atomic with respect to this + // transition (a cross-thread Atomics.notify may race a worker's shutdown). + void markShuttingDown() + { + Locker holder { m_lock }; + m_isShuttingDown = true; + } public: Lock m_lock; - std::atomic m_isShuttingDown { false }; + bool m_isShuttingDown WTF_GUARDED_BY_LOCK(m_lock) { false }; UncheckedKeyHashSet> m_pendingTicketsKeepingEventLoopAlive; UncheckedKeyHashSet> m_pendingTicketsOther; }; diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index e94c6266d17b..a246767cd256 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -204,6 +204,9 @@ unsafe extern "C" { // ABI-identical to non-null `*const`); C++ mutating VM state through it is // interior to the cell. safe fn WebWorker__teardownJSCVM(global: &JSGlobalObject); + // safe: same opaque-handle contract; flips JSCTaskScheduler::m_isShuttingDown + // under its own lock and returns. Idempotent. + safe fn Bun__JSCTaskScheduler__markShuttingDown(global: &JSGlobalObject); // safe: `cpp_worker` is an opaque round-trip pointer owned by C++ (allocated // there, stored in `WebWorker.cpp_worker`, and only ever passed back to C++ // — never dereferenced as Rust data); same contract as `JSC__VM__holdAPILock`'s @@ -1277,6 +1280,11 @@ impl WebWorker { // is step 3 below). rare.close_all_socket_groups(unsafe { &*vm_ptr }); } + // Stop JSCTaskScheduler accepting new work before the drain below + // so a cross-thread Atomics.notify that races this shutdown either + // enqueues (and is caught by the drain) or observes the flag under + // m_lock and drops. Idempotent; teardownJSCVM sets it again. + Bun__JSCTaskScheduler__markShuttingDown(JSGlobalObject::opaque_ref(vm.global)); // Reclaim queued CppTasks (the per-worker stdio/messaging // MessagePort drain tasks that can be in self.tasks mid-tick when // terminate() lands, and any Worker dispatchExit close task from a diff --git a/test/js/web/timers/timer-heap-race.test.ts b/test/js/web/timers/timer-heap-race.test.ts index e1d912a843d0..9afea163eab9 100644 --- a/test/js/web/timers/timer-heap-race.test.ts +++ b/test/js/web/timers/timer-heap-race.test.ts @@ -55,7 +55,7 @@ it.skipIf(!isASAN)( // fixture's own OK line either way, so assert exitCode/signal explicitly. expect({ stdout, stderr, signal, exitCode }).toEqual({ stdout: "OK\n", - stderr: "", + stderr: expect.not.stringContaining("LeakSanitizer"), signal: null, exitCode: 0, }); From bf3fe64a48d8334bdf8b1eec0027d4230c156e60 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:53:57 +0000 Subject: [PATCH 3/3] global_exit: fence JSCTaskScheduler before the concurrent-queue drains Mirror the worker shutdown path so a cross-thread scheduleWorkSoon that races the main-thread BUN_DESTRUCT_VM_ON_EXIT teardown is either caught by the drain or observes m_isShuttingDown under m_lock. The main VM box is static-rooted so LSan would not flag it, but the sibling paths should match. --- src/jsc/VirtualMachine.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 2dc03fbd4246..55a3718620cd 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -372,6 +372,7 @@ unsafe extern "C" { safe fn Bun__closeAllSQLiteDatabasesForTermination(); safe fn Bun__WebView__closeAllForTermination(); safe fn Zig__GlobalObject__destructOnExit(global: &JSGlobalObject); + safe fn Bun__JSCTaskScheduler__markShuttingDown(global: &JSGlobalObject); } pub const HOT_RELOAD_HOT: u8 = 1; @@ -1561,6 +1562,13 @@ impl VirtualMachine { (hooks.terminate_all_workers_and_wait)(10_000); } + // Mirror web_worker.rs::shutdown(): fence DeferredWorkTimer + // producers before the drain so a cross-thread scheduleWorkSoon + // that raced the shutdown either enqueued (and is caught by the + // drain below) or observes the flag under m_lock and drops. + // destructOnExit sets it again (idempotently). + Bun__JSCTaskScheduler__markShuttingDown(self.global()); + // Every worker has now posted its close task to our concurrent // queue (OUTSTANDING is decremented after dispatchExit). Drop // those queued lambdas — without running them — so the captured