From b7bace6f5500c8704e2b3e86953c1be91662b4aa Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 12:06:56 +0200 Subject: [PATCH 1/3] fix(replay): Keep flushed video and segment id when stop follows flush createVideoOf deleted the existing segment video before checking for captured frames, so an immediate stop() after flush() in buffer mode removed the flushed video before the transport read it, and segment 0 was sent without video. The segment-sent callback that advances the converted session strategy hopped to the main thread and was dropped when stop() ran first, so the session reused the flushed segment id for its stop segment and overwrote the flushed video. Advance it on the replay executor the strategies share instead, which orders it before any later segment of the session. Fixes #6247 Co-Authored-By: Claude Opus 5.5 --- .../io/sentry/android/replay/ReplayCache.kt | 7 +-- .../android/replay/ReplayIntegration.kt | 24 ++++------- .../sentry/android/replay/ReplayCacheTest.kt | 12 ++++++ .../android/replay/ReplayIntegrationTest.kt | 43 +++++++++++++++++++ 4 files changed, 68 insertions(+), 18 deletions(-) diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt index 21d8f88c7f..64aec8a077 100644 --- a/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt +++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt @@ -139,16 +139,17 @@ public class ReplayCache(private val options: SentryOptions, private val replayI bitRate: Int, videoFile: File = File(replayCacheDir, "$segmentId.mp4"), ): GeneratedVideo? { - if (videoFile.exists() && videoFile.length() > 0) { - videoFile.delete() - } // Work on a snapshot of frames to avoid races with writers val framesSnapshot = framesLock.acquire().use { if (frames.isEmpty()) mutableListOf() else frames.toMutableList() } if (framesSnapshot.isEmpty()) { + // keep an existing video intact, e.g. one from a flush whose envelope is still queued options.logger.log(DEBUG, "No captured frames, skipping generating a video segment") return null } + if (videoFile.exists() && videoFile.length() > 0) { + videoFile.delete() + } encoder = SimpleVideoEncoder( 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 015e162afc..314c66a90f 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 @@ -340,23 +340,17 @@ public class ReplayIntegration( return } - var activeStrategy: CaptureStrategy = strategy + // Convert before capturing, so the callback knows which strategy continues the replay. + val activeStrategy = strategy.convert() capture(strategy) { newTimestamp -> - enqueueOnMainThread { - val latest = state.get() - // The flush completes asynchronously; ignore it if this replay was stopped, restarted, - // or handed to another strategy in the meantime. - if ( - latest.matches(expectedGeneration, expectedReplayId) && - latest.captureStrategy === activeStrategy - ) { - activeStrategy.currentSegment++ - activeStrategy.segmentTimestamp = newTimestamp - activeStrategy.isFlushed = true - } - } + // Runs on the replay executor, which the converted strategy shares. Advancing the segment + // here lands before any segment the converted strategy creates later (e.g. on stop), so the + // flushed segment id is not reused. A stale strategy (stopped or restarted replay) is not + // in the state anymore, so updating it has no effect. + activeStrategy.currentSegment++ + activeStrategy.segmentTimestamp = newTimestamp + activeStrategy.isFlushed = true } - activeStrategy = strategy.convert() val replayId: SentryId? = activeStrategy.currentReplayId state.set( current.copy( diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt index 5607ebd87c..aeca0c868d 100644 --- a/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt +++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/ReplayCacheTest.kt @@ -107,6 +107,18 @@ class ReplayCacheTest { assertNull(video) } + @Test + fun `when no frames are provided, does not delete existing video`() { + val replayCache = fixture.getSut(tmpDir) + val existingVideo = + File(replayCache.replayCacheDir, "0.mp4").also { it.writeBytes(byteArrayOf(1, 2, 3)) } + + val video = replayCache.createVideoOf(5000L, 0, 0, 100, 200, 1, 20_000) + + assertNull(video) + assertEquals(3, existingVideo.length()) + } + @Test fun `releases the muxer when the encoder fails to start`() { ReplayShadowMediaCodec.throwOnStart = true 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 4f4f7ff3c9..d8bff1f0c3 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 @@ -752,6 +752,49 @@ class ReplayIntegrationTest { assertThat(replay.replayId).isEqualTo(newReplayId) } + @Test + fun `flush followed by immediate stop advances the converted strategy segment`() { + val replayId = SentryId() + var onSegmentSent: ((Date) -> Unit)? = null + val bufferStrategy = mock() + whenever(bufferStrategy.currentReplayId).thenReturn(replayId) + doAnswer { + @Suppress("UNCHECKED_CAST") + onSegmentSent = it.arguments[0] as (Date) -> Unit + } + .whenever(bufferStrategy) + .flush(any()) + val sessionStrategy = mock() + whenever(sessionStrategy.currentReplayId).thenReturn(replayId) + whenever(sessionStrategy.currentSegment).thenReturn(0) + whenever(bufferStrategy.convert()).thenReturn(sessionStrategy) + val replay = + fixture.getSut( + context, + sessionSampleRate = 0.0, + onErrorSampleRate = 0.0, + replayCaptureStrategyProvider = { bufferStrategy }, + mainLooperHandler = MainLooperHandler(), + ) + replay.register(fixture.scopes, fixture.options) + replay.startBuffering() + shadowOf(Looper.getMainLooper()).idle() + + replay.flush() + replay.stop() + shadowOf(Looper.getMainLooper()).idle() + verify(sessionStrategy).stop() + + // the flushed segment completes on the replay executor after stop ran on the main thread, + // but before the stop segment of the converted strategy is created + val timestamp = Date() + onSegmentSent!!.invoke(timestamp) + + verify(sessionStrategy).currentSegment = 1 + verify(sessionStrategy).segmentTimestamp = timestamp + verify(sessionStrategy).isFlushed = true + } + @Test fun `pause does nothing when not recording`() { val captureStrategy = mock() From fb21910bdf3c264e84397acd93567c24ffa3b010 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 12:08:26 +0200 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index acca00fc3c..2eca028b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixes - 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)) - In February 2027, Sentry moves its TLS certificates from DigiCert to Let's Encrypt and Google Trust Services ([announcement](https://sentry.io/changelog/were-changing-our-tls-certificate-authority-in-february-2027)). - Android 7.1.0 (API 25) and older don't trust the Let's Encrypt root certificate (ISRG Root X1), so without this fix, the SDK on those devices can no longer send events to Sentry after the switch. From 00efd2d55b12a87fc32de6f371c6b95a199976d1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 12:42:06 +0200 Subject: [PATCH 3/3] chore(replay): Add ReplayCacheTest to the detekt LargeClass baseline The regression test for #6247 pushed ReplayCacheTest over the LargeClass threshold, like ReplayIntegrationTest already is. Co-Authored-By: Claude Opus 5.5 --- sentry-android-replay/detekt-baseline.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml index 2adcb41234..03a6d77fef 100644 --- a/sentry-android-replay/detekt-baseline.xml +++ b/sentry-android-replay/detekt-baseline.xml @@ -43,6 +43,7 @@ FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_dest: Bitmap FunctionParameterNaming:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow.Companion$_source: Window LargeClass:CanvasStrategy.kt$TextIgnoringDelegateCanvas : Canvas + LargeClass:ReplayCacheTest.kt$ReplayCacheTest LargeClass:ReplayIntegrationTest.kt$ReplayIntegrationTest LongMethod:CaptureStrategy.kt$CaptureStrategy.Companion$@Suppress("NewApi") private fun buildReplay( options: SentryOptions, video: File, currentReplayId: SentryId, segmentTimestamp: Date, segmentId: Int, height: Int, width: Int, frameCount: Int, frameRate: Int, videoDuration: Long, replayType: ReplayType, screenAtStart: String?, breadcrumbs: List<Breadcrumb>, events: Deque<RRWebEvent>, traceIds: List<String>, segmentNames: List<String>, ): ReplaySegment LongMethod:ComposeViewHierarchyNode.kt$ComposeViewHierarchyNode$private fun fromComposeNode( node: LayoutNode, parent: ViewHierarchyNode?, distance: Int, isComposeRoot: Boolean, options: SentryMaskingOptions, logger: ILogger, ): ViewHierarchyNode?