← All reports

[JSC][WASM][Debugger] Fix STW deadlocks when VM blocks in memory.atomic.wait or WebCore operations

Component: JSC | da1f31b

Source/WebCore/workers/WorkerSTWParticipation.h

+void waitWithSTWParticipation(BinarySemaphore& semaphore, VM& vm)
+{
+ static constexpr auto kPollInterval = 50_ms;
+ while (!semaphore.waitFor(kPollInterval)) {
+ if (vm.needStopTheWorld())
+ notifyVMStop(vm, VMStopped);
+ }
+}

Source/JavaScriptCore/runtime/WaiterListManager.cpp

- result = waiter.wait(timeout);
+ while (!waiter.waitFor(kDebuggerSTWCheckInterval)) {
+ if (vm.needStopTheWorld())
+ notifyVMStop(vm, WasmAtomicsWaitBlocked); // skips clearStop()
+ if (MonotonicTime::now() >= deadline)
+ break;
+ }
+ clearStop(vm); // only cleared on exit from waitForSync()

The WASM debugger halts all VMs with a Stop-The-World protocol: a global NeedStopTheWorld flag is set, and the debugger thread waits until every participating VM decrements an active count via notifyVMStop(). JSC normally reaches that check-in through trap check points embedded in the interpreter loop. A worker thread blocked inside memory.atomic.wait or inside a synchronous WebCore operation — SubtleCrypto, IDB, WebSocket, FileSystem, Notification, each driving its own run loop through BinarySemaphore::wait() — never reaches a trap check point, so the STW count never hits zero and the debugger hangs forever. This commit adds polling-based STW participation at each blocking site through a new waitWithSTWParticipation() helper, and modifies WaiterListManager::waitForSync() to poll every 50ms and call notifyVMStop() when the flag is set. A new WasmAtomicsWaitBlocked callback type exists because the atomics-wait site may be entered across multiple STW cycles before the wait completes: it deliberately skips clearStop() so stop data (callee, CFR, PC, MC, stack) persists, cleared only when waitForSync() finally exits.

Before (deadlock):                        After (polling fix):

Debugger thread                           Debugger thread
  set NeedStopTheWorld                      set NeedStopTheWorld
  wait(activeCount == 0)  ← hangs          wait(activeCount == 0)
        ↑                                         ↑
Worker VM                                 Worker VM
  memory.atomic.wait32                      memory.atomic.wait32
    blocks in futex                           polls every 50ms
    (no trap check point)                       if NeedStopTheWorld:
    never calls notifyVMStop()                    notifyVMStop(WasmAtomicsWaitBlocked)
                                                    ↑ skips clearStop()
                                              continue blocking
                                          exit wait → clearStop()

WebCore blocking site (same pattern):
  BinarySemaphore::wait()        waitWithSTWParticipation()
    blocks indefinitely   →        polls every 50ms
                                   if NeedStopTheWorld: notifyVMStop(vm, VMStopped)

This touches concurrency plumbing at the intersection of the WASM debugger, the JSC VM STW protocol, and six distinct WebCore blocking APIs. Layered multi-threaded state machinery is where race conditions and invariant violations hide well, and the assembly-level changes threading stop data through slow-path stack frames raise the review cost further.

Five angles. WasmAtomicsWaitBlocked state invariant: this callback type deliberately skips clearStop() so stop data survives multiple STW cycles. If any error path or early exit in waitForSync() misses the compensating clearStop() on the way out, stale stop data persists into the next STW cycle, potentially corrupting the debugger's view of PC, CFR, and stack for a live running thread. The general pattern — a deliberately-skipped cleanup paired with a single compensating call on one exit path — is worth hunting anywhere else it appears.

Race window at each polling site: between the NeedStopTheWorld check and the return from waitWithSTWParticipation()/waitForSync(), a new STW request can arrive. If the blocking operation completes and the VM re-enters JS before the next poll, it may miss an STW cycle entirely or call notifyVMStop() against the wrong STW epoch counter.

resumeAll() in stepAtBytecode() for the atomics-wait case: stepping over an atomics-wait uses resumeAll() so notifier threads can run. If the step target thread and notifier threads interact incorrectly — a notifier waking the waiter before the debugger finishes setting up the next breakpoint — the thread may execute past the intended stop point.

Assembly slow-path stop data threading in InPlaceInterpreter64.asm: stop data (callee, CFR, PC, MC, stack) is now threaded through the IPInt slow-path stack frame for the atomics-wait case. A mismatch between what the assembly pushes and what the C++ slow path reads as stop data could expose the wrong PC or stack to the debugger, and a crafted WASM module hitting this path repeatedly may be able to confuse execution state.

DebugServer::start() idempotency: the bug allowed re-entrant start() calls to corrupt m_serverSocket, now guarded by isInService(). If isInService() has a TOCTOU window or can be reset by another path, the socket corruption path may still be reachable from a worker VM racing through per-VM init.