Fix activity-recreation crash: render-thread GPU uploads + per-world lifecycle

The swipe-up-dismiss + relaunch bug (process survives, activity
re-creates) crashed or left a sparse grid. Root cause chain, fully
documented in docs/BUGS/recreation-stale-textures.md:

- Each android_main (per activity instance) is a fresh Slint world with
  a fresh wgpu device (i-slint-core context is thread-local); the old
  world zombie-parks (Slint 1.18 android backend never delivers
  TerminateWindow/Destroy to it).
- Worker-side GPU uploads + a process-global loader let textures cross
  world/device boundaries: a texture destroyed with its device left a
  stale handle in the registry (wgpu-core assert in transition_resources
  on the render thread) — either killing the process, or, with the
  interim catch_unwind band-aid, keeping a poisoned world alive and
  corrupting the VISIBLE world's imports (the sparse grid). A/B against
  pristine Slint confirmed the mechanism (pristine: 1 self-killing panic
  per recreation + full grid).

Fix:
- gputex rewrite: workers deliver plain RGBA8; the UI drain tick
  uploads to the device captured from the rendering notifier
  (Slint's borrowed-resource contract). Staleness impossible by
  construction. Keepalive thread removed.
- Per-world lifecycle: CURRENT_WORLD gate silences zombie ticks;
  re-init invalidates the dying device + swaps the engine; render
  notifier handles RenderingTeardown/RenderingSetup (in-place cell
  clear — never set_vec mid-render, which desyncs the for-loop).
- Vendor patch surface reduced to the single final-flush hunk
  (catch_unwind guard reverted to pristine).
This commit is contained in:
Greg Pomerantz 2026-10-28 20:54:04 -04:00
parent 93b27d724b
commit 84c6a4170d
6 changed files with 468 additions and 208 deletions

View File

@ -610,18 +610,30 @@ texture = 1.3 visible hitches/s at 120 Hz. Grey-image A/B: 20 hitches/s →
1. Not a frame-rate, input, or layout issue; Fossify (RenderThread
pipelining) doesn't have this cost.
Fix (no Slint patch, no re-architecture): the **decode worker creates the
texture on Slint's own wgpu device** (captured once from
`GraphicsAPI::WGPU30` at `RenderingSetup`, `src/gputex.rs`) and submits
the upload via `queue.write_texture` (async, no fence wait). UI thread
wraps it in `Image::try_from(texture)` (needs `Rgba8Unorm` +
`TEXTURE_BINDING|RENDER_ATTACHMENT`; `unstable-wgpu-30` slint feature;
Fix (no Slint patch, no re-architecture): the thumbnail is uploaded to
Slint's own wgpu device (captured from `GraphicsAPI::WGPU30` at
`RenderingSetup`, `src/gputex.rs`) via `queue.write_texture` (async, no
fence wait) BEFORE the presenting frame, so first present costs ~0.
`Image::try_from(texture)` needs `Rgba8Unorm` +
`TEXTURE_BINDING|RENDER_ATTACHMENT` (`unstable-wgpu-30` slint feature;
wgpu/Vulkan `bytes_per_row` 256-multiple padding). Fallback to
`from_rgba8` CPU path when the device isn't captured yet.
Result (Pixel 9 Pro, 120 Hz, 400 px): **zero mid-scroll renders > 4.4 ms**
(median 1.9 ms, p95 2.9 ms); only post-idle first-frames are expensive
(invisible — screen static).
**Revised 2026-10-28 (recreation bug, see
docs/BUGS/recreation-stale-textures.md):** the upload now runs **on the
UI thread** (drain tick, between frames) instead of on the decode
workers. Workers deliver plain RGBA8 bytes; `gputex::upload_rgba` creates
the texture + `write_texture` on the device captured from the rendering
notifier — Slint's borrowed-resource contract (use the context the
notifier hands you). A long-lived worker holding a device was the source
of stale-texture panics across activity recreation; with render-thread
uploads a texture is always created on the device of the world that
renders it. The keep-alive thread (default-off anyway) was REMOVED with
the worker-side upload.
Perf file: `/sdcard/Download/.gallery_perf.log` (SCR/TICK/APLY/FR).
Clear it by restarting the app (writer holds the fd).
@ -710,8 +722,9 @@ backends) — the bug doc is written so it can be filed later.
(FR/SCR/TICK/APLYD/STG logging, `stg.rs`, `.gallery_*` knobs, jsamp
thread sampler, perf-file mirroring in nblog, DbgScroll). KEPT:
nblog non-blocking logger (logd backpressure is real), zero-dimension
decode guards (thumb.rs + gputex.rs), keep-alive (still default-off,
`.gallery_keep`), debug-level coverage logs.
decode guards (thumb.rs + gputex.rs), debug-level coverage logs.
(keep-alive REMOVED later the same day with the worker-side GPU upload —
see docs/BUGS/recreation-stale-textures.md.)
**BUG FIXED while cleaning:** the app launched into an unstoppable
fast scroll on the user's phone — a leftover `.gallery_cmd`

View File

@ -0,0 +1,137 @@
# Activity recreation destroys GPU textures; worker-side uploads created stale handles
**Status:** FIXED 2026-10-28 (render-thread uploads + per-world lifecycle,
emulator-verified: 3+ dismiss/relaunch cycles, 0 panics, full grid).
Phone re-verification pending.
## 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
- **Phone re-verification** (Mali G715): the UI-thread upload cost and
the recreation path on real hardware. Expectation: upload cost ≈ the
same `write_texture` work that used to happen on workers (now ~2/tick
on the UI thread, between frames).
- **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.

View File

@ -1,4 +1,4 @@
//! GPU-resident thumbnail textures.
//! GPU-resident thumbnail textures — uploads run ON THE RENDER THREAD.
//!
//! Slint's `Image::from_rgba8` uploads the pixels to the GPU *inside the
//! render frame that first presents them* (its Skia backend additionally
@ -6,58 +6,101 @@
//! ~11 ms per 400 px first-present = the scroll hitch, even though the
//! median render is ~2 ms.
//!
//! Instead: the DECODE WORKER creates the texture on Slint's own wgpu
//! device and submits the upload to the queue — `write_texture` is async
//! (staging-buffer memcpy + GPU blit, no fence wait, no UI involvement).
//! The UI thread only wraps the already-resident texture in
//! `Image::try_from(texture)`; the first-present frame pays ~0.
//! Instead, the UI thread (during its 8 ms drain tick, i.e. between frames
//! — not inside the render pass) creates the texture on Slint's own wgpu
//! device and submits `write_texture` (async: staging-buffer memcpy + GPU
//! blit, no fence wait). The render frame that first presents the cell
//! pays ~0.
//!
//! The device and queue are Slint's own, captured once from
//! **Why the UI thread and not the decode workers** (the design that was
//! here until 2026-10-28): a long-lived worker holding a wgpu device/queue
//! is a context that goes obsolete. On activity recreation the renderer —
//! and therefore the device — is replaced, and worker uploads to the dead
//! device (or textures painted into a world whose renderer is gone) panic
//! wgpu-core's registry. Slint's own contract for borrowed GPU resources
//! is "use the context the rendering-notifier callbacks make available".
//! With uploads on the render thread, a texture is always created on the
//! device of the world that renders it — staleness is impossible by
//! construction. The decode (the expensive part) stays on the workers;
//! they deliver plain RGBA8 bytes.
//!
//! The device and queue are Slint's own, captured from
//! `Window::set_rendering_notifier` (`GraphicsAPI::WGPU30`) at
//! `RenderingSetup` — so the texture is guaranteed to be sampleable by
//! Slint's Skia renderer (`import_wgpu_texture`), with no cross-context
//! import needed.
//! `RenderingSetup` (again after every window/surface recreation) and
//! invalidated at `RenderingTeardown` / activity re-creation.
//!
//! Fallback: until `RenderingSetup` fires (or on a non-wgpu backend)
//! Fallback: until the first `RenderingSetup` (or on a non-wgpu backend)
//! `upload_rgba` returns `None` and the caller delivers the RGBA8 bytes
//! the old way (`Image::from_rgba8`).
use std::sync::{Arc, OnceLock, atomic::AtomicBool};
use std::sync::Mutex;
/// The wgpu 30.x crate re-exported by Slint (same version it renders with).
pub use slint::wgpu_30::wgpu;
static GPU: OnceLock<(wgpu::Device, wgpu::Queue)> = OnceLock::new();
struct Ctx {
device: wgpu::Device,
queue: wgpu::Queue,
}
static GPU: Mutex<Option<Ctx>> = Mutex::new(None);
/// Capture Slint's wgpu device + queue. Called from the rendering notifier
/// (UI thread, once, at `RenderingSetup`).
pub fn init(api: &slint::GraphicsAPI) {
/// (UI thread) at every `RenderingSetup` — i.e. for EVERY renderer, which
/// happens again after a window/surface recreation.
///
/// Returns `true` if an existing device was REPLACED: the caller must
/// invalidate every GPU-backed image in its model (they wrap textures of
/// the destroyed device). The old `Ctx` is dropped, releasing our
/// references to the dead device.
pub fn capture(api: &slint::GraphicsAPI) -> bool {
if let slint::GraphicsAPI::WGPU30 { device, queue, .. } = api {
let _ = GPU.set((device.clone(), queue.clone()));
let mut g = GPU.lock().unwrap();
let replaced = g.is_some();
*g = Some(Ctx { device: device.clone(), queue: queue.clone() });
replaced
} else {
false
}
}
/// The renderer is going away (RenderingTeardown) or the activity was
/// re-created before the new window's first frame: the captured device is
/// dead or about to die. Drop the reference so the next `upload_rgba`
/// falls back to CPU pixels until the new `RenderingSetup` captures.
pub fn invalidate() {
*GPU.lock().unwrap() = None;
}
/// True once Slint's wgpu device has been captured.
pub fn ready() -> bool {
GPU.get().is_some()
GPU.lock().unwrap().is_some()
}
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes and
/// submit the (async) upload. **Runs on the decode worker thread.**
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes,
/// submit the (async) upload and wrap it in a ready-to-present
/// `slint::Image`. **Runs on the UI thread (render thread)** — see the
/// module docs for why this is deliberate.
///
/// Returns `None` until the device is captured (see fallback).
pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option<wgpu::Texture> {
pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option<slint::Image> {
use wgpu::{
Extent3d, Origin3d, TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect,
TextureDescriptor, TextureDimension, TextureUsages,
};
// Defensive: a 0-dimension create_texture is a wgpu validation error
// that PANICS the calling (worker) thread. Callers already guard, but
// never let a bad decode take down a worker.
if width == 0 || height == 0 {
log::warn!("gputex: refusing zero-dimension upload ({width}x{height})");
// that PANICS. Callers already guard, but never let a bad decode take
// down the UI thread.
if width == 0 || height == 0 || rgba.len() < (4 * width as usize * height as usize) {
log::warn!("gputex: refusing bad upload ({width}x{height}, {} bytes)", rgba.len());
return None;
}
let (device, queue) = GPU.get()?;
// Clone the device/queue refs (Arc interior — cheap) and drop the lock
// before the allocation/submit.
let (device, queue) = {
let g = GPU.lock().unwrap();
g.as_ref().map(|c| (c.device.clone(), c.queue.clone()))
}?;
let texture = device.create_texture(&TextureDescriptor {
label: Some("gallery-thumb"),
size: Extent3d { width, height, depth_or_array_layers: 1 },
@ -100,72 +143,5 @@ pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option<wgpu::Texture
},
Extent3d { width, height, depth_or_array_layers: 1 },
);
Some(texture)
}
/// GPU pipeline keep-alive.
///
/// INTENDED: keep the wgpu/Vulkan submission path warm so frames after a
/// 30–80 ms input micro-pause don't pay 23–46 ms of wake-up.
///
/// **DISABLED BY DEFAULT (2026-10-28).** On the Pixel 9 Pro (Mali G715)
/// the 16 ms submission stream from a 5th thread, racing Slint's own
/// render/present path AND the four decode workers on the SAME queue, made
/// the display pipeline freeze — the whole phone UI hung for seconds and
/// 51 in-flight decodes stalled ~6 s. Concurrent submitters on one Mali
/// queue are not safe in practice. Re-enable only via the debug knob
/// `/sdcard/Download/.gallery_keep` (REMOVE after the hunt).
pub struct Keepalive {
stop: Arc<AtomicBool>,
}
static KEEP_BUFS: OnceLock<(wgpu::Buffer, wgpu::Buffer)> = OnceLock::new();
impl Keepalive {
/// Spawn the keep-alive thread. INACTIVE unless the debug knob
/// `/sdcard/Download/.gallery_keep` exists.
pub fn start() -> Self {
#[cfg(target_os = "android")]
if !std::path::Path::new("/sdcard/Download/.gallery_keep").exists() {
log::info!("gputex: keep-alive inactive (default off; .gallery_keep to enable)");
return Self { stop: Arc::new(AtomicBool::new(true)) };
}
let stop = Arc::new(AtomicBool::new(false));
let stop_t = stop.clone();
std::thread::Builder::new()
.name("gpu-keep".into())
.spawn(move || {
crate::prio::set_self_nice(10); // must never jank the UI thread
while !stop_t.load(std::sync::atomic::Ordering::Relaxed) {
if let Some((device, queue)) = GPU.get() {
let (src, dst) = KEEP_BUFS.get_or_init(|| {
let desc = wgpu::BufferDescriptor {
label: Some("gallery-keep"),
size: 16,
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
};
let a = device.create_buffer(&desc);
let b = device.create_buffer(&desc);
queue.write_buffer(&a, 0, &[0u8; 16]);
(a, b)
});
let mut enc =
device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
enc.copy_buffer_to_buffer(src, 0, dst, 0, 8);
queue.submit(Some(enc.finish()));
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
})
.ok();
log::info!("gputex: keep-alive started");
Self { stop }
}
/// Signal the thread to stop (does not join; drop is immediate).
pub fn stop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
log::info!("gputex: keep-alive stopped");
}
slint::Image::try_from(texture).ok()
}

View File

@ -218,8 +218,41 @@ const STALL_ALARM_MS: u64 = 500;
static STATE: OnceLock<AppState> = OnceLock::new();
/// Per-world id (incremented in android_main): each activity (re)creation
/// runs android_main on a fresh thread, which creates a fresh Slint
/// platform/world (i-slint-core's context is thread-local).
static WORLD_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// The world that owns the current activity. A world's drain tick no-ops
/// (and stays a no-op) once this moves on: the old world's window is dead,
/// its renderer/device will be dropped with its context, and — critically —
/// it must never drain the shared loader state again, or it would upload
/// textures into a model that the new world's device does not own (the
/// stale-texture panic class, 2026-10-28).
static CURRENT_WORLD: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(u32::MAX);
/// Create the thumbnail engine (process cache dir + sized worker pool).
fn make_engine() -> Result<Engine, String> {
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
let cache_dir = base.join("thumbs");
let workers =
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
// MAX_WORKER_QUEUE (96): bound each worker's pending queue so stale
// transit work can never delay the viewport (overflow is dropped
// with a `skipped` result; re-entry is a disk-cache hit).
let engine = Engine::new(cache_dir, workers, GRID_TARGET, MAX_WORKER_QUEUE)?;
log::info!(
"gallery: thumb engine up: {} workers, target {} px, cache {}",
workers,
GRID_TARGET,
engine.cache_dir().display()
);
Ok(engine)
}
struct AppState {
engine: Engine,
/// Mutex: an activity re-creation (task dismissed + relaunched while the
/// process lives) REPLACES the engine — see `android_main`.
engine: std::sync::Mutex<Engine>,
/// Current list (newest first). UI thread only.
entries: Mutex<Vec<Entry>>,
/// Cell range the user can see (visible ± MARGIN_ROWS). Set by on_scroll
@ -244,23 +277,9 @@ struct AppState {
/// Create the pool + cache dir once per process.
fn state() -> &'static AppState {
STATE.get_or_init(|| {
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
let cache_dir = base.join("thumbs");
let workers =
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
// MAX_WORKER_QUEUE (96): bound each worker's pending queue so stale
// transit work can never delay the viewport (overflow is dropped
// with a `skipped` result; re-entry is a disk-cache hit).
let engine =
Engine::new(cache_dir, workers, GRID_TARGET, MAX_WORKER_QUEUE).expect("thumb engine");
log::info!(
"gallery: thumb engine up: {} workers, target {} px, cache {}",
workers,
GRID_TARGET,
engine.cache_dir().display()
);
let engine = make_engine().expect("thumb engine");
AppState {
engine,
engine: std::sync::Mutex::new(engine),
entries: Mutex::new(Vec::new()),
viewport: Mutex::new(0..0),
painted: Mutex::new(HashSet::new()),
@ -320,7 +339,7 @@ fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String) {
let _ = slint::invoke_from_event_loop(move || {
let Some(w) = weak.upgrade() else { return };
let Some(app) = STATE.get() else { return };
app.engine.bump_generation();
app.engine.lock().unwrap().bump_generation();
app.inflight.lock().unwrap().clear();
app.painted.lock().unwrap().clear();
app.failed.lock().unwrap().clear();
@ -384,30 +403,27 @@ fn apply_cell_updates(
let Some(cells) = cells_of(&item) else {
continue;
};
let image = if let Some(tex) = r.gpu.clone() { // cheap: Arc interior
// GPU-resident texture: the worker created it on Slint's own
// wgpu device and submitted the upload (async). Zero pixel
// work in this frame — first present costs ~0 (was ~11 ms
// with from_rgba8; see gputex).
match Image::try_from(tex) {
Ok(img) => img,
// Defensive: impossible with our format/usage — the CPU
// fallback bytes are NOT delivered in this case.
Err(e) => {
log::warn!("gputex import failed: {e}");
let mut g = SharedPixelBuffer::<Rgba8Pixel>::new(1, 1);
g.make_mut_bytes().fill(0);
Image::from_rgba8(g)
// ok with no pixels (0-dim decode): treat as failed — never
// re-submit (the fill step checks `failed`).
if r.rgb.is_empty() {
if let Some(e) = entries.get(r.id).filter(|e| e.path == r.path) {
app.failed.lock().unwrap().insert((e.path.clone(), e.mtime, e.size));
}
continue;
}
} else {
// CPU fallback (device not captured yet / non-wgpu backend):
// worker delivers RGBA8 (A=255). Slint's Skia backend has no
// RGB888 path and would convert byte-by-byte in the render
// frame — see images::to_rgba8.
// Upload on THIS thread (the render thread of the world that
// presents the cell) to Slint's current wgpu device — the
// known-good-context contract; see gputex docs. Until the first
// RenderingSetup (or on a non-wgpu backend) fall back to CPU
// pixels — Slint's Skia backend has no RGB888 path, hence the
// worker-side to_rgba8.
let image = match gputex::upload_rgba(&r.rgb, r.width, r.height) {
Some(img) => img,
None => {
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(r.width, r.height);
buffer.make_mut_bytes().copy_from_slice(&r.rgb);
Image::from_rgba8(buffer)
}
};
cells.set_row_data(
r.id % GRID_COLS,
@ -474,18 +490,91 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option<Range<usize
..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n))
}
/// Render notifier: one-time capture of Slint's wgpu device so the decode
/// workers can upload thumbnail textures off the UI thread (gputex). Runs
/// on the UI thread during rendering; the work is a one-shot store.
fn install_render_notifier(window: &Main) {
let _ = window.window().set_rendering_notifier(|state, api| {
if let slint::RenderingState::RenderingSetup = state {
gputex::init(api);
log::info!(
"gputex: wgpu device {}",
if gputex::ready() { "captured" } else { "NOT captured" }
/// Clear all painted cells IN PLACE (the eviction path) and reset the
/// loader state. Call from the render notifier (UI thread).
///
/// In-place: the model objects are NOT replaced. A mid-render
/// `VecModel::set_vec` (reset notification) desyncs the for-loop — row
/// items keep stale nested models and later in-place paints never reach
/// their items (the sparse-grid-after-recreation bug, 2026-10-28).
/// Clearing cells reuses the proven eviction mechanism; the tick
/// re-fills from the disk cache on the new device.
fn reset_grid_in_place(w: &Main, app: &AppState) {
// In-flight results hold textures of the dying device — drop them.
app.engine.lock().unwrap().bump_generation();
app.inflight.lock().unwrap().clear();
app.failed.lock().unwrap().clear();
let painted: Vec<usize> = app.painted.lock().unwrap().drain().collect();
let n = {
let entries = app.entries.lock().unwrap();
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() {
for &id in &painted {
if id >= entries.len() {
continue;
}
let row = id / GRID_COLS;
if row >= m.row_count() {
continue;
}
let Some(item) = m.row_data(row) else { continue };
let Some(cells) = cells_of(&item) else { continue };
cells.set_row_data(
id % GRID_COLS,
Cell {
path: SharedString::from(entries[id].path.clone()),
image: Image::default(),
loaded: false,
},
);
}
}
entries.len()
};
*app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n);
}
/// Render notifier: the official Slint graphics-lifecycle hooks.
///
/// `RenderingTeardown` — "the window will be destroyed and/or graphics
/// resources need to be released due to other constraints" (slint docs).
/// On this backend it fires from `set_window_handle` → `suspend()` →
/// `clear_surface()` whenever the window gets a NEW surface (activity
/// recreation while the process lives). The old wgpu device is about to
/// be dropped: every texture uploaded to it is dead, so block uploads
/// (`mark_stale`) and clear the painted cells — they wrap those
/// textures, and drawing them into any render pass panics wgpu-core
/// (stale id in the shared registry: `transition_resources`).
///
/// `RenderingSetup` — capture the NEW wgpu device so the decode workers
/// can upload off the UI thread (gputex). If this REPLACED a previous
/// device (recreation), the cell Images still wrap dead textures —
/// reset the grid in place; the tick re-fills from the disk cache.
///
/// Both run synchronously on the UI thread before the render walk, so no
/// frame ever draws a dead texture.
fn install_render_notifier(window: &Main, wid: u32) {
let weak = window.as_weak();
let _ = window.window().set_rendering_notifier(move |state, api| {
match state {
slint::RenderingState::RenderingTeardown => {
gputex::invalidate();
let Some(app) = STATE.get() else { return };
let Some(w) = weak.upgrade() else { return };
log::info!("gallery: [W{wid}] rendering teardown — releasing GPU cell state");
reset_grid_in_place(&w, app);
}
slint::RenderingState::RenderingSetup => {
if gputex::capture(api) {
let Some(app) = STATE.get() else { return };
let Some(w) = weak.upgrade() else { return };
log::info!("gallery: [W{wid}] new rendering context — resetting grid from cache");
reset_grid_in_place(&w, app);
} else if gputex::ready() {
log::info!("gputex: [W{wid}] wgpu device captured");
}
}
_ => {}
}
});
}
@ -496,7 +585,6 @@ mod nblog {
//! UI thread for 10–40 ms. Records are formatted at the call site and
//! pushed into a bounded channel; a drain thread does the real
//! `__android_log_write` and DROPS records when saturated.
use std::io::Write as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use log::{Level, Log, Metadata, Record};
@ -596,20 +684,25 @@ fn on_scroll(app: &AppState, y: f32, height: f32, width: f32) {
/// lines are only visible at log level Debug).
static DBG_TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn start_drain_timer(weak: &slint::Weak<Main>) {
fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
let weak = weak.clone();
let timer = Timer::default();
timer.start(
TimerMode::Repeated,
std::time::Duration::from_millis(DRAIN_INTERVAL_MS),
move || {
// Superseded by a re-created activity: this world's window is
// dead. Stop touching the shared state (see CURRENT_WORLD).
if CURRENT_WORLD.load(std::sync::atomic::Ordering::Relaxed) != wid {
return;
}
let Some(app) = STATE.get() else { return };
let Some(w) = weak.upgrade() else { return };
let model = w.get_items();
let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
return
};
let results = app.engine.drain();
let results = app.engine.lock().unwrap().drain();
let entries = app.entries.lock().unwrap();
if entries.is_empty() {
return;
@ -667,10 +760,10 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
.map(|d| d.as_millis())
.unwrap_or(0);
log::debug!(
"DBG tick vp={}..{} p={p} i={i} f={f} oldest={oldest}ms {}",
"DBG tick [W{wid}] vp={}..{} p={p} i={i} f={f} oldest={oldest}ms {}",
viewport.start,
viewport.end,
app.engine.log_summary()
app.engine.lock().unwrap().log_summary()
);
}
@ -772,7 +865,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
.collect();
missing.sort_by_key(|id| center.abs_diff(*id));
if !missing.is_empty() {
let gen = app.engine.generation();
let gen = app.engine.lock().unwrap().generation();
let now = Instant::now();
let jobs: Vec<Job> = missing
.iter()
@ -789,7 +882,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
})
.collect();
drop(inflight);
app.engine.submit(&jobs);
app.engine.lock().unwrap().submit(&jobs);
}
}
@ -797,7 +890,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
let lo = viewport.start.saturating_sub(MARGIN_ROWS * GRID_COLS);
let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len());
let band = lo..hi;
let dropped = app.engine.prune_outside(&band);
let dropped = app.engine.lock().unwrap().prune_outside(&band);
if !dropped.is_empty() {
// The pruned cells are no longer "in flight" — clear the
// tracker so a later re-entry re-submits them (disk-cache
@ -842,7 +935,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
"gallery: stall: {} in flight, oldest {} ms, {}",
inflight.len(),
oldest.as_millis(),
app.engine.log_summary()
app.engine.lock().unwrap().log_summary()
);
}
}
@ -856,7 +949,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
log::info!(
"gallery: {} thumbs painted, {}",
total,
app.engine.log_summary()
app.engine.lock().unwrap().log_summary()
);
}
}
@ -868,15 +961,15 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
/// M1: cached DB paints instantly on relaunch; a background walk refreshes it
/// (streaming into the list on cold start). M3: the tick fills the viewport
/// as it moves — top cells right after (re)paint, visible cells on scroll.
pub fn start_scan(window: &Main) {
install_render_notifier(window);
pub fn start_scan(window: &Main, wid: u32) {
install_render_notifier(window, wid);
let model = ModelRc::new(VecModel::<Item>::default());
window.set_items(model);
let weak = window.as_weak();
let app = state();
window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w));
start_drain_timer(&weak);
start_drain_timer(&weak, wid);
std::thread::spawn(move || {
scan_thread(&weak);
@ -990,33 +1083,87 @@ pub fn android_app() -> Option<&'static slint::android::AndroidApp> {
#[unsafe(no_mangle)]
fn android_main(app: slint::android::AndroidApp) {
nblog::init();
let wid = WORLD_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// This world owns the current activity. Previous worlds' drain ticks
// see the switch and go quiet (see CURRENT_WORLD).
CURRENT_WORLD.store(wid, std::sync::atomic::Ordering::Relaxed);
log::info!("gallery: [W{wid}] android_main entered");
let _ = ANDROID_APP.set(app.clone());
// Activity re-creation (task dismissed + relaunched while the process
// lives): android-activity runs this function AGAIN on a fresh thread,
// and Slint's platform is thread-local (i-slint-core `GLOBAL_CONTEXT`)
// — so this becomes the new (visible) world, and the previous one
// becomes a zombie whose tick is gated off by CURRENT_WORLD above.
// Two cleanups for the transition:
// (a) drop the captured wgpu device: the previous world's renderer
// owns it and is dying; uploading to it would panic wgpu-core.
// The new world's first-frame RenderingSetup re-captures (and
// resets the grid in place) before any paint.
// (b) replace the thumbnail engine: its in-flight results (plain
// pixels now, but the workers themselves) belong to the old
// world; the Drop interrupts the old workers.
if STATE.get().is_some() {
log::info!("gallery: [W{wid}] activity re-created — replacing thumb engine");
gputex::invalidate();
let app = STATE.get().expect("checked above");
let mut g = app.engine.lock().unwrap();
let old = std::mem::replace(&mut *g, make_engine().expect("thumb engine re-init"));
drop(old);
}
// Panic hook: the default hook dumps to stderr (logcat) WITHOUT the
// thread name context we need; route panics through the non-blocking
// logger with thread name + backtrace. A background-thread panic
// (e.g. a stale wgpu handle dropped across a window recreation) must
// never take the process down, but it must be VISIBLE.
std::env::set_var("RUST_BACKTRACE", "1");
std::panic::set_hook(Box::new(|info| {
let name = std::thread::current().name().unwrap_or("<unnamed>").to_string();
let msg = info
.payload()
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_default();
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_default();
let bt = std::backtrace::Backtrace::force_capture();
// Full detail to a file (logcat truncates long lines); short line
// to logcat.
let dir = std::path::Path::new(
"/sdcard/Android/data/wow.gmp.gallery/files/panics", );
let _ = std::fs::create_dir_all(dir);
let n = std::fs::read_dir(dir)
.map(|d| d.count())
.unwrap_or(0);
let _ = std::fs::write(
dir.join(format!("{n:03}.txt")),
format!("thread '{name}' at {loc}\n{msg}\n{bt}"),
);
log::error!("gallery: PANIC thread '{name}' at {loc}: {msg} (backtrace in files/panics/{n:03}.txt)");
}));
// `init_with_event_listener` gives us the raw MainEvent stream
// (Start/Resume/Pause/Stop/SaveState/ConfigChanged/...) while Slint owns
// the event loop. This is Slint's one first-class Android lifecycle hook:
// M5 re-checks storage permission on Resume (user returned from Settings).
// GPU pipeline keep-alive (gputex): started on Start/Resume, stopped
// on Pause/Stop (battery). INACTIVE BY DEFAULT — on Mali it froze the
// display pipeline (see gputex::Keepalive docs). Debug knob:
// .gallery_keep.
slint::android::init_with_event_listener(app, |event| {
use slint::android::android_activity::{MainEvent, PollEvent};
// Fn (not FnMut): mutable state in a static.
static KEEPALIVE: std::sync::Mutex<Option<gputex::Keepalive>> =
std::sync::Mutex::new(None);
match event {
PollEvent::Main(MainEvent::Resume { .. } | MainEvent::Start { .. }) => {
log::info!("gallery: app resumed");
if KEEPALIVE.lock().unwrap().is_none() {
*KEEPALIVE.lock().unwrap() = Some(gputex::Keepalive::start());
}
// TODO(M5): storage::recheck_permission()
}
PollEvent::Main(MainEvent::Pause { .. } | MainEvent::Stop { .. }) => {
if let Some(mut k) = KEEPALIVE.lock().unwrap().take() {
k.stop();
}
// The window is being destroyed (activity destroyed — e.g. task
// dismissed). The renderer will destroy the wgpu device we
// captured — drop the reference NOW so the drain tick falls
// back to CPU pixels instead of touching a dead device.
PollEvent::Main(MainEvent::TerminateWindow { .. }) => {
gputex::invalidate();
log::info!("gallery: window terminated — GPU device invalidated");
}
_ => {}
}
@ -1045,6 +1192,6 @@ fn android_main(app: slint::android::AndroidApp) {
.ok();
let main_window = Main::new().unwrap();
start_scan(&main_window);
start_scan(&main_window, wid);
main_window.run().unwrap();
}

View File

@ -19,7 +19,7 @@ impl log::Log for Stdout {
fn main() -> Result<(), slint::PlatformError> {
let _ = log::set_boxed_logger(Box::new(Stdout)).map(|()| log::set_max_level(log::LevelFilter::Info));
let main_window = Main::new()?;
gallery::start_scan(&main_window);
gallery::start_scan(&main_window, 0);
main_window.show()?;
main_window.run()
}

View File

@ -89,15 +89,16 @@ pub struct JobResult {
/// its in-flight marker; the cell is NOT failed — a re-entry re-submits
/// it (disk-cache hit if it was ever decoded, otherwise a fresh decode).
pub skipped: bool,
/// GPU-resident thumbnail texture (created by the worker on Slint's
/// own wgpu device; upload already submitted, async). When `Some`,
/// `rgb` is empty and the UI wraps it in `Image::try_from(tex)` —
/// zero pixel work in the render frame.
pub gpu: Option<crate::gputex::wgpu::Texture>,
/// Fallback only (gpu == None): RGBA8 (A=255), orientation applied,
/// fitted to the engine's target size. Expanded on the worker because
/// RGBA8 (A=255), orientation applied, fitted to the engine's target
/// size; empty when `!ok`/`skipped`. Expanded on the worker because
/// Slint's Skia backend can't take RGB888 (it would convert
/// byte-by-byte on the UI thread during the render frame).
///
/// Delivered as PLAIN BYTES (no GPU handles): the UI thread uploads
/// them to Slint's own wgpu device at drain time (gputex) — a texture
/// must be created on the device of the world that renders it, and
/// only the render thread knows which that is across window
/// recreations.
pub rgb: Vec<u8>,
pub width: u32,
pub height: u32,
@ -392,7 +393,6 @@ fn dispatch_loop(
path: j.path,
ok: false,
skipped: true,
gpu: None,
rgb: Vec::new(),
width: 0,
height: 0,
@ -481,30 +481,17 @@ fn worker_loop(
// Expand RGB8 -> RGBA8 HERE (worker thread), not in Slint's render
// pass — see JobResult docs. Cost: ~0.3 ms per 400 px thumb.
let rgba = if ok { crate::images::to_rgba8(&rgb) } else { rgb };
// Upload to the GPU on Slint's own device/queue (async, no UI
// involvement). On success the CPU bytes are NOT sent over the
// channel — the texture is already resident. See gputex.
// Zero-dimension decode results (undecodable files) must NOT create
// a texture: wgpu validation panics the worker thread on a 0-width
// create_texture (measured 2026-10-28: 'Dimension X is zero' killed
// thumb-3 mid-scroll).
let gpu = if ok && width > 0 && height > 0 {
crate::gputex::upload_rgba(&rgba, width, height)
} else {
None
};
let rgb = match &gpu {
Some(_) => Vec::new(),
None => rgba,
};
// Delivered as plain bytes: the UI thread uploads to Slint's
// current wgpu device at drain time (gputex::upload_rgba). No
// worker ever touches a wgpu device — that was the source of the
// stale-texture panics across window recreations (2026-10-28).
let _ = res_tx.send(JobResult {
id: job.id,
gen: job.gen,
path: job.path,
ok,
skipped: false,
gpu,
rgb,
rgb: rgba,
width,
height,
from_cache,