From 95f172e84945dea68381887dd4a9cbd96390a5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Ma=C5=82ecki?= Date: Wed, 3 Jun 2026 06:49:33 -0700 Subject: [PATCH] Fix AnimationBackend choreographer deadlock with synchronous VirtualView events Summary: When the shared animation backend is enabled, starting/stopping a native animation calls `AnimationBackendChoreographer.resume()/pause()` on the thread that started the animation, which can be the **JS thread**. The `resume()` method posted a frame callback to ReactChoreographer, acquiring the choreographer's callback queue. The UI thread might simultaneously hold that lock while dispatching a synchronous event through `executeSynchronouslyOnSameThread_CAN_DEADLOCK`. That path is chose after invoking the VirtualView `Visible` mode-change event. The `executeSynchronouslyOnSameThread_CAN_DEADLOCK` blocks main thread to access `jsi::Runtime` from the JS thread while holding a lock on the callbacks queue. There is a chance that resuming animation and the synchronous event will collide, as the first one tries to post a callback to the queue from the JS thread, waiting for release while the other holds a lock on that queue and waits for the JS thread to pass the runtime, causing a deadlock. The fix is to keep the choreographer frame callback permanently registered. It is posted once on the UI thread and re-posts itself every frame, running as a no-op while paused. The `resume` and `pause` methods now only toggle so accessing the lock on the callback queue is not necessary. Changelog: [Android][Fixed] - Fix deadlock between the UI and JS threads when native animations start while a synchronous VirtualView mode-change event is dispatched Differential Revision: D107378627 --- .../fabric/AnimationBackendChoreographer.kt | 59 +++++++++---------- 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/AnimationBackendChoreographer.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/AnimationBackendChoreographer.kt index 49c148abb222..7bdeb518d808 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/AnimationBackendChoreographer.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/AnimationBackendChoreographer.kt @@ -9,10 +9,10 @@ package com.facebook.react.fabric import com.facebook.proguard.annotations.DoNotStripAny import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.modules.core.ReactChoreographer import com.facebook.react.uimanager.GuardedFrameCallback import java.util.concurrent.atomic.AtomicBoolean -import kotlin.synchronized internal fun interface AnimationFrameCallback { fun onAnimationFrame(frameTimeMs: Double) @@ -32,54 +32,49 @@ internal class AnimationBackendChoreographer( executeFrameCallback(frameTimeNanos) } } - private val callbackPosted: AtomicBoolean = AtomicBoolean() + + // When true, the always-registered frame callback runs as a no-op. + // + // The callback is registered with ReactChoreographer once (on the UI thread) + // and re-posts itself every frame regardless of this flag, so it stays + // registered for the lifetime of the choreographer. private val paused: AtomicBoolean = AtomicBoolean(true) - /* - * resume() and pause() should be called with the same lock to avoid race conditions. - */ + init { + // Register the self-reposting callback once, on the UI thread, so the + // callback queues are only ever mutated from the UI thread. + UiThreadUtil.runOnUiThread { postCallback() } + } fun resume() { - if (paused.getAndSet(false)) { - scheduleCallback() - } + paused.set(false) } fun pause() { - val shouldRemove: Boolean - synchronized(paused) { - shouldRemove = !paused.getAndSet(true) && callbackPosted.getAndSet(false) - } - if (shouldRemove) { - reactChoreographer.removeFrameCallback( - ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, - choreographerCallback, - ) - } + paused.set(true) } - private fun scheduleCallback() { - val shouldPost: Boolean - synchronized(paused) { shouldPost = !paused.get() && !callbackPosted.getAndSet(true) } - if (shouldPost) { - reactChoreographer.postFrameCallback( - ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, - choreographerCallback, - ) - } + private fun postCallback() { + reactChoreographer.postFrameCallback( + ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, + choreographerCallback, + ) } private fun executeFrameCallback(frameTimeNanos: Long) { - callbackPosted.set(false) val currentFrameTimeMs = calculateTimestamp(frameTimeNanos) - // It is possible for ChoreographerCallback to be executed twice within the same frame - // due to frame drops. If this occurs, the additional callback execution should be ignored. - if (currentFrameTimeMs > lastFrameTimeMs) { + // Only drive the animation backend while enabled. It is possible for the + // ChoreographerCallback to be executed twice within the same frame due to + // frame drops; if this occurs, the additional callback execution should be + // ignored. + if (!paused.get() && currentFrameTimeMs > lastFrameTimeMs) { frameCallback?.onAnimationFrame(currentFrameTimeMs) } lastFrameTimeMs = currentFrameTimeMs - scheduleCallback() + // Always re-post (on the UI thread) so the callback stays registered for the + // next frame, whether or not we are currently paused. + postCallback() } private fun calculateTimestamp(frameTimeNanos: Long): Double {