-
-
Notifications
You must be signed in to change notification settings - Fork 34.8k
Description
Version
v25.6.1
Platform
macOS (Darwin 24.5.0, arm64)
Subsystem
V8 / event loop
What steps will reproduce the bug?
Atomics.waitAsync() returns a promise that doesn't keep the event loop alive. If it's the only pending async operation, Node exits immediately, even when a worker is about to Atomics.notify milliseconds later.
This breaks WebAssembly + threads (Emscripten/pthreads). C++ workers notify via Atomics.notify but never create JS-visible libuv handles, so from Node's perspective there's nothing left to do.
// repro.mjs — run with: node repro.mjs
import { Worker } from "node:worker_threads";
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab, 0, 1);
view[0] = 0;
// Worker will notify after 50 ms
const code = `
import { workerData, parentPort } from "node:worker_threads";
const view = new Int32Array(workerData, 0, 1);
setTimeout(() => {
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1);
}, 50);
parentPort.postMessage("ready");
`;
const w = new Worker(code, { eval: true, workerData: sab });
await new Promise(r => w.on("message", r));
w.unref();
const result = Atomics.waitAsync(view, 0, 0);
console.log("awaiting...");
const v = await result.value; // Node exits here
console.log("resolved:", v); // never reachedHow often does it reproduce? Is there a required condition?
100% of the time. The only condition is that Atomics.waitAsync must be the sole pending async operation (no other ref'd libuv handles).
What is the expected behavior? Why is that the expected behavior?
Prints resolved: ok. The waitAsync promise has a well-defined external resolution source (Atomics.notify), so it should ref the event loop while pending, same as setTimeout, setInterval, or any I/O handle.
| Operation | Refs event loop? |
|---|---|
setTimeout(fn, 100) |
Yes |
setInterval(fn, 100) |
Yes |
fs.promises.readFile() |
Yes |
new Worker(...) |
Yes |
new Promise(() => {}) |
No |
Atomics.waitAsync(view, 0, 0) |
No |
The promise should ref while pending, unref on resolution (via Atomics.notify or timeout). Same semantics as every other async primitive with an external completion source.
What do you see instead?
awaiting...
Warning: Detected unsettled top-level await at file:///repro.mjs:22
const v = await result.value;
^
Exit code 13. The worker would have notified 50 ms later, but Node is already gone.
Additional information
Real-world case: multithreaded WASM on npm
We hit this in production while building the Node path for trueform, a geometry processing library with a TypeScript SDK compiled via Emscripten. The C++ side uses TBB for parallelism. When JS calls an async operation the WASM module dispatches work onto a TBB thread pool; the JS side awaits completion via Atomics.waitAsync on a shared status field; the C++ worker writes the result and notifies when done.
Works in browsers. In Node the process just exits; TBB workers are Emscripten-managed pthreads with no libuv footprint.
The dispatch
JS side (AsyncDispatcher.ts:42):
const view = new Int32Array(this._memory.buffer, slot, 1);
const result = Atomics.waitAsync(view, 0, 0); // pending promise
if (result.async) await result.value; // Node exits here
const raw = this._retrieve(slot);C++ side (async_dispatcher.hpp:79-87). A TBB task writes the result and notifies via the WASM atomic intrinsic:
_group.run([raw, f = std::forward<F>(fn)]() {
raw->result = f();
__atomic_store_n(&raw->status, 1, __ATOMIC_RELEASE);
__builtin_wasm_memory_atomic_notify(&raw->status, 1); // memory.atomic.notify
});__builtin_wasm_memory_atomic_notify compiles to the memory.atomic.notify instruction, the same operation as Atomics.notify() in JS, just issued from compiled C++ on an Emscripten pthread. We also tested with an EM_JS wrapper that calls Atomics.notify directly from C++, same result. Doesn't matter where the notification comes from; Node is already gone.
Workaround
We detect Node at runtime and hold the event loop open with a dummy interval (lines 54-61):
if (_isNode) {
const keepalive = setInterval(() => {}, 1 << 30);
try { await result.value; } finally { clearInterval(keepalive); }
} else {
await result.value;
}It works, but every Emscripten + threads project shipping to npm will have to independently discover this. The pattern (dispatch to C++ threads, await via Atomics.waitAsync) is standard. Emscripten's own Asyncify and JSPI rely on the same mechanism.
Prior art
- Atomic.waitAsync never wakes #44729: identified this in 2022, closed in favor of [WIP] src: fix integration of Atomics.waitAsync() #44409.
- [WIP] src: fix integration of Atomics.waitAsync() #44409: WIP fix from @joyeecheung, open since Aug 2022, still draft. Blocked on V8 questions about
PostNonNestableDelayedTasklifecycle (v8:13238).
The original issue was about JS worker threads. This adds the WASM/Emscripten use case, which has become increasingly common as more projects ship multithreaded WASM to npm.
If a V8-level fix isn't feasible (the PostNonNestableDelayedTask concerns in #44409), a Node-level wrapper that refs/unrefs around the V8-provided promise would also work.