Skip to content

RuntimeScheduler_Modern::clearQueues destroys queued tasks while holding the non-recursive schedulingMutex_; a task destructor that schedules work deadlocks the JS thread #59204

Open

Description

@fregster

Environment

  • 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.

Activity

  1. react-native-bot commented on Oct 19, 2026

    @react-native-bot
    Collaborator

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions