7.1 KiB
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)inregistry::Registry::getcalled fromflush_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_maininvocation (one per activity instance) creates a fresh Slint world: i-slint-core's context (GLOBAL_CONTEXT) is thread-local, so the secondandroid_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 deliversTerminateWindow; the loop-breaking event isMainEvent::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 thewgpu::Texturehandles 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::Imagestill wrapping a destroyed texture is a stale handle: sampling/transitioning it hits theleft == rightassert in wgpu-core's storage.
Two concrete stale-handle paths:
- Cross-device upload: between the new world's engine swap and its
first
RenderingSetupcapture, in-flight uploads landed on the OLD (dying) device; the NEW world painted those textures and its renderer (new device) hit the assert. - 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)
- Uploads moved to the render thread (
src/gputex.rsrewrite). Workers deliver plain RGBA8 bytes; the UI thread's drain tick (between frames, not in the render pass) callsgputex::upload_rgba(bytes, w, h) -> Option<slint::Image>which creates the texture +write_textureon the device captured fromWindow::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 RGBA8write_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.) - Per-world lifecycle (
src/lib.rs):WORLD_ID/CURRENT_WORLD:android_mainstores its world id; a world's drain tick no-ops onceCURRENT_WORLDmoves 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 firstRenderingSetupcapture) + 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_placeclears the painted cells through the EVICTION path (nestedset_row_datawith an empty image) — neverVecModel::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.
- Vendor patch surface reduced to ONE hunk: the
catch_unwindguard inwgpu_30_surface.rsis GONE (reverted to pristine);vendor/i-slint-renderer-skianow 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 tofiles/panics/) — not a silently corrupted grid. - Removed:
gputex::Keepalive(default-off anyway; its buffers/queue racing is now structurally impossible),STALEflag,capture_count.
Verification (emulator-5554, x86_64, SwiftShader Vulkan)
am start --activity-clear-topcycles (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/33viewport 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
Destroyto 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 — seeEVENTLOOP_PROXYcomment in i-slint-core 1.18.1platform.rs), orstd::process::exit(0)-style process recycle at re-init (changes relaunch UX to a cold start). - Slint ≥ post-1.18 has
ScaleRotateGestureHandlerand (per source checkouts) multi-activity awareness — worth revisiting at the version bump.