← All issues

VMTraps Deadlock Fix in Suspended-Thread Lambda

82fbab5

Source/JavaScriptCore/runtime/VMTraps.cpp

+ auto expectedUID = optionalOwnerThread.value()->uid();
ThreadSuspendLocker locker;
sendMessage(locker, *optionalOwnerThread.value().get(), [&] (PlatformRegisters& registers) -> void {
auto signalContext = SignalContext::tryCreate(registers);
if (!signalContext)
return;
 
- auto ownerThread = vm.apiLock().ownerThread();
// We can't mess with a thread unless it's the one we suspended.
- if (!ownerThread || ownerThread != optionalOwnerThread)
+ // Use ownerThreadUID() instead of ownerThread() to avoid creating a temporary
+ // RefPtr<Thread> copy, which would acquire the Thread control block WordLock.
+ // If the suspended thread was frozen mid-unlock of that same WordLock,
+ // calling ownerThread() here would deadlock.
+ auto currentUID = vm.ownerThreadUID();
+ if (!currentUID || *currentUID != expectedUID)
return;
 
- Thread& thread = *ownerThread->get();
+ Thread& thread = *optionalOwnerThread->get();

VMTraps is JSC's mechanism for asynchronously interrupting a running JavaScript VM, used by the debugger, GC stop-the-world, and watchdog timers. SignalSender::work() suspends the target VM thread via mach thread_suspend(), then executes a lambda while the thread is frozen to install trap breakpoints or verify ownership. Any operation inside that lambda must be effectively async-signal-safe — it must not attempt to acquire any lock that the suspended thread might be holding at an arbitrary point in its execution. Thread reference counting (RefPtr<Thread>) goes through a ThreadSafeWeakPtrControlBlock protected by a WordLock, making even an innocent-looking ownerThread() call a potential deadlock source.

T5 (VM thread)                T2 (SignalSender)
  │                               │
  ├─ strongDeref()                │
  │   └─ acquire WordLock ──►  LOCK HELD
  │       [suspended here] ◄── thread_suspend(T5)
  │                               │
  │                               ├─ ownerThread()
  │                               │   └─ strongRef()
  │                               │       └─ acquire WordLock
  │                               │           └─ DEADLOCK

The fix replaces ownerThread() with ownerThreadUID(), which reads the thread UID directly from the stored pointer with no RefPtr copy and no WordLock acquisition. The expectedUID is captured before suspension and compared inside the lambda.

This was causing full cascade hangs in JSC test infrastructure on arm64 debug builds — other VMs queued behind the deadlocked SignalSender on the serial WorkQueue would never complete, and their ~VM() destructors would block indefinitely on waitForCompletion(). The underlying pattern — acquiring a lock while a thread is suspended holding that same lock — is a general hazard in any code that runs inside mach thread_suspend() callbacks.

🔒

The new ownership-check pattern and the broader sendMessage lambda surface have audit-worthy edge cases worth examining.

Subscribe to read more