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

135 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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