Skip to content

✅ regression test for SkiaAnimatedImageView close ordering#4444

Merged
guiyanakuang merged 4 commits into
mainfrom
test/issue-4443-skia-animated-image-close-ordering-regression
May 20, 2026
Merged

✅ regression test for SkiaAnimatedImageView close ordering#4444
guiyanakuang merged 4 commits into
mainfrom
test/issue-4443-skia-animated-image-close-ordering-regression

Conversation

@guiyanakuang
Copy link
Copy Markdown
Member

Closes #4443

Note

Stacked on #4438 (bb9a381 + 85a49bd). Diff is only the test seam + regression tests; the base will retarget to main once #4438 lands.

Summary

  • Extract internal FrameDecoder / FrameBuffer interfaces + suspend fun runAnimationLoop(...) so the codec/bitmap lifecycle is testable without a real GIF on disk. Production wraps Codec/Bitmap as SkiaCodecDecoder / SkiaBitmapBuffer — semantics unchanged.
  • Add 3 regression tests asserting the bb9a381 invariant: buffer.close and decoder.close fire only after the in-flight decodeFrame returns, and decoder.close is the final event (proving the nested-finally ordering).
  • The fake decoder reproduces readPixels' uninterruptible JNI semantics via withContext(NonCancellable) around a CompletableDeferred — so the test exercises the same cancellation race the bug came from.
  • Document the invariant in a header comment on runAnimationLoop that names commit bb9a381 and the patterns (sibling DisposableEffect.onDispose, produceState.awaitDispose) that would silently reintroduce the UAF.

Test plan

  • ./gradlew ktlintFormat clean
  • ./gradlew app:desktopTest --tests "com.crosspaste.ui.paste.side.preview.SkiaAnimatedImageViewTest" — 17/17 passing (14 existing + 3 new)
  • Mentally verify a regression would trip the new tests: moving decoder.close() out of runAnimationLoop's outer finally into a sibling effect would cause decoder.close to fire before decode.end(...) on cancellation, failing both close-ordering tests.

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.
Extract a testable seam (FrameDecoder / FrameBuffer + runAnimationLoop)
so the bb9a381 UAF fix has explicit test coverage. A future refactor
that re-splits codec/bitmap ownership across sibling effects would
silently reintroduce the SIGSEGV; these tests catch that.

The fake decoder simulates readPixels' uninterruptible JNI semantics
via withContext(NonCancellable), then asserts buffer.close and
decoder.close fire only after the in-flight decodeFrame returns.

Closes #4443
Base automatically changed from fix/skia-animated-image-uaf-race to main May 19, 2026 08:55
…animated-image-close-ordering-regression

# Conflicts:
#	app/src/desktopMain/kotlin/com/crosspaste/ui/paste/side/preview/SkiaAnimatedImageView.kt
@guiyanakuang guiyanakuang merged commit 497de21 into main May 20, 2026
5 checks passed
@guiyanakuang guiyanakuang deleted the test/issue-4443-skia-animated-image-close-ordering-regression branch May 20, 2026 06:19
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.

Regression test for SkiaAnimatedImageView codec/bitmap close ordering (bb9a3819)

1 participant