Theme-swap jank: capture tool and the persistent-helper win
Addresses “theme switcher is VERY janky, capture jank and optimize”, with a
hard target from the user: the new theme visible within about 100 ms of the
Apply tap, touch and animation never stalling. This is a host +
headless-QEMU-under-qemu-riscv64-static result: no board, glass, or
real-finger observation. Run 2026-09-24, worktree
fix/wallpaper-visible-swap-jank, base master b9f81d47.
What exists already, and was checked before writing anything new
Per the task’s own pointers: no tools/shell-motion-trace.py exists in this
repository. tools/analyze-launcher-transitions.py and
tools/analyze-video-presentation.py parse a different, unrelated
telemetry shape (launcher-transition CPU records and video presentation
frames). tools/card-shell-benchmark.py is the closest prior art — a
K230_CARD_BENCH-prefixed telemetry parser with percentile/budget checks
and a --board-gated provenance manifest — but it measures card drag/
tracking motion, not theme-swap events, and has no theme-swap schema.
SWAY_K230_CPU_FRAME_TIMING’s diagnostic Sway build
(k230.shell.frameTiming) is mutually exclusive with coherentShell
(nix/shell.nix’s own assertion), so it cannot instrument the board’s real,
shipped shell session at all — this capture tool deliberately does not
depend on it, and instead uses signals the production coherentShell
build already emits: the Rust shell’s own rust-shell <ms>ms <event> log
(fn log in nix/rust-shell-client/src/main.rs) and K230_CARD_SHELL
sway_log lines from nix/card-shell/adapter.c, both readable from
journalctl -u shell-ui -u shell with no board reconfiguration.
New tools
tools/theme-swap-jank.py— runs on the board as root (needs/proc/<pid>/statfor arbitrary pids andjournalctl), triggers a swap as the shell user viarunuser, samples per-process CPU every ~20 ms (sway, the Rust shell, and any livetheme_catalog.py/theme_helperdhelper process it finds bycmdline), and mergesjournalctl -o jsonlines from both units by wall-clock timestamp into one ordered event timeline. Writes one JSON report under/run/.--self-testexercises its/proc/statparsing, CPU-percent math, and journal-line tagging against fixtures and touches no PID, journal, or/runfile.tools/analyze-theme-swap-jank.py— host-only. Reads that JSON and produces: a frame-interval histogram (bucketed <16.7/33.4/50/100/200/500 ms), gaps over 33/50/100 ms with the two bounding events, the single longest stall plus every event (including the sway side) that happened during it, tap-to-visible latency against a configurable target (default 100 ms, matching the user’s stated target), and per-process CPU avg/max.--self-testproves a smooth-swap fixture passes and a janky-swap fixture (a synthetic ~1.85 s stall) fails on both the tap-to-visible and the gap-over-100ms checks, with the stall’s context correctly including the sway-side event that happened during it.
python3 tools/theme-swap-jank.py --self-test
python3 tools/analyze-theme-swap-jank.py --self-test
Both currently pass (5 and 5 cases).
The one jank source this change actually fixes: Python start-up
Board evidence already on master
(docs/evidence/omarchy-themes/background-decode-cache-qemu/README.md)
measured a bare k230-theme --help at 1259 ms against 71 ms for runuser
alone, and attributed most of that (via PYTHONPROFILEIMPORTTIME) to
importing theme_activate and its own dependents, not bare interpreter
start-up. A chooser Apply calls k230-theme twice (preview then
activate), so close to 2.4 s of the observed 2.8-3.5 s swap was paid
importing modules, not doing theme work.
tools/theme_helperd.py (a new persistent daemon) imports that chain once
and serves every later request over /run/shell/theme-helper.sock by
calling tools/theme_catalog.py’s own build_parser()/handle() —
extracted from its main() with no behavior change (see
tests.test_theme_catalog, all 10 pre-existing cases still pass).
tools/theme_client.py replaces k230-theme’s wrapped entry point: it
tries that socket first, using a second, deliberately minimal parser that
never imports theme_catalog, and falls back to the exact original
in-process path (never a second subprocess) when the daemon is not
reachable.
Full detail, rejected alternatives, and the requirement this adds are in
openspec/changes/archive/2026-10-12-the-shell-swaps-themes-without-a-python-stall/.
Host + QEMU numbers (directional, not the board’s own number)
Built .#handheld-theme-command fresh from this worktree:
/nix/store/6i9i4am2f2nha0lj3yn4m7phd2bn2k9v-handheld-theme-command-0.1
(a riscv64 package, run here under qemu-riscv64-static — this is
cross-architecture emulation overhead dominating the absolute numbers, not
the K230’s actual in-order core; only the relative before/after
comparison is meaningful from this host).
5x k230-theme list --json | per call | |
|---|---|---|
| No daemon (fallback path, i.e. today’s behaviour) | 5.740 s | 1.148 s |
theme-helper.service running | 3.925 s | 0.785 s |
About a 32% reduction on this host under qemu-user emulation, despite qemu
overhead swamping the real per-call import-vs-runtime ratio the K230 itself
will show (its own evidence already isolates ~1.2 s as import cost
specifically, a much larger fraction of a ~1.15 s call there than of a
qemu-emulated call here). python3 -X importtime on a real subprocess
confirms the daemon path never imports theme_catalog/theme_activate/
theme_transaction/theme_preferences/keyboard_appearance
(tests/test_theme_helper_daemon.py’s
test_client_subprocess_never_imports_theme_catalog_when_the_daemon_answers).
Board commands for the coordinator
Reserved-board evidence gate for tasks 2.3/3.4 in the OpenSpec change
above. Exact commands, once a system built from this change (or a later
master that includes it) is installed:
1. Jank capture, before this change lands (on the currently-installed
system, which has no theme-helper.service):
./tools/console.py /dev/ttyACM0 --wait=3 \
"python3 /path/to/tools/theme-swap-jank.py --theme-id catppuccin-latte --duration-after-s 3"
# prints the /run/theme-swap-jank-<epoch>.json path it wrote; copy it off, e.g.:
./tools/console.py /dev/ttyACM0 --wait=3 "cat /run/theme-swap-jank-<epoch>.json" > /tmp/before.json
python3 tools/analyze-theme-swap-jank.py --input /tmp/before.json --output /tmp/before-report.json
(tools/theme-swap-jank.py needs to be present on the board — either
push-file.py it over, or run it from an installed store path once this
change ships a package for it; it is a stdlib-only script with no build
step.)
2. After landing this change (installed system now has
theme-helper.service active — confirm with
systemctl is-active theme-helper), repeat the exact same capture command
and compare tap_to_visible_ms and the gap histograms between the two
reports.
3. Wallpaper visibility, tying back to the sibling investigation in
this same branch: docs/evidence/omarchy-themes/wallpaper-visibility-board-report/README.md
has the exact swaymsg card_shell enter + grim + diagnostic commands.
Neither jank-capture run above has been executed on the board by this
change; /dev/ttyACM0 was not opened. Task 2.3 in the OpenSpec change’s
tasks.md remains open until the coordinator (or whoever holds the board)
runs it and commits both reports here.