# 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` 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.