Skip to content

Fix O(N^2) worker teardown with pending Atomics.waitAsync waiters - #37280

Open
robobun wants to merge 3 commits into
mainfrom
farm/a54ad958/waitasync-teardown-quadratic
Open

Fix O(N^2) worker teardown with pending Atomics.waitAsync waiters#37280
robobun wants to merge 3 commits into
mainfrom
farm/a54ad958/waitasync-teardown-quadratic

Conversation

@robobun

@robobun robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Problem

A Worker exiting with N pending Atomics.waitAsync waiters tears down in O(N^2), and for the whole window Atomics.notify / Atomics.waitAsync issued by every other thread stalls, on any SharedArrayBuffer (unrelated ones included). await worker.terminate() takes seconds to minutes.

// bun a.mjs (compare: node a.mjs)
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
const N = 20000;
if (!isMainThread) {
  const i32 = new Int32Array(workerData.sab);
  for (let k = 0; k < N; k++) Atomics.waitAsync(i32, k % 1024, 0);
  parentPort.postMessage("armed");
  setInterval(() => {}, 1e6);
} else {
  const sab = new SharedArrayBuffer(4096);
  const other = new Int32Array(new SharedArrayBuffer(64)); // unrelated SAB
  const w = new Worker(new URL(import.meta.url), { workerData: { sab } });
  let maxStall = 0;
  setInterval(() => { const a = Date.now(); Atomics.notify(other, 0); maxStall = Math.max(maxStall, Date.now() - a); }, 50).unref();
  w.on("message", () => { const t = Date.now(); w.terminate();
    w.on("exit", () => console.log({ exitAfterMs: Date.now() - t, maxStall })); });
}

Release build, before: N=10k exits in 1.5s (one Atomics.notify call on the main thread stalls 1475ms), 20k in 5.9s, 40k in 25.4s. A clean 4x per 2x. Node v26 does 100k in 56ms.

Cause

VM teardown runs WaiterListManager::unregister(VM*), which cancels every still-pending Atomics.waitAsync ticket through the DeferredWorkTimer onCancelPendingWork hook while holding JSC's process-global waiter-lists lock. The hook's dropPendingTicketLocked removed each ticket with HashSet::removeIf, a full scan of the pending-ticket sets. N cancellations x O(N) scan = O(N^2), all under the global lock, so Atomics.notify / Atomics.waitAsync on other threads (which take the same lock in findList) block for the entire teardown. Atomics.load / add do not touch that lock, matching the observed symptom.

Fix

Use WTF HashSet's O(1) raw-pointer remove overload. The sets are keyed on ticket identity (Ref<Ticket> with pointer hashing), and the overload hashes the pointer value without dereferencing it, exactly like the removeIf predicate compared pendingTicket.ptr() == ticket. Same semantics, constant time.

Verification

New test in test/js/web/atomics.test.ts terminates a worker holding 50k pending waiters and bounds the exit at 15s:

  • unfixed release: exitAfterMs 42837, fails
  • fixed debug+ASAN: exits in well under a second, passes

Debug+ASAN exit times before -> after: N=5k 5561ms -> 348ms, N=10k 21506ms -> 482ms; after the fix teardown is linear through N=100k (3.1s exit, max cross-thread notify stall 1.7s, both debug; release is far lower). Also ran the atomics-waitasync-wtftimer-uaf, timer-heap-race, and worker_threads suites (all pass).


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/atomics.test.ts
bun test v1.4.0 (6fedb5661)

test/js/web/atomics.test.ts:
(pass) Atomics > basic operations > store and load [5.01ms]
(pass) Atomics > basic operations > add [2.81ms]
(pass) Atomics > basic operations > sub [3.30ms]
(pass) Atomics > basic operations > exchange [2.34ms]
(pass) Atomics > basic operations > compareExchange [2.63ms]
(pass) Atomics > bitwise operations > and [2.32ms]
(pass) Atomics > bitwise operations > or [2.78ms]
(pass) Atomics > bitwise operations > xor [2.00ms]
(pass) Atomics > utility functions > isLockFree [2.81ms]
(pass) Atomics > utility functions > pause [2.34ms]
(pass) Atomics > synchronization > wait with timeout [12.40ms]
(pass) Atomics > synchronization > wait with non-matching value [1.80ms]
(pass) Atomics > synchronization > notify [2.02ms]
(pass) Atomics > synchronization > waitAsync with timeout [3.21ms]
(pass) Atomics > different TypedArray types > Int8Array [2.60ms]
(pass) Atomics > different TypedArray types > Int16Array [2.92ms]
(pass) Atomics > different TypedArray types > 
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (7895855bd)

test/js/web/atomics.test.ts:
(pass) Atomics > basic operations > store and load [0.07ms]
(pass) Atomics > basic operations > add [0.04ms]
(pass) Atomics > basic operations > sub [0.03ms]
(pass) Atomics > basic operations > exchange [0.02ms]
(pass) Atomics > basic operations > compareExchange [0.02ms]
(pass) Atomics > bitwise operations > and [0.03ms]
(pass) Atomics > bitwise operations > or [0.03ms]
(pass) Atomics > bitwise operations > xor [0.02ms]
(pass) Atomics > utility functions > isLockFree [0.04ms]
(pass) Atomics > utility functions > pause [0.03ms]
(pass) Atomics > synchronization > wait with timeout [10.10ms]
(pass) Atomics > synchronization > wait with non-matching value [0.04ms]
(pass) Atomics > synchronization > notify [0.02ms]
(pass) Atomics > synchronization > waitAsync with timeout [0.06ms]
(pass) Atomics > different TypedArray types > Int8Array [0.03ms]
(pass) Atomics > different TypedArray types > Int16Array [0.02ms]
(pass) Atomics > different TypedArray types > Int32Array [0.03ms]
(pass) Atomics > different TypedArray types > Uint8Array [0.02ms]
(pass) Atomics > different TypedArray types > Uint16Array [0.04ms]
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/atomics.test.ts
bun test v1.4.0 (6fedb5661)

test/js/web/atomics.test.ts:
(pass) Atomics > basic operations > store and load [4.83ms]
(pass) Atomics > basic operations > add [2.76ms]
(pass) Atomics > basic operations > sub [3.16ms]
(pass) Atomics > basic operations > exchange [2.31ms]
(pass) Atomics > basic operations > compareExchange [2.67ms]
(pass) Atomics > bitwise operations > and [2.27ms]
(pass) Atomics > bitwise operations > or [2.34ms]
(pass) Atomics > bitwise operations > xor [1.95ms]
(pass) Atomics > utility functions > isLockFree [2.83ms]
(pass) Atomics > utility functions > pause [2.35ms]
(pass) Atomics > synchronization > wait with timeout [12.33ms]
(pass) Atomics > synchronization > wait with non-matching value [1.82ms]
(pass) Atomics > synchronization > notify [2.07ms]
(pass) Atomics > synchronization > waitAsync with timeout [3.30ms]
(pass) Atomics > different TypedArray types > Int8Array [2.65ms]
(pass) Atomics > different TypedArray types > Int16Array [3.09ms]
(pass) Atomics > different TypedArray types > 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 659ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen cpp.rs (cppbind)
[1/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92
... (truncated)
diff hotspot
src/jsc/bindings/JSCTaskScheduler.cpp | 13 ++++----
 test/js/web/atomics.test.ts           | 56 +++++++++++++++++++++++++++++++++++
 2 files changed, 61 insertions(+), 8 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                   reads  edits  tests
src/jsc/bindings/JSCTaskScheduler.cpp      2      3      0
test/js/web/atomics.test.ts                1      3      0

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

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The scheduler now removes pending tickets directly from both pending-ticket sets. A worker teardown regression test covers 50,000 pending Atomics.waitAsync waiters and enforces a 15-second completion limit.

Changes

Worker teardown

Layer / File(s) Summary
Constant-time ticket removal
src/jsc/bindings/JSCTaskScheduler.cpp
dropPendingTicketLocked uses direct removal from both pending-ticket sets. Keep-alive detection and fallback removal remain unchanged.
Worker teardown regression coverage
test/js/web/atomics.test.ts
The test creates 50,000 pending waiters, terminates the worker, enforces a process timeout, and checks completion within 15 seconds with exit code 0.

Possibly related PRs

  • oven-sh/bun#36314: Addresses worker and event-loop teardown with pending tasks.
  • oven-sh/bun#36983: Complements the pending-ticket cleanup path used during worker teardown.

Suggested reviewers: dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely identifies the main fix: reducing worker teardown from quadratic behavior with pending waitAsync waiters.
Description check ✅ Passed The description explains the problem, cause, fix, verification steps, performance results, and regression test coverage.

Comment @coderabbitai help to get the list of available commands.

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

LGTM — focused O(N²)→O(1) swap of removeIf for the raw-pointer remove overload on HashSet<Ref<Ticket>>, semantics-preserving and well-measured. One inline nit on the test's failure diagnostics; not blocking.

Extended reasoning...

Overview

This PR replaces two HashSet::removeIf calls (full-set scans) in dropPendingTicketLocked with WTF HashSet's O(1) raw-pointer remove(T*) overload. The sets are UncheckedKeyHashSet<Ref<Ticket>>, which hash/compare on pointer identity, so remove(ticket) is exactly equivalent to the old predicate pendingTicket.ptr() == ticket — including the property that the pointer is hashed by value and never dereferenced (the "ticket may be an invalid pointer" comment still holds). A regression test spawns a worker with 50k pending Atomics.waitAsync waiters, terminates it, and bounds the exit time.

Security risks

None. No untrusted input, no boundary crossings, no lifetime changes — the removed Ref<Ticket> is destroyed identically by both removeIf and remove. The lock discipline and event-loop-ref balancing are unchanged.

Level of scrutiny

Low-to-medium. The C++ change is a 4-line mechanical substitution in a helper that already has three call sites in the same file, all unchanged. The pointer-keyed take() in runPendingWork in the same file already relies on the same smart-pointer HashSet lookup family, so this isn't introducing a new pattern. The added comment documenting the O(1) requirement is useful and accurate.

Other factors

  • The PR description quantifies the quadratic (4× per 2×N) and links it to the process-global waiter-lists lock — root cause is proven, not correlated.
  • The test's 15s bound vs. <1s fixed / 38s+ unfixed gives wide margin against debug/ASAN CI variance and clearly discriminates.
  • The only finding is a nit on subprocess test diagnostics (stderr not piped, exit code checked after JSON.parse). No deadlock risk given the ~30-byte stdout and inherited stderr; it only affects failure-message quality, not correctness or flakiness.
  • Ruled out: the 90s per-test timeout is justified (60s deadline race + kill needs headroom), and the fixture's lack of a worker 'error' handler doesn't cause a hang (the deadline race bounds it).

Comment thread test/js/web/atomics.test.ts Outdated
Comment thread src/jsc/bindings/JSCTaskScheduler.cpp Outdated
Comment thread src/jsc/bindings/JSCTaskScheduler.cpp

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

LGTM — the earlier nit (pipe stderr, assert { stderr, exitCode } before parsing) is addressed in cef6c4d, and the comment was trimmed to a two-line invariant note.

What was reviewed:

  • dropPendingTicketLocked: removeIf(ptr()==ticket)remove(ticket) on UncheckedKeyHashSet<Ref<Ticket>> — WTF's raw-pointer overload hashes/compares by pointer value only, so semantics match the old predicate exactly (including the "ticket may be invalid" case, which is guarded by !isKeepingEventLoopAlive and never dereferenced regardless).
  • Confirmed the sets are Ref<Ticket>-keyed (JSCTaskScheduler.h:37-38), so there is at most one match and the bool return is equivalent.
  • New test: deadline race + SIGKILL avoids hanging on regression; fixture writes ~30 bytes so awaiting exit before draining pipes cannot deadlock; 15s bound vs. <1s fixed / 38s+ unfixed leaves wide CI margin.
Extended reasoning...

Overview

The PR replaces HashSet::removeIf (full-set scan) with HashSet::remove(T*) (O(1) hashed lookup) in dropPendingTicketLocked at src/jsc/bindings/JSCTaskScheduler.cpp. This function is called once per pending Atomics.waitAsync ticket during worker VM teardown while JSC holds the process-global waiter-lists lock, so the old O(N) scan produced O(N²) teardown that also stalled Atomics.notify/waitAsync on every other thread. A regression test in test/js/web/atomics.test.ts terminates a worker holding 50k pending waiters and bounds exit at 15s.

Correctness of the C++ change

The two sets are declared as UncheckedKeyHashSet<Ref<JSC::DeferredWorkTimer::Ticket>> (JSCTaskScheduler.h:37-38). WTF's HashSet<Ref<T>> hashes and compares by pointer identity, and provides a raw T* remove overload via a hash translator that hashes the pointer value and compares stored.ptr() == key — exactly what the old removeIf predicate (pendingTicket.ptr() == ticket) did, without dereferencing the argument. Since a HashSet holds unique keys, at most one entry matches, so remove()'s bool return is equivalent to removeIf()'s. The existing "ticket may be an invalid pointer" comment still applies: the second remove only runs when the first found nothing, and even so it never dereferences ticket. No memory-management, locking, or ref-counting behavior changes — this is a pure algorithmic-complexity fix.

Security risks

None. No untrusted input handling, no new allocations, no lifetime changes. The change strictly reduces time spent under a process-global lock.

Level of scrutiny

Medium file (JSC bindings, concurrent teardown path), but the specific change is a 5-line mechanical substitution of one WTF container API for its semantically-equivalent O(1) counterpart. The PR includes before/after timing evidence (release: 38s+ → <1s at N=50k; debug+ASAN: 21.5s → 482ms at N=10k) and a fails-without/passes-with gate. The fix is at the right layer — the helper all three cancellation paths (onCancelPendingWork, shutdown branch of onScheduleWorkSoon, Bun__deleteDeferredWorkTask) share.

Other factors

My prior review nit (pipe stderr, assert exit state before JSON.parse) was addressed in cef6c4d and the thread is resolved. The comment-cop bot flagged the code comment twice; the author trimmed it to two lines documenting the O(1) invariant and who depends on it, then justified keeping it — reasonable, since it explains why a future refactor to removeIf would be a regression. All timeline threads are resolved. The test follows harness conventions (tempDir, bunExe, bunEnv, concurrent pipe drain, combined-object assertion) and has a fail-fast 60s deadline so a regression doesn't hang the file for 90s. The 15s bound vs. sub-second fixed / 38s+ unfixed gives ample margin for slow debug+ASAN CI runners.

@robobun

robobun commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: 194 of 196 jobs passed. The one red lane is test/cli/test/parallel.test.ts on darwin 14 x64, which is failing on main as well (the >64MB result line test timing out) and is unrelated to this change; it has been reported separately. The remaining failures in the run were known-flaky tests that passed on retry. The new Atomics.waitAsync teardown test passed on every lane.

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