From 5c3a346d712d2adca8784d507f1aabc2d3aeab7b Mon Sep 17 00:00:00 2001 From: Bartlomiej Bloniarz Date: Wed, 1 Apr 2026 08:13:01 -0700 Subject: [PATCH] Fix deadlock in AnimationBackendChoreographer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: scheduleCallback() and pause() held synchronized(paused) while calling ReactChoreographer.postFrameCallback/removeFrameCallback, which acquire synchronized(callbackQueues). ReactChoreographer dispatches frame callbacks inside synchronized(callbackQueues), invoking executeFrameCallback -> scheduleCallback -> synchronized(paused). This is a lock ordering inversion: background thread acquires paused then callbackQueues, main thread acquires callbackQueues then paused. Fix: move postFrameCallback/removeFrameCallback calls outside the synchronized(paused) block. The atomic state check (paused + callbackPosted) stays inside the lock, only the ReactChoreographer interaction moves outside. Why this is safe: - callbackPosted.getAndSet(true) inside the lock guarantees at most one thread proceeds to post. A concurrent scheduleCallback will see callbackPosted=true and bail out. - If pause() runs between the lock release and postFrameCallback: pause sets paused=true and callbackPosted=false, then calls removeFrameCallback. Two outcomes depending on ordering inside callbackQueues (which serializes both calls): (a) post runs first, then remove cancels it — clean. (b) remove runs first (nothing to remove), then post adds a callback. That callback fires once, executeFrameCallback sees paused=true via scheduleCallback and does not re-post. Net effect: one extra no-op frame, then the loop stops. - resume() already operated without synchronized(paused) before this change, so no new races are introduced on that path. Differential Revision: D99099455 --- .../fabric/AnimationBackendChoreographer.kt | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 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 80e52ea320ed..49c148abb222 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 @@ -46,24 +46,26 @@ internal class AnimationBackendChoreographer( } fun pause() { + val shouldRemove: Boolean synchronized(paused) { - if (!paused.getAndSet(true) && callbackPosted.getAndSet(false)) { - reactChoreographer.removeFrameCallback( - ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, - choreographerCallback, - ) - } + shouldRemove = !paused.getAndSet(true) && callbackPosted.getAndSet(false) + } + if (shouldRemove) { + reactChoreographer.removeFrameCallback( + ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, + choreographerCallback, + ) } } private fun scheduleCallback() { - synchronized(paused) { - if (!paused.get() && !callbackPosted.getAndSet(true)) { - reactChoreographer.postFrameCallback( - ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, - choreographerCallback, - ) - } + val shouldPost: Boolean + synchronized(paused) { shouldPost = !paused.get() && !callbackPosted.getAndSet(true) } + if (shouldPost) { + reactChoreographer.postFrameCallback( + ReactChoreographer.CallbackType.NATIVE_ANIMATED_MODULE, + choreographerCallback, + ) } }