-
-
Notifications
You must be signed in to change notification settings - Fork 473
fix(replay): Prevent concurrent PixelCopy frame access #5808
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
45c33ca
d6e0945
98aeb0c
990d805
b5bf05f
aee9b30
241129a
68f4e8b
08d009a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,7 @@ internal class PixelCopyStrategy( | |
| private val contentChanged = AtomicBoolean(false) | ||
| private val unstableCaptures = AtomicInteger(0) | ||
| private val isClosed = AtomicBoolean(false) | ||
| private val frameInFlight = AtomicBoolean(false) | ||
| private val dstOverPaint by | ||
| lazy(NONE) { Paint().apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) } } | ||
| private val screenshotCanvas by lazy(NONE) { Canvas(screenshot) } | ||
|
|
@@ -77,8 +78,15 @@ internal class PixelCopyStrategy( | |
| return | ||
| } | ||
|
|
||
| if (!frameInFlight.compareAndSet(false, true)) { | ||
| options.logger.log(DEBUG, "PixelCopyStrategy capture is already in flight, skipping") | ||
| markContentChanged() | ||
| return | ||
| } | ||
|
|
||
| if (isClosed.get()) { | ||
| options.logger.log(DEBUG, "PixelCopyStrategy is closed, not capturing screenshot") | ||
| finishFrame() | ||
| return | ||
| } | ||
|
|
||
|
|
@@ -90,51 +98,72 @@ internal class PixelCopyStrategy( | |
| { copyResult: Int -> | ||
| if (isClosed.get()) { | ||
| options.logger.log(DEBUG, "PixelCopyStrategy is closed, ignoring capture result") | ||
| finishFrame() | ||
| return@request | ||
| } | ||
|
|
||
| if (copyResult != PixelCopy.SUCCESS) { | ||
| options.logger.log(INFO, "Failed to capture replay recording: %d", copyResult) | ||
| unstableCaptures.set(0) | ||
| lastCaptureSuccessful.set(false) | ||
| finishFrame() | ||
| return@request | ||
| } | ||
|
|
||
| val changedDuringCapture = contentChanged.get() | ||
| if (changedDuringCapture && shouldSkipUnstableCapture()) { | ||
| finishFrame() | ||
| return@request | ||
| } | ||
|
|
||
| // TODO: disableAllMasking here and dont traverse? | ||
| val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) | ||
| val surfaceViewNodes = | ||
| if (options.sessionReplay.isCaptureSurfaceViews) { | ||
| mutableListOf<ViewHierarchyNode.SurfaceViewHierarchyNode>() | ||
| } else { | ||
| null | ||
| } | ||
| root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) | ||
|
|
||
| if (surfaceViewNodes.isNullOrEmpty()) { | ||
| executor.submit( | ||
| ReplayRunnable("screenshot_recorder.mask") { | ||
| applyMaskingAndNotify( | ||
| root, | ||
| viewHierarchy, | ||
| resetUnstableCaptures = !changedDuringCapture, | ||
| // Release the frame gate if anything below throws before we hand work off to the | ||
| // executor — otherwise a single failure wedges captures forever. | ||
| try { | ||
| // TODO: disableAllMasking here and dont traverse? | ||
| val viewHierarchy = ViewHierarchyNode.fromView(root, null, 0, options.sessionReplay) | ||
| val surfaceViewNodes = | ||
| if (options.sessionReplay.isCaptureSurfaceViews) { | ||
| mutableListOf<ViewHierarchyNode.SurfaceViewHierarchyNode>() | ||
| } else { | ||
| null | ||
| } | ||
| root.traverse(viewHierarchy, options.sessionReplay, options.logger, surfaceViewNodes) | ||
|
|
||
| if (surfaceViewNodes.isNullOrEmpty()) { | ||
| val submitted = | ||
| executor.submit( | ||
| ReplayRunnable("screenshot_recorder.mask") { | ||
| try { | ||
| applyMaskingAndNotify( | ||
| root, | ||
| viewHierarchy, | ||
| resetUnstableCaptures = !changedDuringCapture, | ||
| ) | ||
| } finally { | ||
| finishFrame() | ||
| } | ||
| } | ||
| ) | ||
| if (submitted == null) { | ||
| finishFrame() | ||
| } | ||
| ) | ||
| } else { | ||
| // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger | ||
| // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. | ||
| markContentChanged() | ||
| captureSurfaceViews( | ||
| root, | ||
| surfaceViewNodes, | ||
| viewHierarchy, | ||
| resetUnstableCaptures = !changedDuringCapture, | ||
| ) | ||
| } else { | ||
| // Re-arm the recorder's contentChanged gate; SurfaceView redraws don't trigger | ||
| // ViewTreeObserver.OnDrawListener, so we'd otherwise emit the same frame forever. | ||
| markContentChanged() | ||
| captureSurfaceViews( | ||
| root, | ||
| surfaceViewNodes, | ||
| viewHierarchy, | ||
| resetUnstableCaptures = !changedDuringCapture, | ||
| ) | ||
| } | ||
| } catch (e: RuntimeException) { | ||
| // OEM View subclasses have been observed throwing during hierarchy traversal | ||
| // (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() | ||
| } | ||
| }, | ||
| mainLooperHandler.handler, | ||
|
|
@@ -143,6 +172,7 @@ internal class PixelCopyStrategy( | |
| options.logger.log(WARNING, "Failed to capture replay recording", e) | ||
| unstableCaptures.set(0) | ||
| lastCaptureSuccessful.set(false) | ||
| finishFrame() | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -272,37 +302,46 @@ internal class PixelCopyStrategy( | |
| windowY: Int, | ||
| resetUnstableCaptures: Boolean, | ||
| ) { | ||
| executor.submit( | ||
| ReplayRunnable("screenshot_recorder.composite") { | ||
| if (isClosed.get() || screenshot.isRecycled) { | ||
| options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") | ||
| recycleCaptures(captures) | ||
| return@ReplayRunnable | ||
| } | ||
| val submitted = | ||
| executor.submit( | ||
| ReplayRunnable("screenshot_recorder.composite") { | ||
| try { | ||
| if (isClosed.get() || screenshot.isRecycled) { | ||
| options.logger.log(DEBUG, "PixelCopyStrategy is closed, skipping compositing") | ||
| recycleCaptures(captures) | ||
| return@ReplayRunnable | ||
| } | ||
|
|
||
| for (capture in captures) { | ||
| if (capture == null) continue | ||
| if (capture.bitmap.isRecycled) continue | ||
|
|
||
| compositeSurfaceViewInto( | ||
| screenshotCanvas, | ||
| dstOverPaint, | ||
| tmpSrcRect, | ||
| tmpDstRect, | ||
| capture.bitmap, | ||
| capture.x, | ||
| capture.y, | ||
| windowX, | ||
| windowY, | ||
| config.scaleFactorX, | ||
| config.scaleFactorY, | ||
| ) | ||
| capture.bitmap.recycle() | ||
| } | ||
| for (capture in captures) { | ||
| if (capture == null) continue | ||
| if (capture.bitmap.isRecycled) continue | ||
|
|
||
| compositeSurfaceViewInto( | ||
| screenshotCanvas, | ||
| dstOverPaint, | ||
| tmpSrcRect, | ||
| tmpDstRect, | ||
| capture.bitmap, | ||
| capture.x, | ||
| capture.y, | ||
| windowX, | ||
| windowY, | ||
| config.scaleFactorX, | ||
| config.scaleFactorY, | ||
| ) | ||
| capture.bitmap.recycle() | ||
| } | ||
|
|
||
| applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) | ||
| } | ||
| ) | ||
| applyMaskingAndNotify(root, viewHierarchy, resetUnstableCaptures) | ||
| } finally { | ||
| finishFrame() | ||
| } | ||
| } | ||
| ) | ||
| if (submitted == null) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here, it can also be
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good catch, I refactored the executor service to distinguish between rejection and inline execution in 241129a, thanks! |
||
| recycleCaptures(captures) | ||
| finishFrame() | ||
| } | ||
| } | ||
|
|
||
| private fun recycleCaptures(captures: Array<SurfaceViewCapture?>) { | ||
|
|
@@ -322,15 +361,28 @@ internal class PixelCopyStrategy( | |
| } | ||
|
|
||
| override fun emitLastScreenshot() { | ||
| if (lastCaptureSuccessful() && !screenshot.isRecycled) { | ||
| if (!frameInFlight.get() && lastCaptureSuccessful() && !screenshot.isRecycled) { | ||
| screenshotRecorderCallback?.onScreenshotRecorded(screenshot) | ||
| } | ||
| } | ||
|
|
||
| override fun close() { | ||
| isClosed.set(true) | ||
| unstableCaptures.set(0) | ||
| executor.submit( | ||
| if (!frameInFlight.get()) { | ||
| scheduleCleanup() | ||
| } | ||
| } | ||
|
|
||
| private fun finishFrame() { | ||
| frameInFlight.set(false) | ||
| if (isClosed.get()) { | ||
| scheduleCleanup() | ||
| } | ||
| } | ||
|
sentry[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cleanup races with new captureHigh Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit 08d009a. Configure here. |
||
|
|
||
| private fun scheduleCleanup() { | ||
| val cleanup = | ||
| ReplayRunnable( | ||
| "PixelCopyStrategy.close", | ||
| { | ||
|
|
@@ -344,7 +396,12 @@ internal class PixelCopyStrategy( | |
| maskRenderer.close() | ||
| }, | ||
| ) | ||
| ) | ||
| // ReplayExecutorService.submit returns null only on genuine rejection (post-shutdown); | ||
| // inline execution on the worker thread returns a completed future. Fall back to running | ||
| // cleanup here so the bitmap + mask renderer are freed even when the executor is dead. | ||
| if (executor.submit(cleanup) == null) { | ||
| cleanup.run() | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.