Skip to content

Commit b698afe

Browse files
authored
[SR] Ensure bitmaps are recycled properly (#4820)
* Synchronize bitmap modifications Requires to use synchronized blocks over AutoClosableReentrantLock, as only the bitmap object is shared across Strategy <-> ReplayCache. This ensure bitmap.compress is not called in parallel to bitmap.recycle * Address PR comments * Move recycling to background * Address PR feedback * Update Changelog * Address PR comments
1 parent 565c851 commit b698afe

File tree

5 files changed

+63
-30
lines changed

5 files changed

+63
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
- This fixes "java.lang.IllegalArgumentException: The DSN is required" when combining WebFlux and OpenTelemetry
4242
- Session Replay: Do not use recycled screenshots for masking ([#4790](https://github.com/getsentry/sentry-java/pull/4790))
4343
- This fixes native crashes seen in `Canvas.<init>`/`ScreenshotRecorder.capture`
44+
- Session Replay: Ensure bitmaps are recycled properly ([#4820](https://github.com/getsentry/sentry-java/pull/4820))
4445

4546
### Miscellaneous
4647

sentry-android-replay/src/main/java/io/sentry/android/replay/ReplayCache.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,16 @@ public class ReplayCache(private val options: SentryOptions, private val replayI
7979
replayCacheDir?.mkdirs()
8080

8181
val screenshot = File(replayCacheDir, "$frameTimestamp.jpg").also { it.createNewFile() }
82-
screenshot.outputStream().use {
83-
bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it)
84-
it.flush()
82+
synchronized(bitmap) {
83+
if (bitmap.isRecycled) {
84+
return
85+
}
86+
screenshot.outputStream().use {
87+
bitmap.compress(JPEG, options.sessionReplay.quality.screenshotQuality, it)
88+
it.flush()
89+
}
90+
addFrame(screenshot, frameTimestamp, screen)
8591
}
86-
87-
addFrame(screenshot, frameTimestamp, screen)
8892
}
8993

9094
/**

sentry-android-replay/src/main/java/io/sentry/android/replay/ScreenshotRecorder.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,10 @@ internal class ScreenshotRecorder(
142142
}
143143

144144
fun close() {
145+
isCapturing.set(false)
145146
unbind(rootView?.get())
146147
rootView?.clear()
147148
screenshotStrategy.close()
148-
isCapturing.set(false)
149149
}
150150
}
151151

sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/CanvasStrategy.kt

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ internal class CanvasStrategy(
5252

5353
@Volatile private var screenshot: Bitmap? = null
5454

55+
// Lock to synchronize screenshot creation
5556
private val screenshotLock = AutoClosableReentrantLock()
5657
private val prescaledMatrix by
5758
lazy(NONE) { Matrix().apply { preScale(config.scaleFactorX, config.scaleFactorY) } }
@@ -71,19 +72,24 @@ internal class CanvasStrategy(
7172
if (image.planes.size > 0) {
7273
val plane = image.planes[0]
7374

74-
screenshotLock.acquire().use {
75-
if (screenshot == null) {
76-
screenshot =
77-
Bitmap.createBitmap(holder.width, holder.height, Bitmap.Config.ARGB_8888)
78-
}
79-
val bitmap = screenshot
80-
if (bitmap == null || bitmap.isRecycled) {
81-
return@use
75+
if (screenshot == null) {
76+
screenshotLock.acquire().use {
77+
if (screenshot == null) {
78+
screenshot =
79+
Bitmap.createBitmap(holder.width, holder.height, Bitmap.Config.ARGB_8888)
80+
}
8281
}
82+
}
8383

84+
val bitmap = screenshot
85+
if (bitmap != null) {
8486
val buffer = plane.buffer.rewind()
85-
bitmap.copyPixelsFromBuffer(buffer)
86-
lastCaptureSuccessful.set(true)
87+
synchronized(bitmap) {
88+
if (!bitmap.isRecycled) {
89+
bitmap.copyPixelsFromBuffer(buffer)
90+
lastCaptureSuccessful.set(true)
91+
}
92+
}
8793
screenshotRecorderCallback?.onScreenshotRecorded(bitmap)
8894
}
8995
}
@@ -183,14 +189,23 @@ internal class CanvasStrategy(
183189

184190
override fun close() {
185191
isClosed.set(true)
186-
screenshotLock.acquire().use {
187-
screenshot?.apply {
188-
if (!isRecycled) {
189-
recycle()
190-
}
191-
}
192-
screenshot = null
193-
}
192+
executor
193+
.getExecutor()
194+
.submit(
195+
ReplayRunnable(
196+
"CanvasStrategy.close",
197+
{
198+
screenshot?.let {
199+
synchronized(it) {
200+
if (!it.isRecycled) {
201+
it.recycle()
202+
}
203+
}
204+
}
205+
},
206+
)
207+
)
208+
194209
// the image can be free, unprocessed or in transit
195210
freePictureRef.getAndSet(null)?.reader?.close()
196211
unprocessedPictureRef.getAndSet(null)?.reader?.close()

sentry-android-replay/src/main/java/io/sentry/android/replay/screenshot/PixelCopyStrategy.kt

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,25 @@ internal class PixelCopyStrategy(
185185

186186
override fun close() {
187187
isClosed.set(true)
188-
if (!screenshot.isRecycled) {
189-
screenshot.recycle()
190-
}
191-
if (!singlePixelBitmap.isRecycled) {
192-
singlePixelBitmap.recycle()
193-
}
188+
executor.submit(
189+
ReplayRunnable(
190+
"PixelCopyStrategy.close",
191+
{
192+
if (!screenshot.isRecycled) {
193+
synchronized(screenshot) {
194+
if (!screenshot.isRecycled) {
195+
screenshot.recycle()
196+
}
197+
}
198+
}
199+
// since singlePixelBitmap is only used in tasks within the single threaded executor
200+
// there won't be any concurrent access
201+
if (!singlePixelBitmap.isRecycled) {
202+
singlePixelBitmap.recycle()
203+
}
204+
},
205+
)
206+
)
194207
}
195208

196209
private fun Bitmap.dominantColorForRect(rect: Rect): Int {

0 commit comments

Comments
 (0)