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
|
||||
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`
|
||||
|
|
|
|||
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
|
||||
//! render frame that first presents them* (its Skia backend additionally
|
||||
|
|
@ -6,58 +6,101 @@
|
|||
//! ~11 ms per 400 px first-present = the scroll hitch, even though the
|
||||
//! median render is ~2 ms.
|
||||
//!
|
||||
//! Instead: the DECODE WORKER creates the texture on Slint's own wgpu
|
||||
//! device and submits the upload to the queue — `write_texture` is async
|
||||
//! (staging-buffer memcpy + GPU blit, no fence wait, no UI involvement).
|
||||
//! The UI thread only wraps the already-resident texture in
|
||||
//! `Image::try_from(texture)`; the first-present frame pays ~0.
|
||||
//! Instead, the UI thread (during its 8 ms drain tick, i.e. between frames
|
||||
//! — not inside the render pass) creates the texture on Slint's own wgpu
|
||||
//! device and submits `write_texture` (async: staging-buffer memcpy + GPU
|
||||
//! blit, no fence wait). The render frame that first presents the cell
|
||||
//! pays ~0.
|
||||
//!
|
||||
//! The device and queue are Slint's own, captured once from
|
||||
//! **Why the UI thread and not the decode workers** (the design that was
|
||||
//! here until 2026-10-28): a long-lived worker holding a wgpu device/queue
|
||||
//! is a context that goes obsolete. On activity recreation the renderer —
|
||||
//! and therefore the device — is replaced, and worker uploads to the dead
|
||||
//! device (or textures painted into a world whose renderer is gone) panic
|
||||
//! wgpu-core's registry. Slint's own contract for borrowed GPU resources
|
||||
//! is "use the context the rendering-notifier callbacks make available".
|
||||
//! With uploads on the render thread, a texture is always created on the
|
||||
//! device of the world that renders it — staleness is impossible by
|
||||
//! construction. The decode (the expensive part) stays on the workers;
|
||||
//! they deliver plain RGBA8 bytes.
|
||||
//!
|
||||
//! The device and queue are Slint's own, captured from
|
||||
//! `Window::set_rendering_notifier` (`GraphicsAPI::WGPU30`) at
|
||||
//! `RenderingSetup` — so the texture is guaranteed to be sampleable by
|
||||
//! Slint's Skia renderer (`import_wgpu_texture`), with no cross-context
|
||||
//! import needed.
|
||||
//! `RenderingSetup` (again after every window/surface recreation) and
|
||||
//! invalidated at `RenderingTeardown` / activity re-creation.
|
||||
//!
|
||||
//! Fallback: until `RenderingSetup` fires (or on a non-wgpu backend)
|
||||
//! Fallback: until the first `RenderingSetup` (or on a non-wgpu backend)
|
||||
//! `upload_rgba` returns `None` and the caller delivers the RGBA8 bytes
|
||||
//! the old way (`Image::from_rgba8`).
|
||||
|
||||
use std::sync::{Arc, OnceLock, atomic::AtomicBool};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// The wgpu 30.x crate re-exported by Slint (same version it renders with).
|
||||
pub use slint::wgpu_30::wgpu;
|
||||
|
||||
static GPU: OnceLock<(wgpu::Device, wgpu::Queue)> = OnceLock::new();
|
||||
struct Ctx {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
|
||||
static GPU: Mutex<Option<Ctx>> = Mutex::new(None);
|
||||
|
||||
/// Capture Slint's wgpu device + queue. Called from the rendering notifier
|
||||
/// (UI thread, once, at `RenderingSetup`).
|
||||
pub fn init(api: &slint::GraphicsAPI) {
|
||||
/// (UI thread) at every `RenderingSetup` — i.e. for EVERY renderer, which
|
||||
/// happens again after a window/surface recreation.
|
||||
///
|
||||
/// Returns `true` if an existing device was REPLACED: the caller must
|
||||
/// invalidate every GPU-backed image in its model (they wrap textures of
|
||||
/// the destroyed device). The old `Ctx` is dropped, releasing our
|
||||
/// references to the dead device.
|
||||
pub fn capture(api: &slint::GraphicsAPI) -> bool {
|
||||
if let slint::GraphicsAPI::WGPU30 { device, queue, .. } = api {
|
||||
let _ = GPU.set((device.clone(), queue.clone()));
|
||||
let mut g = GPU.lock().unwrap();
|
||||
let replaced = g.is_some();
|
||||
*g = Some(Ctx { device: device.clone(), queue: queue.clone() });
|
||||
replaced
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer is going away (RenderingTeardown) or the activity was
|
||||
/// re-created before the new window's first frame: the captured device is
|
||||
/// dead or about to die. Drop the reference so the next `upload_rgba`
|
||||
/// falls back to CPU pixels until the new `RenderingSetup` captures.
|
||||
pub fn invalidate() {
|
||||
*GPU.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
/// True once Slint's wgpu device has been captured.
|
||||
pub fn ready() -> bool {
|
||||
GPU.get().is_some()
|
||||
GPU.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes and
|
||||
/// submit the (async) upload. **Runs on the decode worker thread.**
|
||||
/// Create a GPU-resident `Rgba8Unorm` texture from packed RGBA8 bytes,
|
||||
/// submit the (async) upload and wrap it in a ready-to-present
|
||||
/// `slint::Image`. **Runs on the UI thread (render thread)** — see the
|
||||
/// module docs for why this is deliberate.
|
||||
///
|
||||
/// Returns `None` until the device is captured (see fallback).
|
||||
pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option<wgpu::Texture> {
|
||||
pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option<slint::Image> {
|
||||
use wgpu::{
|
||||
Extent3d, Origin3d, TexelCopyBufferLayout, TexelCopyTextureInfo, TextureAspect,
|
||||
TextureDescriptor, TextureDimension, TextureUsages,
|
||||
};
|
||||
// Defensive: a 0-dimension create_texture is a wgpu validation error
|
||||
// that PANICS the calling (worker) thread. Callers already guard, but
|
||||
// never let a bad decode take down a worker.
|
||||
if width == 0 || height == 0 {
|
||||
log::warn!("gputex: refusing zero-dimension upload ({width}x{height})");
|
||||
// that PANICS. Callers already guard, but never let a bad decode take
|
||||
// down the UI thread.
|
||||
if width == 0 || height == 0 || rgba.len() < (4 * width as usize * height as usize) {
|
||||
log::warn!("gputex: refusing bad upload ({width}x{height}, {} bytes)", rgba.len());
|
||||
return None;
|
||||
}
|
||||
let (device, queue) = GPU.get()?;
|
||||
// Clone the device/queue refs (Arc interior — cheap) and drop the lock
|
||||
// before the allocation/submit.
|
||||
let (device, queue) = {
|
||||
let g = GPU.lock().unwrap();
|
||||
g.as_ref().map(|c| (c.device.clone(), c.queue.clone()))
|
||||
}?;
|
||||
let texture = device.create_texture(&TextureDescriptor {
|
||||
label: Some("gallery-thumb"),
|
||||
size: Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
|
|
@ -100,72 +143,5 @@ pub fn upload_rgba(rgba: &[u8], width: u32, height: u32) -> Option<wgpu::Texture
|
|||
},
|
||||
Extent3d { width, height, depth_or_array_layers: 1 },
|
||||
);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
/// GPU pipeline keep-alive.
|
||||
///
|
||||
/// INTENDED: keep the wgpu/Vulkan submission path warm so frames after a
|
||||
/// 30–80 ms input micro-pause don't pay 23–46 ms of wake-up.
|
||||
///
|
||||
/// **DISABLED BY DEFAULT (2026-10-28).** On the Pixel 9 Pro (Mali G715)
|
||||
/// the 16 ms submission stream from a 5th thread, racing Slint's own
|
||||
/// render/present path AND the four decode workers on the SAME queue, made
|
||||
/// the display pipeline freeze — the whole phone UI hung for seconds and
|
||||
/// 51 in-flight decodes stalled ~6 s. Concurrent submitters on one Mali
|
||||
/// queue are not safe in practice. Re-enable only via the debug knob
|
||||
/// `/sdcard/Download/.gallery_keep` (REMOVE after the hunt).
|
||||
pub struct Keepalive {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
static KEEP_BUFS: OnceLock<(wgpu::Buffer, wgpu::Buffer)> = OnceLock::new();
|
||||
|
||||
impl Keepalive {
|
||||
/// Spawn the keep-alive thread. INACTIVE unless the debug knob
|
||||
/// `/sdcard/Download/.gallery_keep` exists.
|
||||
pub fn start() -> Self {
|
||||
#[cfg(target_os = "android")]
|
||||
if !std::path::Path::new("/sdcard/Download/.gallery_keep").exists() {
|
||||
log::info!("gputex: keep-alive inactive (default off; .gallery_keep to enable)");
|
||||
return Self { stop: Arc::new(AtomicBool::new(true)) };
|
||||
}
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_t = stop.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("gpu-keep".into())
|
||||
.spawn(move || {
|
||||
crate::prio::set_self_nice(10); // must never jank the UI thread
|
||||
while !stop_t.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if let Some((device, queue)) = GPU.get() {
|
||||
let (src, dst) = KEEP_BUFS.get_or_init(|| {
|
||||
let desc = wgpu::BufferDescriptor {
|
||||
label: Some("gallery-keep"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
};
|
||||
let a = device.create_buffer(&desc);
|
||||
let b = device.create_buffer(&desc);
|
||||
queue.write_buffer(&a, 0, &[0u8; 16]);
|
||||
(a, b)
|
||||
});
|
||||
let mut enc =
|
||||
device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
|
||||
enc.copy_buffer_to_buffer(src, 0, dst, 0, 8);
|
||||
queue.submit(Some(enc.finish()));
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(16));
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
log::info!("gputex: keep-alive started");
|
||||
Self { stop }
|
||||
}
|
||||
|
||||
/// Signal the thread to stop (does not join; drop is immediate).
|
||||
pub fn stop(&mut self) {
|
||||
self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
log::info!("gputex: keep-alive stopped");
|
||||
}
|
||||
slint::Image::try_from(texture).ok()
|
||||
}
|
||||
|
|
|
|||
307
src/lib.rs
307
src/lib.rs
|
|
@ -218,8 +218,41 @@ const STALL_ALARM_MS: u64 = 500;
|
|||
|
||||
static STATE: OnceLock<AppState> = OnceLock::new();
|
||||
|
||||
/// Per-world id (incremented in android_main): each activity (re)creation
|
||||
/// runs android_main on a fresh thread, which creates a fresh Slint
|
||||
/// platform/world (i-slint-core's context is thread-local).
|
||||
static WORLD_ID: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
/// The world that owns the current activity. A world's drain tick no-ops
|
||||
/// (and stays a no-op) once this moves on: the old world's window is dead,
|
||||
/// its renderer/device will be dropped with its context, and — critically —
|
||||
/// it must never drain the shared loader state again, or it would upload
|
||||
/// textures into a model that the new world's device does not own (the
|
||||
/// stale-texture panic class, 2026-10-28).
|
||||
static CURRENT_WORLD: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(u32::MAX);
|
||||
|
||||
/// Create the thumbnail engine (process cache dir + sized worker pool).
|
||||
fn make_engine() -> Result<Engine, String> {
|
||||
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let cache_dir = base.join("thumbs");
|
||||
let workers =
|
||||
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
|
||||
// MAX_WORKER_QUEUE (96): bound each worker's pending queue so stale
|
||||
// transit work can never delay the viewport (overflow is dropped
|
||||
// with a `skipped` result; re-entry is a disk-cache hit).
|
||||
let engine = Engine::new(cache_dir, workers, GRID_TARGET, MAX_WORKER_QUEUE)?;
|
||||
log::info!(
|
||||
"gallery: thumb engine up: {} workers, target {} px, cache {}",
|
||||
workers,
|
||||
GRID_TARGET,
|
||||
engine.cache_dir().display()
|
||||
);
|
||||
Ok(engine)
|
||||
}
|
||||
|
||||
struct AppState {
|
||||
engine: Engine,
|
||||
/// Mutex: an activity re-creation (task dismissed + relaunched while the
|
||||
/// process lives) REPLACES the engine — see `android_main`.
|
||||
engine: std::sync::Mutex<Engine>,
|
||||
/// Current list (newest first). UI thread only.
|
||||
entries: Mutex<Vec<Entry>>,
|
||||
/// Cell range the user can see (visible ± MARGIN_ROWS). Set by on_scroll
|
||||
|
|
@ -244,23 +277,9 @@ struct AppState {
|
|||
/// Create the pool + cache dir once per process.
|
||||
fn state() -> &'static AppState {
|
||||
STATE.get_or_init(|| {
|
||||
let base = db::db_path().parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let cache_dir = base.join("thumbs");
|
||||
let workers =
|
||||
(std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4) / 2).clamp(2, 8);
|
||||
// MAX_WORKER_QUEUE (96): bound each worker's pending queue so stale
|
||||
// transit work can never delay the viewport (overflow is dropped
|
||||
// with a `skipped` result; re-entry is a disk-cache hit).
|
||||
let engine =
|
||||
Engine::new(cache_dir, workers, GRID_TARGET, MAX_WORKER_QUEUE).expect("thumb engine");
|
||||
log::info!(
|
||||
"gallery: thumb engine up: {} workers, target {} px, cache {}",
|
||||
workers,
|
||||
GRID_TARGET,
|
||||
engine.cache_dir().display()
|
||||
);
|
||||
let engine = make_engine().expect("thumb engine");
|
||||
AppState {
|
||||
engine,
|
||||
engine: std::sync::Mutex::new(engine),
|
||||
entries: Mutex::new(Vec::new()),
|
||||
viewport: Mutex::new(0..0),
|
||||
painted: Mutex::new(HashSet::new()),
|
||||
|
|
@ -320,7 +339,7 @@ fn set_list(weak: &slint::Weak<Main>, entries: Vec<Entry>, status: String) {
|
|||
let _ = slint::invoke_from_event_loop(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let Some(app) = STATE.get() else { return };
|
||||
app.engine.bump_generation();
|
||||
app.engine.lock().unwrap().bump_generation();
|
||||
app.inflight.lock().unwrap().clear();
|
||||
app.painted.lock().unwrap().clear();
|
||||
app.failed.lock().unwrap().clear();
|
||||
|
|
@ -384,30 +403,27 @@ fn apply_cell_updates(
|
|||
let Some(cells) = cells_of(&item) else {
|
||||
continue;
|
||||
};
|
||||
let image = if let Some(tex) = r.gpu.clone() { // cheap: Arc interior
|
||||
// GPU-resident texture: the worker created it on Slint's own
|
||||
// wgpu device and submitted the upload (async). Zero pixel
|
||||
// work in this frame — first present costs ~0 (was ~11 ms
|
||||
// with from_rgba8; see gputex).
|
||||
match Image::try_from(tex) {
|
||||
Ok(img) => img,
|
||||
// Defensive: impossible with our format/usage — the CPU
|
||||
// fallback bytes are NOT delivered in this case.
|
||||
Err(e) => {
|
||||
log::warn!("gputex import failed: {e}");
|
||||
let mut g = SharedPixelBuffer::<Rgba8Pixel>::new(1, 1);
|
||||
g.make_mut_bytes().fill(0);
|
||||
Image::from_rgba8(g)
|
||||
}
|
||||
// ok with no pixels (0-dim decode): treat as failed — never
|
||||
// re-submit (the fill step checks `failed`).
|
||||
if r.rgb.is_empty() {
|
||||
if let Some(e) = entries.get(r.id).filter(|e| e.path == r.path) {
|
||||
app.failed.lock().unwrap().insert((e.path.clone(), e.mtime, e.size));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Upload on THIS thread (the render thread of the world that
|
||||
// presents the cell) to Slint's current wgpu device — the
|
||||
// known-good-context contract; see gputex docs. Until the first
|
||||
// RenderingSetup (or on a non-wgpu backend) fall back to CPU
|
||||
// pixels — Slint's Skia backend has no RGB888 path, hence the
|
||||
// worker-side to_rgba8.
|
||||
let image = match gputex::upload_rgba(&r.rgb, r.width, r.height) {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(r.width, r.height);
|
||||
buffer.make_mut_bytes().copy_from_slice(&r.rgb);
|
||||
Image::from_rgba8(buffer)
|
||||
}
|
||||
} 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(
|
||||
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))
|
||||
}
|
||||
|
||||
/// Render notifier: one-time capture of Slint's wgpu device so the decode
|
||||
/// workers can upload thumbnail textures off the UI thread (gputex). Runs
|
||||
/// on the UI thread during rendering; the work is a one-shot store.
|
||||
fn install_render_notifier(window: &Main) {
|
||||
let _ = window.window().set_rendering_notifier(|state, api| {
|
||||
if let slint::RenderingState::RenderingSetup = state {
|
||||
gputex::init(api);
|
||||
log::info!(
|
||||
"gputex: wgpu device {}",
|
||||
if gputex::ready() { "captured" } else { "NOT captured" }
|
||||
);
|
||||
/// Clear all painted cells IN PLACE (the eviction path) and reset the
|
||||
/// loader state. Call from the render notifier (UI thread).
|
||||
///
|
||||
/// In-place: the model objects are NOT replaced. A mid-render
|
||||
/// `VecModel::set_vec` (reset notification) desyncs the for-loop — row
|
||||
/// items keep stale nested models and later in-place paints never reach
|
||||
/// their items (the sparse-grid-after-recreation bug, 2026-10-28).
|
||||
/// Clearing cells reuses the proven eviction mechanism; the tick
|
||||
/// re-fills from the disk cache on the new device.
|
||||
fn reset_grid_in_place(w: &Main, app: &AppState) {
|
||||
// In-flight results hold textures of the dying device — drop them.
|
||||
app.engine.lock().unwrap().bump_generation();
|
||||
app.inflight.lock().unwrap().clear();
|
||||
app.failed.lock().unwrap().clear();
|
||||
let painted: Vec<usize> = app.painted.lock().unwrap().drain().collect();
|
||||
let n = {
|
||||
let entries = app.entries.lock().unwrap();
|
||||
if let Some(m) = w.get_items().as_any().downcast_ref::<VecModel<Item>>() {
|
||||
for &id in &painted {
|
||||
if id >= entries.len() {
|
||||
continue;
|
||||
}
|
||||
let row = id / GRID_COLS;
|
||||
if row >= m.row_count() {
|
||||
continue;
|
||||
}
|
||||
let Some(item) = m.row_data(row) else { continue };
|
||||
let Some(cells) = cells_of(&item) else { continue };
|
||||
cells.set_row_data(
|
||||
id % GRID_COLS,
|
||||
Cell {
|
||||
path: SharedString::from(entries[id].path.clone()),
|
||||
image: Image::default(),
|
||||
loaded: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
entries.len()
|
||||
};
|
||||
*app.viewport.lock().unwrap() = 0..INITIAL_VIEWPORT_CELLS.min(n);
|
||||
}
|
||||
|
||||
/// Render notifier: the official Slint graphics-lifecycle hooks.
|
||||
///
|
||||
/// `RenderingTeardown` — "the window will be destroyed and/or graphics
|
||||
/// resources need to be released due to other constraints" (slint docs).
|
||||
/// On this backend it fires from `set_window_handle` → `suspend()` →
|
||||
/// `clear_surface()` whenever the window gets a NEW surface (activity
|
||||
/// recreation while the process lives). The old wgpu device is about to
|
||||
/// be dropped: every texture uploaded to it is dead, so block uploads
|
||||
/// (`mark_stale`) and clear the painted cells — they wrap those
|
||||
/// textures, and drawing them into any render pass panics wgpu-core
|
||||
/// (stale id in the shared registry: `transition_resources`).
|
||||
///
|
||||
/// `RenderingSetup` — capture the NEW wgpu device so the decode workers
|
||||
/// can upload off the UI thread (gputex). If this REPLACED a previous
|
||||
/// device (recreation), the cell Images still wrap dead textures —
|
||||
/// reset the grid in place; the tick re-fills from the disk cache.
|
||||
///
|
||||
/// Both run synchronously on the UI thread before the render walk, so no
|
||||
/// frame ever draws a dead texture.
|
||||
fn install_render_notifier(window: &Main, wid: u32) {
|
||||
let weak = window.as_weak();
|
||||
let _ = window.window().set_rendering_notifier(move |state, api| {
|
||||
match state {
|
||||
slint::RenderingState::RenderingTeardown => {
|
||||
gputex::invalidate();
|
||||
let Some(app) = STATE.get() else { return };
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
log::info!("gallery: [W{wid}] rendering teardown — releasing GPU cell state");
|
||||
reset_grid_in_place(&w, app);
|
||||
}
|
||||
slint::RenderingState::RenderingSetup => {
|
||||
if gputex::capture(api) {
|
||||
let Some(app) = STATE.get() else { return };
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
log::info!("gallery: [W{wid}] new rendering context — resetting grid from cache");
|
||||
reset_grid_in_place(&w, app);
|
||||
} else if gputex::ready() {
|
||||
log::info!("gputex: [W{wid}] wgpu device captured");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -496,7 +585,6 @@ mod nblog {
|
|||
//! UI thread for 10–40 ms. Records are formatted at the call site and
|
||||
//! pushed into a bounded channel; a drain thread does the real
|
||||
//! `__android_log_write` and DROPS records when saturated.
|
||||
use std::io::Write as _;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use log::{Level, Log, Metadata, Record};
|
||||
|
|
@ -596,20 +684,25 @@ fn on_scroll(app: &AppState, y: f32, height: f32, width: f32) {
|
|||
/// lines are only visible at log level Debug).
|
||||
static DBG_TICKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
fn start_drain_timer(weak: &slint::Weak<Main>) {
|
||||
fn start_drain_timer(weak: &slint::Weak<Main>, wid: u32) {
|
||||
let weak = weak.clone();
|
||||
let timer = Timer::default();
|
||||
timer.start(
|
||||
TimerMode::Repeated,
|
||||
std::time::Duration::from_millis(DRAIN_INTERVAL_MS),
|
||||
move || {
|
||||
// Superseded by a re-created activity: this world's window is
|
||||
// dead. Stop touching the shared state (see CURRENT_WORLD).
|
||||
if CURRENT_WORLD.load(std::sync::atomic::Ordering::Relaxed) != wid {
|
||||
return;
|
||||
}
|
||||
let Some(app) = STATE.get() else { return };
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let model = w.get_items();
|
||||
let Some(m) = model.as_any().downcast_ref::<VecModel<Item>>() else {
|
||||
return
|
||||
};
|
||||
let results = app.engine.drain();
|
||||
let results = app.engine.lock().unwrap().drain();
|
||||
let entries = app.entries.lock().unwrap();
|
||||
if entries.is_empty() {
|
||||
return;
|
||||
|
|
@ -667,10 +760,10 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
log::debug!(
|
||||
"DBG tick vp={}..{} p={p} i={i} f={f} oldest={oldest}ms {}",
|
||||
"DBG tick [W{wid}] vp={}..{} p={p} i={i} f={f} oldest={oldest}ms {}",
|
||||
viewport.start,
|
||||
viewport.end,
|
||||
app.engine.log_summary()
|
||||
app.engine.lock().unwrap().log_summary()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -772,7 +865,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
.collect();
|
||||
missing.sort_by_key(|id| center.abs_diff(*id));
|
||||
if !missing.is_empty() {
|
||||
let gen = app.engine.generation();
|
||||
let gen = app.engine.lock().unwrap().generation();
|
||||
let now = Instant::now();
|
||||
let jobs: Vec<Job> = missing
|
||||
.iter()
|
||||
|
|
@ -789,7 +882,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
})
|
||||
.collect();
|
||||
drop(inflight);
|
||||
app.engine.submit(&jobs);
|
||||
app.engine.lock().unwrap().submit(&jobs);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -797,7 +890,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
let lo = viewport.start.saturating_sub(MARGIN_ROWS * GRID_COLS);
|
||||
let hi = (viewport.end + MARGIN_ROWS * GRID_COLS).min(entries.len());
|
||||
let band = lo..hi;
|
||||
let dropped = app.engine.prune_outside(&band);
|
||||
let dropped = app.engine.lock().unwrap().prune_outside(&band);
|
||||
if !dropped.is_empty() {
|
||||
// The pruned cells are no longer "in flight" — clear the
|
||||
// tracker so a later re-entry re-submits them (disk-cache
|
||||
|
|
@ -842,7 +935,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
"gallery: stall: {} in flight, oldest {} ms, {}",
|
||||
inflight.len(),
|
||||
oldest.as_millis(),
|
||||
app.engine.log_summary()
|
||||
app.engine.lock().unwrap().log_summary()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -856,7 +949,7 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
log::info!(
|
||||
"gallery: {} thumbs painted, {}",
|
||||
total,
|
||||
app.engine.log_summary()
|
||||
app.engine.lock().unwrap().log_summary()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -868,15 +961,15 @@ fn start_drain_timer(weak: &slint::Weak<Main>) {
|
|||
/// M1: cached DB paints instantly on relaunch; a background walk refreshes it
|
||||
/// (streaming into the list on cold start). M3: the tick fills the viewport
|
||||
/// as it moves — top cells right after (re)paint, visible cells on scroll.
|
||||
pub fn start_scan(window: &Main) {
|
||||
install_render_notifier(window);
|
||||
pub fn start_scan(window: &Main, wid: u32) {
|
||||
install_render_notifier(window, wid);
|
||||
let model = ModelRc::new(VecModel::<Item>::default());
|
||||
window.set_items(model);
|
||||
let weak = window.as_weak();
|
||||
let app = state();
|
||||
|
||||
window.on_scroll_changed(move |y: f32, h: f32, w: f32| on_scroll(app, y, h, w));
|
||||
start_drain_timer(&weak);
|
||||
start_drain_timer(&weak, wid);
|
||||
|
||||
std::thread::spawn(move || {
|
||||
scan_thread(&weak);
|
||||
|
|
@ -990,33 +1083,87 @@ pub fn android_app() -> Option<&'static slint::android::AndroidApp> {
|
|||
#[unsafe(no_mangle)]
|
||||
fn android_main(app: slint::android::AndroidApp) {
|
||||
nblog::init();
|
||||
let wid = WORLD_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
// This world owns the current activity. Previous worlds' drain ticks
|
||||
// see the switch and go quiet (see CURRENT_WORLD).
|
||||
CURRENT_WORLD.store(wid, std::sync::atomic::Ordering::Relaxed);
|
||||
log::info!("gallery: [W{wid}] android_main entered");
|
||||
let _ = ANDROID_APP.set(app.clone());
|
||||
|
||||
// Activity re-creation (task dismissed + relaunched while the process
|
||||
// lives): android-activity runs this function AGAIN on a fresh thread,
|
||||
// and Slint's platform is thread-local (i-slint-core `GLOBAL_CONTEXT`)
|
||||
// — so this becomes the new (visible) world, and the previous one
|
||||
// becomes a zombie whose tick is gated off by CURRENT_WORLD above.
|
||||
// Two cleanups for the transition:
|
||||
// (a) drop the captured wgpu device: the previous world's renderer
|
||||
// owns it and is dying; uploading to it would panic wgpu-core.
|
||||
// The new world's first-frame RenderingSetup re-captures (and
|
||||
// resets the grid in place) before any paint.
|
||||
// (b) replace the thumbnail engine: its in-flight results (plain
|
||||
// pixels now, but the workers themselves) belong to the old
|
||||
// world; the Drop interrupts the old workers.
|
||||
if STATE.get().is_some() {
|
||||
log::info!("gallery: [W{wid}] activity re-created — replacing thumb engine");
|
||||
gputex::invalidate();
|
||||
let app = STATE.get().expect("checked above");
|
||||
let mut g = app.engine.lock().unwrap();
|
||||
let old = std::mem::replace(&mut *g, make_engine().expect("thumb engine re-init"));
|
||||
drop(old);
|
||||
}
|
||||
|
||||
// Panic hook: the default hook dumps to stderr (logcat) WITHOUT the
|
||||
// thread name context we need; route panics through the non-blocking
|
||||
// logger with thread name + backtrace. A background-thread panic
|
||||
// (e.g. a stale wgpu handle dropped across a window recreation) must
|
||||
// never take the process down, but it must be VISIBLE.
|
||||
std::env::set_var("RUST_BACKTRACE", "1");
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
let name = std::thread::current().name().unwrap_or("<unnamed>").to_string();
|
||||
let msg = info
|
||||
.payload()
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| info.payload().downcast_ref::<String>().cloned())
|
||||
.unwrap_or_default();
|
||||
let loc = info
|
||||
.location()
|
||||
.map(|l| format!("{}:{}", l.file(), l.line()))
|
||||
.unwrap_or_default();
|
||||
let bt = std::backtrace::Backtrace::force_capture();
|
||||
// Full detail to a file (logcat truncates long lines); short line
|
||||
// to logcat.
|
||||
let dir = std::path::Path::new(
|
||||
"/sdcard/Android/data/wow.gmp.gallery/files/panics", );
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
let n = std::fs::read_dir(dir)
|
||||
.map(|d| d.count())
|
||||
.unwrap_or(0);
|
||||
let _ = std::fs::write(
|
||||
dir.join(format!("{n:03}.txt")),
|
||||
format!("thread '{name}' at {loc}\n{msg}\n{bt}"),
|
||||
);
|
||||
log::error!("gallery: PANIC thread '{name}' at {loc}: {msg} (backtrace in files/panics/{n:03}.txt)");
|
||||
}));
|
||||
|
||||
// `init_with_event_listener` gives us the raw MainEvent stream
|
||||
// (Start/Resume/Pause/Stop/SaveState/ConfigChanged/...) while Slint owns
|
||||
// the event loop. This is Slint's one first-class Android lifecycle hook:
|
||||
// M5 re-checks storage permission on Resume (user returned from Settings).
|
||||
// GPU pipeline keep-alive (gputex): started on Start/Resume, stopped
|
||||
// on Pause/Stop (battery). INACTIVE BY DEFAULT — on Mali it froze the
|
||||
// display pipeline (see gputex::Keepalive docs). Debug knob:
|
||||
// .gallery_keep.
|
||||
slint::android::init_with_event_listener(app, |event| {
|
||||
use slint::android::android_activity::{MainEvent, PollEvent};
|
||||
// Fn (not FnMut): mutable state in a static.
|
||||
static KEEPALIVE: std::sync::Mutex<Option<gputex::Keepalive>> =
|
||||
std::sync::Mutex::new(None);
|
||||
match event {
|
||||
PollEvent::Main(MainEvent::Resume { .. } | MainEvent::Start { .. }) => {
|
||||
log::info!("gallery: app resumed");
|
||||
if KEEPALIVE.lock().unwrap().is_none() {
|
||||
*KEEPALIVE.lock().unwrap() = Some(gputex::Keepalive::start());
|
||||
}
|
||||
// TODO(M5): storage::recheck_permission()
|
||||
}
|
||||
PollEvent::Main(MainEvent::Pause { .. } | MainEvent::Stop { .. }) => {
|
||||
if let Some(mut k) = KEEPALIVE.lock().unwrap().take() {
|
||||
k.stop();
|
||||
}
|
||||
// The window is being destroyed (activity destroyed — e.g. task
|
||||
// dismissed). The renderer will destroy the wgpu device we
|
||||
// captured — drop the reference NOW so the drain tick falls
|
||||
// back to CPU pixels instead of touching a dead device.
|
||||
PollEvent::Main(MainEvent::TerminateWindow { .. }) => {
|
||||
gputex::invalidate();
|
||||
log::info!("gallery: window terminated — GPU device invalidated");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -1045,6 +1192,6 @@ fn android_main(app: slint::android::AndroidApp) {
|
|||
.ok();
|
||||
|
||||
let main_window = Main::new().unwrap();
|
||||
start_scan(&main_window);
|
||||
start_scan(&main_window, wid);
|
||||
main_window.run().unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
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
|
||||
/// it (disk-cache hit if it was ever decoded, otherwise a fresh decode).
|
||||
pub skipped: bool,
|
||||
/// GPU-resident thumbnail texture (created by the worker on Slint's
|
||||
/// own wgpu device; upload already submitted, async). When `Some`,
|
||||
/// `rgb` is empty and the UI wraps it in `Image::try_from(tex)` —
|
||||
/// zero pixel work in the render frame.
|
||||
pub gpu: Option<crate::gputex::wgpu::Texture>,
|
||||
/// Fallback only (gpu == None): RGBA8 (A=255), orientation applied,
|
||||
/// fitted to the engine's target size. Expanded on the worker because
|
||||
/// RGBA8 (A=255), orientation applied, fitted to the engine's target
|
||||
/// size; empty when `!ok`/`skipped`. Expanded on the worker because
|
||||
/// Slint's Skia backend can't take RGB888 (it would convert
|
||||
/// byte-by-byte on the UI thread during the render frame).
|
||||
///
|
||||
/// Delivered as PLAIN BYTES (no GPU handles): the UI thread uploads
|
||||
/// them to Slint's own wgpu device at drain time (gputex) — a texture
|
||||
/// must be created on the device of the world that renders it, and
|
||||
/// only the render thread knows which that is across window
|
||||
/// recreations.
|
||||
pub rgb: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
|
|
@ -392,7 +393,6 @@ fn dispatch_loop(
|
|||
path: j.path,
|
||||
ok: false,
|
||||
skipped: true,
|
||||
gpu: None,
|
||||
rgb: Vec::new(),
|
||||
width: 0,
|
||||
height: 0,
|
||||
|
|
@ -481,30 +481,17 @@ fn worker_loop(
|
|||
// Expand RGB8 -> RGBA8 HERE (worker thread), not in Slint's render
|
||||
// pass — see JobResult docs. Cost: ~0.3 ms per 400 px thumb.
|
||||
let rgba = if ok { crate::images::to_rgba8(&rgb) } else { rgb };
|
||||
// Upload to the GPU on Slint's own device/queue (async, no UI
|
||||
// involvement). On success the CPU bytes are NOT sent over the
|
||||
// channel — the texture is already resident. See gputex.
|
||||
// Zero-dimension decode results (undecodable files) must NOT create
|
||||
// a texture: wgpu validation panics the worker thread on a 0-width
|
||||
// create_texture (measured 2026-10-28: 'Dimension X is zero' killed
|
||||
// thumb-3 mid-scroll).
|
||||
let gpu = if ok && width > 0 && height > 0 {
|
||||
crate::gputex::upload_rgba(&rgba, width, height)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let rgb = match &gpu {
|
||||
Some(_) => Vec::new(),
|
||||
None => rgba,
|
||||
};
|
||||
// Delivered as plain bytes: the UI thread uploads to Slint's
|
||||
// current wgpu device at drain time (gputex::upload_rgba). No
|
||||
// worker ever touches a wgpu device — that was the source of the
|
||||
// stale-texture panics across window recreations (2026-10-28).
|
||||
let _ = res_tx.send(JobResult {
|
||||
id: job.id,
|
||||
gen: job.gen,
|
||||
path: job.path,
|
||||
ok,
|
||||
skipped: false,
|
||||
gpu,
|
||||
rgb,
|
||||
rgb: rgba,
|
||||
width,
|
||||
height,
|
||||
from_cache,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user