Commit Graph

31 Commits

Author SHA1 Message Date
915e04c2c3 120Hz: jank_eval referee + slot-based wake guard + MISS ledger
tools/jank_eval.py: strict PASS/FAIL jank evaluator (the referee).
- slot grid from VSYNC ftn edges (hardware truth); anchor fallback only
- PASS iff per active window: no dropped slot, no same-slot double, no
  present past the SF latch, no >20ms present gap, phase spread <=0.5ms
- self-test: 7 synthetic log files through the same main() path, all
  classified correctly; validated on the emulator (60 Hz emu display
  detected, active windows found)
- re-judges slt4 honestly: my 'clean' drag-1 window is FAIL (30.6ms +
  21ms stalls, 20 past-latch presents, 1.1ms spread) — the user saw it

lib.rs/stg.rs: slot-based wake guard replaces the interval freshness
guard (0.2ms false-reject margin). A wake renders iff its edge-grid slot
is strictly after the last rendered slot; a gap of >=2 slots emits
MISS {mon, slots, nowake} — the missing evidence for stall slots
(wakes lost vs loop blocked vs guard).

sltest: log drain batch-flush (64 lines) — per-line FUSE flush stalled
the drain thread for minutes (file frozen, process alive).
gallery: MISS lines added to the perf-log allowlist.
2026-10-30 13:29:50 -04:00
baff764b0d 120Hz: render-ahead VERIFIED on phone — steady scroll effectively zero-jank
sltest (minimal 100-row grid), phone, 2x4s drags, 1433 transitions:
- steady 401-frame burst: phase 0.39-1.06ms (spread 0.67, was 1.5),
  1/401 slot jumps, 0 doubles — the held frame presents at render start
  (edge+~1ms), constant phase, 4ms deadline margin
- residual 0.7% drops/doubles concentrated at drag start/stop transitions
- spike classifier (rcpu): all 13 spikes >6ms are CPU-bound (Skia/driver
  CPU, not GPU block) and render-ahead ABSORBS them (13 spikes -> 1
  skipped frame)

Remaining: user finger-perception test (sltest + gallery), then the 1.3%
CPU spikes if anything remains.
2026-10-30 12:39:52 -04:00
8f06b85376 120Hz: re-enable render-ahead under the corrected timing model
The 'callback arrives 3-6 ms late' model that invalidated render-ahead in
earlier sessions was a mislabeled STG field: the Choreographer callback
actually fires at edge+0.5 ms (measured from the VSYNC ftn/mon pair). With
the wake ON the edge, the held frame presented at render START lands at
edge+~1.5 ms (constant phase, 4 ms of deadline margin), and render spikes
(5-10 ms, the measured cause of the residual 1% drops + 1.5 ms phase
spread) only stretch the PREPARE phase of the frame held for the NEXT
slot — spikes stop delaying visible frames.

- lib.rs: RENDER_AHEAD_ACTIVE = locked again (was false)
- lib.rs/stg.rs: IT lines gain rcpu (CPU us inside do_render) — spike
  classifier: rcpu≈render ⇒ Skia/driver CPU work; rcpu≪render ⇒ blocked
  on GPU/driver
- hypotheses-120hz.md: model correction + sltest baseline + results

Emulator behavior verified: scroll, settle (transition flush), mid-drag
pause/resume (no black window), no AlreadyAcquired panics, held-frame
presents at 0.6-2.1 ms. Phone cadence measurement pending (phone out for
user use ~2h).
2026-10-30 12:12:44 -04:00
c72bfa481d 120Hz hunt: EW wake probe + poll/render CPU-time splits; sltest baseline
Measurements only, no behavior change:
- vsync.rs: EW lines (every early-waker landing, EW_ON flag, off by default)
- lib.rs/stg.rs: IT lines gain dec (locked/saw_wake/pr_before/do_render) and
  pcpu (CPU us inside poll_events — 2% = asleep in epoll, rules out CPU
  spin/deschedule as the wake latency)

Findings (docs/BUGS/hypotheses-120hz.md, sltest baseline):
- Choreographer callback fires at edge+0.52ms (p90 0.65) — the 'late
  Choreographer' model from earlier sessions was a mislabeled STG field
  (poll block time, not callback latency). The pipeline actually works at
  median: present at edge+~4ms, inside the 5.5ms deadline.
- sltest zero-jank definition measured: 98.8% of frames land in the next
  display slot; residual = 0.5-0.7% drops + 0.7-0.9% doubles + 1.4ms
  phase spread (p10-p90) = the visible micro-judder.
- Residual cost is VARIANCE, not median: per-frame render 3.5ms median
  (SKD 1.07 + CLR 0.8 + PRE 1.0 + SKF/ORD 0.65) with spikes to 10.7ms
  (SKD max 5.2, CLR max 3.2, PRE max 2.5). Spikes push presents past the
  deadline (drops) and shake the phase (judder).
Next: classify spikes (CPU work vs GPU/driver block) via render-window
CPU splits, then kill the top source.
2026-10-30 11:59:39 -04:00
7c2d0f354e 120Hz: early waker thread + render freshness guard (vsync-edge pacing)
The Choreographer vsync callback reaches the UI thread only 2.5-6 ms
AFTER the vsync edge (measured on the phone). With a 2.5-5 ms Skia
render plus present, that left no phase margin inside the 8.3 ms slot:
51% of frames landed one slot late (judder) and ~4% two slots late
(hitches), on every build tested so far.

Attempted fixes, all measured and retired (see commit history):
- render-ahead hold (present previous frame at next render start):
  the held present lands after the NEXT wake's 3.2-6 ms Choreographer
  latency -> 52% one-slot late.
- present-at-end + present GATE (sleep on the UI thread to quantize
  the present to the vsync grid): the gate sleep (2.5-4 ms) added to
  the slot budget (wake 5.3 + core 3 + gate 3 + present 1 = 12.3 ms >
  8.3 ms) -> cadence collapsed to 60 Hz.

Working fix (this commit):
- vsync.rs: an 'early waker' thread learns the vsync grid from the
  callback's frame_time_nanos (vkprobe::note_vsync_edge) and wakes the
  main looper ~0.8 ms BEFORE each edge while animating. Render start
  moves from edge+2.5..6 to edge-0.8..+0.5, so the present lands at a
  natural ~edge+3..5 ms phase with no UI-thread sleep.
- lib.rs: a render freshness guard (min gap = period - 1.5 ms) makes
  the FIRST wake of each slot (early or the late Choreographer
  fallback) render and rejects the second (double render = 60 Hz
  collapse). Threshold sits between late-after-early (5.8 ms, reject)
  and early-after-late (7.5 ms, accept).
- lib.rs: locked renders no longer consume pending_redraw (it carried
  the motion invalidations; consuming it starved the follow-up
  unlocked iteration -> 60 Hz collapse).
- stg.rs: IT lines now log the render decision (locked/saw_wake/
  pr_before/do_render); vkprobe logs gate sleep (GATE_US, now 0).

Measured (phone, 8580-file grid, slow 4 s drags):
  in-burst cadence: 8.29-8.44 ms median, p90 8.6-10.2 ms
  (was 8.36 median / 10.2 p90 with 3.7-20% two-slot gaps)
  in-burst >16.7 ms gaps: 0-2 per burst (was 3-13)
  present-to-present: 27% exact one slot, 33% one-slot-late steady
  state (imvisible), 3% f-jumps (Skia core 2.5-5 ms variance).
Residual judder lever: Skia core cost variance (next step).

Test hygiene reminders (HANDOFF 8.10): verify ResumedActivity, keep
the task clean (sz=1), record the perf-log byte offset before each
run (the file accumulates across process launches).
2026-10-30 11:06:58 -04:00
99d155e859 HANDOFF §8.10: pop-in root causes (paint-cap drop, scan storm, black window) + test hygiene 2026-10-30 09:05:08 -04:00
b9317e7d23 Fix thumbnail pop-in: hold-queue for paint-cap overflow + append-only scan streaming + window-liveness watchdog
The slow pop-in (warm and cold) had two root causes, both in the
drain tick / scan pipeline:

1. Paint-cap drop bug: finished decodes beyond MAX_PAINT_CELLS_PER_TICK
   were silently dropped (out of inflight, not in painted) so FILL
   re-submitted them — the same cell re-decoded 3-6x per viewport and
   a new viewport took 500-850ms to fill after a flick. Now excess
   results hold in app.ready (bounded READY_CAP=48) and paint on a
   later tick at zero re-decode cost; FILL skips ready_paths. The cap
   is adaptive: 2 during grid motion (frame budget), 4 when settled
   (fast pop-in).

2. Cold-start scan storm: every 512-entry scan batch re-sorted the
   WHOLE list and set_list did a full model reset + generation bump,
   orphaning every queued decode (arrived marked stale, dropped,
   re-submitted) — a 17x re-decode storm (max observed dups = 18 =
   batch count) plus full row churn per batch. Streaming now appends
   (each 512 group mtime-sorted, groups concatenated = stable prefix),
   and set_list detects appends (prefix match) to extend the model
   in place (extend_from_slice) with NO reset/generation-bump. The
   final global sort is ONE reset at scan end; viewport re-fills from
   the disk cache (~2ms/cell). Measured: scan 7637ms -> ~4.3-4.9s,
   decode dups 18 -> 2-3.

Window black after pause (regression found while testing): the OS can
invalidate our surface (system dialog, app switch) with NO window
event and no pending model change -> the window returned black and
stayed black (nothing ever set pending_redraw). Fixes:
 - request_redraw() unconditionally in RenderingSetup
 - drain-tick watchdog: window visible but no frame completed for
   >2.5s -> one forced redraw (LAST_PRESENT_US, updated in
   AfterRendering; now_us/NOW_T0 no longer perf-gated)

Also: debuggable=false — the system 16KB-compat dialog (debuggable
apps only) is its own activity, it paused us on every cold launch,
and its inconsistent lifecycle (TermWin or not) was the main black-
window trigger in testing.

Phone-verified (cold, 8580 files, fresh DB): clean launch, grid
streams in with thumbnails, flicks render 120Hz (median 8.3-8.5ms,
0-2 late frames), viewport fully painted after flick, grid intact
after an app switch (no black window).
2026-10-30 09:04:01 -04:00
2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00
e10a71b00a Viewer input fixes: tap during fling stops the fling (no open); zoom sticks at native resolution
1) Tap/touch during a fling: the ListView's Flickable already stops the
   fling when a press lands (its press handler resets the fling
   animation — slint core items/flickable.rs, remove_binding =
   set_animated_value(current, none)); the bug was that the press is
   then forwarded to the cell (~100 ms later, FORWARD_DELAY) and the
   release opened the viewer. The cell TouchArea now reports its
   pointer-event down (1.18 has no pressed callback) via
   cell-pressed → on_cell_pressing, which marks the tap as a
   fling-stopper when the grid moved within the last 150 ms (the tick
   tracks raw list-y changes in grid_motion); on_open_image then
   swallows the tap.

2) Zooming past max resolution moved the image: zoom_at applied the
   raw factor to tx/ty BEFORE clamping s — at the cap s stayed put
   but the translation still shifted about the (moving) pinch
   center, so the image followed the fingers like a two-finger drag.
   zoom_at now clamps s first (factor derived from the clamped s:
   at the cap factor = 1, a true no-op), and Input::pinch sticks at
   both caps (the handler reports a CUMULATIVE scale from the pinch
   start, so past the cap every later update would otherwise drift).
   The zoom cap is now NATIVE resolution: max(fit, 1.0) — 1 device
   pixel per image pixel (small images keep fit); zooming further
   only pixelates, so it is not allowed (was 20× fit).

Tests: zoom_at_past_cap_is_noop, pinch_sticks_at_caps (new),
clamp_zoom_bounds (native cap + small-image fit), updated
pinch_handler_sequence (4× fit now clamps at native). 36/36.
Emulator: fling+tap → 0 viewer opens, grid settled; normal tap
still opens (cached path).
2026-10-29 12:30:42 -04:00
9a1654136f M4.1: instant viewer (no splash) + idle full-res prefetch
open_viewer never shows 'Loading…' anymore:
 1. full-res LRU cache hit (8 entries / 64 MP of RGBA bytes) -> the
    image is shown in the same frame as the tap (decode already done
    by idle prefetch or a previous view); the ~3 ms GPU upload is the
    grid's proven hitch-free path (slint::Image is !Send, so the
    cache holds bytes and uploads on tap);
 2. else the tapped cell's THUMBNAIL (already a GPU texture, same
    aspect) is shown instantly and the full decode (urgent: bypasses
    the idle budget) swaps in silently on arrival - same aspect, so
    s is rescaled by old/new (Input::rebase_content), tx/ty (viewer
    px) stay put: identical bounds, one content change, any
    pan/zoom/pinch the user did on the thumb is preserved;
 3. only if the cell has no painted thumb either is there a black
    frame (ViewerUi.waiting names the path the viewer waits on; a
    failed decode prints an error only for that path).

Tick step 4c (prefetch_full): when the viewport is stable, the
viewer is closed and the thumb pipeline is caught up (inflight
empty after FILL - during fast scrolling it stays non-empty, so
prefetch only starts once scrolling is done), decode + cache one
center-out visible image per tick, max 2 in flight. In-flight
decodes are NOT cancelled by scrolling: results still land in the
cache (that IS the value). All caches clear on set_list / world
reset.

Verified on the emulator: cache-hit tap '(full 1200x1600 cached)'
instant; uncached tap '(thumb 159x400 up, full in flight)' ->
'(swapped in)' 137 ms later; corrupt file -> error text, no splash.
New unit test: rebase_content_preserves_viewer_mapping (34 pass).
2026-10-29 11:53:45 -04:00
02dec62405 M4: full-screen viewer with pinch/pan zoom
- src/viewer.rs: full-size decode (turbojpeg DCT shrink, 2x-screen cap
  bounded by GPU max texture, EXIF orientation applied) + transform
  model (fit/clamp/zoom_at) + single-finger pan & double-tap state
  machine. 18 unit tests.
- Pinch: Slint 1.18's ScaleRotateGestureHandler. Key finding: the CORE
  (TouchState in internal/core/input.rs), not the backend, synthesizes
  PinchGesture from raw two-finger touches on every backend including
  Android — the second finger never reaches a TouchArea, so manual
  two-finger math is dead code (the earlier draft was removed).
- main.slint: Viewer overlay (image + status + close button + full-
  surface TouchArea + ScaleRotateGestureHandler). The close button must
  be declared AFTER the full-surface TouchArea: 1.18 TouchArea grabs
  every press (GrabMouse) and aborts the front-to-back sibling
  traversal.
- lib.rs: open/close/pinch/pointer wiring; Android back key (via the
  vendor CloseRequested patch) closes the viewer first.
- vendor: androidwindowadapter back-key -> CloseRequested patch
  (Slint 1.18 has no back-key close handling).
- gputex.rs: max_texture_size() for the decode cap.
- HANDOFF: M4 done; new gotchas 6.14-16; emulator two-finger sendevent
  (MT-B) injection recipe.

Verified on emulator (pad_avd): real two-finger pinch zoom, double-tap
zoom/reset, pan, button + back close, grid scroll unaffected. Phone
(Pixel 9 Pro, release aarch64): grid, viewer open, 4080x3072 decode,
double-tap, back. Pinch not injectable on the user build (SELinux
blocks /dev/input writes) — covered by unit tests + emulator.
2026-10-29 10:51:56 -04:00
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
ec9fd7c189 docs: recreation fix verified on phone (Mali G715) 2026-10-28 21:10:55 -04:00
84c6a4170d 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).
2026-10-28 20:54:04 -04:00
93b27d724b Root-cause and fix the Mali scroll jank; strip jank-hunt instrumentation
The 19-26 ms mid-scroll stalls (Mali-only, fixed content offsets) were
the Skia renderer's final synchronous GrDirectContext::flush() blocking
the UI thread on Mali driver back-pressure — proven by per-stage
timestamps in the vendored renderer (stall frames: flush 19-24 ms,
walk 1-3 ms, 1 binding re-eval, image imports 0.1 ms each).

- vendor/i-slint-renderer-skia (pinned 1.18.1, [patch.crates-io]):
  remove that one flush. Redundant on the wgpu backend: the surface
  already calls flush_and_submit before queue.present, same Vulkan
  queue, in-order. Phone: 0 stalls across 4 runs (max 3.6-7.4 ms vs
  the 8.3 ms budget), display verified clean.
- docs/BUGS/slint-skia-final-flush-jank.md: full evidence, prior art
  (slint-ui/slint#12030 closest), open question for a future
  upstream filing (not filed yet).
- Profiling also retired the O(n)-walk theory: Slint 1.18 prunes the
  item walk to ~133-152 items; the windowed Flickable built on that
  theory was already reverted (ListView is smooth with the fix).
- Strip all jank-hunt instrumentation: FR/SCR/TICK/APLYD/STG logging,
  stg markers, .gallery_* file knobs, jsamp thread sampler,
  DbgScroll programmatic scroll, nblog perf-file mirroring.
  Also fixes the 'launches into unstoppable fast scroll' bug: a
  leftover .gallery_cmd was re-read on every launch (file never
  deleted) and overrode user swipes — the hook is gone.
- Keep: nblog (logd backpressure is real), zero-dimension decode
  guards, keep-alive (default-off).
- HANDOFF: replace stale windowed-Flickable section with the actual
  root cause/fix; 15 unit tests pass.

Verified on phone (aarch64, 8580 photos) and emulator (x86_64).
2026-10-28 18:23:50 -04:00
835659131e No reload flash when the rescan finds nothing new
The scan's final set_list replaced the whole model unconditionally:
generation bump + painted-state clear + set_vec, so Slint recreated
every row and every visible thumbnail flashed out and back — even
when the rescan changed nothing (the normal warm start). Now:
- unchanged list -> status line only, model untouched
- displayed list is a prefix of the final one (cold-start fill)
  -> append the tail rows in place (no row recreation)
- genuine change -> full replacement (the correct signal there)
2026-10-28 13:31:47 -04:00
16526a402a Keep-alive inactive by default: it froze the Mali display pipeline
The 16 ms keep-alive submits from a 5th thread, racing Slint's
render/present path and the four decode workers on the same wgpu
queue, hung the phone UI for seconds (51 decodes stalled ~6 s).
System survived (no reboot) but it read as a crash. Debug knob
.gallery_keep re-enables it.

Also: jsamp one-shot permission probes (the cgroup/PSI polling
loop flooded the SELinux audit subsystem at ~100 denials/s and
ANR'd the app); cgroup/PSI probing removed (the device has the
cgroup cpu controller disabled, so quota was ruled out anyway).
Sampling at 5 ms. HANDOFF 8.8 records the full findings.
2026-10-28 12:20:19 -04:00
ed52109dba GPU-resident thumbnails: worker uploads to Slint's wgpu device (gputex)
Root cause of scroll jank: first-present of each Image uploaded pixels
inside the render frame (~11-30 ms per 400px texture, 1.3 visible
hitches/s at 120 Hz; grey-image A/B: 20 -> 1). Slint has no render
thread and no pre-upload API.

Fix: decode worker creates the Rgba8Unorm texture on Slint's own wgpu
device (captured from GraphicsAPI::WGPU30 at RenderingSetup) and submits
the async upload; UI wraps it in Image::try_from(tex). Zero pixel work
in the render frame. CPU from_rgba8 fallback retained.

Measured (Pixel 9 Pro 120 Hz, 400 px, per-frame FR hook):
  before: >10ms renders 20 in 15s (max 30 ms), 3 borderline 8-9 ms
  after:  0 mid-scroll renders > 4.4 ms (median 1.9, p95 2.9);
          only post-idle first-frames expensive (invisible)

Also: FR per-frame logging via set_rendering_notifier; APLY timestamp.
2026-10-28 10:57:15 -04:00
d38dcd3495 Jank A/B script for the phone (scripts/jank_ab.sh)
One-shot: install release APK, verify thread priorities (thumb-* nice 2,
slint vsync nice 0), then two 30 s user-scroll windows — real textures vs
grey 1x1 images — with SCRDBG cadence analysis (on-grid %, stalls, p95,
worst). Decisive fork: real-janky+grey-smooth -> texture/GPU path; both
janky -> base render/present path; both smooth -> priority fix worked.
Dry-run tested on the emulator (grey knob detection, priority readback).
2026-10-28 00:56:37 -04:00
9c0525e93f Jank fix: thread priorities — raise vsync waker, demote decoders
Slint's android-activity backend wakes the main loop from a dedicated
"slint vsync" Choreographer thread while any animation is active
(i-slint-backend-android-activity/vsync.rs). Android schedules that
thread at nice +10 (lowest on device), while our 4 turbojpeg workers
inherited the UI thread's nice -10. Under CPU bursts the +10 waker
starves and the main loop's next wakeup lands 3-4 frames late: 25-33 ms
of pure wake latency at 120 Hz, with zero cost in our tick (d=7-34 us) —
exactly the phone stall signature (measured: 28-39 ms gaps, catch-up
events).

- prio module: setpriority FFI (self + by-tid), raise_slint_vsync()
- thumb workers + dispatcher: nice +2 at spawn (ahead of background
  apps, behind the UI's -10)
- android_main: prio-fix thread raises "slint vsync" to nice 0 with a
  readback log
- verified on emulator: thumb-* hold nice 2, slint vsync holds 0 while
  foreground (Android re-nices backgrounded processes — expected)
- emulator scroll cadence unchanged (llvmpipe rasterization ceiling,
  not a priority issue) — phone is the acceptance target, A/B test
  planned for when it is back online

Also: drop unused android_logger dependency (nblog replaced it).
2026-10-28 00:53:30 -04:00
a6b5e31924 Jank hunt: repro on emulator, rule out 6 causes, add A/B tooling
Findings (emulator programmatic-scroll repro, phone log data):
- In-place cell updates (cells.set_row_data) replaced whole-row
  replacement: no subtree teardown, texture upload only on pixel
  change. MAX_PAINT_CELLS_PER_TICK=2.
- Static placeholder replaces the animation-tick() shimmer: the per-cell
  sin() binding was O(all 8577 cells) every frame (71% CPU idle).
  O(visible) shimmer is M6 work.
- nblog: non-blocking logger (bounded mpsc + drain thread calling
  __android_log_write). logd backpressure was stalling the UI thread
  10-40 ms. android_logger dependency no longer used on-device.
- cpuload (SELinux-blocked) removed; min_refresh_rate=120 pin kills the
  SystemUI 60Hz flap (noted in HANDOFF, reversible).
- Emulator: during every 28-146 ms stall the UI thread is 100% on-CPU;
  capping the list to 300 items does NOT help (scene size is not the
  cost); grey 1x1 images cut 30-146 ms to a steady ~16.6 ms -> the
  cost is software-GL rasterization of ~30 textured cells at 1080x2400
  (emulator ceiling; the emulator validates logic, not feel).
- Phone 28-39 ms spikes are NOT first-paint/upload timing (they happen
  ~10x more often than cells enter the viewport) and not upload CPU
  cost (1.2-2.9 ms). Left: GPU/present contention vs Slint EGL path.

Tooling (all tagged REMOVE after the hunt, file-gated, off by default):
- .gallery_cmd scroll hook + SCRDBG/TICK/APLY logs
- .gallery_max (item cap) and .gallery_grey (1x1 images) A/B knobs
- HANDOFF 8.5: full findings + decisive phone A/B plan for tomorrow
- jsample.c (NDK /proc sampler, /tmp): per-thread state+wchan

Also: fix pre-existing doctest (indented module-doc block parsed as
Rust); 15 unit tests + doctest green; aarch64 release APK builds.
2026-10-28 00:43:35 -04:00
2e9e5b78b6 M3: fundamental thumbnail-loader redesign — viewport is first at every queue level
The reported 'fast scroll → stop → long shimmer → small move → everything
pops' survived the first fix (scroll-handler re-submission) and the second
(central-queue prepend): re-submission only happened in on_scroll, and the
workers' FIFO mpsc still held stale transit work ahead of fresh batches.

Root causes and the full redesign:
- on_scroll now ONLY records the viewport (zero work). The 10 Hz tick is
  the sole authority AND sole submitter: CONSUME (paint viewport results,
  capped), EVICT (far pixels, MAX_PAINTED=120), FILL (viewport,
  center-first), PRUNE (queued jobs outside the retention band).
- Invariant restored unconditionally each tick and debug_asserted: every
  viewport cell is PAINTED, IN-FLIGHT, or FAILED. A visible cell is empty
  only while its decode is genuinely in flight — never until the next
  scroll event.
- Worker queues: Mutex<VecDeque>+Condvar (mpsc is FIFO-only). The
  dispatcher drains the central queue as priority-ordered bursts and
  PREPENDS them, so fresh viewport work always jumps ahead of stale
  transit work. Per-worker cap (96) drops the oldest tail with a
  'skipped' result (UI clears the in-flight marker; re-entry re-submits
  — disk-cache hit, never a re-decode). A burst (<= viewport ~45) always
  survives the cap.

Stress-verified on the emulator (debug build) before phone deployment:
- fast up/down x5 (the repro): fully covered 0-119 ms after stop
- cold landings: 1.0-1.2 s = pure decode throughput (p rises
  monotonically while stale jobs wait behind, harmlessly)
- chaos x6 + 25 s idle soak: covered before every stop; max oldest
  in-flight job 217 ms (pre-fix: 8,055 ms); max queue depth 45; 0
  invariant violations in ~1,450 ticks; 0 panics; idle -> 0 queued
Phone (release aarch64, 8,577 photos): 99.98% cache hits in stress
scroll, 0 panics/ANRs.

Tests: 15 pass incl. fresh_batch_preempts_stale_worker_queue (new) and
the existing scroll_back_cache_hit_not_starved; 5k engine test uses an
uncapped queue (usize::MAX cap param).
2026-10-27 21:02:35 -04:00
ebb4023955 Fix scroll-back-to-top shimmer: prepend-on-submit job queue
Root cause: the worker job queue was FIFO (mpsc). Scrolling down filled it
with slow cold decodes of far cells; scrolling back up re-submitted the top
cells as fast disk-cache hits that then waited behind the whole backlog —
seconds of shimmer. Deterministic repro measured 6.3 s behind a 180-cold
backlog (emulator UI showed the same while 220 cold jobs waited 0.5-0.9 s).

Fix: job queue is now Mutex<VecDeque> + Condvar; Engine::submit prepends
each new batch so the most-recent intent (the viewport, or what you scrolled
back to) is decoded before stale far-away prefetch. Cold backlog keeps
warming the disk cache in the background (total work unchanged).

- regression: thumb::tests::scroll_back_cache_hit_not_starved (6.3 s -> 130 ms)
- dispatcher exits via a closed flag set in Engine::drop (replaces the
  mpsc channel-close shutdown)
- HANDOFF: M3 fix note, prepend rationale, emulator realtest_extra dataset
2026-10-27 19:38:45 -04:00
c9a6066ace HANDOFF: M3 real-device scroll-FPS result + SurfaceFlinger latency method
Pixel 9 Pro (release aarch64, 8,577 photos): sustained 60 fps, 0 display
hitches while scrolling, cold and warm cache. dumpsys gfxinfo reports 0
frames for Slint's NativeActivity window on Android 15 — document the
dumpsys SurfaceFlinger --latency <layer> method instead.
2026-10-27 18:45:46 -04:00
59787f1d3f M3: 3-column grid UI with shimmer, scroll prefetch, bounded retention
- main.slint: ListView of GridRows (3 square CellViews each); Cell struct
  {path, image, loaded}; shimmer placeholder via animation-tick()
  expression (1.18 has no from:/to: animation objects); scroll-changed
  callback feeds Rust.
- lib.rs: active-range model (visible rows ± 4-row margin); on_scroll is
  cheap (re-anchor + submit prefetch only); a 10 Hz drain timer owns ALL
  model mutation, capped at 16 row-replacements/tick (ANR guard);
  apply_row_batch replaces each affected outer row once — the only update
  path that reaches ListView rows recycled while off-screen; clearing a
  cell releases its dedupe entry so re-entry re-submits (disk-cache hit).
- thumb.rs: LIST_TARGET(64) -> GRID_TARGET(500); cache key includes target.
- Verified on emulator (150 real JPEGs + 5k stubs): first paint 0.6 s
  cold, warm relaunch from disk cache, 0 ANRs through 16 fast swipes,
  backlog drains to 0. 13 unit tests + 5k engine test pass.
- HANDOFF: M3 status, Slint gotchas 11-13 (in-property binding, nested
  model updates vs row recycling, ListView spacing), emulator test-data
  notes.
2026-10-27 18:13:28 -04:00
572409b702 M2: thumbnail engine — turbojpeg DCT-shrink decode, worker pool, disk cache
- images.rs: libjpeg-turbo 1/2..1/8 shrink decode (48MP @1/8 ~2.3MB peak),
  all 8 EXIF orientations, imageops::resize final fit, PNG via image crate,
  q85 re-encode for the disk cache
- thumb.rs: dispatcher + N=cores/2 workers (2..8), cache
  thumbs/{sha1(path|mtime|size|target)}.jpg (target in key: list 64px and
  grid 500px coexist), atomic rename, generation-based cancellation,
  per-worker sqlite set_thumb, 256-job cadence logs
- lib.rs: AppState OnceLock, 10Hz drain timer (leaked !Send Timer),
  scroll prefetch (50-row margin), do_prefetch gating for scan batches
- main.slint/db.rs: rows carry images; list_entries + set_thumb
- build.sh: NDK CMake toolchain env for turbojpeg-sys (ANDROID_JAR vs
  ANDROID_PLATFORM conflict), nasm on PATH, ADB_SERIAL, debug-aarch64
- HANDOFF: M2 done + acceptance data; HEIC deferred (0 HEIC on device);
  transient first-launch black screen parked as §10.7 (unreproducible)

Verified: 12 unit tests + 5k engine test; Pixel 9 Pro 8577 photos —
cold scan ~5s streaming, warm relaunch instant, thumbnails in every row,
~100% cache hits on relaunch; release aarch64 APK 9.8 MB.
2026-10-27 16:32:12 -04:00
c72b00f7ec M1: instant list paint + streaming first scan
- relaunch: DB list paints immediately ('N files (cached)'), background
  walk refreshes ('Updating… N') — no more 5.5 s blank list on warm start
- first launch: walk_stream() feeds the list in 512-file batches while
  scanning; UI never waits for the full DB build
- skip hidden dirs (.thumbnails/.trash/…): -559 entries on the phone
- db.rs: cfg-gate ANDROID_PACKAGE (dead-code warning on desktop)

Phone (8.5k photos): cold scan 4.2 s with live list; warm relaunch
paints instantly, rescan 4.2 s in background.
2026-10-27 13:45:37 -04:00
e1982eade4 M1: metadata scan + sqlite DB + placeholder list
- scan.rs: recursive walk of Pictures/DCIM (GALLERY_SCAN_DIRS override),
  image-ext detection, mtime/size capture, no symlink-dir following
- db.rs: rusqlite(bundled) store; incremental apply (insert/update/delete)
  keyed on path with mtime/size change detection; derived columns reset on
  change; Android DB path in Android/data/<pkg>/files (EXTERNAL_DATA_DIR is
  unset in this NativeActivity setup)
- lib.rs: start_scan() background thread -> invoke_from_event_loop ->
  VecModel + status line; called from desktop and Android entry points
- main.slint: ListView of paths (M1), status line with scan stats
- main.rs: fix desktop bin for 1.18.1 (no slint::Result; ComponentHandle)
- build.sh: export NDK clang CC_/CXX_/AR_ per target (bundled sqlite)
- tests: 5k-file scan < 2s, incremental apply, roundtrip

Verified on emulator: 5002 files in 153-226 ms; relaunch 0+ 0~ 0-;
touch -> 1~, delete -> 1-.
2026-10-27 13:28:33 -04:00
53ba74747d README: point at HANDOFF.md, build instructions, license note 2026-10-27 12:23:12 -04:00
4863e42bae M0: Slint gallery scaffold + development hand-off
- Slint 1.18.1 (backend-android-activity-06), cargo-apk2 packaging
- Manifest permissions: READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE (maxSdk 32),
  MANAGE_EXTERNAL_STORAGE
- android_main with permission-aware init_with_event_listener hook and
  AndroidApp clone stash for JNI (storage flow, later BLE/etc.)
- scripts/build.sh: release/debug x86_64 + aarch64, sign, adb install
- HANDOFF.md: full dev plan (architecture, thumbnail pipeline, milestones,
  gotchas, perf targets) from the Slint/eframe evaluation
- Verified: builds, installs, runs on pad_avd emulator (API 35)
2026-10-27 12:22:59 -04:00
gmp
2fe5f5917a Initial commit 2026-10-27 12:13:22 -04:00