Skip to content

🐛 serialize Skia codec/bitmap close with in-flight readPixels#4438

Merged
guiyanakuang merged 2 commits into
mainfrom
fix/skia-animated-image-uaf-race
May 19, 2026
Merged

🐛 serialize Skia codec/bitmap close with in-flight readPixels#4438
guiyanakuang merged 2 commits into
mainfrom
fix/skia-animated-image-uaf-race

Conversation

@guiyanakuang
Copy link
Copy Markdown
Member

Closes #4437

Summary

  • Fold produceCodecState + AnimatedFrames into a single LaunchedEffect(path) inside SkiaAnimatedImageView that owns the entire codec + bitmap lifecycle. Cleanup lives in one try { ... } finally { bitmaps.forEach { it.close() }; codec.close() }, which guarantees finally runs only after any in-flight withContext(ioDispatcher) { readPixels } returns naturally. No cross-composable handoff, no Mutex needed (and Mutex would not work anyway because onDispose cannot suspend).
  • isPlaying is now read via rememberUpdatedState, so toggling play/pause no longer restarts the effect (previously safe only via a frameTick == 0 skip-prime trick; now structurally correct).
  • The LoadingIndicator stays up until frame 0 is primed, eliminating the brief empty-bitmap flash on first frame.
  • Public SkiaAnimatedImageView signature unchanged; preserved helpers (isAnimatedImage, frameDurationMs, MIN_FRAME_DURATION_MS) keep all 13 existing tests passing.

Test plan

  • ./gradlew app:desktopTest --tests "com.crosspaste.ui.paste.side.preview.SkiaAnimatedImageViewTest" — all 13 tests pass
  • ./gradlew :app:compileKotlinDesktop compiles clean
  • Open the side preview on an animated GIF, then rapidly close it / scroll past — no SIGSEGV
  • Toggle play/pause on a GIF — does not restart from frame 0 (would indicate the effect restarted and re-primed)

Decoding frames on ioDispatcher (#4422) made codec.readPixels a
synchronous JNI call that ignores coroutine cancellation, while
DisposableEffect.onDispose closed bitmaps and produceState.awaitDispose
closed the codec — both could free native pixel buffers under an active
decode and crash the process.

Fold codec + bitmap ownership into a single LaunchedEffect whose finally
runs only after the last withContext returns, so close happens after
readPixels regardless of when composition leaves.
- Nest try/finally so codec.close() still runs if Bitmap.allocPixels
  throws (e.g. OOM) before the inner try is entered.
- Replace the 20ms !playing polling delay with snapshotFlow { playing }
  .first { it } so a hidden preview suspends instead of waking ~50x/s.
@guiyanakuang guiyanakuang merged commit 42f31cd into main May 19, 2026
5 checks passed
@guiyanakuang guiyanakuang deleted the fix/skia-animated-image-uaf-race branch May 19, 2026 08:55
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.

Use-after-free race in SkiaAnimatedImageView when composition leaves mid-decode

1 participant