Skip to content

Commit 7414e9b

Browse files
romtsncodexclaude
authored
fix(replay): Prevent concurrent PixelCopy frame access (#5808)
* fix(replay): Prevent concurrent PixelCopy frame access Keep PixelCopy, masking, compositing, and cleanup from accessing the shared bitmap concurrently. Fixes GH-5340 Co-Authored-By: Codex <noreply@openai.com> * changelog * fix(android-replay): Preserve pending PixelCopy capture Re-arm the recorder's content-change gate when a capture is skipped because another frame is still in flight. This ensures the latest UI state is retried on the next capture interval. Refs GH-5340 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(replay): Release PixelCopy frame gate and cleanup on failure paths Two bugbot findings on #5808: - Frame gate stuck on callback errors: viewhierarchy traversal or captureSurfaceViews throwing between the PixelCopy success check and the executor submit left frameInFlight = true forever, silently wedging all future captures. Wrap the post-success block in a try/finally that releases the gate unless work was successfully handed off. - Cleanup lost after executor shutdown: close() typically runs after ReplayIntegration has shut down the replay executor, so submit() returns null and the bitmap + maskRenderer were never released. Fall back to running cleanup inline in that case. * chore(replay): Drop redundant handedOff flag in capture callback The mask branch already knows via 'submitted == null' whether the executor took ownership; the surface-view branch always hands off. Flatten to explicit finishFrame() calls on the two failure paths (mask null-submit, outer catch) instead of tracking handoff state across a try/finally. * fix(replay): Distinguish inline execution from executor rejection ReplayExecutorService.submit previously returned null both when the caller was on the worker thread (task ran inline) and when the executor rejected the submission (task did NOT run). Callers had no way to tell them apart. Return a CompletedFuture sentinel for inline execution; null now means only rejection. Also narrow PixelCopyStrategy's frame-processing catch from Throwable to RuntimeException so OOM/LinkageError still propagate. * chore(replay): Drop redundant cleanupScheduled guard Cleanup body is already idempotent (screenshot.isRecycled check, MaskRenderer.close guards on isInitialized + isRecycled). A stray extra scheduleCleanup would just submit a no-op — not worth the AtomicBoolean. * test(replay): Fix recursive close and assert masking is skipped on close race The mock executor closed the strategy on every submit(); since close() itself submits the cleanup task, this recursed close() -> scheduleCleanup() -> submit() until the stack overflowed. Close only when the mask task is submitted. The prior "does not crash" assertion was also vacuous under the frameInFlight gate (passed even with the isClosed guard removed). Assert instead that no screenshot is emitted once close() races masking, which fails if the guard in applyMaskingAndNotify is removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(replay): Guard finishFrame cleanup with CAS to avoid recycling a bitmap a new capture is using finishFrame cleared frameInFlight before checking isClosed, so a new capture could take the gate and start PixelCopy into the shared screenshot while the old finishFrame went on to scheduleCleanup after close() flipped isClosed — recycling the bitmap mid-write. Re-take the gate with compareAndSet before cleaning up so the losing frame backs off. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(replay): Make close's idle cleanup claim the gate atomically close() checked !frameInFlight.get() non-atomically before scheduleCleanup, so a capture racing in after the check could take the gate, see isClosed, and have its finishFrame schedule a second cleanup. Extract the shared "claim the gate, then the winner cleans up once" invariant into cleanUpIfIdle() so close() and finishFrame() use the same CAS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(replay): Return CompletedFuture from inlineExecutor to match ReplayExecutorService inlineExecutor() returned null from submit, which under the executor's contract means "rejected" — making capture()'s null-fallback finishFrame run on top of the mask task's own finally, a double-release production never hits on the inline path. Return CompletedFuture so the fixture matches the real inline semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(replay): Hold frame gate across emitLastScreenshot to prevent concurrent bitmap access emitLastScreenshot runs on the main thread, so the downstream consumer queues its bitmap read (JPEG compress) to the executor asynchronously. Without the gate, the next capture tick's PixelCopy.request writes into the shared screenshot while the queued read is still in flight. Submit the consumer call to the executor so it runs inline on the worker thread while the gate is held, same pattern as the masked capture path. Also fixes the close-race test mock to return CompletedFuture instead of null, matching the executor contract (same fix as inlineExecutor in cd3a7c0). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6335e35 commit 7414e9b

4 files changed

Lines changed: 434 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
### Fixes
1010

11+
- Prevent concurrent PixelCopy access during Session Replay masking and bitmap cleanup ([#5808](https://github.com/getsentry/sentry-java/pull/5808))
1112
- Release `MediaMuxer` when the replay video encoder fails to start to avoid a resource leak ([#5607](https://github.com/getsentry/sentry-java/pull/5607))
1213

1314
### Performance

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

Lines changed: 147 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ internal class PixelCopyStrategy(
5959
private val contentChanged = AtomicBoolean(false)
6060
private val unstableCaptures = AtomicInteger(0)
6161
private val isClosed = AtomicBoolean(false)
62+
private val frameInFlight = AtomicBoolean(false)
6263
private val dstOverPaint by
6364
lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } }
6465
private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) }
@@ -77,8 +78,15 @@ internal class PixelCopyStrategy(
7778
return
7879
}
7980

81+
if (!frameInFlight.compareAndSet(false, true)) {
82+
options.logger.log(DEBUG, "PixelCopyStrategy capture is already in flight, skipping")
83+
markContentChanged()
84+
return
85+
}
86+
8087
if (isClosed.get()) {
8188
options.logger.log(DEBUG, "PixelCopyStrategy is closed, not capturing screenshot")
89+
finishFrame()
8290
return
8391
}
8492

@@ -90,51 +98,72 @@ internal class PixelCopyStrategy(
9098
{ copyResult: Int ->
9199
if (isClosed.get()) {
92100
options.logger.log(DEBUG, "PixelCopyStrategy is closed, ignoring capture result")
101+
finishFrame()
93102
return@request
94103
}
95104

96105
if (copyResult != PixelCopy.SUCCESS) {
97106
options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult)
98107
unstableCaptures.set(0)
99108
lastCaptureSuccessful.set(false)
109+
finishFrame()
100110
return@request
101111
}
102112

103113
val changedDuringCapture = contentChanged.get()
104114
if (changedDuringCapture && shouldSkipUnstableCapture()) {
115+
finishFrame()
105116
return@request
106117
}
107118

108-
// TODO: disableAllMasking here and dont traverse?
109-
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay)
110-
val surfaceViewNodes =
111-
if (options.sessionReplay.isCaptureSurfaceViews) {
112-
mutableListOf<ViewHierarchyNode.SurfaceViewHierarchyNode>()
113-
} else {
114-
null
115-
}
116-
root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
117-
118-
if (surfaceViewNodes.isNullOrEmpty()) {
119-
executor.submit(
120-
ReplayRunnable("screenshot_recorder.mask") {
121-
applyMaskingAndNotify(
122-
root,
123-
viewHierarchy,
124-
resetUnstableCaptures = !changedDuringCapture,
119+
// Release the frame gate if anything below throws before we hand work off to the
120+
// executor — otherwise a single failure wedges captures forever.
121+
try {
122+
// TODO: disableAllMasking here and dont traverse?
123+
val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay)
124+
val surfaceViewNodes =
125+
if (options.sessionReplay.isCaptureSurfaceViews) {
126+
mutableListOf<ViewHierarchyNode.SurfaceViewHierarchyNode>()
127+
} else {
128+
null
129+
}
130+
root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes)
131+
132+
if (surfaceViewNodes.isNullOrEmpty()) {
133+
val submitted =
134+
executor.submit(
135+
ReplayRunnable("screenshot_recorder.mask") {
136+
try {
137+
applyMaskingAndNotify(
138+
root,
139+
viewHierarchy,
140+
resetUnstableCaptures = !changedDuringCapture,
141+
)
142+
} finally {
143+
finishFrame()
144+
}
145+
}
125146
)
147+
if (submitted == null) {
148+
finishFrame()
126149
}
127-
)
128-
} else {
129-
// Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger
130-
// ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever.
131-
markContentChanged()
132-
captureSurfaceViews(
133-
root,
134-
surfaceViewNodes,
135-
viewHierarchy,
136-
resetUnstableCaptures = !changedDuringCapture,
137-
)
150+
} else {
151+
// Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger
152+
// ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever.
153+
markContentChanged()
154+
captureSurfaceViews(
155+
root,
156+
surfaceViewNodes,
157+
viewHierarchy,
158+
resetUnstableCaptures = !changedDuringCapture,
159+
)
160+
}
161+
} catch (e: RuntimeException) {
162+
// OEM View subclasses have been observed throwing during hierarchy traversal
163+
// (e.g. Redmi's TextView NPE). Release the frame gate so a single bad frame
164+
// doesn't wedge the recorder. Errors (OOM, LinkageError) intentionally propagate.
165+
options.logger.log(WARNING, "Failed to process replay frame", e)
166+
finishFrame()
138167
}
139168
},
140169
mainLooperHandler.handler,
@@ -143,6 +172,7 @@ internal class PixelCopyStrategy(
143172
options.logger.log(WARNING, "Failed to capture replay recording", e)
144173
unstableCaptures.set(0)
145174
lastCaptureSuccessful.set(false)
175+
finishFrame()
146176
}
147177
}
148178

@@ -272,37 +302,46 @@ internal class PixelCopyStrategy(
272302
windowY: Int,
273303
resetUnstableCaptures: Boolean,
274304
) {
275-
executor.submit(
276-
ReplayRunnable("screenshot_recorder.composite") {
277-
if (isClosed.get() || screenshot.isRecycled) {
278-
options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing")
279-
recycleCaptures(captures)
280-
return@ReplayRunnable
281-
}
305+
val submitted =
306+
executor.submit(
307+
ReplayRunnable("screenshot_recorder.composite") {
308+
try {
309+
if (isClosed.get() || screenshot.isRecycled) {
310+
options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing")
311+
recycleCaptures(captures)
312+
return@ReplayRunnable
313+
}
282314

283-
for (capture in captures) {
284-
if (capture == null) continue
285-
if (capture.bitmap.isRecycled) continue
286-
287-
compositeSurfaceViewInto(
288-
screenshotCanvas,
289-
dstOverPaint,
290-
tmpSrcRect,
291-
tmpDstRect,
292-
capture.bitmap,
293-
capture.x,
294-
capture.y,
295-
windowX,
296-
windowY,
297-
config.scaleFactorX,
298-
config.scaleFactorY,
299-
)
300-
capture.bitmap.recycle()
301-
}
315+
for (capture in captures) {
316+
if (capture == null) continue
317+
if (capture.bitmap.isRecycled) continue
318+
319+
compositeSurfaceViewInto(
320+
screenshotCanvas,
321+
dstOverPaint,
322+
tmpSrcRect,
323+
tmpDstRect,
324+
capture.bitmap,
325+
capture.x,
326+
capture.y,
327+
windowX,
328+
windowY,
329+
config.scaleFactorX,
330+
config.scaleFactorY,
331+
)
332+
capture.bitmap.recycle()
333+
}
302334

303-
applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures)
304-
}
305-
)
335+
applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures)
336+
} finally {
337+
finishFrame()
338+
}
339+
}
340+
)
341+
if (submitted == null) {
342+
recycleCaptures(captures)
343+
finishFrame()
344+
}
306345
}
307346

308347
private fun recycleCaptures(captures: Array<SurfaceViewCapture?>) {
@@ -322,15 +361,57 @@ internal class PixelCopyStrategy(
322361
}
323362

324363
override fun emitLastScreenshot() {
325-
if (lastCaptureSuccessful() && !screenshot.isRecycled) {
326-
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
364+
if (!frameInFlight.compareAndSet(false, true)) {
365+
return
366+
}
367+
if (!lastCaptureSuccessful() || screenshot.isRecycled) {
368+
finishFrame()
369+
return
370+
}
371+
// Submit to the executor so the downstream consumer's bitmap read (JPEG compress) runs inline
372+
// on the worker thread while the gate is held, same as the masked capture path.
373+
val submitted =
374+
executor.submit(
375+
ReplayRunnable("PixelCopyStrategy.emit") {
376+
try {
377+
screenshotRecorderCallback?.onScreenshotRecorded(screenshot)
378+
} finally {
379+
finishFrame()
380+
}
381+
}
382+
)
383+
if (submitted == null) {
384+
finishFrame()
327385
}
328386
}
329387

330388
override fun close() {
331389
isClosed.set(true)
332390
unstableCaptures.set(0)
333-
executor.submit(
391+
cleanUpIfIdle()
392+
}
393+
394+
private fun finishFrame() {
395+
frameInFlight.set(false)
396+
if (isClosed.get()) {
397+
cleanUpIfIdle()
398+
}
399+
}
400+
401+
/**
402+
* Schedules cleanup only for the caller that owns the gate. Whoever wins [frameInFlight]'s CAS
403+
* (close when no frame is running, or the finishFrame of the last in-flight frame after close)
404+
* runs cleanup exactly once; a racing capture that took the gate loses the CAS and backs off, so
405+
* we never recycle the shared screenshot while that capture is still using it.
406+
*/
407+
private fun cleanUpIfIdle() {
408+
if (frameInFlight.compareAndSet(false, true)) {
409+
scheduleCleanup()
410+
}
411+
}
412+
413+
private fun scheduleCleanup() {
414+
val cleanup =
334415
ReplayRunnable(
335416
"PixelCopyStrategy.close",
336417
{
@@ -344,7 +425,12 @@ internal class PixelCopyStrategy(
344425
maskRenderer.close()
345426
},
346427
)
347-
)
428+
// ReplayExecutorService.submit returns null only on genuine rejection (post-shutdown);
429+
// inline execution on the worker thread returns a completed future. Fall back to running
430+
// cleanup here so the bitmap + mask renderer are freed even when the executor is dead.
431+
if (executor.submit(cleanup) == null) {
432+
cleanup.run()
433+
}
348434
}
349435
}
350436

sentry-android-replay/src/main/java/io/sentry/android/replay/util/ReplayExecutorService.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.sentry.SentryLevel.ERROR
44
import io.sentry.SentryOptions
55
import java.util.concurrent.Future
66
import java.util.concurrent.ScheduledExecutorService
7+
import java.util.concurrent.TimeUnit
78
import java.util.concurrent.TimeUnit.MILLISECONDS
89

910
/**
@@ -14,11 +15,20 @@ internal class ReplayExecutorService(
1415
private val delegate: ScheduledExecutorService,
1516
private val options: SentryOptions,
1617
) : ScheduledExecutorService by delegate {
18+
/**
19+
* Submits [task] for execution and returns a [Future] describing what happened. The return value
20+
* has three distinct outcomes callers can rely on:
21+
* - [CompletedFuture] — the caller is already on the replay worker thread, so the task was run
22+
* inline before this method returned. Skips the queue.
23+
* - A regular [Future] from the underlying [ScheduledExecutorService] — the task was queued and
24+
* will run asynchronously.
25+
* - `null` — the underlying executor rejected the submission (typically because it has been shut
26+
* down). The task did NOT run; callers that need cleanup must handle it themselves.
27+
*/
1728
override fun submit(task: Runnable): Future<*>? {
1829
if (Thread.currentThread().name.startsWith("SentryReplayIntegration")) {
19-
// we're already on the worker thread, no need to submit
2030
task.run()
21-
return null
31+
return CompletedFuture
2232
}
2333
return try {
2434
delegate.submit {
@@ -68,3 +78,16 @@ internal class ReplayExecutorService(
6878
}
6979

7080
internal class ReplayRunnable(val taskName: String, delegate: Runnable) : Runnable by delegate
81+
82+
/** A Future that represents an already-completed inline execution — never used as null. */
83+
internal object CompletedFuture : Future<Unit> {
84+
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
85+
86+
override fun isCancelled(): Boolean = false
87+
88+
override fun isDone(): Boolean = true
89+
90+
override fun get() {}
91+
92+
override fun get(timeout: Long, unit: TimeUnit) {}
93+
}

0 commit comments

Comments
 (0)