Watching is now exactly what the OS tells us, with no filesystem
scanning on the event path:
- MainActivity's ContentObserver receives the changed URI from
Android. It queries that ONE row (~ms, no debounce) and drops a
one-line payload: A|media_id|path|size (row exists) or
D|media_id (row gone). Per-event files (.ms_recent.N, never
overwritten) so a burst cannot clobber an unread event. A zero-arg
JNI call wakes Rust; a 1 s backstop also drains the queue, so a
lost wake self-heals in a second.
- Rust applies the payload directly: upsert with media_id stored, or
delete resolved by id (items row first, then a media_map table that
records id→path for EVERY add event — including rows created by a
filesystem walk, which doesn't know the id). Content changes reset
decode-failure state so a still-being-written photo retries.
- The disk is the truth: the payload size is ignored in favor of a
stat (the scanner can leave _size NULL).
- The full FS walk remains as startup baseline + 5-min reconciliation,
and the mtime-diff incremental rescan remains as a second
independent path. Verified on the emulator: add → grid in ~2 s
(incl. OS scanner), delete → grid in ~2 s via media_id, missed wake
drained in 1 s.
Truncation machinery REMOVED (per user: FUSE is reliable; the
'truncations' were counting errors and a stale-DB cross-check firing
on legitimate changes): the two-strike mass-delete guard, the
walked_max watermark, the layer-2 re-walk, the per-directory stale
cross-check, and the read_dir_full retries are all gone. The one
invariant that replaces them: a walk that hits ANY filesystem error
fails and is NOT applied (an error is not 'those files are gone').
The phone DB now converges to the exact disk count (8,439 = 8,399
Camera + 27 Screenshots + 11 Messaging + 2 Bluesky); the earlier
10,166/8,438 swings were real disk-state changes, not truncation.
Emulator display fixed: the AVD image ships with
debug.hwui.drawing_enabled=0 (every window renders black, HWUI
drawing disabled system-wide). 'adb shell setprop
debug.hwui.drawing_enabled 1' after each boot restores rendering —
it is a runtime prop, so it must be re-set on every cold boot.
- Camera-photo latency: root cause was READ_MEDIA_IMAGES never
granted (granted=false on the phone), so the MediaStore fast-path
query silently returned nothing and photos only appeared via the
slow full FUSE walk. The app now requests the permission at
runtime (once, with a single retry on denial). The camera writes
through MediaStore at capture time, so the ContentObserver fires
immediately and the photo appears in ~1 s.
- Delete blank boxes: two JS flaws — (1) no img onerror handler, so a
cell that requested a thumb for an already-deleted file (404) sat
as an empty dark box forever; (2) recycled cells were matched by
slot index only, so after a deletion shifted the list a cell could
keep a stale id. Now: onerror drops stale ids (idSet membership) or
retries transient ones, and cells reassign on idx OR id change.
- Truncation guard (chicken-and-egg): the mass-delete suspicion
threshold was 20% but observed FUSE truncations lose 15-17%, which
sailed under it and poisoned the DB. Now 10%; a confirmed deletion
(two-strike passed) lowers the new walked_max watermark, a refused
one keeps it. Layer-2 re-walk compares against max(db, walked_max)
so a poisoned (shrunk) DB cannot silence truncation detection.
Emulator suite: healthy→truncated (all 3 layers, 0 loss),
poisoned-DB→truncated (layer 2 now fires), healthy walk self-heals.
- Verified on the phone: DB matches disk exactly (8,398 Camera +
27 screenshots + 11 messaging + 2 bluesky = 8,438); an earlier
10,166 count was a transient user-system state, not truncation.
- MediaStore fast path: Kotlin's ContentObserver queries MediaStore
for images added in the last 120s and writes path|added|size lines
to .ms_recent, then wakes Rust via the proven zero-arg
nativeFsChange() JNI. Rust upserts those rows and emits
immediately — a camera photo appears in ~1s without waiting for
the ~10s FUSE re-read of a 10,000-file directory. (Direct
Kotlin->Rust JNI string passing and Rust->Java calls crash under
the emulator's x86_64->arm64 native bridge; file handoff plus
zero-arg JNI is the only reliable channel.)
- js-ready handshake: the first gallery-files emit fires ~30 ms
after process start, before the WebView's JS exists (Tauri events
are fire-and-forget, so it was lost). JS now signals
'gtauri-js-ready'; Rust re-emits the current state.
- Virtualized grid: the document is ONE spacer (full scroll height)
plus ONE band of recycled cells pinned by transform — ~80 DOM
nodes total for a 10,000-photo library. Launch cost no longer
scales with library size; the first screen renders as soon as
the WebView boots and the first thumbs arrive.
- 'Missing photos' report resolved: the full walk of the 9,961-file
Camera dir enumerates exactly 8,438 images with no guard firing;
the difference is videos, which a photo gallery correctly
excludes.
Live watching is now event-driven, no tree polling:
- Android: MainActivity registers a MediaStore ContentObserver
(Images, notifyForDescendants) and wakes the Rust watcher thread via
a JNI call (Java_wow_gmp_gallery_MainActivity_nativeFsChange) — a
single channel send, no main-thread work. Covers camera saves,
media-scanner indexing, other apps' indexed writes.
- Desktop: inotify/FSEvents via notify on the scan roots.
- On wake: coalesce + debounce, diff dir mtimes, rescan only the
changed subtrees. The 1 s recv timeout doubles as a cheap backstop
(raw out-of-band copies the scanner hasn't indexed yet); a 5-minute
full pass is the safety net. minSdk 30 (3-arg ContentObserver API).
Stale-listing truncation bug found and fixed (reproduced on the
emulator's 9p /sdcard; the phone's FUSE does the same): a readdir over
a large directory can return a SILENT early EOF — a stale cached
listing with NO error. The phone's 9961-file Camera dir truncated at
~8400 once and db::apply deleted ~1700 good rows. Three layers now:
1. scan::read_dir_full — retry whole-dir reads on mid-stream errors;
2. per-directory cross-check in walk_dir: if the enumeration is far
below the DB's count for that dir, re-read up to 3x keeping the
best listing; whole-walk re-check in rescan keeps the larger of two
walks;
3. db::apply mass-delete guard: never delete >20% of the DB in one
apply unless a previous walk already saw the low count (two-strike;
a meta.walked row tracks it). Genuine gradual deletions still flow
through (verified: a 4000-file mass move streams the grid down
progressively), and a second consecutive low walk still deletes.
Emulator reliability suite (all pass): 3x launch count stability,
add +1 / remove -1 in ~2 s, 20-file burst, DB recovery from damage,
stale-listing refusal (0 rows lost), two-strike delete, live restore,
idle 0.0% dropped frames, deep fling loads thumbnails. A/B vs ttimg
(no-backend CSS-scroll control): adb-swipe drop deltas are at the
input-injection artifact level, idle identical (0.0%).
Root cause of 'adds/removes not showing up': the watcher's file-COUNT
tree signature took 8-10 s per tick on the phone (FUSE readdir of the
8400-file Camera dir is ~1 ms/entry), so the 1 s poll was effectively
a 10 s walk, and the old 30 s forced FULL rescan (walk+stat of every
file, ~30 s) kept the watcher thread perpetually busy.
New design:
- 1 s tick stats DIRECTORY mtimes only (~1 ms). Verified on the phone:
Android's FUSE DOES update dir mtime on file add/remove (stat
before/after a copy showed it) — the earlier 'never fires' reading
was wrong; no files had actually been added in that trace window.
- On a changed dir: incremental rescan of ONLY that subtree. Small
dirs (<=2000 files) get a full walk with stat (adds, deletes AND
in-place edits). The huge Camera dir gets a readdir-only walk: new
files are stat'd, known files keep their DB mtime/size, deletions
are detected by set difference. Everything outside the changed
subtree keeps its DB metadata — cost is proportional to the change.
- Full rescan every 5 min as the safety net (in-place edits in the
huge dir, anything missed).
- DB: busy_timeout(10s) so the startup scan and watcher can transact
concurrently (WAL).
Measured on the phone: add to Pictures -> grid +1 in ~5 s; add to
Camera -> +1 in ~12 s; removals picked up on the next tick. Also
observed tracking a live 1700-photo user import (grid 8440 -> 10166
as the import progressed).
JS: event payload normalizer for thumb-ready/full-ready (object vs
JSON string across WebView builds), and js files/emit instrumentation
to compare send vs receive counts.
Pinch: the image now follows the hand (midpoint drift) and zooms
ABOUT the midpoint — the image point under the fingers stays put
while the scale changes. Per-frame delta math, clamped as before
(fit min, image-edge pan limits).
Live watching: new photos appear in the grid within ~2s of being
written. inotify does not work on Android's FUSE-backed external
storage, so the watcher polls a tree signature (dir mtimes only —
a few dozen stats/s) every second; on change (or a 30s safety-net
full pass) it debounces 600ms for the write burst, then runs the
incremental rescan (walk + DB diff + emit) off the main thread.
Supporting changes:
- Staged corruption: a decode failure no longer marks an id broken
immediately — 3 consecutive failures are required, and a success
clears the count. A photo still being written fails once or twice
and then decodes; genuine garbage gives up and stays gray.
- Grid rebuild preserves the viewport across a live rescan (pins to
the cell that was on top), so a new photo landing at the top does
not jump the scroll position.
Pan is now limited to the image bounds (|offset| <= (rendered -
viewport)/2 per axis), so the image can never be dragged off-screen
leaving blank space around it; on a letterboxed axis panning is
locked to centered. Pinch can no longer zoom out past fit. Clamping
lives in vApply() so pan, pinch and reset all share it; still
compositor-only (transform only).
The backlog must never delay on-screen work. Urgent jobs (real
Chromium requests) are now stale-dropped too, not just prefetch:
a fast fling queues urgent work for every band it crosses, and all
of it except the final band is dead — it filled the queue cap and
made fresh on-screen requests wait for the JS retry timer.
Safe because the anchor now settles at the final scroll position
(trailing send), and on-screen cells have idx >= VPORT so they can
never be dropped. A pure LIFO would not fix this: stale jobs sit
at the stack bottom, never popped, still blocking the cap.
Emulator fling test: 309 stale urgent jobs purged, queue peak 28,
all 850 decodable images loaded.
Root causes found via per-event tracing (trace.log) + heartbeats:
1. Decode backlog: single worker at ~300ms/photo could not keep up with
fast long scrolls — phone session ended with a 2400-deep queue
draining at ~3 cells/s (13+ min to catch up). That was the
'scroll far down and nothing loads no matter how long I wait' zone.
- 4 parallel decode workers (~12 photos/s)
- viewport-aware stale drops: prefetch jobs more than 60 rows behind
the viewport are dropped, not decoded (fleeing leaves no backlog)
- Q_CAP bounds the queue; JS 1.5s safety net re-requests
- queue peak in repro test: 42 (was 2405); all 850 decodable
emulator images now load (708 before)
2. Stale-anchor bug: sendAnchor was leading-edge throttled with no
trailing call — if a scroll ended inside the 150ms window the Rust
anchor stayed stale forever, and the worker then dropped fresh
URGENT jobs (tap → full read) as 'off-screen'. Fix: trailing
anchor send; and URGENT jobs are never stale-dropped (only
prefetch work is droppable).
3. Viewer latency: full-res read ran on Chromium's main thread
(wry shouldInterceptRequest is synchronous) — 30-50MB flash read
froze the whole WebView. Now: thumb shown instantly (mem cache,
~1ms), full read queued urgent and done off-main; full-ready event
swaps in the full-res (256MB full-res LRU makes re-opens instant).
Measured click→full-res: 4ms on emulator.
4. Scan: skip OS junk (._* AppleDouble forks, dotfiles) — the phone's
DCIM carried 72 of them, rendering as permanent gray cells.
5. Viewer for a corrupt file now shows 'image unavailable' instead of
'loading…' forever; grid retry timers skip far-off-screen cells.
Chromium owns scroll/compositor/lazy-load/image-cache; Rust owns
scan/SQLite/thumbnail-decode behind a gallery:// custom protocol.
Hard-won gotchas (see in-code comments):
- Android: no native custom-scheme support → wry rewrites the URL to
http://gallery.localhost/... (custom_protocol_workaround). The JS
MUST request that form; the handler receives the reverted
gallery://localhost/... and must strip the authority.
- tauri_build::build() in build.rs must run UNCONDITIONALLY (build
scripts run on the host: a cfg(target_os) gate silently disables it,
which skips the rustc-cfg=mobile emission → no mobile_entry_point →
.so with no JNI symbols → UnsatisfiedLinkError).
- build.rs must list frontend files for rerun-if-changed (a dir-level
directive only watches the dir mtime, not content edits) — the .so
bakes the frontend at compile time and cargo otherwise ships stale
UI with a 'successful' build.
- logcat drops ~75% of lines under load: use the file trace.
- fetch() is CORS-restricted (page origin http://tauri.localhost);
<img> is not — add ACAO:* anyway.
Emulator (5853 real files): grid renders, lazy thumbs via
gallery:// with on-disk cache (87 files 35-72KB), FR log clean at
60Hz, viewer opens full-res with compositor-only pan/zoom. The single
'broken' cell is a 17-byte fake test JPEG (correct behavior).
The A/B/C harness series: sltest (Slint) / egtest (egui) / tttest
(Tauri 2 → wry → Android WebView/Chromium). Identical scene to
sltest: 8580 full-width rows, 150 px + 2 px divider, alternating
colors, virtualized (26-element DOM pool), custom touch drag +
exponential-decay fling (sltest decay constant 7.42), rAF-driven
frame loop.
FR logging: the rAF callback records gap/cost exactly like the Rust
harnesses; lines batch (~120) and cross the Tauri event bridge once
per second (JNI round trip kept off the per-frame path). Rust side
drains to a dedicated writer thread →
/sdcard/Android/data/wow.gmp.tttest/files/Documents/fr.log
(Tauri document_dir = getExternalFilesDir(DOCUMENTS) — the framework
CREATES it; a raw std::fs mkdir of the app's external tree is
SELinux-denied on Android 11+). Same FR-line format → fr_capture.py
is the referee (its launch check now falls back to the package's
LAUNCHER activity via monkey for non-NativeActivity apps).
Gotchas learned (see frontend/dist/index.html comments):
- touch-action is NOT inherited: touches land on .row, so each row
must declare touch-action:none or the WebView claims the pan and
cancels the page's drag (only a few px register).
- Fling velocity must be a per-EVENT EMA; frame-based tracking dies
when touch events sample slower than rAF (adb swipe ≈ 17 Hz).
- Drag sign: finger up → offset grows (offset -= finger dy).
- Tauri v2 'brownfield' is the NORMAL direct-IPC mode (not an
app-provides-content mode); the mobile schema rejects
security.pattern entirely.
- Custom #[tauri::command]s hit the two-pass app-command ACL
chicken-and-egg; the core:default event bridge avoids it.
- Rust log output needs a logcat backend (log/std +
__android_log_write) or it is invisible.
Emulator (60 Hz, scripted swipes): 1319 frames, median 16.70 ms,
5 isolated drops (0.38%), all at swipe-injection moments, render
cost med 2 ms. aarch64 phone build compiles (11 MB APK); phone
testing pending (personal-device policy).
Build: harnesses/tttest/scripts/build.sh [x86_64|aarch64] —
cargo-tauri CLI (v2.12.4, prebuilt) drives the gradle build; signed
with the debug keystore.
Slint-on-Mali was conclusively janky at its base level (sltest: 0.33-0.83%
slot drops on a scene with no app logic); egui on the same phone measured
3-7x cleaner (egtest: 0.12%, same referee/scene).
Architecture (see HANDOFF §8.11-8.12):
- winit 0.30 ApplicationHandler on android-activity 0.6, raw EGL (ES3),
egui_glow::EguiGlow. winit 0.30 never delivers RedrawRequested on
Android → the frame driver is app-owned: World::tick runs every loop
iteration from about_to_wait (liveness), render when dirty or
animating, park ~8 ms (one vblank) at rest.
- Virtualized grid painter (3 cols, visible rows ±4), GPU-resident
textures, turbojpeg worker pool decoding off-thread.
- CRITICAL pixel-pipeline fix: RGB8→Vec<Color32> conversion happens on
the WORKER (egui 0.31's from_rgba_unmultiplied is O(n) per-pixel,
8-60 ms/2 MB on the UI thread → 9.57% drops + phone OOM crash).
UI thread only MOVES the vector into ColorImage.
- Fling: per-event EMA velocity (frame-based dies at slow touch sampling),
clamp 14000 px/s; decay exp(-7.42 dt).
- Painter::image uv is NORMALIZED (0..1) — a screen-space uv made every
cell a solid rectangle (the emulator's solid-color test images hid it).
- Memory caps for a personal phone: full cache 6 entries / 16 MP.
Verified on emulator: grid, scroll, fling, viewer, back-key. Phone
deploy: thumbnails render, 8580 files. Pending: user finger test +
fr_capture phone run (per-pixel fix expected to cut render cost
18.8 ms → <8.3 ms).
Deletes: vendor/ (instrumented Slint tree), shim/, build.rs, gputex,
vkshim, baseline60, main.slint — the Slint zero-jank campaign is
concluded in docs/BUGS/.
shim: 512 for aarch64-linux-android
Using package `gallery` in `/mnt/Backup/prog/android/gallery/Cargo.toml`
Compiling apk resources...
Linking apk resources...
Optimizing apk resources...
Compiling Java sources...
Creating classes.jar from compiled Java sources...
'classes.dex'...
'lib/arm64-v8a/libgallery.so'...
Verifying alignment of /mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk (4)...
49 AndroidManifest.xml (OK - compressed)
1072 resources.arsc (OK)
1153 classes.dex (OK - compressed)
1635 lib/arm64-v8a/libgallery.so (OK - compressed)
Verification succesful
Signing `/mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk` with keystore `/home/gmp/.android/debug.keystore`
APK: target/release/apk/gallery.apk (512) strips the [patch.crates-io] section so
the whole Slint stack (renderer, backend, core, wgpu-hal) resolves to
the stock 1.18.1 sources: no render-ahead, no vsync-locked render, no
app-owned frame driver, no wake-fd trampoline, no vkshim, no perf
instrumentation. All vendor-internal references in the app are now
#[cfg(feature = "perf")]-gated so the stock build is genuinely stock.
Purpose: the CONTROL experiment for the 120Hz zero-jank campaign.
If the grid still janks on the phone at 60 Hz with this build, the
jank lives in the app / GPU pipeline (drain tick, texture uploads,
model updates), not in the vendored fix stack.
60 Hz lock: NOT via JNI — the public API turned out to be
unavailable (Display.setRefreshRate is hidden-API-blocked;
WindowManagerImpl lacks setPreferredDisplayRefreshRate on both
tested devices). Instead the display is locked with the device's
own user setting:
(restored after the test). The baseline60 feature is a pure
marker; the build is otherwise identical to the app code.
Verified on emulator (x86_64, 60 Hz, SwiftShader): launch, grid
render, thumbnail fill, scroll + prefetch all work.
The user's ask: 'put in our own frame rate timer that doesn't drop
frames so we are not depending on the OS.' The OS can still own the
display latch (SF) — render-ahead presents ~1 ms after the edge with
~7 ms margin, so owning the RENDER clock is sufficient.
WHILE ANIMATING, the UI thread no longer waits on any OS wake:
* poll is non-blocking (timeout 0); the loop spins at ~3-4k iters/s
(250 us hrtimer park after each no-render iteration — an hrtimer
park cannot be swallowed the way ALooper timeouts were, measured
4-6 ms late from inside kernel/epoll)
* the render decision is grid-driven: render when in a not-yet-
rendered slot AND >=0.3 ms past its edge. No saw_wake.
* the Choreographer + eventfd remain only as the GRID SENSOR.
Two grid bugs found and fixed while landing this (both made the first
driver build DOUBLES 8-10% of frames):
* ANCHOR POLICY (wgpu_30_surface::note_vsync_edge): VSYNC_EDGE_NS is
a SMOOTH grid anchor, not 'last observed edge'. A callback that
fits the extrapolation (<1.5 ms deviation) does not move the
anchor — a LATE callback re-anchoring shifts the anchor by
(interval - P) and makes an already-rendered slot look NEW to a
value-space guard. Period updates now require single-slot deltas.
* BOOKKEEPING in SLOT-INDEX space (last_rendered_slot), not edge
values, and ALL render paths mark it: locked accept, unlocked
dirty render, and the render-ahead FLUSH (the flush not marking
caused the +1.1/+1.2 true doubles after animating flickers).
* POINTER window 200 -> 400 ms: bursty input (adb drags; finger
pauses) expired 'animating' mid-drag, firing the locked->unlocked
flush on top of the dirty render (double per flicker, ~5%).
* flush now also skips when the iteration already rendered.
Phone, sltest (3s+1s+3s adb drags, jank_eval referee):
buildC (pre-wake-fd-fix): 127 drops + 12 stalls
slt14-spin (this driver): 2 drops + 1 stall + 8 dbl + 4 late
in 978 frames; phase p10-p90 0.86-0.90 ms
gallery (real textures, same driver): drag windows 6+2 drops,
7+11 doubles in 820 frames; phase p10-p90 0.73-0.95 ms
Remaining (visible next): drag-START warmup (first frame 20-40 ms:
CPU clock ramp after idle — the 'fling start jank' the user reports)
and isolated 2-slot drops (gallery: FullCache insert/eviction cost on
the UI thread).
Cost: ~60-70% of one core during active scroll; the perpetual
runnability also forces EAS big-core placement (the thread was
measured at 820 MHz on little cores during scroll — a large part of
the gallery's finger jank).
2ms cap (vendor backend): the residual 8/1266 phone slip-frames all
correlated with polls that ran 5.5-10.7ms despite the 6ms cap
(ALooper_pollOnce(6) returns 2-5ms LATE on this device — the delay is
inside kernel/epoll; vsw=nvol=0 on every slip iteration, so the UI
thread was never descheduled mid-iteration). A 2ms cap bounds the
damage of a lost/late wake: the next poll picks up the pending edge
within 2ms → render start edge+2-3ms → present inside the slot.
Emulator A/B (2 vs 6ms): no difference (5-8 drops/capture both ways,
vs 47-147 for the pre-evfd build) — the evfd wake-fd fix dominates;
the shorter cap is kept.
tools/record_check.py: the DISPLAY-side referee. jank_capture judges
the app's self-report; this records the actual display output with
`adb shell screenrecord` (30 Mbps) during scripted (or hand) drags and
measures inter-frame gaps in the mp4:
* median refresh period is computed from ACTIVE-WINDOW gaps only —
rest periods emit idle frames at irregular 20-100ms intervals that
would pollute the reference (found during validation)
* active windows come from real wall-clock drag times (1.5s pad)
* verdict: CLEAN (0 gaps >1.8x med), SUSPECT (1.25-1.8x), JANK
* selftest: 4 cases incl. drop-in/outside-active-window + real
ffmpeg 120fps mp4 through the ffprobe pipeline
Validated end-to-end on the emulator (record → drags → pull → analyze).
When the phone is free: one `record_check.py record --app wow.gmp.sltest
--drags 4,2,4` run gives the visual-certain answer the user asked for —
did the glass actually show every frame?
ROOT CAUSE of the 8-12% slot drops (uniform dec=1/0/1/0: locked, no wake
seen, dirty, no render): AOSP Looper::pollInner (system/core/libutils/
Looper.cpp) drains the wake fd in the SAME epoll batch (up to 16 fds) as
any ready input fd, and pollOnce returns the input's ident — the POLL_WAKE
result is a local that gets dropped. ALooper_wake's byte is consumed
WITHOUT PollEvent::Wake ever reaching Slint, unrecoverable. With motion
events arriving 1-4 ms after the edge (always for a real 120-240 Hz
finger; ~1 in 5 frames for 20 Hz adb input) the wake is lost and the
locked decision (saw_wake && slot-guard) drops the dirty frame for that
slot.
Fix (vendor backend): a dedicated eventfd registered with the MAIN looper
under ALOOPER_POLL_CALLBACK. The Looper invokes the C trampoline INSIDE
pollInner (before pollOnce returns, regardless of which ident is handed
back), so delivery can never be swallowed by a co-batched input response.
- frame_callback: note the edge, write the eventfd, then ALooper_wake
(secondary). The fd is leaked: it belongs to the main looper for the
process lifetime.
- trampoline: DRAINS the eventfd (level-triggered — without the drain the
counter stays >0, the fd stays readable and the loop busy-spins on
POLL_CALLBACK at ~10 kHz: 1812 spin iterations measured) and publishes
the edge into delivered_edge.
- main loop: saw_wake |= (delivered_edge newer than last seen).
- DIRTY BACKSTOP: the 6 ms poll cap was never a backstop — timeout
iterations have saw_wake=false so the locked decision never rendered
(the 20-40 ms silences). Now: dirty && last render >= 2 slots old
renders regardless of wake state.
Measured on the phone (Pixel 9 Pro, 120 Hz, sltest, identical capture):
buildC (before): 127 drops + 12 stalls in 1142 frames
buildE (after): 8 drops + 0 stalls in 1266 frames
Phase (p10-p90): 0.72-1.00 ms in all windows. Remaining 8/1266 (0.63%)
are slip-and-recover pairs where the UI thread is DESCHEDULED 6-8 ms
while sleeping in epoll (wakes arrive on time, cb-lat 0.5 ms) — EAS
scheduling variance, no app-side contention in sltest.
Referee fixes found while judging this data (tools/jank_eval.py):
- NEAREST-OBSERVED-EDGE slot grid: the 120 Hz panel clock phase-slips
after idle blanking (re-locks at arbitrary phase), invalidating any
grid anchored at ftns[0]. An entire drag window read late=230/
dropped=24 while per-cycle tracing showed presents at edge+2.0 ms.
slot_of_ftn assigns each present to the nearest observed hardware
edge in a time-continuous slot domain (slip-immune: a constant delta
shifts all slots, differences preserved; stall-accurate: ftn times
are CLOCK_MONOTONIC).
- tools/jank_capture.py: capture is INVALID unless the screen is Awake
AND the keyguard is down (a run on a sleeping phone silently injects
no input: 49 IT lines, 1 present).
vsync.rs:
- set_state(animating, dirty) replaces set_animating. The Choreographer
callback chain re-posts while animating||dirty: gating on animating
alone let the chain die during flings (pointer inactivity >200ms) —
with EW disabled the loop then froze (measured: 30 frames in 4s,
then silence). The dirty mirror is pushed every main-loop iteration
(<=8ms stale); the 6ms poll backstop bounds chain-break damage.
- EW_ON now gates the WAKE itself: the early waker raced the
Choreographer (fires at edge+0.52ms measured) and its independent
phase caused the -4..+4ms render-phase wander. Choreographer is the
single wake source.
- MISS lines carry forensics: sw/sn (wake slot vs last rendered slot),
eage (ms since last observed vsync edge), lastage (ms since last
render) — distinguishes chain-stall / loop-stuck / guard-reject /
no-wake-iteration without a second run.
stg.rs:
- ctxsw_sample() reads /proc/self/task/<pid>/stat for voluntary/involuntary
context switches, CONDITIONAL on iteration >5ms (per-iteration procfs
reads were a measured confounder: 20-50us each, pushed phase across
the slot boundary, 10x worse drops). vsw/nvol on the IT line.
- precpu: CPU time inside Queue::present on the PRES line (pre_us >>
precpu = blocked on binder/fence/SF; pre_us ~= precpu = CPU-bound).
wgpu_30_surface.rs: EW_ON moved here (vkprobe) so vsync.rs can gate the
wake without a new crate dependency.
lib.rs: poll backstop uses the dirty state; 6ms cap during active phases.
jank_capture.py: one command owns the whole pipeline so provenance can
never be wrong (the failure mode this replaces: append-only logs spanning
processes, hand byte-offsets, tail-guessing, wrong-build data):
1. record build identity (versionName + apk sha256 + tag)
2. force-stop + DELETE it.log (file can then contain exactly ONE process)
3. cold launch, verify ResumedActivity (before and after)
4. scripted drags ('4,2,4' = 4s drag, 2s settle, 4s drag)
5. pull the complete log
6. report: PROVENANCE + jank_eval verdict + MISS forensics
verdicts: PASS(0) / FAIL(1) / SUSPECT(fitted grid) / INVALID(multi-process)
MISS forensics: groups consecutive misses into episodes, classifies each
(CHAIN-STALL / LOOP-STUCK / GUARD-REJECT / NO-WAKE-ITER) with a +-33 ms
all-thread context window (IT poll/ev/rd/dec/pc/rc/vsw/nvol, VSYNC
latency, PRES, FR, SLOWEV, DECODE, OTHR, INVK).
self-test: synthetic 120Hz log with a 30 ms stuck-poll episode.
jank_eval.py fixes found under emulator stress:
- auto-windows active threshold was circular: 0.75x the refresh rate
meant a JANKY app (low present rate) is never detected as active.
Now 0.4x rate / 250 ms buckets. (Emulated 60 Hz scroll at 30-40 Hz
present was invisible to the old threshold.)
- BUCK 500->250 ms required the density multiplier *2 -> *4 (forgot,
caught by stress: only the fastest spans were windowed).
- fallback period (no VSYNC lines) was a telescoping mean (spans idle
gaps: 391 presents over 23 s fit '59.6 ms'). Now median-of-deltas
snapped to standard rates. A fitted grid cannot prove drops/doubles:
verdict on it excludes them (stalls+lates+spread remain) and the
verdict is marked SUSPECT (exit 3) instead of PASS/FAIL.
- run_file now returns (ok, suspect); small-log early exit fixed.
Stress matrix on emulator (x86_64, 60 Hz SF) — all pass:
capture pipeline x3 (standard 4,2,4 / fast 1,1,1 / slow 6,2,6)
pause/resume mid-drag (teardown -> occluded -> resume burst)
determinism (identical report twice), multi-process -> INVALID,
explicit --windows, --period-ms override, no-VSYNC -> SUSPECT path,
small log -> exit 1, synthetic perfect 120 Hz -> PASS exit 0.
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.
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).
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.
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).
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).
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).
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).
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).
- 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.
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.
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).
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).
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)
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.
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.
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).
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.
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).
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