Note: This bug report was generated by AI under my guidance while investigating
#27755.
In #26659 and #26735, side-channel notifications (_emscripten_thread_notify)
were introduced to interrupt threads blocked in emscripten_futex_wait. The
intent in #26735 was that such interruptions return -EINTR rather than 0
so that POSIX/musl internals handle them as interrupted waits.
However, emscripten_futex_wait can still return 0 (ATOMICS_WAIT_OK)
spuriously before the application's wait condition is met. Because callers often
assume 0 means the address was explicitly signaled for completion (and PR
#26849 optimized wait loops to only retry on r == EINTR), this leads to race
conditions where waiting threads wake up prematurely and assume success.
Root Cause 1: Multi-wake race in _emscripten_thread_notify
In system/lib/pthread/emscripten_futex_wake.c, _emscripten_thread_notify
loops until the target clears wait_addr:
void _emscripten_thread_notify(pthread_t target) {
uintptr_t wait_addr = atomic_fetch_or(&target->wait_addr, NOTIFY_BIT);
if (wait_addr == 0 || (wait_addr & NOTIFY_BIT)) {
return;
}
while (target->wait_addr == (wait_addr | NOTIFY_BIT)) {
emscripten_futex_wake((void*)wait_addr, INT_MAX);
sched_yield();
}
}
This creates a race window when the waiter re-enters wait:
- Thread A is waiting on
addr with self->wait_addr = addr.
- Thread B notifies Thread A: sets
target->wait_addr = addr | NOTIFY_BIT
and calls emscripten_futex_wake(addr, INT_MAX).
- Thread A wakes up, runs:
bool notified = atomic_exchange(&self->wait_addr, 0) & NOTIFY_BIT;
notified is true, so Thread A returns -EINTR.
- The caller of
emscripten_futex_wait observes -EINTR and immediately
re-enters emscripten_futex_wait(addr, ...) to continue waiting.
- Thread A sets
self->wait_addr = addr (without NOTIFY_BIT) and calls
__builtin_wasm_memory_atomic_wait32.
- Meanwhile, Thread B was already in the process of executing a second
emscripten_futex_wake iteration (e.g. before observing that wait_addr
changed, or due to scheduling/pipelining).
- Thread B's second
futex_wake wakes Thread A from its new wait.
- Thread A wakes up, inspects
self->wait_addr & NOTIFY_BIT (which is now
false), and returns 0 (ATOMICS_WAIT_OK), even though the
underlying condition was never satisfied!
Root Cause 2: Collateral wakeups on shared futex addresses
_emscripten_thread_notify(target) calls:
emscripten_futex_wake((void*)wait_addr, INT_MAX);
If multiple threads are waiting on the same wait_addr (e.g. a condition
variable or barrier):
- Only
target has NOTIFY_BIT set on its struct pthread.
- All other threads waiting on
wait_addr are woken up by INT_MAX.
- Because
NOTIFY_BIT is not set on those other threads, they all return 0
spuriously.
Root Cause 3: Async wait cancellations
emscripten_atomic_cancel_wait_async calls Atomics.notify on the wait
address to facilitate JavaScript garbage collection (WebAssembly/threads#176).
Any synchronous thread waiting on that address is woken with return value 0.
Recommended Solutions
-
Update API Documentation (threading_primitives.h):
Document explicitly in system/include/emscripten/threading_primitives.h
that emscripten_futex_wait may experience spurious wakeups returning 0,
matching the Linux futex(2) contract. Callers must always invoke it
in a loop verifying their application condition rather than assuming 0
guarantees state change.
-
Audit internal callers:
Ensure all internal loops (like proxying_legacy.c, library_pthread.c,
etc.) verify the underlying condition rather than relying solely on r == EINTR.
-
Explore tightening _emscripten_thread_notify:
Investigate whether the loop in _emscripten_thread_notify can avoid
sending redundant INT_MAX wakes once the target thread has observed the
notification.
Note: This bug report was generated by AI under my guidance while investigating
#27755.
In #26659 and #26735, side-channel notifications (
_emscripten_thread_notify)were introduced to interrupt threads blocked in
emscripten_futex_wait. Theintent in #26735 was that such interruptions return
-EINTRrather than0so that POSIX/musl internals handle them as interrupted waits.
However,
emscripten_futex_waitcan still return0(ATOMICS_WAIT_OK)spuriously before the application's wait condition is met. Because callers often
assume
0means the address was explicitly signaled for completion (and PR#26849 optimized wait loops to only retry on
r == EINTR), this leads to raceconditions where waiting threads wake up prematurely and assume success.
Root Cause 1: Multi-wake race in
_emscripten_thread_notifyIn
system/lib/pthread/emscripten_futex_wake.c,_emscripten_thread_notifyloops until the target clears
wait_addr:This creates a race window when the waiter re-enters wait:
addrwithself->wait_addr = addr.target->wait_addr = addr | NOTIFY_BITand calls
emscripten_futex_wake(addr, INT_MAX).notifiedistrue, so Thread A returns-EINTR.emscripten_futex_waitobserves-EINTRand immediatelyre-enters
emscripten_futex_wait(addr, ...)to continue waiting.self->wait_addr = addr(withoutNOTIFY_BIT) and calls__builtin_wasm_memory_atomic_wait32.emscripten_futex_wakeiteration (e.g. before observing thatwait_addrchanged, or due to scheduling/pipelining).
futex_wakewakes Thread A from its new wait.self->wait_addr & NOTIFY_BIT(which is nowfalse), and returns0(ATOMICS_WAIT_OK), even though theunderlying condition was never satisfied!
Root Cause 2: Collateral wakeups on shared futex addresses
_emscripten_thread_notify(target)calls:If multiple threads are waiting on the same
wait_addr(e.g. a conditionvariable or barrier):
targethasNOTIFY_BITset on itsstruct pthread.wait_addrare woken up byINT_MAX.NOTIFY_BITis not set on those other threads, they all return0spuriously.
Root Cause 3: Async wait cancellations
emscripten_atomic_cancel_wait_asynccallsAtomics.notifyon the waitaddress to facilitate JavaScript garbage collection (WebAssembly/threads#176).
Any synchronous thread waiting on that address is woken with return value
0.Recommended Solutions
Update API Documentation (
threading_primitives.h):Document explicitly in
system/include/emscripten/threading_primitives.hthat
emscripten_futex_waitmay experience spurious wakeups returning0,matching the Linux
futex(2)contract. Callers must always invoke itin a loop verifying their application condition rather than assuming
0guarantees state change.
Audit internal callers:
Ensure all internal loops (like
proxying_legacy.c,library_pthread.c,etc.) verify the underlying condition rather than relying solely on
r == EINTR.Explore tightening
_emscripten_thread_notify:Investigate whether the loop in
_emscripten_thread_notifycan avoidsending redundant
INT_MAXwakes once the target thread has observed thenotification.