fix(replay): Prevent concurrent PixelCopy frame access#5808
Merged
Conversation
📲 Install BuildsAndroid
|
Contributor
Performance metrics 🚀
|
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 22f4345 | 307.87 ms | 354.51 ms | 46.64 ms |
| 8c7718c | 307.42 ms | 374.84 ms | 67.42 ms |
| bb0ff41 | 321.00 ms | 378.28 ms | 57.28 ms |
| c8125f3 | 397.65 ms | 485.14 ms | 87.49 ms |
| eb95ded | 317.51 ms | 369.08 ms | 51.57 ms |
| f634d01 | 375.06 ms | 420.04 ms | 44.98 ms |
| 9fbb112 | 359.71 ms | 421.85 ms | 62.14 ms |
| d501a7e | 307.33 ms | 341.94 ms | 34.61 ms |
| bb0ff41 | 317.76 ms | 384.66 ms | 66.90 ms |
| 9054d65 | 330.94 ms | 403.24 ms | 72.30 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 22f4345 | 1.58 MiB | 2.29 MiB | 719.83 KiB |
| 8c7718c | 0 B | 0 B | 0 B |
| bb0ff41 | 0 B | 0 B | 0 B |
| c8125f3 | 1.58 MiB | 2.10 MiB | 532.32 KiB |
| eb95ded | 0 B | 0 B | 0 B |
| f634d01 | 1.58 MiB | 2.10 MiB | 533.40 KiB |
| 9fbb112 | 1.58 MiB | 2.11 MiB | 539.18 KiB |
| d501a7e | 0 B | 0 B | 0 B |
| bb0ff41 | 0 B | 0 B | 0 B |
| 9054d65 | 1.58 MiB | 2.29 MiB | 723.38 KiB |
Previous results on branch: rz/fix/pixelcopy-frame-race
Startup times
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 8dd71e1 | 473.33 ms | 530.16 ms | 56.83 ms |
| fcda53a | 318.75 ms | 365.57 ms | 46.82 ms |
| 6d9b47e | 325.95 ms | 360.48 ms | 34.53 ms |
| 9c58070 | 317.66 ms | 408.00 ms | 90.34 ms |
| 1969846 | 317.74 ms | 376.00 ms | 58.26 ms |
| 1976346 | 313.33 ms | 371.69 ms | 58.35 ms |
| fab3793 | 330.67 ms | 352.10 ms | 21.43 ms |
| d2a3d5d | 388.86 ms | 456.52 ms | 67.66 ms |
| 13ff16d | 318.73 ms | 374.79 ms | 56.06 ms |
| 381c693 | 319.27 ms | 379.62 ms | 60.36 ms |
App size
| Revision | Plain | With Sentry | Diff |
|---|---|---|---|
| 8dd71e1 | 0 B | 0 B | 0 B |
| fcda53a | 0 B | 0 B | 0 B |
| 6d9b47e | 0 B | 0 B | 0 B |
| 9c58070 | 0 B | 0 B | 0 B |
| 1969846 | 0 B | 0 B | 0 B |
| 1976346 | 0 B | 0 B | 0 B |
| fab3793 | 0 B | 0 B | 0 B |
| d2a3d5d | 0 B | 0 B | 0 B |
| 13ff16d | 0 B | 0 B | 0 B |
| 381c693 | 0 B | 0 B | 0 B |
romtsn
marked this pull request as ready for review
July 22, 2026 15:17
romtsn
requested review from
0xadam-brown,
adinauer,
markushi and
runningcode
as code owners
July 22, 2026 15:17
romtsn
added a commit
that referenced
this pull request
Jul 22, 2026
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.
8 tasks
runningcode
reviewed
Jul 22, 2026
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f771b64. Configure here.
Keep PixelCopy, masking, compositing, and cleanup from accessing the shared bitmap concurrently. Fixes GH-5340 Co-Authored-By: Codex <noreply@openai.com>
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.
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.
…ose 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>
…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>
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>
…layExecutorService 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>
romtsn
force-pushed
the
rz/fix/pixelcopy-frame-race
branch
from
July 24, 2026 10:56
447c665 to
fb275d7
Compare
romtsn
enabled auto-merge (squash)
July 24, 2026 11:08
romtsn
disabled auto-merge
July 24, 2026 11:26
…current 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>
romtsn
force-pushed
the
rz/fix/pixelcopy-frame-race
branch
from
July 24, 2026 15:16
fb275d7 to
63eb6f1
Compare
romtsn
enabled auto-merge (squash)
July 24, 2026 15:32
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

📜 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.