← 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); // clearStop()을 건너뜁니다
+ if (MonotonicTime::now() >= deadline)
+ break;
+ }
+ clearStop(vm); // waitForSync()를 빠져나갈 때만 clear됩니다

WASM debugger는 Stop-The-World protocol을 이용해 모든 VM을 정지시킵니다. 전역 NeedStopTheWorld 플래그가 설정되면, debugger thread는 참여 중인 모든 VM이 notifyVMStop()을 통해 active count를 감소시킬 때까지 대기합니다. JSC는 보통 interpreter loop에 내장된 trap check point를 통해 이 check-in에 도달합니다.

문제는 worker thread가 memory.atomic.wait 내부나 SubtleCrypto, IDB, WebSocket, FileSystem, Notification처럼 각자 BinarySemaphore::wait()를 통해 자체 run loop를 도는 동기적 WebCore operation 안에서 블록되어 있을 때입니다. 이런 경로에서는 trap check point에 결코 도달하지 않기 때문에, STW count가 0에 이르지 못하고 debugger가 영구히 멈춰버립니다. 이 commit은 새로운 waitWithSTWParticipation() helper를 통해 각 blocking 지점에 polling 기반 STW 참여를 추가하고, WaiterListManager::waitForSync()가 50ms마다 polling하면서 플래그가 설정되어 있으면 notifyVMStop()을 호출하도록 수정합니다. 새로운 WasmAtomicsWaitBlocked callback type이 존재하는 이유는, atomics-wait 지점이 wait 완료 전까지 여러 STW 사이클에 걸쳐 진입될 수 있기 때문입니다. 이 callback type은 의도적으로 clearStop()을 건너뛰어서 stop data(callee, CFR, PC, MC, stack)를 유지시키며, waitForSync()가 최종적으로 종료될 때만 clear됩니다.

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)

WASM debugger와 JSC VM의 STW protocol, 그리고 여섯 가지 서로 다른 WebCore blocking API가 맞물리는 지점의 concurrency plumbing을 건드리는 변경입니다. 여러 layer로 쌓인 multi-threaded state machinery는 race condition과 invariant violation이 숨기 좋은 곳입니다. 게다가 stop data를 slow-path stack frame까지 관통시키는 assembly 수준의 변경이 더해지면서, review 비용이 한층 높아집니다.

다섯 가지 방향에서 점검할 필요가 있습니다.

WasmAtomicsWaitBlocked state invariant: 이 callback type은 여러 STW 사이클에 걸쳐 stop data가 살아남도록 의도적으로 clearStop()을 건너뜁니다. 만약 waitForSync()의 어떤 error path나 조기 종료 경로가 빠져나가면서 보상용 clearStop() 호출을 놓친다면, stale stop data가 다음 STW 사이클까지 남아 살아 있는 thread의 PC, CFR, stack에 대한 debugger의 view를 오염시킬 가능성이 있습니다. 의도적으로 생략된 cleanup과 단 하나의 exit path에 붙은 보상 호출이 짝을 이루는 이 패턴은, 다른 곳에서도 동일한 형태로 나타나는지 찾아볼 가치가 있습니다.

각 polling 지점에서의 race window: NeedStopTheWorld check와 waitWithSTWParticipation()/waitForSync()의 반환 사이에는, 새로운 STW request가 도착할 수 있는 여지가 있습니다. blocking operation이 완료되고 VM이 다음 poll 전에 JS로 재진입하면, STW 사이클 하나를 완전히 놓치거나 잘못된 STW epoch counter를 대상으로 notifyVMStop()을 호출하게 될 가능성이 있습니다.

atomics-wait 케이스에서 stepAtBytecode()resumeAll(): atomics-wait를 step over하는 과정은 notifier thread들이 동작할 수 있도록 resumeAll()을 사용합니다. step target thread와 notifier thread 사이의 상호작용이 어긋나는 경우, 예를 들어 debugger가 다음 breakpoint 설정을 마치기 전에 notifier가 waiter를 깨워버리는 경우, thread가 의도된 정지 지점을 지나쳐 실행될 수 있습니다.

InPlaceInterpreter64.asm의 assembly slow-path stop data threading: atomics-wait 케이스에서 stop data(callee, CFR, PC, MC, stack)가 이제 IPInt slow-path stack frame을 관통해 전달됩니다. assembly가 push하는 값과 C++ slow path가 stop data로 읽어들이는 값 사이에 불일치가 있다면, 잘못된 PC나 stack이 debugger에 노출될 수 있습니다. 조작된 WASM module이 이 경로를 반복적으로 타격하면 execution state를 혼란시킬 가능성도 존재합니다.

DebugServer::start()의 idempotency: 기존 버그는 re-entrant start() 호출이 m_serverSocket을 손상시킬 수 있게 했으며, 이제는 isInService()로 보호됩니다. isInService()에 TOCTOU window가 존재하거나 다른 경로에서 reset될 수 있다면, worker VM이 VM별 초기화 과정에서 race를 타는 방식으로 socket 손상 경로가 여전히 도달 가능할 수 있습니다.