From 5792bcc3a64d8956c21058a681d9123d80f41c58 Mon Sep 17 00:00:00 2001 From: Hicabi Date: Tue, 20 Oct 2026 12:15:31 +0300 Subject: [PATCH] Fix deadlock in RuntimeScheduler clearQueues when a task destructor schedules work clearQueues() destroyed the queued tasks while holding schedulingMutex_. If a task's callback captured an object whose destructor schedules a task, scheduleTask() tried to lock the same non-recursive mutex on the same thread and the JS thread deadlocked. This happens when a task throws and handleTaskError() clears the queues. Swap the queue out under the lock and destroy the drained tasks after the lock is released. Fixes #59204. The fix was proposed by @fregster in the issue. Co-authored-by: fregster <630468+fregster@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../RuntimeScheduler_Modern.cpp | 4 +- .../tests/RuntimeSchedulerTest.cpp | 42 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp index 296ea85ca9da..791a4f3a8d0b 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/RuntimeScheduler_Modern.cpp @@ -504,9 +504,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; } diff --git a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp index c264972043c9..5d1d3499804d 100644 --- a/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/runtimescheduler/tests/RuntimeSchedulerTest.cpp @@ -946,6 +946,48 @@ TEST_P(RuntimeSchedulerTest, clearsQueuesOnError) { EXPECT_EQ(stubErrorUtils_->getReportFatalCallCount(), 1); } +TEST_P(RuntimeSchedulerTest, clearQueuesAllowsTaskDestructorsToScheduleTasks) { + // Only for event loop + if (!GetParam()) { + return; + } + + bool didRunScheduledFromDestructor = false; + + auto scheduleTask = [&]() { + runtimeScheduler_->scheduleTask( + SchedulerPriority::NormalPriority, [&](jsi::Runtime& /*runtime*/) { + didRunScheduledFromDestructor = true; + }); + }; + // Schedules a task when it is destroyed. + auto scheduleOnDestroy = + std::make_shared>( + std::move(scheduleTask)); + + auto throwingCallback = + createHostFunctionFromLambda([&](bool /*unused*/) -> jsi::Value { + throw jsi::JSError(*runtime_, "Test error"); + }); + + runtimeScheduler_->scheduleTask( + SchedulerPriority::ImmediatePriority, std::move(throwingCallback)); + runtimeScheduler_->scheduleTask( + SchedulerPriority::LowPriority, + [scheduleOnDestroy = + std::move(scheduleOnDestroy)](jsi::Runtime& /*runtime*/) {}); + + EXPECT_EQ(stubQueue_->size(), 1); + + // The throwing task clears the queues, which destroys the low priority task + // and runs its capture's destructor. That must not deadlock, and the task it + // schedules must still run. + stubQueue_->tick(); + + EXPECT_TRUE(didRunScheduledFromDestructor); + EXPECT_EQ(stubErrorUtils_->getReportFatalCallCount(), 1); +} + TEST_P(RuntimeSchedulerTest, basicSameThreadExecution) { bool didRunSynchronousTask = false; std::thread t1([this, &didRunSynchronousTask]() {