You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
RuntimeScheduler_Modern::clearQueues destroys queued tasks while holding the non-recursive schedulingMutex_; a task destructor that schedules work deadlocks the JS thread #59204
react-native 0.88.0 (New Architecture, Hermes), Expo SDK 58
iOS 27 Simulator (sampled); the code is platform independent
Description
handleTaskError (ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp:497) now always calls clearQueues() when a scheduled task or a microtask checkpoint throws (call sites at lines 428, 431, 467, 471). clearQueues (line 505) does:
{
std::unique_lock lock(schedulingMutex_);
taskQueue_ = {}; // line 508: Task destructors run here, under the lock
isEventLoopScheduled_ = false;
}
Task owns its callback, so destroying the queue destroys every queued callback and everything it captured, with schedulingMutex_ (a std::shared_mutex, RuntimeScheduler_Modern.h:176) held exclusively. If any capture's destructor schedules work (calls scheduleTask, which takes std::unique_lock lock(schedulingMutex_)), the JS thread blocks on itself. The main thread then blocks on the same mutex, and the app freezes. onTaskError_ is never reached, so the original error is not reported either.
We hit it through a library whose queued task captured a native object that, when released, schedules a cleanup job on the JS thread; a sample of the frozen app on the iOS 27 Simulator showed the JS thread stuck on schedulingMutex_ (already held by clearQueues on that thread) and the main thread then blocked on the same mutex. Any callback capturing something with a destructor that re-enters the scheduler hits the same path.
Reproduction
Host-side, against the real 0.88.0 RuntimeScheduler_Modern.cpp: queue a task whose callback captures an object with a destructor calling scheduleTask on the same scheduler, then have another task throw (or fail a microtask checkpoint) so handleTaskError runs. The pristine source deadlocks in clearQueues; with the fix below it returns, the re-entrant task is queued and the event loop is re-armed. (We have a small test harness and can attach it.) In an app it needs a queued task with such a capture plus an uncaught JS error in the same turn.
Root cause
Destroying user-provided callbacks (and their captures) inside a critical section.
Proposed fix
Swap the queue out under the lock and let the drained tasks be destroyed when the function returns, after the lock is released:
--- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp+++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp@@ -503,9 +503,11 @@ void RuntimeScheduler_Modern::handleTaskError(
void RuntimeScheduler_Modern::clearQueues() {
+ // Destroyed after the lock is released: task destructors may schedule work.+ decltype(taskQueue_) drainedTasks;
{
std::unique_lock lock(schedulingMutex_);
- taskQueue_ = {};+ drainedTasks.swap(taskQueue_);
isEventLoopScheduled_ = false;
}
A task scheduled by one of those destructors goes into the now-empty queue and, because isEventLoopScheduled_ is already false, schedules the event loop again. (pendingRenderingUpdates_ = {} after the block is already outside the lock.)
Disclosure: drafted with AI assistance (Claude) from a patch we carry locally; code references were checked against the published package sources. Happy to clarify or open a PR.
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
For majority of bugs: send us a Pull Request with the RNTesterPlayground.js edited to reproduce your bug.
Activity
react-native-bot commentedon Oct 19, 2026
Warning
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
You can read more about about it on our website: How to report a bug.
[ios][jsi] Batch promise and callback releases off the JS thread