135 lines
7.1 KiB
Markdown
135 lines
7.1 KiB
Markdown
# 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.
|