gallery/src/thumb.rs
Greg Pomerantz d5bbb41bbd Jank-hunt perf feature: FR/STG/DECODE instrumentation, off by default
Residual scroll jank at 120 Hz (suspected Slint event-loop bug) needs
per-frame + per-stage timing the logcat ring buffer can't hold. New
cargo feature `perf` (default OFF — all sites are cfg-gated, zero
overhead when off):

- FR: per-frame app-side render cost + frame gap, from the rendering
  notifier (BeforeRendering/AfterRendering).
- STG: event-loop stage costs (wake/timers/events/poll/lastrender) from
  timestamps instrumented into the pinned vendor copy of
  i-slint-backend-android-activity (stg.rs; Cargo.toml now depends on
  it directly + optional under `perf`).
- DECODE: per-thumbnail-job cost on the workers.
- All lines mirrored to /sdcard/Download/.gallery_perf.log (logcat
  rotates too fast at ~120 lines/s).

Format + reading guide: docs/BUGS/scroll-jank-120hz.md.
2026-10-29 08:11:51 -04:00

839 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Thumbnail engine (M2): worker pool, disk cache, cancellation, timing logs.
//!
//! ```text
//! UI ──submit──> dispatcher ──round-robin──> N workers ──results──> UI
//! worker:
//! 1. disk cache hit? thumbs/{sha1(path|mtime|size)}.jpg → decode, done
//! 2. else: shrink-decode → orient → fit → encode → cache (atomic rename)
//! 3. generation check → deliver (id, rgba8) — the UI thread builds the
//! slint::Image (Image is !Send, pixel bytes are not)
//! ```
//!
//! Cancellation is a process-wide generation counter (`AtomicU64`): the UI
//! bumps it whenever the list is reset (new scan); workers that finish a job
//! from a stale generation keep the disk-cache write (content-keyed, still
//! valid) but drop the UI delivery. The queue is unbounded on purpose —
//! backlog is visible in the timing logs (acceptance: it must not grow while
//! scrolling).
//!
//! Queue ordering is PREPEND (not FIFO): `submit` puts each new batch at the
//! FRONT of the queue. The most recently requested content is what the user
//! is looking at (or scrolling back to); letting a backlog of stale far-away
//! cold decodes sit ahead of it starved fast disk-cache-hit re-submissions —
//! the scroll-back-to-top shimmer bug. Regression: `scroll_back_cache_hit_not_starved`.
use std::collections::VecDeque;
use std::fs;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Instant;
use sha1::{Digest, Sha1};
use crate::db;
use crate::images;
/// Grid thumbnails: ~400 px (longest side). Cells render ~350–420 px physical
/// (3 columns) on a phone. Was 500: each newly-painted cell's 700 KB texture
/// upload stalls the render frame 25–40 ms on the phone (measured 2026-10-28:
/// the 25–40 ms stall bucket tracks cell-entry rate and vanishes with 1×1
/// test images). 400 cuts the upload ~36% at a 1.04× upscale — visually
/// identical in a 3-column grid. The target is part of the cache key, so a
/// future different-size target coexists in the same dir.
pub const GRID_TARGET: u32 = 400;
const JPEG_QUALITY: u8 = 85;
/// Per-worker queue cap. A burst (one tick's submissions) is never bigger
/// than the viewport (~45 cells), so fresh viewport work ALWAYS survives the
/// cap; overflow is dropped from the TAIL (oldest transit work). 96 ≈ two
/// screens per worker of headroom.
pub const MAX_WORKER_QUEUE: usize = 96;
#[derive(Debug, Clone)]
pub struct Job {
/// Row index in the UI model at submit time.
pub id: usize,
pub path: String,
pub mtime: i64,
pub size: i64,
/// Generation the job belongs to (stale jobs are not delivered).
pub gen: u64,
}
/// One worker's job queue: Mutex<VecDeque> + Condvar, NOT mpsc — mpsc is
/// FIFO-only, but the point of this design is that a new batch (the user's
/// current intent) must be placed AHEAD of older queued work. The
/// dispatcher prepends each burst in order; the worker pops the front.
struct WorkerQueue {
q: Mutex<VecDeque<Job>>,
cv: Condvar,
}
impl WorkerQueue {
fn new() -> Self {
Self { q: Mutex::new(VecDeque::new()), cv: Condvar::new() }
}
}
#[derive(Debug)]
pub struct JobResult {
pub id: usize,
pub gen: u64,
pub path: String,
pub ok: bool,
/// The job was DROPPED before being decoded (worker-queue overflow:
/// it was stale transit work behind newer viewport work). The UI clears
/// 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,
/// 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,
pub from_cache: bool,
pub elapsed_ms: u128,
}
pub struct Engine {
/// Job queue: Mutex<VecDeque> + Condvar (not mpsc — mpsc is FIFO-only).
/// NEW submissions are PREPENDED: the most recently requested content is
/// what the user is looking at (or scrolling back to); stale far-away
/// prefetch must not starve it. Concretely fixes the scroll-back-to-top
/// case where fast disk-cache-hit jobs waited seconds behind a backlog of
/// slow cold decodes (regression: `scroll_back_cache_hit_not_starved`).
jobs: Arc<Mutex<VecDeque<Job>>>,
job_cv: Arc<Condvar>,
closed: Arc<AtomicBool>,
/// Only the UI thread drains; the Mutex just makes Engine Sync so it can
/// live in a process-wide OnceLock.
rx: Mutex<mpsc::Receiver<JobResult>>,
gen: Arc<AtomicU64>,
cache_dir: PathBuf,
in_flight: Arc<AtomicUsize>,
done: Arc<AtomicU64>,
hits: Arc<AtomicU64>,
errs: Arc<AtomicU64>,
target: u32,
}
impl Drop for Engine {
fn drop(&mut self) {
self.closed.store(true, Ordering::SeqCst);
self.job_cv.notify_all();
}
}
/// Cache file for a job: `thumbs/{sha1(path|mtime|size|target)}.jpg` — the
/// target is in the key so different render sizes coexist (M2 list 64 px,
/// M3 grid 500 px).
pub fn cache_path(cache_dir: &Path, path: &str, mtime: i64, size: i64, target: u32) -> PathBuf {
let mut h = Sha1::new();
h.update(path.as_bytes());
h.update(b"|");
h.update(mtime.to_le_bytes());
h.update(b"|");
h.update(size.to_le_bytes());
h.update(b"|");
h.update(target.to_le_bytes());
let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
cache_dir.join(format!("{hex}.jpg"))
}
impl Engine {
/// Spawn the dispatcher + `workers` worker threads. All jobs render at
/// `target` px (longest side). `max_worker_queue` bounds each worker's
/// pending queue (stale transit work beyond the cap is dropped with a
/// `skipped` result); pass `usize::MAX` for uncapped (tests).
pub fn new(cache_dir: PathBuf, workers: usize, target: u32, max_worker_queue: usize) -> Result<Self, String> {
fs::create_dir_all(&cache_dir).map_err(|e| format!("mkdir {}: {e}", cache_dir.display()))?;
let jobs = Arc::new(Mutex::new(VecDeque::<Job>::new()));
let job_cv = Arc::new(Condvar::new());
let closed = Arc::new(AtomicBool::new(false));
let (res_tx, res_rx) = mpsc::channel::<JobResult>();
let gen = Arc::new(AtomicU64::new(1));
let in_flight = Arc::new(AtomicUsize::new(0));
let stats = Stats {
done: Arc::new(AtomicU64::new(0)),
hits: Arc::new(AtomicU64::new(0)),
errs: Arc::new(AtomicU64::new(0)),
};
let mut wqs: Vec<Arc<WorkerQueue>> = Vec::with_capacity(workers);
for i in 0..workers {
let wq = Arc::new(WorkerQueue::new());
let (wq_t, res_tx, gen, in_flight, closed_w) =
(wq.clone(), res_tx.clone(), gen.clone(), in_flight.clone(), closed.clone());
let (done, hits, errs) = (stats.done.clone(), stats.hits.clone(), stats.errs.clone());
let cache_dir = cache_dir.clone();
std::thread::Builder::new()
.name(format!("thumb-{i}"))
.spawn(move || {
// Jank fix: decode bursts must not preempt the UI thread
// (same process priority by default) or starve the vsync
// waker. +2 keeps them ahead of background apps.
crate::prio::set_self_nice(2);
worker_loop(
&wq_t, res_tx, gen, in_flight, done, hits, errs, cache_dir, target, closed_w,
)
})
.map_err(|e| e.to_string())?;
wqs.push(wq);
}
// Dispatcher: drains the central queue as priority-ordered bursts
// and PREPENDS each burst to the worker queues (the cap drops the
// oldest tail work with `skipped` results). Exits when the engine is
// dropped and the queue has drained.
let (d_jobs, d_cv, d_closed) = (jobs.clone(), job_cv.clone(), closed.clone());
let (d_res, d_inflight) = (res_tx.clone(), in_flight.clone());
std::thread::Builder::new()
.name("thumb-dispatch".into())
.spawn(move || {
crate::prio::set_self_nice(2);
dispatch_loop(d_jobs, d_cv, d_closed, wqs, d_res, d_inflight, max_worker_queue)
})
.map_err(|e| e.to_string())?;
drop(res_tx);
Ok(Self {
jobs,
job_cv,
closed,
rx: Mutex::new(res_rx),
gen,
cache_dir,
in_flight,
done: stats.done,
hits: stats.hits,
errs: stats.errs,
target,
})
}
pub fn target(&self) -> u32 {
self.target
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
pub fn generation(&self) -> u64 {
self.gen.load(Ordering::Relaxed)
}
/// Invalidate all in-flight/pending work (list reset).
pub fn bump_generation(&self) -> u64 {
self.gen.fetch_add(1, Ordering::Relaxed) + 1
}
/// Submit jobs, PREPENDED to the queue: fresh submissions express the
/// user's current (or return) intent and must be decoded before stale
/// far-away prefetch. Within a batch the original order is preserved.
pub fn submit(&self, jobs: &[Job]) {
if jobs.is_empty() {
return;
}
{
let mut dq = self.jobs.lock().unwrap();
for j in jobs.iter().rev() {
dq.push_front(j.clone());
}
}
self.in_flight.fetch_add(jobs.len(), Ordering::Relaxed);
self.job_cv.notify_one();
}
/// Drop QUEUED (not yet started) jobs outside `range`, returning the
/// number dropped. Queued work for cells the user has scrolled away from
/// is low value: if they come back, the disk cache serves it in ~10 ms.
/// This bounds the queue to the retention band, so viewport work never
/// waits behind stale transit work (in-flight jobs finish; their
/// out-of-viewport results are dropped at consumption).
pub fn prune_outside(&self, range: &Range<usize>) -> Vec<Job> {
let mut dq = self.jobs.lock().unwrap();
let mut dropped: Vec<Job> = Vec::new();
dq.retain(|j| {
if range.contains(&j.id) {
true
} else {
dropped.push(j.clone());
false
}
});
if !dropped.is_empty() {
self.in_flight.fetch_sub(dropped.len(), Ordering::Relaxed);
}
dropped
}
/// Non-blocking drain (UI thread). Stale-generation results are dropped.
pub fn drain(&self) -> Vec<JobResult> {
let mut out = Vec::new();
let gen = self.generation();
let rx = self.rx.lock().unwrap();
while let Ok(r) = rx.try_recv() {
if r.gen == gen {
out.push(r);
}
}
out
}
/// "queued" = accepted but not yet finished.
pub fn queued(&self) -> usize {
self.in_flight.load(Ordering::Relaxed)
}
pub fn log_summary(&self) -> String {
format!(
"thumbs: {} done ({} cache hit, {} failed), {} queued",
self.done.load(Ordering::Relaxed),
self.hits.load(Ordering::Relaxed),
self.errs.load(Ordering::Relaxed),
self.queued(),
)
}
}
#[derive(Clone)]
struct Stats {
done: Arc<AtomicU64>,
hits: Arc<AtomicU64>,
errs: Arc<AtomicU64>,
}
/// The dispatcher (one thread): drains the central queue as a single
/// burst — the deque is already in priority order (newest batch first, and
/// center-first within a batch) — splits it round-robin over the workers,
/// and PREPENDS each worker's share. Overflow beyond `max_worker_queue`
/// is dropped from the TAIL (the oldest transit work) with a `skipped`
/// result so the UI can clear its in-flight marker.
///
/// This is what makes "the viewport is first" true at EVERY level of the
/// pipeline: no matter how much stale transit work is queued — or in
/// flight — a fresh submission (the current viewport) is placed ahead of
/// it. A burst never exceeds the viewport (~45) < the cap (96), so fresh
/// viewport work is never itself dropped.
fn dispatch_loop(
jobs: Arc<Mutex<VecDeque<Job>>>,
cv: Arc<Condvar>,
closed: Arc<AtomicBool>,
wqs: Vec<Arc<WorkerQueue>>,
res_tx: mpsc::Sender<JobResult>,
in_flight: Arc<AtomicUsize>,
max_worker_queue: usize,
) {
loop {
// Drain the whole central deque as one burst (priority order).
let burst: Vec<Job> = {
let mut dq = jobs.lock().unwrap();
while dq.is_empty() {
if closed.load(Ordering::SeqCst) {
return; // engine dropped, queue drained
}
dq = cv.wait(dq).unwrap();
}
dq.drain(..).collect()
};
// Round-robin split, preserving per-worker order.
let n = wqs.len();
let mut per: Vec<Vec<Job>> = vec![Vec::new(); n];
for (i, j) in burst.into_iter().enumerate() {
per[i % n].push(j);
}
for w in 0..n {
if per[w].is_empty() {
continue;
}
let dropped: Vec<Job> = {
let mut g = wqs[w].q.lock().unwrap();
let mut dropped = Vec::new();
// Cap: prepend the burst; drop overflow from the tail
// (oldest first: existing tail, then the burst's own tail).
let overflow = (per[w].len() + g.len()).saturating_sub(max_worker_queue);
for _ in 0..overflow {
match g.pop_back() {
Some(j) => dropped.push(j),
None => break,
}
}
while per[w].len().saturating_sub(g.len()) > max_worker_queue {
match per[w].pop() {
Some(j) => dropped.push(j),
None => break,
}
}
// Prepend in order (last-first): the front stays the newest.
for j in per[w].drain(..).rev() {
g.push_front(j);
}
dropped
};
wqs[w].cv.notify_one();
for j in dropped {
in_flight.fetch_sub(1, Ordering::Relaxed);
let _ = res_tx.send(JobResult {
id: j.id,
gen: j.gen,
path: j.path,
ok: false,
skipped: true,
rgb: Vec::new(),
width: 0,
height: 0,
from_cache: false,
elapsed_ms: 0,
});
}
}
}
}
fn worker_loop(
wq: &Arc<WorkerQueue>,
res_tx: mpsc::Sender<JobResult>,
gen: Arc<AtomicU64>,
in_flight: Arc<AtomicUsize>,
done: Arc<AtomicU64>,
hits: Arc<AtomicU64>,
errs: Arc<AtomicU64>,
cache_dir: PathBuf,
target: u32,
closed: Arc<AtomicBool>,
) {
// One WAL connection per worker; the scan thread keeps its own.
let mut conn = match db::open(&db::db_path()) {
Ok(c) => Some(c),
Err(e) => {
log::warn!("gallery: thumb worker db error: {e}");
None
}
};
loop {
let job = {
let mut g = wq.q.lock().unwrap();
while g.is_empty() {
if closed.load(Ordering::SeqCst) {
return; // engine dropped, queue drained
}
g = wq.cv.wait(g).unwrap();
}
g.pop_front().unwrap()
};
let t0 = Instant::now();
let (ok, thumb, from_cache) = process(&job, &cache_dir, target, &hits, &errs);
let elapsed = t0.elapsed().as_millis();
// Jank-hunt (`perf` feature): per-job decode cost, correlated
// against FR gap spikes. See docs/BUGS/scroll-jank-120hz.md.
#[cfg(feature = "perf")]
{
log::info!(
"DECODE {}ms {} {}",
elapsed,
if from_cache { "hit" } else { "miss" },
job.path
);
}
if let Some(ref mut c) = conn {
if let Some(ref t) = thumb {
if let Err(e) = db::set_thumb(
c,
&job.path,
t.src_width as i64,
t.src_height as i64,
t.orientation as i64,
cache_path(&cache_dir, &job.path, job.mtime, job.size, target)
.to_str()
.unwrap_or(""),
) {
log::warn!("gallery: db set_thumb failed for {}: {e}", job.path);
}
}
}
in_flight.fetch_sub(1, Ordering::Relaxed);
let n = done.fetch_add(1, Ordering::Relaxed) + 1;
if n % 256 == 0 {
log::info!(
"gallery: thumbs {} ({} hit, {} err, {} queued, last {} ms)",
n,
hits.load(Ordering::Relaxed),
errs.load(Ordering::Relaxed),
in_flight.load(Ordering::Relaxed),
elapsed,
);
}
// Stale generation: cache write stays (content-keyed), skip delivery.
if gen.load(Ordering::Relaxed) != job.gen {
continue;
}
let (rgb, width, height) = match thumb {
Some(t) => (t.rgb, t.width, t.height),
None => (Vec::new(), 0, 0),
};
// 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 };
// 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,
rgb: rgba,
width,
height,
from_cache,
elapsed_ms: elapsed,
});
}
}
fn process(
job: &Job,
cache_dir: &Path,
target: u32,
hits: &Arc<AtomicU64>,
errs: &Arc<AtomicU64>,
) -> (bool, Option<images::Thumb>, bool) {
let cache = cache_path(cache_dir, &job.path, job.mtime, job.size, target);
if cache.exists() {
if let Ok(t) = images::decode_jpeg(&cache) {
hits.fetch_add(1, Ordering::Relaxed);
return (true, Some(t), true);
}
// Corrupt cache entry: regenerate.
let _ = fs::remove_file(&cache);
}
match images::make_thumb(Path::new(&job.path), target) {
Ok(t) => {
match images::encode_jpeg(&t.rgb, t.width, t.height, JPEG_QUALITY) {
Ok(bytes) => {
let tmp = cache.with_extension("tmp");
if fs::write(&tmp, &bytes).is_ok() {
if let Err(e) = fs::rename(&tmp, &cache) {
log::warn!("gallery: cache rename failed: {e}");
let _ = fs::remove_file(&tmp);
}
}
}
Err(e) => log::warn!("gallery: cache encode failed for {}: {e}", job.path),
}
(true, Some(t), false)
}
Err(e) => {
errs.fetch_add(1, Ordering::Relaxed);
log::warn!("gallery: thumb failed for {}: {e}", job.path);
(false, None, false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::images;
use crate::scan::Entry;
fn make_jpeg(w: u32, h: u32, r: u8) -> Vec<u8> {
let mut rgb = Vec::with_capacity(3 * w as usize * h as usize);
for _ in 0..w * h as u32 {
rgb.extend_from_slice(&[r, 120, 200]);
}
images::encode_jpeg(&rgb, w, h, 90).unwrap()
}
/// Synthetic 5k-set engine test (HANDOFF §8/§9 M2): cold fill, timing
/// logs, instant warm refill from the disk cache, generation cancellation.
#[test]
fn engine_5k_cold_warm_cancel() {
const N: usize = 5000;
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("thumbs");
// Keep the worker threads' DB writes out of the dev database.
// (Only this test touches the DB path; the others pass explicit tempdirs.)
std::env::set_var("GALLERY_DB_PATH", dir.path().join("gallery.db"));
// 1. Generate the synthetic set (varied sizes, a few "large").
let entries = (0..N).map(|i| {
let big = i % 500 == 0; // every 500th is a "large" photo
let (w, h) = if big {
(4000u32, 3000u32)
} else {
(600u32 + (i % 8) as u32 * 200, 400u32 + (i % 6) as u32 * 150)
};
let p = dir.path().join(format!("p{i:05}.jpg"));
std::fs::write(&p, make_jpeg(w, h, (i % 250) as u8 + 2)).unwrap();
Entry { path: p.to_str().unwrap().into(), mtime: i as i64, size: std::fs::metadata(&p).unwrap().len() as i64 }
}).collect::<Vec<_>>();
// 2. Cold run.
let eng = Engine::new(cache.clone(), 4, GRID_TARGET, usize::MAX).unwrap();
let gen = eng.generation();
let t0 = Instant::now();
eng.submit(&entries.iter().enumerate().map(|(i, e)| Job {
id: i, path: e.path.clone(), mtime: e.mtime, size: e.size, gen,
}).collect::<Vec<_>>());
let mut first_48 = None;
let mut got = 0usize;
let mut fails = 0;
let mut cache_hits = 0;
while got < N {
std::thread::sleep(std::time::Duration::from_millis(5));
for r in eng.drain() {
got += 1;
if r.from_cache { cache_hits += 1; }
if !r.ok { fails += 1; }
if first_48.is_none() && got >= 48 { first_48 = Some(t0.elapsed()); }
}
}
let cold_total = t0.elapsed();
assert_eq!(fails, 0, "all 5k synthetic thumbnails must decode");
assert_eq!(cache_hits, 0);
assert_eq!(eng.queued(), 0);
assert!(first_48.is_some());
eprintln!("gallery test: cold {N} thumbs in {cold_total:?}, first 48 in {first_48:?}");
assert_eq!(std::fs::read_dir(&cache).unwrap().count(), N, "cache holds all entries");
// 3. Warm run: everything must come from the disk cache.
let eng2 = Engine::new(cache.clone(), 4, GRID_TARGET, usize::MAX).unwrap();
let gen2 = eng2.generation();
let t0 = Instant::now();
eng2.submit(&entries.iter().enumerate().map(|(i, e)| Job {
id: i, path: e.path.clone(), mtime: e.mtime, size: e.size, gen: gen2,
}).collect::<Vec<_>>());
let mut got = 0usize;
let mut cache_hits = 0;
while got < N {
std::thread::sleep(std::time::Duration::from_millis(5));
for r in eng2.drain() {
got += 1;
assert!(r.ok);
if r.from_cache { cache_hits += 1; }
}
}
let warm_total = t0.elapsed();
assert_eq!(cache_hits, N, "warm run is 100% cache hits");
eprintln!("gallery test: warm {N} thumbs in {warm_total:?} (100% cache)");
assert!(warm_total.as_secs_f64() < cold_total.as_secs_f64());
// 4. Cancellation: submit, bump generation before draining -> dropped.
let eng3 = Engine::new(cache.clone(), 4, GRID_TARGET, usize::MAX).unwrap();
let stale = eng3.generation();
let p = &entries[0];
// Use a fresh path (mtime bump) so it is a cache miss and takes time.
let fresh = dir.path().join("fresh.jpg");
std::fs::write(&fresh, make_jpeg(3000, 2000, 99)).unwrap();
let sz = std::fs::metadata(&fresh).unwrap().len() as i64;
eng3.submit(&[Job { id: 0, path: fresh.to_str().unwrap().into(), mtime: 1, size: sz, gen: stale }]);
std::thread::sleep(std::time::Duration::from_millis(20));
let new_gen = eng3.bump_generation();
assert!(new_gen != stale);
// Drain until the (now stale) result is gone: nothing must arrive.
std::thread::sleep(std::time::Duration::from_millis(2000));
assert!(eng3.drain().is_empty(), "stale-generation results are dropped");
assert_eq!(eng3.queued(), 0);
let _ = p;
}
/// Scroll-back bug (regression): a user scrolls down (queue fills with
/// slow cold decodes of far cells), then back to the top. The top cells'
/// jobs are fast disk-cache HITS but, on a FIFO queue, they wait behind
/// the entire cold backlog — the user sees shimmer for seconds.
/// Re-submitted (recently requested) work must be decoded first.
#[test]
fn scroll_back_cache_hit_not_starved() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("thumbs");
std::env::set_var("GALLERY_DB_PATH", dir.path().join("gallery.db"));
// Detailed (noise) JPEGs: non-trivial cold decodes, still small files.
let mk = |name: &str| -> Entry {
let (w, h) = (2048u32, 1536u32);
let mut rgb = Vec::with_capacity(3 * (w * h) as usize);
let mut x: u64 = 0x9e3779b97f4a7c15;
for _ in 0..(w * h) {
x = x
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rgb.extend_from_slice(&[(x >> 33) as u8, (x >> 17) as u8, (x >> 1) as u8]);
}
let p = dir.path().join(name);
std::fs::write(&p, images::encode_jpeg(&rgb, w, h, 80).unwrap()).unwrap();
Entry {
path: p.to_str().unwrap().into(),
mtime: 1,
size: std::fs::metadata(&p).unwrap().len() as i64,
}
};
let top: Vec<Entry> = (0..60).map(|i| mk(&format!("top{i:03}.jpg"))).collect();
let far: Vec<Entry> = (0..180).map(|i| mk(&format!("far{i:03}.jpg"))).collect();
let eng = Engine::new(cache.clone(), 4, GRID_TARGET, 96).unwrap();
let gen = eng.generation();
let jobs = |es: &[Entry], base: usize| {
es.iter()
.enumerate()
.map(|(i, e)| Job {
id: base + i,
path: e.path.clone(),
mtime: e.mtime,
size: e.size,
gen,
})
.collect::<Vec<_>>()
};
// Warm the top 60 into the disk cache (they were painted earlier).
eng.submit(&jobs(&top, 0));
let mut got = 0;
while got < 60 {
std::thread::sleep(std::time::Duration::from_millis(5));
got += eng.drain().len();
}
// "Scroll down": 180 cold far jobs. "Scroll back up": 60 warm tops.
let t0 = Instant::now();
eng.submit(&jobs(&far, 1000));
eng.submit(&jobs(&top, 0));
let mut tops = 0;
let mut total = 0;
let mut t_tops = None;
while total < 240 {
std::thread::sleep(std::time::Duration::from_millis(2));
for r in eng.drain() {
total += 1;
if r.id < 60 && t_tops.is_none() {
tops += 1;
if tops == 60 {
t_tops = Some(t0.elapsed());
}
}
}
}
let t_tops = t_tops.unwrap();
eprintln!(
"scroll-back: all 60 cache-hit tops in {t_tops:?} behind a 180-cold-job backlog (total {:?})",
t0.elapsed()
);
assert!(
t_tops.as_secs_f64() < 0.75,
"cache-hit re-entry waited {t_tops:?} behind the cold backlog — FIFO starvation"
);
}
/// Worker-level priority (regression): the old design put every batch
/// into the workers' FIFO mpsc, so a fresh viewport batch had to wait
/// behind ALL stale transit work already queued in a worker. A new
/// batch must now be placed AHEAD of it: with one worker, a fresh job
/// submitted while a slow batch is queued must finish within the first
/// two results (never after the whole old queue).
#[test]
fn fresh_batch_preempts_stale_worker_queue() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("thumbs");
std::env::set_var("GALLERY_DB_PATH", dir.path().join("gallery.db"));
// Detailed noise JPEGs with real decode cost.
let mk = |name: &str| -> Entry {
let (w, h) = (1600u32, 1200u32);
let mut rgb = Vec::with_capacity(3 * (w * h) as usize);
let mut x: u64 = 0x243f6a8885a308d3;
for _ in 0..(w * h) {
x = x
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
rgb.extend_from_slice(&[(x >> 33) as u8, (x >> 17) as u8, (x >> 1) as u8]);
}
let p = dir.path().join(name);
std::fs::write(&p, images::encode_jpeg(&rgb, w, h, 80).unwrap()).unwrap();
Entry {
path: p.to_str().unwrap().into(),
mtime: 1,
size: std::fs::metadata(&p).unwrap().len() as i64,
}
};
let stale: Vec<Entry> = (0..10).map(|i| mk(&format!("stale{i}.jpg"))).collect();
let fresh = mk("fresh_viewport.jpg");
// ONE worker: no round-robin rescue — the ordering is purely the
// queue's.
let eng = Engine::new(cache, 1, GRID_TARGET, 96).unwrap();
let gen = eng.generation();
let stale_jobs: Vec<Job> = stale
.iter()
.enumerate()
.map(|(i, e)| Job { id: i, path: e.path.clone(), mtime: e.mtime, size: e.size, gen })
.collect();
eng.submit(&stale_jobs);
// Wait until the worker has started (first result = stale job 0
// finished; the rest sit in the worker queue).
let mut got = 0;
while got < 1 {
std::thread::sleep(std::time::Duration::from_millis(5));
got += eng.drain().len();
}
// Fresh viewport submission: must jump AHEAD of the 9 queued stale
// jobs. It may finish just before/after the one stale job already
// in flight (job 1) — but it can NEVER be behind jobs 2..9.
let fresh_job = Job {
id: 100,
path: fresh.path.clone(),
mtime: fresh.mtime,
size: fresh.size,
gen,
};
eng.submit(&[fresh_job.clone()]);
let mut seen: Vec<usize> = Vec::new();
while seen.iter().position(|id| *id == 100).is_none() {
std::thread::sleep(std::time::Duration::from_millis(5));
for r in eng.drain() {
seen.push(r.id);
}
assert!(seen.len() < 8, "fresh job arrived behind the stale queue: {seen:?}");
}
let pos = seen.iter().position(|id| *id == 100).unwrap();
// At most the in-flight stale job (id 1) may precede it.
assert!(pos <= 1, "fresh job arrived in position {pos}: {seen:?}");
}
#[test]
fn cache_key_stable_and_distinct() {
let d = Path::new("/tmp/x");
let a = cache_path(d, "/a.jpg", 1, 2, 64);
let b = cache_path(d, "/a.jpg", 1, 2, 64);
let c = cache_path(d, "/a.jpg", 3, 2, 64);
let e = cache_path(d, "/a.jpg", 1, 2, 500); // different render size
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(a, e);
assert_eq!(a.file_name().unwrap().to_str().unwrap().len(), 40 + 4); // sha1 hex + .jpg
}
}