Skip to content

Fix mutable images losing pixels across app suspension on Metal (#5153)#5157

Merged
shai-almog merged 1 commit into
masterfrom
fix/5153-mutable-image-suspend-backup
Jun 3, 2026
Merged

Fix mutable images losing pixels across app suspension on Metal (#5153)#5157
shai-almog merged 1 commit into
masterfrom
fix/5153-mutable-image-suspend-backup

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

Problem

Issue #5153: with Metal active, the FloatingActionButton is sometimes drawn with a violet/garbage background after the app returns to the foreground following a long pause.

The FAB is just the visible symptom. The real bug is broader: on the iOS Metal port every mutable image is backed by an MTLStorageModePrivate texture (CN1MetalEnsureMutableTexture, CN1Metalcompat.m). The system is free to discard the contents of those private textures while the app is suspended. Any mutable image that survives the suspension and is then re-displayed without being redrawn (a cached RoundBorder/RoundRectBorder shadow, an offscreen buffer, a pre-rendered sprite, …) samples uninitialized GPU memory on resume — which renders as violet/garbage. It's intermittent because it depends on the system actually reclaiming that texture memory, which only happens under memory pressure during a long suspension.

The GL path never had this because GL textures (and CPU-backed images) survive suspension — this is specific to Metal private-storage textures.

Fix

Preserve mutable-image content across the suspend/resume cycle at the source:

  1. Weak registry of every GLUIImage that currently owns a mutable texture (CN1Metalcompat.m). Registered when a mutable texture is assigned (GLUIImage.setMtlMutableTexture:), auto-dropped via weak refs / dealloc.
  2. Backup on suspendCN1MetalBackupMutableImagesForSuspend() runs from cn1ApplicationWillResignActive (covers both the UIScene and legacy app-delegate paths). For each live mutable image it reads the current GPU pixels back into the image's CPU-side UIImage backing and drops the volatile texture. This runs while the app is still active, so GPU use is legal (unlike didEnterBackground, where off-screen GPU work risks termination).
  3. Lazy restore on resume — the texture is rebuilt transparently from the UIImage backing the next time the image is painted or sampled. CN1MetalEnsureMutableTexture and GLUIImage.getMTLTexture already re-seed from getImage, so no eager resume work is needed and content is byte-for-byte preserved.

Tests

MutableImageReadbackTest (portable AbstractTest) draws a known pattern into a mutable image and reads it back, asserting pixel fidelity — including accumulation across a second draw. This pins the draw → read-back contract the restore depends on. On JavaSE it covers the generic mutable-image path; on an iOS device build it exercises CN1MetalReadMutableImagePixels, the exact read-back used by the suspend backup.

Verification

  • Java core + iOS port modules compile.
  • The new native ObjC was syntax-checked in isolation under MRR (these files build with CN1_USE_ARC undefined). The native sources are only fully compiled during an actual iOS app build, so on-device verification of the full suspend/resume round trip is still recommended before release.

Notes / trade-offs

  • The backup reads each mutable image back synchronously on the main thread during willResignActive. For a handful of mutable images this is negligible; an app holding many/large mutable images would pay a larger one-time cost per resign. A future optimization could batch the read-backs into fewer command buffers.
  • willResignActive also fires for transient interruptions (Control Center, notifications). In that case the textures are backed up and lazily rebuilt without harm — correct, just slightly wasteful. willResignActive was chosen over didEnterBackground specifically to keep all GPU work in the active state.

🤖 Generated with Claude Code

…#5153)

On the iOS Metal port every mutable image is backed by an
MTLStorageModePrivate texture. The system can discard the contents of
those textures while the app is suspended in the background, so any
mutable image that survives the suspension and is re-displayed without
being redrawn samples uninitialized GPU memory on resume. The reported
symptom is the FloatingActionButton's cached RoundBorder shadow showing
a violet/garbage background after returning from a long pause, but the
problem affects every mutable image the app holds, not just the FAB.

Fix it at the source: keep a weak registry of every GLUIImage that owns
a mutable texture and, on applicationWillResignActive (while the app is
still active and GPU use is legal), read each one back into its CPU-side
UIImage backing and drop the volatile texture. The texture is rebuilt
transparently from that backing the next time the image is painted or
sampled - CN1MetalEnsureMutableTexture and GLUIImage.getMTLTexture both
re-seed from getImage - so the pixels survive the round trip.

- CN1Metalcompat: weak mutable-image registry + CN1MetalBackupMutableImagesForSuspend
- GLUIImage: register on mutable-texture assignment, unregister on dealloc
- CodenameOne_GLAppDelegate: invoke the backup from cn1ApplicationWillResignActive
  (covers both the UIScene and legacy app-delegate paths)
- tests: mutable-image draw -> read-back round-trip fidelity (the read-back
  the restore relies on)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented Jun 3, 2026

Compared 122 screenshots: 122 matched.

Native Android coverage

  • 📊 Line coverage: 12.92% (7704/59625 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 10.48% (38243/364914), branch 4.51% (1552/34402), complexity 5.51% (1816/32974), method 9.64% (1486/15413), class 15.75% (340/2159)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 12.92% (7704/59625 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 10.48% (38243/364914), branch 4.51% (1552/34402), complexity 5.51% (1816/32974), method 9.64% (1486/15413), class 15.75% (340/2159)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 884.000 ms
Base64 CN1 encode 242.000 ms
Base64 encode ratio (CN1/native) 0.274x (72.6% faster)
Base64 native decode 1054.000 ms
Base64 CN1 decode 565.000 ms
Base64 decode ratio (CN1/native) 0.536x (46.4% faster)
Image encode benchmark status skipped (SIMD unsupported)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 3, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented Jun 3, 2026

Compared 122 screenshots: 122 matched.
✅ Native Mac screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 102 seconds

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 594.000 ms
Base64 CN1 encode 1177.000 ms
Base64 encode ratio (CN1/native) 1.981x (98.1% slower)
Base64 native decode 325.000 ms
Base64 CN1 decode 876.000 ms
Base64 decode ratio (CN1/native) 2.695x (169.5% slower)
Base64 SIMD encode 382.000 ms
Base64 encode ratio (SIMD/native) 0.643x (35.7% faster)
Base64 encode ratio (SIMD/CN1) 0.325x (67.5% faster)
Base64 SIMD decode 372.000 ms
Base64 decode ratio (SIMD/native) 1.145x (14.5% slower)
Base64 decode ratio (SIMD/CN1) 0.425x (57.5% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 62.000 ms
Image createMask (SIMD on) 20.000 ms
Image createMask ratio (SIMD on/off) 0.323x (67.7% faster)
Image applyMask (SIMD off) 177.000 ms
Image applyMask (SIMD on) 103.000 ms
Image applyMask ratio (SIMD on/off) 0.582x (41.8% faster)
Image modifyAlpha (SIMD off) 125.000 ms
Image modifyAlpha (SIMD on) 73.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.584x (41.6% faster)
Image modifyAlpha removeColor (SIMD off) 143.000 ms
Image modifyAlpha removeColor (SIMD on) 82.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.573x (42.7% faster)
Image PNG encode (SIMD off) 964.000 ms
Image PNG encode (SIMD on) 784.000 ms
Image PNG encode ratio (SIMD on/off) 0.813x (18.7% faster)
Image JPEG encode 411.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented Jun 3, 2026

Compared 122 screenshots: 122 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 256 seconds

Build and Run Timing

Metric Duration
Simulator Boot 70000 ms
Simulator Boot (Run) 1000 ms
App Install 17000 ms
App Launch 8000 ms
Test Execution 316000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 765.000 ms
Base64 CN1 encode 1527.000 ms
Base64 encode ratio (CN1/native) 1.996x (99.6% slower)
Base64 native decode 348.000 ms
Base64 CN1 decode 938.000 ms
Base64 decode ratio (CN1/native) 2.695x (169.5% slower)
Base64 SIMD encode 418.000 ms
Base64 encode ratio (SIMD/native) 0.546x (45.4% faster)
Base64 encode ratio (SIMD/CN1) 0.274x (72.6% faster)
Base64 SIMD decode 424.000 ms
Base64 decode ratio (SIMD/native) 1.218x (21.8% slower)
Base64 decode ratio (SIMD/CN1) 0.452x (54.8% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 58.000 ms
Image createMask (SIMD on) 11.000 ms
Image createMask ratio (SIMD on/off) 0.190x (81.0% faster)
Image applyMask (SIMD off) 136.000 ms
Image applyMask (SIMD on) 106.000 ms
Image applyMask ratio (SIMD on/off) 0.779x (22.1% faster)
Image modifyAlpha (SIMD off) 171.000 ms
Image modifyAlpha (SIMD on) 60.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.351x (64.9% faster)
Image modifyAlpha removeColor (SIMD off) 336.000 ms
Image modifyAlpha removeColor (SIMD on) 141.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.420x (58.0% faster)
Image PNG encode (SIMD off) 1031.000 ms
Image PNG encode (SIMD on) 800.000 ms
Image PNG encode ratio (SIMD on/off) 0.776x (22.4% faster)
Image JPEG encode 692.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented Jun 3, 2026

Compared 122 screenshots: 122 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 211 seconds

Build and Run Timing

Metric Duration
Simulator Boot 60000 ms
Simulator Boot (Run) 1000 ms
App Install 10000 ms
App Launch 3000 ms
Test Execution 233000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 576.000 ms
Base64 CN1 encode 1529.000 ms
Base64 encode ratio (CN1/native) 2.655x (165.5% slower)
Base64 native decode 405.000 ms
Base64 CN1 decode 1008.000 ms
Base64 decode ratio (CN1/native) 2.489x (148.9% slower)
Base64 SIMD encode 595.000 ms
Base64 encode ratio (SIMD/native) 1.033x (3.3% slower)
Base64 encode ratio (SIMD/CN1) 0.389x (61.1% faster)
Base64 SIMD decode 386.000 ms
Base64 decode ratio (SIMD/native) 0.953x (4.7% faster)
Base64 decode ratio (SIMD/CN1) 0.383x (61.7% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 74.000 ms
Image createMask (SIMD on) 11.000 ms
Image createMask ratio (SIMD on/off) 0.149x (85.1% faster)
Image applyMask (SIMD off) 214.000 ms
Image applyMask (SIMD on) 88.000 ms
Image applyMask ratio (SIMD on/off) 0.411x (58.9% faster)
Image modifyAlpha (SIMD off) 375.000 ms
Image modifyAlpha (SIMD on) 96.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.256x (74.4% faster)
Image modifyAlpha removeColor (SIMD off) 213.000 ms
Image modifyAlpha removeColor (SIMD on) 124.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.582x (41.8% faster)
Image PNG encode (SIMD off) 1304.000 ms
Image PNG encode (SIMD on) 864.000 ms
Image PNG encode ratio (SIMD on/off) 0.663x (33.7% faster)
Image JPEG encode 619.000 ms

@shai-almog shai-almog merged commit 538d999 into master Jun 3, 2026
24 checks passed
@shai-almog shai-almog deleted the fix/5153-mutable-image-suspend-backup branch June 3, 2026 03:13
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.

Metal issue: on iPhone 15 the FloatingActionButton is sometimes shown with violet background

1 participant