From 4af06f2c9a3a843ac7d2cce078ae7e619fd66576 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Jul 2026 17:38:18 +0200 Subject: [PATCH 1/2] fix(replay): Post checkCanRecord to main thread to prevent deadlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onScreenshotRecorded can run on the replay executor thread (PixelCopy masked-capture and emit paths). checkCanRecord -> pauseInternal acquires lifecycleLock — if another thread holds that lock and submits to the same single-threaded executor, we deadlock. When not already on the main thread, post checkCanRecord to the main looper so it never runs on the executor. On main thread, call directly to preserve existing synchronous behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + .../sentry/android/replay/ReplayIntegration.kt | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd2e8d0f78..7c3d84b6151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes +- Fix potential ANR/deadlock in Session Replay when `checkCanRecord` runs on the replay executor thread ([#5837](https://github.com/getsentry/sentry-java/pull/5837)) - Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607)) ### Performance diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt index d9cd15d891a..5134ddc03bb 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayIntegration.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.os.Build +import android.os.Looper import android.view.MotionEvent import io.sentry.Breadcrumb import io.sentry.DataCategory.All @@ -352,7 +353,7 @@ public class ReplayIntegration( } addFrame(bitmap, frameTimeStamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun onScreenshotRecorded(screenshot: File, frameTimestamp: Long) { @@ -375,7 +376,7 @@ public class ReplayIntegration( } addFrame(screenshot, frameTimestamp, screen) } - checkCanRecord() + postOnMainThread { checkCanRecord() } } override fun close() { @@ -444,6 +445,17 @@ public class ReplayIntegration( captureStrategy?.onTouchEvent(event) } + // Runs [block] on the main thread. If already there, executes inline; otherwise posts via + // the main looper handler. Prevents deadlocks when lifecycle-lock-acquiring code (e.g. + // checkCanRecord -> pauseInternal) is called from the replay executor thread. + private inline fun postOnMainThread(crossinline block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainLooperHandler.post { block() } + } + } + /** * Check if we're offline or rate-limited and pause for session mode to not overflow the envelope * cache. From 6257cfc023647d6baaaac0990475fde5566fbabf Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Mon, 27 Jul 2026 21:15:08 +0200 Subject: [PATCH 2/2] fix test --- .../io/sentry/android/replay/ReplaySmokeTest.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt index c26e6be9c41..b84b1b53347 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplaySmokeTest.kt @@ -23,6 +23,7 @@ import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebVideoEvent import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider +import io.sentry.transport.RateLimiter import java.time.Duration import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -41,6 +42,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.check import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -61,11 +63,17 @@ class ReplaySmokeTest { internal class Fixture { val options = SentryOptions() val scope = Scope(options) + val rateLimiter = + mock { + on { isActiveForCategory(any()) }.thenReturn(false) + } val scopes = mock { doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(it) .configureScope(any()) + + on { rateLimiter }.doReturn(rateLimiter) } private class ImmediateHandler : @@ -91,7 +99,10 @@ class ReplaySmokeTest { mainLooperHandler = mock { whenever(mock.handler).thenReturn(ImmediateHandler()) - whenever(mock.post(any())).then { (it.arguments[0] as Runnable).run() } + whenever(mock.post(any())).then { + (it.arguments[0] as Runnable).run() + true + } whenever(mock.postDelayed(any(), anyLong())).then { // have to use another thread here otherwise it will block the test thread recordingThread.schedule(