Skip to content

Commit df5f050

Browse files
runningcodeclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 4670d89 commit df5f050

2 files changed

Lines changed: 39 additions & 1 deletion

File tree

sentry-android-replay/src/main/java/io/sentry/android/replay/capture/SessionCaptureStrategy.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import io.sentry.android.replay.ReplayCache
1010
import io.sentry.android.replay.ScreenshotRecorderConfig
1111
import io.sentry.android.replay.capture.CaptureStrategy.ReplaySegment
1212
import io.sentry.android.replay.util.ReplayRunnable
13+
import io.sentry.android.replay.util.submitSafely
1314
import io.sentry.protocol.SentryId
1415
import io.sentry.transport.ICurrentDateProvider
1516
import io.sentry.util.FileUtils
@@ -134,8 +135,13 @@ internal class SessionCaptureStrategy(
134135
}
135136

136137
if ((now - replayStartTimestamp.get() >= options.sessionReplay.sessionDuration)) {
137-
options.replayController.stop()
138138
options.logger.log(INFO, "Session replay deadline exceeded (1h), stopping recording")
139+
// dispatch off the replay worker thread, otherwise stop() would run the final segment
140+
// encoding synchronously while holding the replay lifecycle lock, blocking a foreground
141+
// start() on the main thread (ANR)
142+
options.executorService.submitSafely(options, "$TAG.stop") {
143+
options.replayController.stop()
144+
}
139145
}
140146
}
141147
)

sentry-android-replay/src/test/java/io/sentry/android/replay/capture/SessionCaptureStrategyTest.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import io.sentry.protocol.SentryId
2929
import io.sentry.rrweb.RRWebBreadcrumbEvent
3030
import io.sentry.rrweb.RRWebMetaEvent
3131
import io.sentry.rrweb.RRWebOptionsEvent
32+
import io.sentry.test.DeferredExecutorService
33+
import io.sentry.test.ImmediateExecutorService
3234
import io.sentry.transport.CurrentDateProvider
3335
import io.sentry.transport.ICurrentDateProvider
3436
import java.io.File
@@ -66,6 +68,7 @@ class SessionCaptureStrategyTest {
6668
setReplayController(
6769
mock { on { breadcrumbConverter }.thenReturn(DefaultReplayBreadcrumbConverter()) }
6870
)
71+
executorService = ImmediateExecutorService()
6972
}
7073
val scope = Scope(options)
7174
val scopes =
@@ -291,6 +294,35 @@ class SessionCaptureStrategyTest {
291294
verify(fixture.options.replayController).stop()
292295
}
293296

297+
@Test
298+
fun `onScreenshotRecorded dispatches deadline stop off the calling thread`() {
299+
val deferredExecutor = DeferredExecutorService()
300+
fixture.options.executorService = deferredExecutor
301+
var count = 0
302+
val strategy =
303+
fixture.getSut(
304+
dateProvider = {
305+
if (count++ == 2) {
306+
System.currentTimeMillis() + (fixture.options.sessionReplay.sessionDuration * 2)
307+
} else {
308+
System.currentTimeMillis()
309+
}
310+
}
311+
)
312+
strategy.start()
313+
strategy.onConfigurationChanged(mock<ScreenshotRecorderConfig>())
314+
315+
strategy.onScreenshotRecorded(mock<Bitmap>()) {}
316+
317+
// stop must not run inline on the caller (replay worker) thread, otherwise it would encode the
318+
// final segment while holding the lifecycle lock and block a foreground start() (ANR)
319+
verify(fixture.options.replayController, never()).stop()
320+
321+
deferredExecutor.runAll()
322+
323+
verify(fixture.options.replayController).stop()
324+
}
325+
294326
@Test
295327
fun `onConfigurationChanged creates new segment and updates config`() {
296328
val strategy = fixture.getSut()

0 commit comments

Comments
 (0)