From 1ef6a418d1fb87c07df8814394c54f016fe6222a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 13:26:44 +0200 Subject: [PATCH 1/4] fix(replay): Keep replays of this process in the startup cleanup finalizePreviousReplay runs once on the shared executor and can run late, e.g. behind ANR processing. cleanupReplays then deleted every replay folder except the current replay id, which is empty after stop(), so it removed the folder of a replay that had just stopped, including segment videos the transport had not read yet. It could also read this process's replay id from the persisted scope after a flush and treat it as a previous replay to recover, which deletes the folder of the running replay. Track the replay ids started in this process, keep their folders in the cleanup and skip finalizing them. Co-Authored-By: Claude Opus 5.5 --- .../android/replay/ReplayIntegration.kt | 24 +++++-- .../android/replay/ReplayIntegrationTest.kt | 65 +++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) 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 314c66a90f..cfcde701c0 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 @@ -59,6 +59,7 @@ import java.io.Closeable import java.io.File import java.util.Date import java.util.LinkedList +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory @@ -138,6 +139,9 @@ public class ReplayIntegration( private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null private val state = AtomicReference(ReplayState()) + // Replays recorded by this process. Their folders hold segment videos the transport may still + // read after the replay stopped, so the startup cleanup of previous replays must keep them. + private val replayIdsOfThisProcess: MutableSet = ConcurrentHashMap.newKeySet() override fun register(scopes: IScopes, options: SentryOptions) { this.options = options @@ -247,6 +251,9 @@ public class ReplayIntegration( recorder?.start() strategy.start() val replayId: SentryId? = strategy.currentReplayId + if (replayId != null && replayId != SentryId.EMPTY_ID) { + replayIdsOfThisProcess += replayId.toString() + } state.set( ReplayState( generation = current.generation + 1, @@ -638,15 +645,13 @@ public class ReplayIntegration( } private fun cleanupReplays(unfinishedReplayId: String = "") { - // clean up old replays + // clean up old replays, but keep the replays of this process and the previous process's + // unfinished replay (its folder is cleaned up after its envelope is assembled) + val keptReplayIds = replayIdsOfThisProcess + listOfNotNull(unfinishedReplayId.ifBlank { null }) options.cacheDirPath?.let { cacheDir -> File(cacheDir).listFiles()?.forEach { file -> val name = file.name - if ( - name.startsWith("replay_") && - !name.contains(replayId.toString()) && - !(unfinishedReplayId.isNotBlank() && name.contains(unfinishedReplayId)) - ) { + if (name.startsWith("replay_") && keptReplayIds.none { name.contains(it) }) { FileUtils.deleteRecursively(file) } } @@ -663,7 +668,12 @@ public class ReplayIntegration( return@submitSafely } val previousReplayId = SentryId(previousReplayIdString) - if (previousReplayId == SentryId.EMPTY_ID) { + // when this task runs late, the persisted replay id can already be one this process recorded, + // which is not a previous replay to recover + if ( + previousReplayId == SentryId.EMPTY_ID || + previousReplayId.toString() in replayIdsOfThisProcess + ) { cleanupReplays() return@submitSafely } diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index d8bff1f0c3..f82e1039fb 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1263,6 +1263,71 @@ class ReplayIntegrationTest { assertFalse(evenOlderReplay.exists()) } + @Test + fun `delayed startup cleanup keeps replays recorded by this process`() { + val replayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val olderReplay = + File(fixture.options.cacheDirPath, "replay_${SentryId()}").also { it.mkdirs() } + // hold back the startup work, like a busy executor does + val deferredTasks = mutableListOf() + fixture.options.executorService = mock { + doAnswer { deferredTasks += it.arguments[0] as Runnable } + .whenever(mock) + .submit(any()) + } + + val captureStrategy = mock { on { currentReplayId }.thenReturn(replayId) } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + // a flushed segment video that is still waiting for the transport + val stoppedReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + replay.stop() + + deferredTasks.forEach { it.run() } + + assertTrue(File(stoppedReplay, "0.mp4").exists()) + assertFalse(olderReplay.exists()) + } + + @Test + fun `delayed startup task does not finalize a replay recorded by this process`() { + val replayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + fixture.options.addScopeObserver(PersistingScopeObserver(fixture.options)) + val deferredTasks = mutableListOf() + fixture.options.executorService = mock { + doAnswer { deferredTasks += it.arguments[0] as Runnable } + .whenever(mock) + .submit(any()) + } + + val captureStrategy = mock { on { currentReplayId }.thenReturn(replayId) } + val replay = fixture.getSut(context, replayCaptureStrategyProvider = { captureStrategy }) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + val currentReplay = + File(fixture.options.cacheDirPath, "replay_$replayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + // the scope observer persisted this replay's id after a flush, before the startup task ran + File(fixture.options.cacheDirPath, PersistingScopeObserver.SCOPE_CACHE).also { + it.mkdirs() + File(it, PersistingScopeObserver.REPLAY_FILENAME).writeText("\"$replayId\"") + } + + deferredTasks.forEach { it.run() } + + assertTrue(File(currentReplay, "0.mp4").exists()) + verify(fixture.scopes, never()).captureReplay(any(), any()) + } + @Test fun `onScreenshotRecorded supplies screen from scope to replay cache`() { val captureStrategy = From 859149a0e4c84c0fac3e61e39caece5383510a7a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 13:27:18 +0200 Subject: [PATCH 2/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c03fd882c..6eb827360e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - Session Replay: Fix a native crash (`SIGABRT` on the RenderThread) when stopping a replay that uses the canvas screenshot strategy ([#6342](https://github.com/getsentry/sentry-java/pull/6342)) +- Session Replay: Keep the segment videos of a replay that stopped before the startup cleanup of previous replays ran ([#6343](https://github.com/getsentry/sentry-java/pull/6343)) - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) - Session Replay: Keep the flushed video and do not reuse its segment id when `stop()` is called right after `flush()` in buffer mode ([#6340](https://github.com/getsentry/sentry-java/pull/6340)) - Keep sending events from Android 7.1.0 and older devices after Sentry changes its TLS certificate authority ([#6227](https://github.com/getsentry/sentry-java/pull/6227)) From ef9b988504b4af19410017f14002d54e05ef6a5b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 13:57:45 +0200 Subject: [PATCH 3/4] fix(replay): Use CopyOnWriteArraySet for the replay ids of this process ConcurrentHashMap.newKeySet() requires API 24 and the min SDK is 21. Co-Authored-By: Claude Opus 5.5 --- .../main/java/io/sentry/android/replay/ReplayIntegration.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 cfcde701c0..8ad9b96c0f 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 @@ -59,7 +59,7 @@ import java.io.Closeable import java.io.File import java.util.Date import java.util.LinkedList -import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArraySet import java.util.concurrent.CountDownLatch import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory @@ -141,7 +141,7 @@ public class ReplayIntegration( private val state = AtomicReference(ReplayState()) // Replays recorded by this process. Their folders hold segment videos the transport may still // read after the replay stopped, so the startup cleanup of previous replays must keep them. - private val replayIdsOfThisProcess: MutableSet = ConcurrentHashMap.newKeySet() + private val replayIdsOfThisProcess = CopyOnWriteArraySet() override fun register(scopes: IScopes, options: SentryOptions) { this.options = options From 184849cefb3a1e464d2553cef6e5b821d81a1d7b Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 22:42:34 +0200 Subject: [PATCH 4/4] fix(replay): Share the replay ids of this process across SDK inits A second Sentry.init registers a new ReplayIntegration, whose startup cleanup would delete the folders of replays the first instance recorded, including videos still waiting for the transport. Co-Authored-By: Claude Opus 5.5 --- .../android/replay/ReplayIntegration.kt | 8 ++++--- .../android/replay/ReplayIntegrationTest.kt | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) 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 8ad9b96c0f..4d6614a783 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 @@ -86,6 +86,11 @@ public class ReplayIntegration( SentryIntegrationPackageStorage.getInstance() .addPackage("maven:io.sentry:sentry-android-replay", BuildConfig.VERSION_NAME) } + + // Replays recorded by this process. Their folders hold segment videos the transport may still + // read after the replay stopped, so the startup cleanup of previous replays must keep them. + // Shared across instances, because a second SDK init registers a new ReplayIntegration. + val replayIdsOfThisProcess = CopyOnWriteArraySet() } // needed for the Java's call site @@ -139,9 +144,6 @@ public class ReplayIntegration( private var mainLooperHandler: MainLooperHandler = MainLooperHandler() private var gestureRecorderProvider: (() -> GestureRecorder)? = null private val state = AtomicReference(ReplayState()) - // Replays recorded by this process. Their folders hold segment videos the transport may still - // read after the replay stopped, so the startup cleanup of previous replays must keep them. - private val replayIdsOfThisProcess = CopyOnWriteArraySet() override fun register(scopes: IScopes, options: SentryOptions) { this.options = options diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt index f82e1039fb..70e8426d12 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayIntegrationTest.kt @@ -1295,6 +1295,30 @@ class ReplayIntegrationTest { assertFalse(olderReplay.exists()) } + @Test + fun `second SDK init keeps replays recorded by the first one`() { + val firstReplayId = SentryId() + fixture.options.cacheDirPath = tmpDir.newFolder().absolutePath + val firstStrategy = mock { on { currentReplayId }.thenReturn(firstReplayId) } + val first = fixture.getSut(context, replayCaptureStrategyProvider = { firstStrategy }) + first.register(fixture.scopes, fixture.options) + first.startBuffering() + val firstReplay = + File(fixture.options.cacheDirPath, "replay_$firstReplayId").also { + it.mkdirs() + File(it, "0.mp4").writeBytes(byteArrayOf(1, 2, 3)) + } + first.stop() + first.close() + + // a new Sentry.init registers a new integration, which runs the startup cleanup again + val secondStrategy = mock { on { currentReplayId }.thenReturn(SentryId()) } + val second = fixture.getSut(context, replayCaptureStrategyProvider = { secondStrategy }) + second.register(fixture.scopes, fixture.options) + + assertTrue(File(firstReplay, "0.mp4").exists()) + } + @Test fun `delayed startup task does not finalize a replay recorded by this process`() { val replayId = SentryId()