Skip to content

fix(replay): Prevent concurrent PixelCopy frame access#5808

Merged
romtsn merged 12 commits into
mainfrom
rz/fix/pixelcopy-frame-race
Jul 24, 2026
Merged

fix(replay): Prevent concurrent PixelCopy frame access#5808
romtsn merged 12 commits into
mainfrom
rz/fix/pixelcopy-frame-race

Conversation

@romtsn

@romtsn romtsn commented Jul 21, 2026

Copy link
Copy Markdown
Member

📜 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 replay
executor 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 libhwui crashes.

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 apiDump

Added regression coverage for requests overlapping PixelCopy, queued masking, last-frame emission,
and cleanup during an outstanding request.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.

🔮 Next steps

  • Harden capture submission around Window and SurfaceView lifecycle transitions separately.
  • Consider restoring RGB_565 when SurfaceView capture is disabled in a separate change.

@linear-code

linear-code Bot commented Jul 21, 2026

Copy link
Copy Markdown

JAVA-490

@sentry

sentry Bot commented Jul 21, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.50.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 349.59 ms 434.77 ms 85.17 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

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
romtsn marked this pull request as ready for review 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.

@runningcode runningcode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

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

romtsn and others added 6 commits July 24, 2026 12:55
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.
romtsn and others added 5 commits July 24, 2026 12:56
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
romtsn force-pushed the rz/fix/pixelcopy-frame-race branch from 447c665 to fb275d7 Compare July 24, 2026 10:56
@romtsn
romtsn enabled auto-merge (squash) July 24, 2026 11:08
@romtsn
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
romtsn force-pushed the rz/fix/pixelcopy-frame-race branch from fb275d7 to 63eb6f1 Compare July 24, 2026 15:16
@romtsn
romtsn enabled auto-merge (squash) July 24, 2026 15:32
@romtsn
romtsn merged commit 7414e9b into main Jul 24, 2026
67 of 71 checks passed
@romtsn
romtsn deleted the rz/fix/pixelcopy-frame-race branch July 24, 2026 15:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session Replay: race between PixelCopy.request and executor compositing/masking on shared screenshot bitmap

2 participants