Skip to content

Atomics.waitAsync: keep the event loop alive once notify schedules the resolve task - #34911

Closed
robobun wants to merge 3 commits into
mainfrom
farm/6d709a04/atomics-waitasync-notify-keepalive
Closed

Atomics.waitAsync: keep the event loop alive once notify schedules the resolve task#34911
robobun wants to merge 3 commits into
mainfrom
farm/6d709a04/atomics-waitasync-notify-keepalive

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Repro

const ia = new Int32Array(new SharedArrayBuffer(4));
const got = [];
for (let i = 0; i < 5; i++)
  Atomics.waitAsync(ia, 0, 0, 3000).value.then(v => {
    got.push("w" + i + ":" + v);
    if (got.length === 5) console.log("resolved:", got.join(","));
  });
setTimeout(() => console.log("notify count =", Atomics.notify(ia, 0)), 40);
process.on("exit", () => {
  if (got.length !== 5) console.log("only " + got.length + "/5 settled before exit");
});

Node prints notify count = 5 then resolved: w0:ok,w1:ok,w2:ok,w3:ok,w4:ok. Bun prints notify count = 5 then only 0/5 settled before exit.

Cause

Atomics.waitAsync registers its DeferredWorkTimer ticket as WorkType::AtSomePoint, so JSCTaskScheduler::onAddPendingWork stores it in m_pendingTicketsOther without taking an event-loop ref. That is correct while the waiter is dormant: a pending waitAsync alone must not keep the process alive.

When Atomics.notify wakes the waiter, WaiterListManager::notifyWaiterImpl calls scheduleWorkSoon on that ticket. JSCTaskScheduler::onScheduleWorkSoon enqueues a ConcurrentTask to run the promise resolution but leaves the ticket in m_pendingTicketsOther. VirtualMachine::is_event_loop_alive() counts the uws active handles, EventLoop::tasks, and EventLoop::concurrent_ref, but not the concurrent_tasks queue itself. In the repro the notify runs inside the callback of the last live handle, so when control returns to the while vm.is_event_loop_alive() loop in run_command.rs every input is zero and the process exits with the five resolution tasks still sitting in concurrent_tasks.

Fix

In JSCTaskScheduler::onScheduleWorkSoon, when the ticket is found in m_pendingTicketsOther, move it to m_pendingTicketsKeepingEventLoopAlive and take an event-loop ref via Bun__eventLoop__incrementRefConcurrently(+1). The work is imminent at this point, so the ticket now has the same lifetime accounting as an ImminentlyScheduled one. runPendingWork already decrements the ref when it takes the ticket from the keep-alive set, and Bun__deleteDeferredWorkTask does the same via dropPendingTicketLocked, so the balance is unchanged for tickets that started as ImminentlyScheduled. Tickets that were cancelled before scheduling (Waiter::cancelAndClear calls cancelPendingWork then scheduleWorkSoon with a no-op) are absent from both sets and take the existing no-ref path.

A pending waitAsync with no notify still does not keep the loop alive; a second test asserts that.

Verification

$ USE_SYSTEM_BUN=1 bun test test/js/web/atomics.test.ts -t "waitAsync promises resolve"
(fail) Atomics > synchronization > waitAsync promises resolve when notify is the last live handle
  "notify=5
- resolved:w0:ok,w1:ok,w2:ok,w3:ok,w4:ok
+ exit-with-unsettled:0
  "

$ bun bd test test/js/web/atomics.test.ts
 30 pass
 0 fail

test/js/web/timers/timer-heap-race.test.ts and test/regression/issue/atomics-waitasync-wtftimer-uaf.test.ts still 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 (bd1eeb0fb)

test/js/web/atomics.test.ts:
(pass) Atomics > basic operations > store and load [4.66ms]
(pass) Atomics > basic operations > add [3.03ms]
(pass) Atomics > basic operations > sub [3.31ms]
(pass) Atomics > basic operations > exchange [2.38ms]
(pass) Atomics > basic operations > compareExchange [2.96ms]
(pass) Atomics > bitwise operations > and [2.33ms]
(pass) Atomics > bitwise operations > or [2.36ms]
(pass) Atomics > bitwise operations > xor [2.12ms]
(pass) Atomics > utility functions > isLockFree [2.98ms]
(pass) Atomics > utility functions > pause [2.43ms]
(pass) Atomics > synchronization > wait with timeout [12.34ms]
(pass) Atomics > synchronization > wait with non-matching value [1.89ms]
(pass) Atomics > synchronization > notify [2.04ms]
(pass) Atomics > synchronization > waitAsync with timeout [3.34ms]
182 |         cmd: [bunExe(), "-e", src],
183 |         env: bunEnv,
184 |         stderr: "pipe",
185 |       });
186 |       const [stdout, stderr, exitCode] = await Promise.a
... (truncated)

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

test/js/web/atomics.test.ts:
(pass) Atomics > basic operations > store and load [0.09ms]
(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.03ms]
(pass) Atomics > bitwise operations > and [0.02ms]
(pass) Atomics > bitwise operations > or [0.02ms]
(pass) Atomics > bitwise operations > xor [0.02ms]
(pass) Atomics > utility functions > isLockFree [0.03ms]
(pass) Atomics > utility functions > pause [0.03ms]
(pass) Atomics > synchronization > wait with timeout [10.13ms]
(pass) Atomics > synchronization > wait with non-matching value [0.11ms]
(pass) Atomics > synchronization > notify [0.03ms]
(pass) Atomics > synchronization > waitAsync with timeout [0.07ms]
(pass) Atomics > synchronization > waitAsync promises resolve when notify is the last live handle [52.38ms]
(pass) Atomics > synchronization > waitAsync alone does not keep the event loop alive [11.85ms]
(pass) Atomics > different TypedArray types > Int8Array [0.08ms]
(pass) Atomics > different TypedArray types > Int16Array [0.
... (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 (bd1eeb0fb)

test/js/web/atomics.test.ts:
(pass) Atomics > basic operations > store and load [4.80ms]
(pass) Atomics > basic operations > add [3.01ms]
(pass) Atomics > basic operations > sub [3.29ms]
(pass) Atomics > basic operations > exchange [2.41ms]
(pass) Atomics > basic operations > compareExchange [3.24ms]
(pass) Atomics > bitwise operations > and [2.36ms]
(pass) Atomics > bitwise operations > or [2.33ms]
(pass) Atomics > bitwise operations > xor [2.05ms]
(pass) Atomics > utility functions > isLockFree [3.00ms]
(pass) Atomics > utility functions > pause [2.27ms]
(pass) Atomics > synchronization > wait with timeout [12.37ms]
(pass) Atomics > synchronization > wait with non-matching value [1.93ms]
(pass) Atomics > synchronization > notify [1.99ms]
(pass) Atomics > synchronization > waitAsync with timeout [3.30ms]
(pass) Atomics > synchronization > waitAsync promises resolve when notify is the last live handle [474.35ms]
(pass) Atomics > synchronization > waitAsync alone does not keep the
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 630ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[2/7] gen cpp.rs (cppbind)
[2/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_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compili
... (truncated)
diff hotspot
src/jsc/bindings/JSCTaskScheduler.cpp |  8 ++++++
 test/js/web/atomics.test.ts           | 52 +++++++++++++++++++++++++++++++++++
 2 files changed, 60 insertions(+)

gate history · 2 passed · 0 rejected · iteration 0

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

… resolve task

An Atomics.waitAsync waiter registers its DeferredWorkTimer ticket as
WorkType::AtSomePoint, so it is tracked in m_pendingTicketsOther and holds no
event-loop ref while dormant (a pending waitAsync must not, by itself, keep the
process alive).

When Atomics.notify wakes the waiter, WaiterListManager::notifyWaiterImpl calls
scheduleWorkSoon on that ticket. JSCTaskScheduler::onScheduleWorkSoon enqueues a
ConcurrentTask to run the promise resolution but left the ticket in
m_pendingTicketsOther. VirtualMachine::is_event_loop_alive() counts the uws
active handles, EventLoop::tasks, and EventLoop::concurrent_ref, but not the
concurrent_tasks queue itself, so if the notify happens inside the callback of
the last live handle (the repro's setTimeout), the run loop in
run_command.rs:boot exits with the resolution tasks still sitting in
concurrent_tasks, and the waitAsync promises never settle.

Promote the ticket from m_pendingTicketsOther to
m_pendingTicketsKeepingEventLoopAlive and take an event-loop ref at the point
scheduleWorkSoon makes the work imminent. runPendingWork already decrements the
ref when it takes the ticket from the keep-alive set, so the balance is
unchanged for tickets that were ImminentlyScheduled from the start. Tickets
that were cancelled before scheduling (Waiter::cancelAndClear) are absent from
both sets and are skipped as before.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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

How do review 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 refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ea2bb5ad-6cc9-4163-8ee6-6648d44e20aa

📥 Commits

Reviewing files that changed from the base of the PR and between e550f2c and bd1eeb0.

📒 Files selected for processing (2)
  • src/jsc/bindings/JSCTaskScheduler.cpp
  • test/js/web/atomics.test.ts

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

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced from the report; the 8-line change in JSCTaskScheduler::onScheduleWorkSoon promotes the AtSomePoint ticket to the keep-alive set at schedule time so the event loop drains the queued resolve task before exiting.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:12 AM PT - Jul 21st, 2026

@robobun, your commit bd1eeb0fbc780b6d9e1ce5d3d65b6f789d63e75e passed in Build #76803! 🎉


🧪   To try this PR locally:

bunx bun-pr 34911

That installs a local version of the PR into your bun-34911 executable, so you can run:

bun-34911 --bun

Comment thread test/js/web/atomics.test.ts Outdated
Per REVIEW.md, avoid a standalone expect(stderr).toBe("") so ASAN/debug
allocator warnings do not cause spurious failures. stderr is matched with
expect.any(String) so it still appears in the diff on a real failure.

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

The earlier stderr nit is addressed. I didn't find any correctness issues, but this touches event-loop keep-alive ref accounting in the JSC deferred-work scheduler, so it's worth a maintainer glance before merging.

What was reviewed:

  • Traced ref balance across all ticket paths: add(AtSomePoint) → scheduleWorkSoon promotes + refs → runPendingWork / onCancelPendingWork / Bun__deleteDeferredWorkTask each deref via the keep-alive set. ImminentlyScheduled tickets and cancelled-then-scheduled tickets miss the m_pendingTicketsOther.take() and are unaffected.
  • Confirmed Bun__eventLoop__incrementRefConcurrently(+1) under m_lock matches the existing pattern in onAddPendingWork and is just an atomic bump on the Rust side.
  • The negative test ("waitAsync alone does not keep the event loop alive") would hang if the fix over-reffed a dormant waiter — it doesn't, since promotion only happens inside onScheduleWorkSoon.
Extended reasoning...

Overview

The PR adds 8 lines to JSCTaskScheduler::onScheduleWorkSoon in src/jsc/bindings/JSCTaskScheduler.cpp: when an AtSomePoint ticket (Atomics.waitAsync) is scheduled for imminent work, it is moved from m_pendingTicketsOther to m_pendingTicketsKeepingEventLoopAlive and an event-loop ref is taken. Two subprocess tests are added to test/js/web/atomics.test.ts — one asserting notify-scheduled resolutions run before exit, one asserting a dormant waiter does not keep the loop alive.

Security risks

None. This is internal event-loop bookkeeping with no user-controlled input reaching the changed code path beyond the existing Atomics.waitAsync/notify surface.

Level of scrutiny

Moderate-to-high. The diff is small and the mechanism is well-argued in the PR description, but event-loop ref accounting is a critical path: an unbalanced ref means either a process hang (over-ref) or premature exit with queued work dropped (under-ref). I traced every path that touches these two sets — onAddPendingWork, onScheduleWorkSoon (both the shutting-down and normal branches), onCancelPendingWork, runPendingWork, and Bun__deleteDeferredWorkTask — and the +1 taken here is balanced by the existing -1 in each terminal path. Tickets that were already in the keep-alive set (ImminentlyScheduled: FinalizationRegistry, Wasm compile) or already removed (cancelAndClear → cancelPendingWork before scheduleWorkSoon) return null from m_pendingTicketsOther.take() and skip the new block, so their accounting is unchanged.

Calling Bun__eventLoop__incrementRefConcurrently while holding m_lock mirrors onAddPendingWork and runPendingWork; the Rust side (src/jsc/JSCScheduler.rs:40) is just an atomic inc/dec on EventLoop::concurrent_ref, so no lock-ordering concern.

Other factors

  • My earlier inline nit about expect(stderr).toBe("") was addressed in bd1eeb0 — both tests now use the combined { stdout, stderr, exitCode } form with stderr: expect.any(String).
  • The mechgate evidence's "release without fix: all passed" line is a labeling artifact (that run used canary 9375564 which already contains the fix commit 18da7dc); the USE_SYSTEM_BUN=1 failure in the description is the real fails-without-fix proof.
  • Given this is C++ ref-count bookkeeping on a path reachable cross-thread from Atomics.notify, I'm deferring rather than approving so a maintainer can confirm the accounting reasoning.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the behavior this PR adds is on main as of #37075 (9d519e8, "Worker / worker_threads: WebCore-shaped lifetimes, joined threads, one ordered VM teardown").

That change fixed the gap described in this PR at the event loop rather than in JSCTaskScheduler: VirtualMachine::is_event_loop_alive_excluding_immediates in src/jsc/VirtualMachine.rs now counts a non-empty concurrent_tasks queue as keeping the loop alive, so the resolve tasks queued by Atomics.notify are drained before the process exits. The ticket promotion in onScheduleWorkSoon from this PR is not needed for that.

Verified against a debug build of main (04148c8): this PR's version of test/js/web/atomics.test.ts passes unmodified (30 pass, 0 fail, twice), the two added cases ("waitAsync promises resolve when notify is the last live handle" and "waitAsync alone does not keep the event loop alive") pass in three further runs on their own, and the repro from the description prints notify count = 5 followed by the five resolutions in 10 out of 10 runs.

@robobun robobun closed this Aug 13, 2026
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.

1 participant