fix(replay): Prevent concurrent PixelCopy frame access#5808
Conversation
Keep PixelCopy, masking, compositing, and cleanup from accessing the shared bitmap concurrently. Fixes GH-5340 Co-Authored-By: Codex <noreply@openai.com>
📲 Install BuildsAndroid
|
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| eb95ded | 317.51 ms | 369.08 ms | 51.57 ms |
| 2124a46 | 319.19 ms | 415.04 ms | 95.85 ms |
| c3ee041 | 310.64 ms | 361.90 ms | 51.26 ms |
| dcc6bbf | 382.58 ms | 462.13 ms | 79.54 ms |
| 7a19fee | 315.46 ms | 368.62 ms | 53.16 ms |
| 9054d65 | 330.94 ms | 403.24 ms | 72.30 ms |
| d500866 | 326.13 ms | 378.70 ms | 52.58 ms |
| ad8da22 | 365.86 ms | 427.00 ms | 61.14 ms |
| ed33deb | 337.52 ms | 484.06 ms | 146.54 ms |
| d15471f | 310.26 ms | 377.04 ms | 66.78 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| eb95ded | 0 B | 0 B | 0 B |
| 2124a46 | 1.58 MiB | 2.12 MiB | 551.51 KiB |
| c3ee041 | 0 B | 0 B | 0 B |
| dcc6bbf | 1.58 MiB | 2.12 MiB | 553.10 KiB |
| 7a19fee | 0 B | 0 B | 0 B |
| 9054d65 | 1.58 MiB | 2.29 MiB | 723.38 KiB |
| d500866 | 0 B | 0 B | 0 B |
| ad8da22 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| ed33deb | 1.58 MiB | 2.13 MiB | 559.52 KiB |
| d15471f | 1.58 MiB | 2.13 MiB | 559.54 KiB |
Previous results on branch: rz/fix/pixelcopy-frame-race
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1976346 | 313.33 ms | 371.69 ms | 58.35 ms |
| 8dd71e1 | 473.33 ms | 530.16 ms | 56.83 ms |
| fab3793 | 330.67 ms | 352.10 ms | 21.43 ms |
| fcda53a | 318.75 ms | 365.57 ms | 46.82 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 1976346 | 0 B | 0 B | 0 B |
| 8dd71e1 | 0 B | 0 B | 0 B |
| fab3793 | 0 B | 0 B | 0 B |
| fcda53a | 0 B | 0 B | 0 B |
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>
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.
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.
| resetUnstableCaptures = !changedDuringCapture, | ||
| ) | ||
| } | ||
| } catch (e: Throwable) { |
There was a problem hiding this comment.
My understanding is that we are creating this PR because we discovered some crashes.
How will we know that these changes are working if we are catching all the Throwables?
| } | ||
| } | ||
| ) | ||
| if (submitted == null) { |
There was a problem hiding this comment.
Same here, it can also be null when immediately run.
There was a problem hiding this comment.
good catch, I refactored the executor service to distinguish between rejection and inline execution in 241129a, thanks!
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.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 68f4e8b. Configure here.
| // (e.g. Redmi's TextView NPE). Release the frame gate so a single bad frame | ||
| // doesn't wedge the recorder. Errors (OOM, LinkageError) intentionally propagate. | ||
| options.logger.log(WARNING, "Failed to process replay frame", e) | ||
| finishFrame() |
There was a problem hiding this comment.
Premature gate release on SurfaceViews
Medium Severity
The new try/catch is meant to call finishFrame only before async handoff, but captureSurfaceViews sits inside that block. holder.surface access is also outside the per-item try. If that throws after some SurfaceView PixelCopy requests already started, finishFrame runs early while those callbacks never decrement remaining to zero, so compositing never runs, SurfaceView bitmaps can leak, and the frame gate opens while that work is still outstanding.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 68f4e8b. Configure here.


📜 Description
Prevent concurrent access to the bitmap shared by PixelCopy and replay processing.
The strategy now drops a capture while another frame is in flight, keeps the gate held through
masking and SurfaceView compositing, suppresses last-frame emission during that work, and defers
bitmap cleanup until the outstanding PixelCopy callback completes.
💡 Motivation and Context
PixelCopy.request()returns before RenderThread finishes writing its destination. The replayexecutor could therefore draw masks or SurfaceView content into the same bitmap, while
close()could recycle it before the asynchronous request completed. This could produce torn or improperly
masked frames and may contribute to native
libhwuicrashes.Dropping an overlapping frame avoids blocking the main thread or allocating a second recording
bitmap. Replay time remains continuous; the previous frame is displayed longer when a capture is
dropped. This should be rare at the default 1 FPS, while sustained overload at higher frame rates
may reduce visual smoothness rather than emit a corrupt or improperly masked frame.
Fixes #5340
Refs #5340
💚 How did you test it?
./gradlew ':sentry-android-replay:testDebugUnitTest' --tests='*PixelCopyStrategyTest*' --info./gradlew spotlessApply apiDumpAdded regression coverage for requests overlapping PixelCopy, queued masking, last-frame emission,
and cleanup during an outstanding request.
📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
RGB_565when SurfaceView capture is disabled in a separate change.