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
- 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.
- 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.
- 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-.