From 7895855bdf9ca1d9dcce462861a12142a074a769 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:27:49 +0000 Subject: [PATCH 1/3] JSCTaskScheduler: drop pending tickets by pointer, not a set scan A worker exiting with N pending Atomics.waitAsync waiters cancels each ticket through onCancelPendingWork, and dropPendingTicketLocked removed the ticket with removeIf, a full scan of the pending-ticket hash sets: O(N^2) for the whole teardown. The scans also ran while WaiterListManager::unregister held the process-global waiter-lists lock, so Atomics.notify and Atomics.waitAsync on every other thread stalled for the entire window (20k waiters = 5.9s, 40k = 25s). Use the O(1) raw-pointer HashSet remove instead; it hashes the pointer value without dereferencing it, exactly like the removeIf predicate did. --- src/jsc/bindings/JSCTaskScheduler.cpp | 17 +++++---- test/js/web/atomics.test.ts | 55 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/jsc/bindings/JSCTaskScheduler.cpp b/src/jsc/bindings/JSCTaskScheduler.cpp index 4f6b8ceae48f..94cea182c5e8 100644 --- a/src/jsc/bindings/JSCTaskScheduler.cpp +++ b/src/jsc/bindings/JSCTaskScheduler.cpp @@ -35,17 +35,18 @@ class JSCDeferredWorkTask { // Drop `ticket` from whichever pending set holds it. Caller holds m_lock; the // event-loop ref is balanced after the caller releases the lock. +// +// Must stay O(1): ~VM -> WaiterListManager::unregister reaches this once per +// still-pending Atomics.waitAsync ticket while holding the process-global +// waiter-lists lock, so a per-call scan makes a worker exiting with N waiters +// O(N^2) and stalls Atomics.notify/waitAsync on every other thread for the +// whole window. 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; - }); + bool isKeepingEventLoopAlive = scheduler.m_pendingTicketsKeepingEventLoopAlive.remove(ticket); // -- At this point, ticket may be an invalid pointer. - if (!isKeepingEventLoopAlive) { - scheduler.m_pendingTicketsOther.removeIf([ticket](auto pendingTicket) { - return pendingTicket.ptr() == ticket; - }); - } + if (!isKeepingEventLoopAlive) + scheduler.m_pendingTicketsOther.remove(ticket); return isKeepingEventLoopAlive; } diff --git a/test/js/web/atomics.test.ts b/test/js/web/atomics.test.ts index a36b5cf80cd2..22a702008a1a 100644 --- a/test/js/web/atomics.test.ts +++ b/test/js/web/atomics.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; describe("Atomics", () => { describe("basic operations", () => { @@ -307,3 +308,57 @@ describe("Atomics", () => { }); }); }); + +describe("Atomics.waitAsync worker teardown", () => { + // A worker exiting with N pending Atomics.waitAsync waiters used to tear down in + // O(N^2): ~VM cancels each waiter's ticket, and each cancellation scanned the whole + // pending-ticket set. The scan also ran while JSC held the process-global + // waiter-lists lock, stalling Atomics.notify/waitAsync on every other thread. + // With N = 50000 the quadratic teardown took 38s+ in release; linear teardown is + // well under a second. The 15s bound leaves wide margin for slow debug/ASAN CI. + test("worker with many pending waiters exits quickly", async () => { + using dir = tempDir("atomics-waiter-teardown", { + "waiter-teardown-fixture.mjs": ` + import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + if (!isMainThread) { + const i32 = new Int32Array(workerData.sab); + for (let k = 0; k < 50000; k++) Atomics.waitAsync(i32, k % 1024, 0); + parentPort.postMessage("armed"); + setInterval(() => {}, 1e6); + } else { + const sab = new SharedArrayBuffer(4096); + const w = new Worker(new URL(import.meta.url), { workerData: { sab } }); + w.on("message", () => { + const start = Date.now(); + w.terminate(); + w.on("exit", () => { + console.log(JSON.stringify({ exitAfterMs: Date.now() - start })); + process.exit(0); + }); + }); + } + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "waiter-teardown-fixture.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + }); + + // Fail fast instead of waiting out a quadratic teardown (minutes in debug). + let timer: Timer | undefined; + const deadline = new Promise<"deadline">(resolve => { + timer = setTimeout(() => resolve("deadline"), 60_000); + }); + const first = await Promise.race([proc.exited, deadline]); + clearTimeout(timer); + if (first === "deadline") proc.kill("SIGKILL"); + expect(first).not.toBe("deadline"); + + const stdout = await proc.stdout.text(); + const { exitAfterMs } = JSON.parse(stdout); + expect(exitAfterMs).toBeLessThan(15_000); + expect(await proc.exited).toBe(0); + }, 90_000); +}); From cef6c4d4c81715963dfa25a62ebe38deaad2039f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:40:35 +0000 Subject: [PATCH 2/3] test: pipe stderr and assert exit state before parsing fixture output --- test/js/web/atomics.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/js/web/atomics.test.ts b/test/js/web/atomics.test.ts index 22a702008a1a..f18bb3640ba4 100644 --- a/test/js/web/atomics.test.ts +++ b/test/js/web/atomics.test.ts @@ -344,6 +344,7 @@ describe("Atomics.waitAsync worker teardown", () => { env: bunEnv, cwd: String(dir), stdout: "pipe", + stderr: "pipe", }); // Fail fast instead of waiting out a quadratic teardown (minutes in debug). @@ -356,9 +357,9 @@ describe("Atomics.waitAsync worker teardown", () => { if (first === "deadline") proc.kill("SIGKILL"); expect(first).not.toBe("deadline"); - const stdout = await proc.stdout.text(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); const { exitAfterMs } = JSON.parse(stdout); expect(exitAfterMs).toBeLessThan(15_000); - expect(await proc.exited).toBe(0); }, 90_000); }); From 6fedb566174b0e891185d6cf61cdf450e3654dea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:42:18 +0000 Subject: [PATCH 3/3] Tighten the dropPendingTicketLocked comment --- src/jsc/bindings/JSCTaskScheduler.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/jsc/bindings/JSCTaskScheduler.cpp b/src/jsc/bindings/JSCTaskScheduler.cpp index 94cea182c5e8..5e91d3cc9d17 100644 --- a/src/jsc/bindings/JSCTaskScheduler.cpp +++ b/src/jsc/bindings/JSCTaskScheduler.cpp @@ -35,12 +35,8 @@ class JSCDeferredWorkTask { // Drop `ticket` from whichever pending set holds it. Caller holds m_lock; the // event-loop ref is balanced after the caller releases the lock. -// // Must stay O(1): ~VM -> WaiterListManager::unregister reaches this once per -// still-pending Atomics.waitAsync ticket while holding the process-global -// waiter-lists lock, so a per-call scan makes a worker exiting with N waiters -// O(N^2) and stalls Atomics.notify/waitAsync on every other thread for the -// whole window. +// pending Atomics.waitAsync ticket under the process-global waiter-lists lock. static bool dropPendingTicketLocked(Bun::JSCTaskScheduler& scheduler, Ticket* ticket) WTF_REQUIRES_LOCK(scheduler.m_lock) { bool isKeepingEventLoopAlive = scheduler.m_pendingTicketsKeepingEventLoopAlive.remove(ticket);