#4966·Detox

iOS: intermittent deadlock between DTXSyncManager and JSTimerSync dispatch queues (currentStatus hangs indefinitely)

Author: tpaulshippyCreated Jul 17, 2026Updated Aug 18, 2026

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.