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:
parent
93b27d724b
commit
84c6a4170d
29
HANDOFF.md
29
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
|
1. Not a frame-rate, input, or layout issue; Fossify (RenderThread
|
||||||
pipelining) doesn't have this cost.
|
pipelining) doesn't have this cost.
|
||||||
|
|
||||||
Fix (no Slint patch, no re-architecture): the **decode worker creates the
|
Fix (no Slint patch, no re-architecture): the thumbnail is uploaded to
|
||||||
texture on Slint's own wgpu device** (captured once from
|
Slint's own wgpu device (captured from `GraphicsAPI::WGPU30` at
|
||||||
`GraphicsAPI::WGPU30` at `RenderingSetup`, `src/gputex.rs`) and submits
|
`RenderingSetup`, `src/gputex.rs`) via `queue.write_texture` (async, no
|
||||||
the upload via `queue.write_texture` (async, no fence wait). UI thread
|
fence wait) BEFORE the presenting frame, so first present costs ~0.
|
||||||
wraps it in `Image::try_from(texture)` (needs `Rgba8Unorm` +
|
`Image::try_from(texture)` needs `Rgba8Unorm` +
|
||||||
`TEXTURE_BINDING|RENDER_ATTACHMENT`; `unstable-wgpu-30` slint feature;
|
`TEXTURE_BINDING|RENDER_ATTACHMENT` (`unstable-wgpu-30` slint feature;
|
||||||
wgpu/Vulkan `bytes_per_row` 256-multiple padding). Fallback to
|
wgpu/Vulkan `bytes_per_row` 256-multiple padding). Fallback to
|
||||||
`from_rgba8` CPU path when the device isn't captured yet.
|
`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**
|
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
|
(median 1.9 ms, p95 2.9 ms); only post-idle first-frames are expensive
|
||||||
(invisible — screen static).
|
(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).
|
Perf file: `/sdcard/Download/.gallery_perf.log` (SCR/TICK/APLY/FR).
|
||||||
Clear it by restarting the app (writer holds the fd).
|
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
|
(FR/SCR/TICK/APLYD/STG logging, `stg.rs`, `.gallery_*` knobs, jsamp
|
||||||
thread sampler, perf-file mirroring in nblog, DbgScroll). KEPT:
|
thread sampler, perf-file mirroring in nblog, DbgScroll). KEPT:
|
||||||
nblog non-blocking logger (logd backpressure is real), zero-dimension
|
nblog non-blocking logger (logd backpressure is real), zero-dimension
|
||||||
decode guards (thumb.rs + gputex.rs), keep-alive (still default-off,
|
decode guards (thumb.rs + gputex.rs), debug-level coverage logs.
|
||||||
`.gallery_keep`), 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
|
**BUG FIXED while cleaning:** the app launched into an unstoppable
|
||||||
fast scroll on the user's phone — a leftover `.gallery_cmd`
|
fast scroll on the user's phone — a leftover `.gallery_cmd`
|
||||||
|
|
|
||||||
137
docs/BUGS/recreation-stale-textures.md
Normal file
137
docs/BUGS/recreation-stale-textures.md
Normal 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.
|
||||||
162
src/gputex.rs
162
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
|
//! Slint's `Image::from_rgba8` uploads the pixels to the GPU *inside the
|
||||||
//! render frame that first presents them* (its Skia backend additionally
|
//! 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
|
//! ~11 ms per 400 px first-present = the scroll hitch, even though the
|
||||||
//! median render is ~2 ms.
|
//! median render is ~2 ms.
|
||||||
//!
|
//!
|
||||||
//! Instead: the DECODE WORKER creates the texture on Slint's own wgpu
|
//! Instead, the UI thread (during its 8 ms drain tick, i.e. between frames
|
||||||
//! device and submits the upload to the queue — `write_texture` is async
|
//! — not inside the render pass) creates the texture on Slint's own wgpu
|
||||||
//! (staging-buffer memcpy + GPU blit, no fence wait, no UI involvement).
|
//! device and submits `write_texture` (async: staging-buffer memcpy + GPU
|
||||||
//! The UI thread only wraps the already-resident texture in
|
//! blit, no fence wait). The render frame that first presents the cell
|
||||||
//! `Image::try_from(texture)`; the first-present frame pays ~0.
|
//! 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
|
//! `Window::set_rendering_notifier` (`GraphicsAPI::WGPU30`) at
|
||||||
//! `RenderingSetup` — so the texture is guaranteed to be sampleable by
|
//! `RenderingSetup` (again after every window/surface recreation) and
|
||||||
//! Slint's Skia renderer (`import_wgpu_texture`), with no cross-context
|
//! invalidated at `RenderingTeardown` / activity re-creation.
|
||||||
//! import needed.
|
|
||||||
//!
|
//!
|
||||||
//! 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
|
//! `upload_rgba` returns `None` and the caller delivers the RGBA8 bytes
|
||||||
//! the old way (`Image::from_rgba8`).
|
//! 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).
|
/// The wgpu 30.x crate re-exported by Slint (same version it renders with).
|
||||||
pub use slint::wgpu_30::wgpu;
|
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
|
/// Capture Slint's wgpu device + queue. Called from the rendering notifier
|
||||||
/// (UI thread, once, at `RenderingSetup`).
|
/// (UI thread) at every `RenderingSetup` — i.e. for EVERY renderer, which
|
||||||
pub fn init(api: &slint::GraphicsAPI) {
|
/// 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 {
|
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.
|
/// True once Slint's wgpu device has been captured.
|
||||||
pub fn ready() -> bool {
|
pub fn ready() -> bool {
|
||||||
GPU.get().is_some()
|
GPU.lock().unwrap().is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes and
|
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes,
|
||||||
/// submit the (async) upload. **Runs on the decode worker thread.**
|
/// 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).
|
/// 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::{
|
use wgpu::{
|
||||||
Extent3d, Origin3d, TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect,
|
Extent3d, Origin3d, TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect,
|
||||||
TextureDescriptor, TextureDimension, TextureUsages,
|
TextureDescriptor, TextureDimension, TextureUsages,
|
||||||
};
|
};
|
||||||
// Defensive: a 0-dimension create_texture is a wgpu validation error
|
// Defensive: a 0-dimension create_texture is a wgpu validation error
|
||||||
// that PANICS the calling (worker) thread. Callers already guard, but
|
// that PANICS. Callers already guard, but never let a bad decode take
|
||||||
// never let a bad decode take down a worker.
|
// down the UI thread.
|
||||||
if width == 0 || height == 0 {
|
if width == 0 || height == 0 || rgba.len() < (4 * width as usize * height as usize) {
|
||||||
log::warn!("gputex: refusing zero-dimension upload ({width}x{height})");
|
log::warn!("gputex: refusing bad upload ({width}x{height}, {} bytes)", rgba.len());
|
||||||
return None;
|
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 {
|
let texture = device.create_texture(&TextureDescriptor {
|
||||||
label: Some("gallery-thumb"),
|
label: Some("gallery-thumb"),
|
||||||
size: Extent3d { width, height, depth_or_array_layers: 1 },
|
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 },
|
Extent3d { width, height, depth_or_array_layers: 1 },
|
||||||
);
|
);
|
||||||
Some(texture)
|
slint::Image::try_from(texture).ok()
|
||||||
}
|
|
||||||
|
|
||||||
/// 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
307
src/lib.rs
307
src/lib.rs
|
|
@ -218,8 +218,41 @@ const STALL_ALARM_MS: u64 = 500;
|
||||||
|
|
||||||
static STATE: OnceLock<AppState> = OnceLock::new();
|
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 {
|
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.
|
/// Current list (newest first). UI thread only.
|
||||||
entries: Mutex<Vec<Entry>>,
|
entries: Mutex<Vec<Entry>>,
|
||||||
/// Cell range the user can see (visible ± MARGIN_ROWS). Set by on_scroll
|
/// 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.
|
/// Create the pool + cache dir once per process.
|
||||||
fn state() -> &'static AppState {
|
fn state() -> &'static AppState {
|
||||||
STATE.get_or_init(|| {
|
STATE.get_or_init(|| {
|
||||||
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
|
let engine = make_engine().expect("thumb engine");
|
||||||
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()
|
|
||||||
);
|
|
||||||
AppState {
|
AppState {
|
||||||
engine,
|
engine: std::sync::Mutex::new(engine),
|
||||||
entries: Mutex::new(Vec::new()),
|
entries: Mutex::new(Vec::new()),
|
||||||
viewport: Mutex::new(0..0),
|
viewport: Mutex::new(0..0),
|
||||||
painted: Mutex::new(HashSet::new()),
|
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 _ = slint::invoke_from_event_loop(move || {
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
let Some(app) = STATE.get() 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.inflight.lock().unwrap().clear();
|
||||||
app.painted.lock().unwrap().clear();
|
app.painted.lock().unwrap().clear();
|
||||||
app.failed.lock().unwrap().clear();
|
app.failed.lock().unwrap().clear();
|
||||||
|
|
@ -384,30 +403,27 @@ fn apply_cell_updates(
|
||||||
let Some(cells) = cells_of(&item) else {
|
let Some(cells) = cells_of(&item) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let image = if let Some(tex) = r.gpu.clone() { // cheap: Arc interior
|
// ok with no pixels (0-dim decode): treat as failed — never
|
||||||
// GPU-resident texture: the worker created it on Slint's own
|
// re-submit (the fill step checks `failed`).
|
||||||
// wgpu device and submitted the upload (async). Zero pixel
|
if r.rgb.is_empty() {
|
||||||
// work in this frame — first present costs ~0 (was ~11 ms
|
if let Some(e) = entries.get(r.id).filter(|e| e.path == r.path) {
|
||||||
// with from_rgba8; see gputex).
|
app.failed.lock().unwrap().insert((e.path.clone(), e.mtime, e.size));
|
||||||
match Image::try_from(tex) {
|
}
|
||||||
Ok(img) => img,
|
continue;
|
||||||
// Defensive: impossible with our format/usage — the CPU
|
}
|
||||||
// fallback bytes are NOT delivered in this case.
|
// Upload on THIS thread (the render thread of the world that
|
||||||
Err(e) => {
|
// presents the cell) to Slint's current wgpu device — the
|
||||||
log::warn!("gputex import failed: {e}");
|
// known-good-context contract; see gputex docs. Until the first
|
||||||
let mut g = SharedPixelBuffer::<Rgba8Pixel>::new(1, 1);
|
// RenderingSetup (or on a non-wgpu backend) fall back to CPU
|
||||||
g.make_mut_bytes().fill(0);
|
// pixels — Slint's Skia backend has no RGB888 path, hence the
|
||||||
Image::from_rgba8(g)
|
// 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)
|
||||||
}
|
}
|
||||||
} 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::<Rgba8Pixel>::new(r.width, r.height);
|
|
||||||
buffer.make_mut_bytes().copy_from_slice(&r.rgb);
|
|
||||||
Image::from_rgba8(buffer)
|
|
||||||
};
|
};
|
||||||
cells.set_row_data(
|
cells.set_row_data(
|
||||||
r.id % GRID_COLS,
|
r.id % GRID_COLS,
|
||||||
|
|
@ -474,17 +490,90 @@ fn viewport_for(y: f32, height: f32, width: f32, n: usize) -> Option<Range<usize
|
||||||
..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n))
|
..((first + visible + MARGIN_ROWS) * GRID_COLS).min(n))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render notifier: one-time capture of Slint's wgpu device so the decode
|
/// Clear all painted cells IN PLACE (the eviction path) and reset the
|
||||||
/// workers can upload thumbnail textures off the UI thread (gputex). Runs
|
/// loader state. Call from the render notifier (UI thread).
|
||||||
/// on the UI thread during rendering; the work is a one-shot store.
|
///
|
||||||
fn install_render_notifier(window: &Main) {
|
/// In-place: the model objects are NOT replaced. A mid-render
|
||||||
let _ = window.window().set_rendering_notifier(|state, api| {
|
/// `VecModel::set_vec` (reset notification) desyncs the for-loop — row
|
||||||
if let slint::RenderingState::RenderingSetup = state {
|
/// items keep stale nested models and later in-place paints never reach
|
||||||
gputex::init(api);
|
/// their items (the sparse-grid-after-recreation bug, 2026-10-28).
|
||||||
log::info!(
|
/// Clearing cells reuses the proven eviction mechanism; the tick
|
||||||
"gputex: wgpu device {}",
|
/// re-fills from the disk cache on the new device.
|
||||||
if gputex::ready() { "captured" } else { "NOT captured" }
|
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
|
//! 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
|
//! pushed into a bounded channel; a drain thread does the real
|
||||||
//! `__android_log_write` and DROPS records when saturated.
|
//! `__android_log_write` and DROPS records when saturated.
|
||||||
use std::io::Write as _;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use log::{Level, Log, Metadata, Record};
|
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).
|
/// lines are only visible at log level Debug).
|
||||||
static DBG_TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
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 weak = weak.clone();
|
||||||
let timer = Timer::default();
|
let timer = Timer::default();
|
||||||
timer.start(
|
timer.start(
|
||||||
TimerMode::Repeated,
|
TimerMode::Repeated,
|
||||||
std::time::Duration::from_millis(DRAIN_INTERVAL_MS),
|
std::time::Duration::from_millis(DRAIN_INTERVAL_MS),
|
||||||
move || {
|
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(app) = STATE.get() else { return };
|
||||||
let Some(w) = weak.upgrade() else { return };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
let model = w.get_items();
|
let model = w.get_items();
|
||||||
let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
|
let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
|
||||||
return
|
return
|
||||||
};
|
};
|
||||||
let results = app.engine.drain();
|
let results = app.engine.lock().unwrap().drain();
|
||||||
let entries = app.entries.lock().unwrap();
|
let entries = app.entries.lock().unwrap();
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -667,10 +760,10 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
||||||
.map(|d| d.as_millis())
|
.map(|d| d.as_millis())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
log::debug!(
|
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.start,
|
||||||
viewport.end,
|
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();
|
.collect();
|
||||||
missing.sort_by_key(|id| center.abs_diff(*id));
|
missing.sort_by_key(|id| center.abs_diff(*id));
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
let gen = app.engine.generation();
|
let gen = app.engine.lock().unwrap().generation();
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let jobs: Vec<Job> = missing
|
let jobs: Vec<Job> = missing
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -789,7 +882,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
drop(inflight);
|
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 lo = viewport.start.saturating_sub(MARGIN_ROWS * GRID_COLS);
|
||||||
let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len());
|
let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len());
|
||||||
let band = lo..hi;
|
let band = lo..hi;
|
||||||
let dropped = app.engine.prune_outside(&band);
|
let dropped = app.engine.lock().unwrap().prune_outside(&band);
|
||||||
if !dropped.is_empty() {
|
if !dropped.is_empty() {
|
||||||
// The pruned cells are no longer "in flight" — clear the
|
// The pruned cells are no longer "in flight" — clear the
|
||||||
// tracker so a later re-entry re-submits them (disk-cache
|
// 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, {}",
|
"gallery: stall: {} in flight, oldest {} ms, {}",
|
||||||
inflight.len(),
|
inflight.len(),
|
||||||
oldest.as_millis(),
|
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!(
|
log::info!(
|
||||||
"gallery: {} thumbs painted, {}",
|
"gallery: {} thumbs painted, {}",
|
||||||
total,
|
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
|
/// 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
|
/// (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.
|
/// as it moves — top cells right after (re)paint, visible cells on scroll.
|
||||||
pub fn start_scan(window: &Main) {
|
pub fn start_scan(window: &Main, wid: u32) {
|
||||||
install_render_notifier(window);
|
install_render_notifier(window, wid);
|
||||||
let model = ModelRc::new(VecModel::<Item>::default());
|
let model = ModelRc::new(VecModel::<Item>::default());
|
||||||
window.set_items(model);
|
window.set_items(model);
|
||||||
let weak = window.as_weak();
|
let weak = window.as_weak();
|
||||||
let app = state();
|
let app = state();
|
||||||
|
|
||||||
window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w));
|
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 || {
|
std::thread::spawn(move || {
|
||||||
scan_thread(&weak);
|
scan_thread(&weak);
|
||||||
|
|
@ -990,33 +1083,87 @@ pub fn android_app() -> Option<&'static slint::android::AndroidApp> {
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
fn android_main(app: slint::android::AndroidApp) {
|
fn android_main(app: slint::android::AndroidApp) {
|
||||||
nblog::init();
|
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());
|
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
|
// `init_with_event_listener` gives us the raw MainEvent stream
|
||||||
// (Start/Resume/Pause/Stop/SaveState/ConfigChanged/...) while Slint owns
|
// (Start/Resume/Pause/Stop/SaveState/ConfigChanged/...) while Slint owns
|
||||||
// the event loop. This is Slint's one first-class Android lifecycle hook:
|
// the event loop. This is Slint's one first-class Android lifecycle hook:
|
||||||
// M5 re-checks storage permission on Resume (user returned from Settings).
|
// 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| {
|
slint::android::init_with_event_listener(app, |event| {
|
||||||
use slint::android::android_activity::{MainEvent, PollEvent};
|
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 {
|
match event {
|
||||||
PollEvent::Main(MainEvent::Resume { .. } | MainEvent::Start { .. }) => {
|
PollEvent::Main(MainEvent::Resume { .. } | MainEvent::Start { .. }) => {
|
||||||
log::info!("gallery: app resumed");
|
log::info!("gallery: app resumed");
|
||||||
if KEEPALIVE.lock().unwrap().is_none() {
|
|
||||||
*KEEPALIVE.lock().unwrap() = Some(gputex::Keepalive::start());
|
|
||||||
}
|
|
||||||
// TODO(M5): storage::recheck_permission()
|
// TODO(M5): storage::recheck_permission()
|
||||||
}
|
}
|
||||||
PollEvent::Main(MainEvent::Pause { .. } | MainEvent::Stop { .. }) => {
|
// The window is being destroyed (activity destroyed — e.g. task
|
||||||
if let Some(mut k) = KEEPALIVE.lock().unwrap().take() {
|
// dismissed). The renderer will destroy the wgpu device we
|
||||||
k.stop();
|
// 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();
|
.ok();
|
||||||
|
|
||||||
let main_window = Main::new().unwrap();
|
let main_window = Main::new().unwrap();
|
||||||
start_scan(&main_window);
|
start_scan(&main_window, wid);
|
||||||
main_window.run().unwrap();
|
main_window.run().unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ impl log::Log for Stdout {
|
||||||
fn main() -> Result<(), slint::PlatformError> {
|
fn main() -> Result<(), slint::PlatformError> {
|
||||||
let _ = log::set_boxed_logger(Box::new(Stdout)).map(|()| log::set_max_level(log::LevelFilter::Info));
|
let _ = log::set_boxed_logger(Box::new(Stdout)).map(|()| log::set_max_level(log::LevelFilter::Info));
|
||||||
let main_window = Main::new()?;
|
let main_window = Main::new()?;
|
||||||
gallery::start_scan(&main_window);
|
gallery::start_scan(&main_window, 0);
|
||||||
main_window.show()?;
|
main_window.show()?;
|
||||||
main_window.run()
|
main_window.run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
src/thumb.rs
39
src/thumb.rs
|
|
@ -89,15 +89,16 @@ pub struct JobResult {
|
||||||
/// its in-flight marker; the cell is NOT failed — a re-entry re-submits
|
/// 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).
|
/// it (disk-cache hit if it was ever decoded, otherwise a fresh decode).
|
||||||
pub skipped: bool,
|
pub skipped: bool,
|
||||||
/// GPU-resident thumbnail texture (created by the worker on Slint's
|
/// RGBA8 (A=255), orientation applied, fitted to the engine's target
|
||||||
/// own wgpu device; upload already submitted, async). When `Some`,
|
/// size; empty when `!ok`/`skipped`. Expanded on the worker because
|
||||||
/// `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
|
|
||||||
/// Slint's Skia backend can't take RGB888 (it would convert
|
/// Slint's Skia backend can't take RGB888 (it would convert
|
||||||
/// byte-by-byte on the UI thread during the render frame).
|
/// 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 rgb: Vec<u8>,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
|
|
@ -392,7 +393,6 @@ fn dispatch_loop(
|
||||||
path: j.path,
|
path: j.path,
|
||||||
ok: false,
|
ok: false,
|
||||||
skipped: true,
|
skipped: true,
|
||||||
gpu: None,
|
|
||||||
rgb: Vec::new(),
|
rgb: Vec::new(),
|
||||||
width: 0,
|
width: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
|
|
@ -481,30 +481,17 @@ fn worker_loop(
|
||||||
// Expand RGB8 -> RGBA8 HERE (worker thread), not in Slint's render
|
// Expand RGB8 -> RGBA8 HERE (worker thread), not in Slint's render
|
||||||
// pass — see JobResult docs. Cost: ~0.3 ms per 400 px thumb.
|
// pass — see JobResult docs. Cost: ~0.3 ms per 400 px thumb.
|
||||||
let rgba = if ok { crate::images::to_rgba8(&rgb) } else { rgb };
|
let rgba = if ok { crate::images::to_rgba8(&rgb) } else { rgb };
|
||||||
// Upload to the GPU on Slint's own device/queue (async, no UI
|
// Delivered as plain bytes: the UI thread uploads to Slint's
|
||||||
// involvement). On success the CPU bytes are NOT sent over the
|
// current wgpu device at drain time (gputex::upload_rgba). No
|
||||||
// channel — the texture is already resident. See gputex.
|
// worker ever touches a wgpu device — that was the source of the
|
||||||
// Zero-dimension decode results (undecodable files) must NOT create
|
// stale-texture panics across window recreations (2026-10-28).
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
let _ = res_tx.send(JobResult {
|
let _ = res_tx.send(JobResult {
|
||||||
id: job.id,
|
id: job.id,
|
||||||
gen: job.gen,
|
gen: job.gen,
|
||||||
path: job.path,
|
path: job.path,
|
||||||
ok,
|
ok,
|
||||||
skipped: false,
|
skipped: false,
|
||||||
gpu,
|
rgb: rgba,
|
||||||
rgb,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
from_cache,
|
from_cache,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user