From df5f050ca7ff6bd86ec70fc7cb5ec0ac24d184ca Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Jul 2026 14:16:08 +0200 Subject: [PATCH 1/3] fix(replay): Dispatch session-deadline stop off the replay worker thread (DART-323) When a session replay reaches its duration deadline, the stop was invoked inline from the replay worker thread inside the frame-processing task. Because ReplayExecutorService.submit() runs tasks synchronously when already on that thread, stop() encoded the final segment and deleted the replay cache while holding the replay lifecycle lock. A foreground start() on the main thread then parked on that lock long enough to trigger a background ANR. Dispatch the deadline stop through options.executorService so the segment encoding is enqueued asynchronously and the lifecycle lock is only held for the fast state transition, matching the existing timer-executor stop path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../replay/capture/SessionCaptureStrategy.kt | 8 ++++- .../capture/SessionCaptureStrategyTest.kt | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt index d62efb534cc..37dd5b47d1e 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt @@ -10,6 +10,7 @@ import io.sentry.android.replay.ReplayCache import io.sentry.android.replay.ScreenshotRecorderConfig import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment import io.sentry.android.replay.util.ReplayRunnable +import io.sentry.android.replay.util.submitSafely import io.sentry.protocol.SentryId import io.sentry.transport.ICurrentDateProvider import io.sentry.util.FileUtils @@ -134,8 +135,13 @@ internal class SessionCaptureStrategy( } if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) { - options.replayController.stop() options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording") + // dispatch off the replay worker thread, otherwise stop() would run the final segment + // encoding synchronously while holding the replay lifecycle lock, blocking a foreground + // start() on the main thread (ANR) + options.executorService.submitSafely(options, "$TAG.stop") { + options.replayController.stop() + } } } ) diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt index fc2354eb1c0..6e7b00eaf5e 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt @@ -29,6 +29,8 @@ import io.sentry.protocol.SentryId import io.sentry.rrweb.RRWebBreadcrumbEvent import io.sentry.rrweb.RRWebMetaEvent import io.sentry.rrweb.RRWebOptionsEvent +import io.sentry.test.DeferredExecutorService +import io.sentry.test.ImmediateExecutorService import io.sentry.transport.CurrentDateProvider import io.sentry.transport.ICurrentDateProvider import java.io.File @@ -66,6 +68,7 @@ class SessionCaptureStrategyTest { setReplayController( mock { on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) } ) + executorService = ImmediateExecutorService() } val scope = Scope(options) val scopes = @@ -291,6 +294,35 @@ class SessionCaptureStrategyTest { verify(fixture.options.replayController).stop() } + @Test + fun `onScreenshotRecorded dispatches deadline stop off the calling thread`() { + val deferredExecutor = DeferredExecutorService() + fixture.options.executorService = deferredExecutor + var count = 0 + val strategy = + fixture.getSut( + dateProvider = { + if (count++ == 2) { + System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2) + } else { + System.currentTimeMillis() + } + } + ) + strategy.start() + strategy.onConfigurationChanged(mock()) + + strategy.onScreenshotRecorded(mock()) {} + + // stop must not run inline on the caller (replay worker) thread, otherwise it would encode the + // final segment while holding the lifecycle lock and block a foreground start() (ANR) + verify(fixture.options.replayController, never()).stop() + + deferredExecutor.runAll() + + verify(fixture.options.replayController).stop() + } + @Test fun `onConfigurationChanged creates new segment and updates config`() { val strategy = fixture.getSut() From c9c263191ab13e4e1dc83dbe45c74e27998c42f5 Mon Sep 17 00:00:00 2001 From: Nelson Osacky Date: Thu, 23 Jul 2026 14:16:52 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f46c77b306c..7109829ab90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixes +- Fix background ANR when a session replay reaches its duration deadline: the resulting `stop()` (and its segment encoding) no longer runs on the replay worker thread while holding the replay lifecycle lock, which could block a foreground `start()` on the main thread ([#5826](https://github.com/getsentry/sentry-java/pull/5826)) - Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) ### Performance From 608c130df42f47cd2bd1ed90936c78dcb09361d1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Fri, 24 Jul 2026 16:51:58 +0200 Subject: [PATCH 3/3] Update CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 219cf44a16a..20603aa01d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ ### Fixes - Fix background ANR when a session replay reaches its duration deadline: the resulting `stop()` (and its segment encoding) no longer runs on the replay worker thread while holding the replay lifecycle lock, which could block a foreground `start()` on the main thread ([#5826](https://github.com/getsentry/sentry-java/pull/5826)) -- Pin the published Sentry Android SDK's AAR metadata `minCompileSdk` to our `minSdk` (`21`) instead of AGP 9's new default of the SDK's own `compileSdk` (`37`), so apps that depend on the SDK aren't forced to raise their `compileSdk` ([#5823](https://github.com/getsentry/sentry-java/pull/5823)) - 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