diff --git a/CHANGELOG.md b/CHANGELOG.md
index acca00fc3c3..2476f781ced 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,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))
- 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))
- 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)).
diff --git a/sentry-android-replay/detekt-baseline.xml b/sentry-android-replay/detekt-baseline.xml
index 2adcb412344..114440612eb 100644
--- a/sentry-android-replay/detekt-baseline.xml
+++ b/sentry-android-replay/detekt-baseline.xml
@@ -158,6 +158,7 @@
TooManyFunctions:WindowRecorder.kt$WindowRecorder : RecorderOnRootViewsChangedListenerExecutorProvider
UnusedPrivateProperty:DebugOverlayDrawable.kt$DebugOverlayDrawable$private val padding = 6f
UseCheckOrError:ReplayShadowMediaCodec.kt$ReplayShadowMediaCodec$throw IllegalStateException("Simulated codec start failure")
+ UtilityClassWithPublicConstructor:CanvasStrategyTest.kt$DeferredSurfacePixelCopyShadow
UtilityClassWithPublicConstructor:PixelCopyStrategyTest.kt$DeferredWindowPixelCopyShadow
diff --git a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt
index c3deefbf71b..6956782cc27 100644
--- a/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt
+++ b/sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt
@@ -58,6 +58,9 @@ internal class CanvasStrategy(
private val lastCaptureSuccessful = AtomicBoolean(false)
private val textIgnoringCanvas = TextIgnoringDelegateCanvas()
private val isClosed = AtomicBoolean(false)
+ // Only accessed on the background handler, which runs the render task, the PixelCopy callback and
+ // the cleanup, so it needs no synchronization
+ private var pixelCopyInFlight = false
private val surfaceTexture =
SurfaceTexture(false).apply {
@@ -110,15 +113,19 @@ internal class CanvasStrategy(
)
return@Runnable
}
+ pixelCopyInFlight = true
PixelCopy.request(
surface,
screenshot!!,
{ result ->
+ pixelCopyInFlight = false
if (isClosed.get()) {
options.logger.log(
SentryLevel.DEBUG,
"CanvasStrategy is closed, ignoring capture result",
)
+ // close() skipped the cleanup while this copy was still writing into the screenshot
+ releaseResources()
return@request
}
if (result == PixelCopy.SUCCESS) {
@@ -138,6 +145,8 @@ internal class CanvasStrategy(
executor.getBackgroundHandler(),
)
} catch (t: Throwable) {
+ // if the request itself threw, no callback will clear the flag
+ pixelCopyInFlight = false
options.logger.log(SentryLevel.ERROR, "Canvas Strategy: picture render failed", t)
lastCaptureSuccessful.set(false)
}
@@ -174,14 +183,22 @@ internal class CanvasStrategy(
.getBackgroundHandler()
.postSafely(
ReplayRunnable("CanvasStrategy.close") {
- screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } }
- surface.release()
- surfaceTexture.release()
+ // PixelCopy writes into the screenshot on the RenderThread; recycling it mid-copy aborts
+ // the process, so an in-flight copy releases the resources from its callback instead
+ if (!pixelCopyInFlight) {
+ releaseResources()
+ }
}
)
unprocessedPictureRef.getAndSet(null)
}
+ private fun releaseResources() {
+ screenshot?.let { synchronized(it) { if (!it.isRecycled) it.recycle() } }
+ surface.release()
+ surfaceTexture.release()
+ }
+
override fun lastCaptureSuccessful(): Boolean = lastCaptureSuccessful.get()
override fun emitLastScreenshot() {
diff --git a/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt
new file mode 100644
index 00000000000..55137a59948
--- /dev/null
+++ b/sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt
@@ -0,0 +1,125 @@
+package io.sentry.android.replay.screenshot
+
+import android.graphics.Bitmap
+import android.os.Handler
+import android.os.Looper
+import android.view.PixelCopy
+import android.view.Surface
+import android.view.View
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import io.sentry.SentryOptions
+import io.sentry.android.replay.ExecutorProvider
+import io.sentry.android.replay.ScreenshotRecorderConfig
+import io.sentry.android.replay.util.MainLooperHandler
+import java.util.concurrent.ScheduledExecutorService
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [30], shadows = [DeferredSurfacePixelCopyShadow::class])
+class CanvasStrategyTest {
+
+ private class Fixture {
+ val options = SentryOptions()
+ val config = ScreenshotRecorderConfig(100, 100, 1f, 1f, 1, 1000)
+
+ fun getSut(): CanvasStrategy =
+ CanvasStrategy(
+ object : ExecutorProvider {
+ override fun getExecutor(): ScheduledExecutorService = mock()
+
+ override fun getMainLooperHandler(): MainLooperHandler = MainLooperHandler()
+
+ // runs the render task, the PixelCopy callback and the cleanup, like production
+ override fun getBackgroundHandler(): Handler = Handler(Looper.getMainLooper())
+ },
+ mock(),
+ options,
+ config,
+ )
+ }
+
+ private val fixture = Fixture()
+
+ @BeforeTest
+ fun setup() {
+ DeferredSurfacePixelCopyShadow.reset()
+ }
+
+ @Test
+ fun `close keeps the screenshot alive until the in-flight pixel copy finishes`() {
+ val strategy = fixture.getSut()
+ strategy.capture(View(ApplicationProvider.getApplicationContext()))
+ shadowOf(Looper.getMainLooper()).idle()
+ val screenshot = DeferredSurfacePixelCopyShadow.dest!!
+
+ strategy.close()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ // the copy is still writing into the screenshot, recycling it now would abort the RenderThread
+ assertThat(screenshot.isRecycled).isFalse()
+
+ DeferredSurfacePixelCopyShadow.flush()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertThat(screenshot.isRecycled).isTrue()
+ }
+
+ @Test
+ fun `close recycles the screenshot right away when no pixel copy is in flight`() {
+ val strategy = fixture.getSut()
+ strategy.capture(View(ApplicationProvider.getApplicationContext()))
+ shadowOf(Looper.getMainLooper()).idle()
+ val screenshot = DeferredSurfacePixelCopyShadow.dest!!
+ DeferredSurfacePixelCopyShadow.flush()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ strategy.close()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ assertThat(screenshot.isRecycled).isTrue()
+ }
+}
+
+/** Holds back the result of a [PixelCopy] from a [Surface] until [flush] is called. */
+@Implements(PixelCopy::class)
+class DeferredSurfacePixelCopyShadow {
+ companion object {
+ private val pendingCallbacks = mutableListOf<() -> Unit>()
+ var dest: Bitmap? = null
+
+ fun reset() {
+ pendingCallbacks.clear()
+ dest = null
+ }
+
+ fun flush() {
+ val callbacks = pendingCallbacks.toList()
+ pendingCallbacks.clear()
+ callbacks.forEach { it.invoke() }
+ }
+
+ @JvmStatic
+ @Implementation
+ @Suppress("UNUSED_PARAMETER")
+ fun request(
+ source: Surface,
+ dest: Bitmap,
+ listener: PixelCopy.OnPixelCopyFinishedListener,
+ listenerThread: Handler,
+ ) {
+ this.dest = dest
+ pendingCallbacks.add {
+ listenerThread.post { listener.onPixelCopyFinished(PixelCopy.SUCCESS) }
+ }
+ }
+ }
+}