iOS: intermittent deadlock between DTXSyncManager and JSTimerSync dispatch queues (currentStatus hangs indefinitely)
Description
We're seeing an intermittent full hang of the Detox<->app bridge during iOS e2e test runs (Detox 20.51.4, iOS 26.2 simulator, React Native 0.81.5, Expo SDK 54). When it happens, a currentStatus request to the app times out (no response within 5000ms), and the app never becomes responsive again for the rest of the test run (we've observed native/Detox-side unresponsiveness lasting 4-5+ minutes, until Jest's own hook/test timeout eventually fires).
Using sample (Xcode command line tool) to capture native, all-thread stacks of the app process at the moment of the hang, we found a circular deadlock between two of Detox's own internal serial dispatch queues:
Thread on com.wix.DTXSyncManager queue (computing a status/busy-resources query)
1760 Thread_45094 DispatchQueue_28: com.wix.DTXSyncManager (serial)
+ 1760 start_wqthread (in libsystem_pthread.dylib) + 8
+ 1760 _pthread_wqthread (in libsystem_pthread.dylib) + 288
+ 1760 _dispatch_workloop_worker_thread (in libdispatch.dylib) + 692
+ 1760 _dispatch_root_queue_drain_deferred_wlh (in libdispatch.dylib) + 288
+ 1760 _dispatch_lane_invoke (in libdispatch.dylib) + 396
+ 1760 _dispatch_lane_serial_drain (in libdispatch.dylib) + 984
+ 1760 _dispatch_client_callout (in libdispatch.dylib) + 12
+ 1760 _dispatch_call_block_and_release (in libdispatch.dylib) + 24
+ 1760 __46+[DTXSyncManager statusWithCompletionHandler:]_block_invoke (in DetoxSync) + 36
+ 1760 +[DTXSyncManager _syncStatus] (in DetoxSync) + 56
+ 1760 +[DTXSyncManager busyResourcesDescriptions] (in DetoxSync) + 92
+ 1760 -[NSArray(Functional) map:] (in DetoxSync) + 228
+ 1760 -[DTXJSTimerSyncResource jsonDescription] (in DetoxSync) + 180
+ 1760 __detox_sync_dispatch_sync (in DetoxSync) + 184
+ 1760 _dispatch_sync_f_slow (in libdispatch.dylib) + 160
+ 1760 __DISPATCH_WAIT_FOR_QUEUE__ (in libdispatch.dylib) + 364
+ 1760 _dispatch_event_loop_wait_for_ownership (in libdispatch.dylib) + 532
+ 1760 _dispatch_kq_poll (in libdispatch.dylib) + 216
+ 1760 kevent_id (in libsystem_kernel.dylib) + 8This thread is inside -[DTXJSTimerSyncResource jsonDescription], doing a synchronous dispatch (__detox_sync_dispatch_sync -> dispatch_sync_f_slow) onto the com.detox.sync.JSTimerSync queue, and is blocked waiting for it.
Thread on com.detox.sync.JSTimerSync queue (running a timer cleanup callback), at the same moment
1760 Thread_45628 DispatchQueue_32: com.detox.sync.JSTimerSync (serial)
+ 1760 start_wqthread (in libsystem_pthread.dylib) + 8
+ 1760 _pthread_wqthread (in libsystem_pthread.dylib) + 288
+ 1760 _dispatch_workloop_worker_thread (in libdispatch.dylib) + 692
+ 1760 _dispatch_root_queue_drain_deferred_wlh (in libdispatch.dylib) + 288
+ 1760 _dispatch_lane_invoke (in libdispatch.dylib) + 396
+ 1760 _dispatch_lane_serial_drain (in libdispatch.dylib) + 304
+ 1760 _dispatch_source_invoke (in libdispatch.dylib) + 1808
+ 1760 _dispatch_continuation_pop (in libdispatch.dylib) + 776
+ 1760 _dispatch_client_callout (in libdispatch.dylib) + 12
+ 1760 __50-[DTXJSTimerSyncResource scheduleCleanupForTimer:]_block_invoke (in DetoxSync) + 372
+ 1760 __detox_sync_dispatch_async (in DetoxSync) + 136
+ 1760 -[DTXDispatchQueueSyncResource addWorkBlock:operation:moreInfo:] (in DetoxSync) + 356
+ 1760 +[DTXSyncManager performUpdateWithEventIdentifier:eventDescription:objectDescription:additionalDescription:syncResource:block:] (in DetoxSync) + 424
+ 1760 _dispatch_sync_f_slow (in libdispatch.dylib) + 160
+ 1760 __DISPATCH_WAIT_FOR_QUEUE__ (in libdispatch.dylib) + 364
+ 1760 _dispatch_event_loop_wait_for_ownership (in libdispatch.dylib) + 532
+ 1760 _dispatch_kq_poll (in libdispatch.dylib) + 216
+ 1760 kevent_id (in libsystem_kernel.dylib) + 8This thread is inside a scheduled timer cleanup (-[DTXJSTimerSyncResource scheduleCleanupForTimer:]), which calls -[DTXDispatchQueueSyncResource addWorkBlock:operation:moreInfo:] -> +[DTXSyncManager performUpdateWithEventIdentifier:...], which does a synchronous dispatch back onto the com.wix.DTXSyncManager queue, and is blocked waiting for it.
Result: DTXSyncManager queue is doing dispatch_sync onto JSTimerSync, while JSTimerSync queue is doing dispatch_sync onto DTXSyncManager at the same time - a classic circular wait deadlock between two serial queues. Every other thread in the app (main thread, JS thread, websocket thread, etc.) is also blocked, all funneling through +[DTXSyncManager performUpdateWithEventIdentifier:...] waiting on the same wedged DTXSyncManager queue.
We confirmed this is a stable, persistent deadlock (not a momentary snapshot artifact) by taking multiple samples ~14 seconds apart during the same hang - the exact same two-thread deadlock stack was present in both.
Why this seems to be a genuine race, not something we can control
- It happens rarely/intermittently (we see it roughly 1 in 10-20 runs of the same suite), consistent with needing an unlucky timing overlap between (a) a
currentStatus/busy-resources query starting to enumerate allDTXJSTimerSyncResources, and (b) one of those timers' scheduled cleanup callback firing on theJSTimerSyncqueue, at the same moment. - It doesn't appear tied to any particular number of JS timers in the app - it doesn't require a "timer leak"; a single ordinary, short-lived timer completing at the wrong moment appears to be enough to trigger the race.
- Setting
detoxEnableSynchronization: 0as a launch arg does not prevent it, since Detox's own internal status-query/timer-tracking machinery still runs and races regardless of whether test actions wait on busy state. - We're on the latest Detox release (
20.51.4); this appears to still be present there.
Environment
- Detox:
20.51.4 - React Native:
0.81.5 - Expo SDK:
54 - Platform: iOS Simulator (iPhone 17, iOS 26.2), Xcode 26.3.0
- Test runner: Jest (
jest-circus),maxWorkers: 1
Reproduction
Unfortunately we don't have a minimal, reliable repro - this is an intermittent race in a large e2e suite. We were able to capture it directly with the native sample tool running in a loop (every 10s) alongside our CI test runs, which is how we got the stacks above. Happy to share more samples/context if useful.
Source: wix/Detox