gallery/docs/BUGS/recreation-stale-textures.md

7.1 KiB
Raw Permalink Blame History

Activity recreation destroys GPU textures; worker-side uploads created stale handles

Status: FIXED 2026-10-28 (render-thread uploads + per-world lifecycle). Verified on emulator (4+ recreation cycles) AND on the Pixel 9 Pro (Mali G715): 3 worlds in one process, 0 panics, full grid every time, scroll + scroll-back clean.

Symptom

Launch → swipe-up dismiss task → relaunch (process survives, activity re-creates). The app either:

  • crashed (pristine Slint, or before any handling): wgpu-core assert on the render thread, Cannot get TextureId(N,M), found other resource TextureId(N,K) in registry::Registry::get called from flush_and_submit → transition_resources (i-slint-renderer-skia), or
  • showed a SPARSE grid (~1/3 of cells painted, stable for minutes) — the variant seen when the transition panic was catch_unwind-ed in the vendored renderer.

HOME + relaunch never reproduced (fresh process). Only the dismiss-while-alive + relaunch path.

Architecture that produced it (pre-fix)

  • Each android_main invocation (one per activity instance) creates a fresh Slint world: i-slint-core's context (GLOBAL_CONTEXT) is thread-local, so the second android_main (fresh thread) gets a fresh platform, fresh renderer, and a fresh wgpu device. The new world is the visible one; the old world becomes a zombie whose event loop parks (Slint 1.18's android backend never delivers TerminateWindow; the loop-breaking event is MainEvent::Destroy, which the old app never receives).
  • The thumbnail engine and its upload slot (gputex) were process-global: decode workers created wgpu textures on the captured device and sent the wgpu::Texture handles over the result channel; the UI tick drained and painted them into cell models.
  • A wgpu texture is a resource of its device. When a world's renderer dies the device dies, its textures are destroyed, and the registry's id allocator reuses the freed ids. Any slint::Image still wrapping a destroyed texture is a stale handle: sampling/transitioning it hits the left == right assert in wgpu-core's storage.

Two concrete stale-handle paths:

  1. Cross-device upload: between the new world's engine swap and its first RenderingSetup capture, in-flight uploads landed on the OLD (dying) device; the NEW world painted those textures and its renderer (new device) hit the assert.
  2. Cross-world drain: the old world's (zombie) tick drained the SHARED engine's results — now uploaded to the NEW device — and painted them into its own model; the OLD renderer hit the assert.

Why the catch_unwind "fix" made it worse

Wrapping transition_resources in catch_unwind (a band-aid added mid-hunt) stopped the crash but kept the poisoned world ALIVE: it re-panicked every frame while holding the stale handles, and the shared wgpu registry stayed corrupted, so texture imports in the VISIBLE world silently failed — the sparse grid. (A/B: pristine build → 1 panic per recreation + FULL grid, because the panic unwound and KILLED the zombie world's thread, freeing the stale handles. Patched build → 0 panics + sparse grid. The panic was the cleanup.)

The fix (what is in the tree)

  1. Uploads moved to the render thread (src/gputex.rs rewrite). Workers deliver plain RGBA8 bytes; the UI thread's drain tick (between frames, not in the render pass) calls gputex::upload_rgba(bytes, w, h) -> Option<slint::Image> which creates the texture + write_texture on the device captured from Window::set_rendering_notifier (RenderingSetup) — i.e. the context Slint hands us, per its borrowed-resource contract. A texture is now always created on the device of the world that renders it: staleness is impossible by construction. No worker ever touches a wgpu device. (Upload cost on the UI thread: ~2 cells/tick × 400×400 RGBA8 write_texture — well inside the 8.3 ms frame budget; the jank fix, §8.9, already established uploads were never the jank — the CPU-bound final flush was.)
  2. Per-world lifecycle (src/lib.rs):
    • WORLD_ID / CURRENT_WORLD: android_main stores its world id; a world's drain tick no-ops once CURRENT_WORLD moves on, so a zombie never drains shared loader state (kills path 2 and its CPU cost).
    • Re-init branch of android_main (STATE already set): gputex::invalidate() (drop the reference to the dying device; uploads fall back to CPU pixels for the few ms until the new world's first RenderingSetup capture) + replace the thumbnail engine (interrupts the old workers, drops their pending results).
    • Render notifier: RenderingTeardown → invalidate() + reset_grid_in_place (the window's device is being destroyed; its painted cells wrap dead textures); RenderingSetup → capture(), and if the device was REPLACED → reset_grid_in_place.
    • reset_grid_in_place clears the painted cells through the EVICTION path (nested set_row_data with an empty image) — never VecModel::set_vec: a mid-render model reset desyncs Slint 1.18's for-loop (row items keep stale nested models; in-place paints never reach their items) — a second contributor to the sparse grid found during the hunt.
  3. Vendor patch surface reduced to ONE hunk: the catch_unwind guard in wgpu_30_surface.rs is GONE (reverted to pristine); vendor/i-slint-renderer-skia now contains only the final-flush removal (docs/BUGS/slint-skia-final-flush-jank.md). With (1)+(2) no stale texture can reach the renderer, and if one ever did, we want the panic (hooked, backtraced to files/panics/) — not a silently corrupted grid.
  4. Removed: gputex::Keepalive (default-off anyway; its buffers/queue racing is now structurally impossible), STALE flag, capture_count.

Verification (emulator-5554, x86_64, SwiftShader Vulkan)

  • am start --activity-clear-top cycles (the 3-button-nav equivalent of dismiss+relaunch; same same-PID/new-activity path): 4+ cycles total across runs — 0 panics, 0 caught transitions, full grid every time, ticks healthy (p=33/33 viewport painted), scroll working.
  • Control (pristine upstream renderer, same app code): 1 panic per recreation (the zombie thread killing itself) + full grid — confirms the stale-handle mechanism and that the grid itself was never the problem.
  • Host: 15/15 unit tests.

Open / follow-ups

  • Zombie worlds persist (Slint 1.18 android backend + android-activity 0.6.1 never deliver Destroy to the old app). They are now inert (tick gated, no shared state) but their threads + context + device stay resident until process death. If this ever matters (memory on long-lived processes), options: file upstream (Slint platform-reset on android is explicitly anticipated — see EVENTLOOP_PROXY comment in i-slint-core 1.18.1 platform.rs), or std::process::exit(0)-style process recycle at re-init (changes relaunch UX to a cold start).
  • Slint ≥ post-1.18 has ScaleRotateGestureHandler and (per source checkouts) multi-activity awareness — worth revisiting at the version bump.