diff --git a/HANDOFF.md b/HANDOFF.md index 9504aa5..01d6280 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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` diff --git a/docs/BUGS/recreation-stale-textures.md b/docs/BUGS/recreation-stale-textures.md new file mode 100644 index 0000000..95b1128 --- /dev/null +++ b/docs/BUGS/recreation-stale-textures.md @@ -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` 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. diff --git a/src/gputex.rs b/src/gputex.rs index d05016e..6f04589 100644 --- a/src/gputex.rs +++ b/src/gputex.rs @@ -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> = 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 { +pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option { 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, -} - -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() } diff --git a/src/lib.rs b/src/lib.rs index 6e6382a..8c99497 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -218,8 +218,41 @@ const STALL_ALARM_MS: u64 = 500; static STATE: OnceLock = 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 { + 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, /// Current list (newest first). UI thread only. entries: Mutex>, /// 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
, entries: Vec, 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::::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; + } + // 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::::new(r.width, r.height); + buffer.make_mut_bytes().copy_from_slice(&r.rgb); + Image::from_rgba8(buffer) } - } 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. - let mut buffer = SharedPixelBuffer::::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,17 +490,90 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option = 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::>() { + 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
) { +fn start_drain_timer(weak: &slint::Weak
, 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::>() 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
) { .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
) { .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 = missing .iter() @@ -789,7 +882,7 @@ fn start_drain_timer(weak: &slint::Weak
) { }) .collect(); drop(inflight); - app.engine.submit(&jobs); + app.engine.lock().unwrap().submit(&jobs); } } @@ -797,7 +890,7 @@ fn start_drain_timer(weak: &slint::Weak
) { 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
) { "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
) { 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
) { /// 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::::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("").to_string(); + let msg = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().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> = - 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(); } diff --git a/src/main.rs b/src/main.rs index d4bcec3..f9b6af6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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() } diff --git a/src/thumb.rs b/src/thumb.rs index eaa473d..ec03a68 100644 --- a/src/thumb.rs +++ b/src/thumb.rs @@ -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, - /// 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, 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,