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) + 8
This 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) + 8
This 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 all DTXJSTimerSyncResources, and (b) one of those timers' scheduled cleanup callback firing on the JSTimerSync queue, 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: 0 as 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.
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 Native0.81.5, Expo SDK 54). When it happens, acurrentStatusrequest 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.DTXSyncManagerqueue (computing a status/busy-resources query)This thread is inside
-[DTXJSTimerSyncResource jsonDescription], doing a synchronous dispatch (__detox_sync_dispatch_sync->dispatch_sync_f_slow) onto thecom.detox.sync.JSTimerSyncqueue, and is blocked waiting for it.Thread on
com.detox.sync.JSTimerSyncqueue (running a timer cleanup callback), at the same momentThis thread is inside a scheduled timer cleanup (
-[DTXJSTimerSyncResource scheduleCleanupForTimer:]), which calls-[DTXDispatchQueueSyncResource addWorkBlock:operation:moreInfo:]->+[DTXSyncManager performUpdateWithEventIdentifier:...], which does a synchronous dispatch back onto thecom.wix.DTXSyncManagerqueue, and is blocked waiting for it.Result:
DTXSyncManagerqueue is doingdispatch_syncontoJSTimerSync, whileJSTimerSyncqueue is doingdispatch_syncontoDTXSyncManagerat 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 wedgedDTXSyncManagerqueue.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
currentStatus/busy-resources query starting to enumerate allDTXJSTimerSyncResources, and (b) one of those timers' scheduled cleanup callback firing on theJSTimerSyncqueue, at the same moment.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.20.51.4); this appears to still be present there.Environment
20.51.40.81.554jest-circus),maxWorkers: 1Reproduction
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
sampletool 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.