Minimal reproduction of the WorkletRuntime::runSyncSerialized deadlock that fires on the com.margelo.camera.frame thread when a vision-camera frame processor calls a Nitro HybridObject method registered on the JS runtime.
- worklets tracker: software-mansion/react-native-reanimated#9317
- vision-camera tracker: mrousavy/react-native-vision-camera#3824
After a few minutes of preview frames, the app SIGABRTs with:
F libc: Fatal signal 6 (SIGABRT) in tid N (lo.camera.frame), pid M
F DEBUG: Abort message: 'Caused HeapTaskDaemon failure : SuspendAll timeout;
remaining threads: Thread[..."com.margelo.camera.frame"]
Final wait time: 10.012s; appr. total wait time: 24.028s'
F DEBUG: backtrace:
#02 NonPI::MutexLockWithTimeout
#03 std::__ndk1::recursive_mutex::lock()
#04 libworklets.so
#05 worklets::WorkletRuntime::runSyncSerialized
...
#13 facebook::jsi::DecoratedHostFunction::operator() ← inner HostFunction
...
#20 facebook::jsi::DecoratedHostFunction::operator() ← outer HostFunction
...
#42 RuntimeDecorator::call ← worklet JS body
#43 WithRuntimeDecorator<WorkletsReentrancyCheck>
#45 WithRuntimeDecorator<worklets::AroundLock> ← FP runtime lock held
#46 margelo::nitro::SyncJSCallback::call ← invoked by vision-camera
The frame processor runs exactly two HostFunction calls plus frame.dispose():
const gpu = resizer.resize(frame) // HostFunction on JS-registered HybridObject
gpu.dispose() // HostFunction on JS-registered HybridObject
frame.dispose() // HostFunction on FP-registered HybridObjectframe.dispose() alone (in a loop, with no resizer) does not trigger the deadlock — Frame is created on the FP runtime, so the call is same-runtime and doesn't go through runSyncSerialized.
resize() and gpu.dispose() are called on a Resizer created by useResizer() in a React hook (JS runtime). Calling them from the FP runtime forces worklets to dispatch via DecoratedHostFunction → runSyncSerialized → acquire the JS runtime's lock. After enough crossings the AB-BA pattern manifests:
- A =
worklets::AroundLockon the FP runtime, held bySyncJSCallbackfor the entire JS call (frame #45) - B = the JS runtime's
recursive_mutexthatrunSyncSerializedneeds (frame #03)
A is held by the camera frame thread waiting for B; B is held by the JS thread waiting for A. Neither releases. The Android ART HeapTaskDaemon then fails its SuspendAll and aborts the process.
npm install
npx expo prebuild --clean
npx expo run:androidLeave the app open on the camera preview. Within ~10 minutes (Pixel 10, Android 16, 30 fps) it will crash. In parallel:
adb logcat | grep -E "SuspendAll|signal 6|HardwareBuffer.close|Surface.release"- Expo SDK 55 (RN 0.83.4)
- react-native-vision-camera ^5.0.10
- react-native-vision-camera-resizer ^5.0.10
- react-native-worklets ^0.8.3
- react-native-nitro-modules ^0.35.7
- react-native-nitro-image ^0.14.0
- New Architecture enabled
- Tested on: Pixel 10 / Android 16
- Not a resizer-specific bug. Any Nitro HybridObject method registered on the JS runtime and called from the FP runtime takes the same
DecoratedHostFunction→runSyncSerializedpath. - Not a workload-size bug. The minimal FP does no inference, no OpenCV, no JS allocations, no
scheduleOnRN, and still deadlocks. - Not a HardwareBuffer leak. The
HardwareBuffer.close failedwarnings sometimes seen in the OP's repo do not always precede the deadlock — see the vision-camera tracker for separateSurface.releasewarnings. - Probabilistic, not deterministic. Time-to-crash scales with frame rate (~10 min at 30 fps, ~15 min at 60 fps with heavier per-frame work in the original repos).
MIT — share, fork, and link from issue trackers freely.