evidence · 13.1 KiB

board-diagnosis-2026-09-24.md

Committed at docs/evidence/omarchy-themes/instant-theme-swap/board-diagnosis-2026-09-24.md. This is what grounds the requirements that cite it.

Open raw source

Board diagnosis: the helper wasn’t the bottleneck, prepare() was

Follow-up to README.md in this same directory, after the coordinator ran the landed perf/instant-theme-swap work (master b3183866, plus the later d87728a4 preview-race fix) on the reserved board, system 766yhn3l.... This file is a host + code-reading diagnosis of that board run (the board run itself is the coordinator’s own, not repeated here) plus the fix and instrumentation this worktree (perf/instant-swap-board, base master at d87728a4 or later) adds. No board access from this worktree; nothing here is a new board measurement.

The board numbers (coordinator’s own run, timed directly with the

installed k230-theme, shell user, chooser closed)

CommandWall time
list --json886-890 ms
preview <id> (catppuccin/-latte, already prepared, has background.cache)3950-4095 ms
activate --json --expected-generation <g> <id>4183-4263 ms

journalctl -u theme-helper --since -3min showed no entries. /run/shell/theme-helper.sock existed, owned by shell. Before the daemon existed at all, activate took 2.8-3.5 s — so the daemon made this worse, not better.

Root cause (found by reading the actual code path, confirmed by a host measurement)

theme_activate.py’s prepare() is called by every preview/activate, daemon-served or not. It used to check whether the destination generation already existed only at the very end — after:

  1. hashing the whole theme source tree (source_digest, reading every background image’s full bytes) and the whole omarchy-theme-tools tree,
  2. staging: copying every background image and config file into a scratch directory (checked_copy, a second full read-and-write of the same bytes source_digest just read),
  3. invoking the external omarchy-theme-color --all helper (a bash script that itself spawns several awk subprocesses per call for colour mixing),
  4. invoking omarchy-theme-set-templates (another bash script, which itself re-invokes omarchy-theme-color --all again internally, then runs sed -f <script> per template file),
  5. (only for a new generation) building the wallpaper cache.

For an already-prepared generation, none of steps 2-5 do anything useful — the exact same output already exists on disk — but all of it ran anyway, every single call, and was thrown away the moment the (previously last-checked) destination.exists() test finally ran. This is real, subprocess-spawning, disk-I/O work, unrelated to Python interpreter start-up (which theme-helper.service already correctly eliminated), so removing the interpreter cost alone could never have fixed it — consistent with the board showing the daemon reachable yet the numbers not improving.

theme_client.py compounded this. Its --helper-timeout-s default was 0.3 s, sized for “how long should Python start-up take”, not “how long can a real prepare() legitimately take”. Since a real preview/activate routinely took several seconds (per above), the client’s socket read timed out on an already-working daemon reply almost every time, abandoned it, and fell back to the full in-process path — paying the wasted 0.3 s and the ~1.2 s import chain and prepare()’s cost a second time. This fully explains the board’s activate number landing above the pre-daemon baseline: 0.3 + 1.2 + ~2.7 (prepare) ≈ 4.2 s, matching the observed 4.18-4.26 s almost exactly.

journalctl -u theme-helper showing nothing was not a sign of failure — neither theme_helperd.py nor theme_catalog.py printed anything on success or failure. There was nothing to see, not evidence the daemon wasn’t being used.

The fix

Test-suite proof (host only)

QEMU smoke test of the actual packaged binary (not just host unit tests)

Built .#handheld-theme-command fresh from this worktree (/nix/store/rsmbxhf0j5xfg6lj76kfqm5a78bxx4wl-handheld-theme-command-0.1, a riscv64 package) and ran its bin/k230-theme wrapper under qemu-riscv64-static (via this host’s registered binfmt_misc handler, so the wrapper script runs exactly as it would from a board shell), no daemon socket present (fallback path, so this exercises theme_activate.prepare() and theme_timing’s real syslog calls in a real riscv64 interpreter, not this host’s own Python):

list --json:                 1.735 s
preview <id> --json (cold):  5.920 s
preview <id> --json (again): 1.799 s   -- byte-identical output to the cold run

Confirms, independent of the host unittest numbers above: theme_timing.py is correctly packaged (no ModuleNotFoundError), stdout carries nothing but the JSON result in both the cache-hit and cache-miss case (syslog never leaks into it, even where /dev/log may not exist in this qemu-user environment — theme_timing.log()’s own except Exception: pass holds), and the cache-hit preview matches list’s own baseline cost almost exactly (~1.7-1.8 s, all qemu-user-emulation + Python-start-up overhead, nothing left from staging/external-helper work), while the cache-miss preview pays for two additional emulated subprocess trees (omarchy-theme-color/omarchy-theme-set-templates, each themselves spawning several awk/sed children) at roughly 3x the cost. This is still qemu-user overhead dominating the absolute numbers, never the K230’s own in-order core — only the relative cache-hit-vs-miss comparison (and the packaging/stdout-cleanliness proof) is meaningful from this host.

Exact board commands for the coordinator

Confirm the daemon picked up the new build and is reachable:

systemctl status theme-helper --no-pager
ls -la /run/shell/theme-helper.sock

Repeat the exact timed sequence from the original report (same theme ids, so this lands on the same, already-prepared generation and exercises the new cache-hit fast path), as the shell user with the shell session’s own runtime dir and display:

sudo -u shell env XDG_RUNTIME_DIR=/run/shell WAYLAND_DISPLAY=<shell's own value> \
  bash -c 'time k230-theme list --json' >/tmp/list.json
sudo -u shell env XDG_RUNTIME_DIR=/run/shell WAYLAND_DISPLAY=<shell's own value> \
  bash -c 'time k230-theme preview --json <catppuccin-or-latte-id>' | tee /tmp/preview.json
GEN=$(python3 -c "import json,sys;print(json.load(open('/tmp/preview.json'))['generation'])")
sudo -u shell env XDG_RUNTIME_DIR=/run/shell WAYLAND_DISPLAY=<shell's own value> \
  bash -c "time k230-theme activate --json --expected-generation $GEN <same-id>"

Expected: preview/activate well under 500 ms each now that the generation is already prepared (task 4’s own target). Pull the new self-diagnosing timing from the journal right after:

# Every THEME_TIMING line from every process on the machine (client,
# daemon, and the two-phase exchange), regardless of which one emitted it:
journalctl -t k230-theme-timing --since "-5 min" --no-pager

# The daemon's own view specifically (same lines, scoped to its unit):
journalctl -u theme-helper --since "-5 min" --no-pager

Look for, per the coordinator’s own diagnostic questions:

Then, separately, actually use the on-screen theme chooser (not just the raw CLI) to exercise the Rust catalog bridge end to end, and pull its own timing:

journalctl -u shell-ui --since "-5 min" --no-pager | grep 'theme-command'

Look for rust-shell <ms>ms theme-command <action> <id> duration=<ms>ms — the chooser’s own wait on each k230-theme call, which should now track the fast path=daemon/cache=hit numbers above rather than the multi-second numbers from before this fix.

None of the commands in this section have been run by this worktree; /dev/ttyACM0 was not opened.