From 9ebe0d13dc06d5aa7d13ea196e4d22718c1fe5ea Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 13:04:00 +0200 Subject: [PATCH 1/2] fix(replay): Do not recycle the canvas screenshot during a pixel copy CanvasStrategy.close() recycled the screenshot bitmap and released the surface on the background handler even when a PixelCopy request was still writing into that bitmap on the RenderThread. libhwui then aborted the process with "cannot access an invalid/free'd bitmap". Track the in-flight copy on the background handler, which runs the render task, the copy callback and the cleanup. close() skips the cleanup while a copy is in flight, and the copy callback releases the resources once it sees the strategy is closed. Co-Authored-By: Claude Opus 5.5 --- sentry-android-replay/detekt-baseline.xml | 1 + .../replay/screenshot/CanvasStrategy.kt | 23 +++- .../replay/screenshot/CanvasStrategyTest.kt | 125 ++++++++++++++++++ 3 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 sentry-android-replay/src/test/java/io/sentry/android/replay/screenshot/CanvasStrategyTest.kt 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) } + } + } + } +} From 44421dc49c6cbfdc890f98c5dd301c0439adc6ad Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 21 Oct 2026 13:04:35 +0200 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)).