Apply of an already-warm generation: skip the redundant prepare
Implements OpenSpec tasks 6.1-6.5 from
openspec/changes/archive/2026-10-12-the-shell-swaps-themes-without-a-python-stall/tasks.md,
against the coordinator’s board run in
docs/evidence/omarchy-themes/instant-theme-swap/board-chooser-2026-09-25.md
(system phj9y4fggpb177b0hgaawbh510chhwil, master f4f75998). This is a
host-only result: no board, glass, or real-finger observation. Worktree
perf/instant-apply, base master 9ff0462c.
What the board run showed, and what each fix targets
| Board number | Cause found | Fix |
|---|---|---|
parse=65.0ms | theme_helperd.py built a fresh argparse.ArgumentParser on every request instead of once at daemon start | 6.1: build the parser once in Helperd.__init__() |
part of prepare_entry=90.6ms | theme_activate.prepare() re-hashed the theme’s own source tree (full content read) on every call, even for an unchanged theme | 6.2: theme_digest() caches source_digest(), invalidated by a cheap stat-only source_fingerprint() |
activate_generation=336.0ms (the largest single cost) | activate_generation() always sent a fresh prepare to both receivers before commit, even when a receiver already held that exact generation staged from the chooser’s own earlier preview/prepare-ahead | 6.3: track per-endpoint prepared state; skip prepare for a receiver already warm for this generation; commit directly; transparently retry with a real prepare if a “warm” assumption turns out stale |
cold prepare-ahead 4071ms when the carousel first opens | only the centred theme is ever warmed, and only after a 220 ms dwell — the first theme a person looks at pays the full cold cost | 6.5: queue the active theme’s immediate list neighbours for warming as soon as the list reply itself loads, drained whenever nothing dwell-driven is due |
| “under 100 ms from tap to visible, optimistic UI” (coordinator’s “better still”) | — | 6.4: considered, explicitly not implemented — see below |
6.1: parser built once, not per request
tools/theme_helperd.py’s Helperd.handle_line() called
theme_catalog.build_parser() on every request. build_parser()
constructs an argparse.ArgumentParser with every subcommand’s own
subparser and arguments — real work, but identical on every call for the
life of the daemon. Moved to Helperd.__init__(); handle_line() reuses
self.parser. Verify:
python3 -m unittest tests.test_theme_helper_daemon
test_the_argument_parser_is_built_once_not_once_per_request counts
build_parser calls across several requests on one daemon instance.
6.2: theme source digest cached, invalidated by a cheap fingerprint
theme_activate.prepare() calls source_digest(theme) (a full read-and-hash
of every file in the theme’s source tree) at least twice per call: once to
compute the generation identity, once again to detect a source change during
staging. Both were previously uncached, unlike the already-cached tools
(helper_digest) hash from task 5.3, deliberately: a person’s own theme
files can change while the daemon keeps running, so the theme’s own digest
must never go stale.
tools/theme_sources.py gains source_fingerprint(theme): the same
directory walk source_digest() performs (same symlink rejection, depth,
entry-count, and per-file/total-byte limits), but collecting only
(relative_path, size, mtime_ns) per file — no content read. theme_ activate.theme_digest(theme) wraps source_digest() with a cache keyed by
the theme’s resolved path, valid only while the fingerprint is unchanged;
both call sites in prepare() now go through theme_digest() instead of
source_digest() directly. An edited file changes its size and/or mtime, so
it is still re-hashed on the very next call; an untouched theme is not.
Verify:
python3 -m unittest tests.test_omarchy_theme_sources tests.test_omarchy_theme_activation
test_fingerprint_matches_digest_sensitivity_without_reading_content proves
the fingerprint changes on any edit or added file without reading content;
test_theme_and_helper_digests_are_cached_across_repeated_preparations
(renamed from the task-5.3 test) and the new test_theme_digest_cache_is_ invalidated_by_an_edit_between_preparations cover the cache hit/invalidate
behaviour for prepare() itself.
6.3: skip the redundant prepare on an already-warm Apply
The board’s dominant cost: activate_generation() in tools/theme_ transaction.py sent prepare to both receivers (the Rust chooser and the
Sway/card-shell compositor) before commit, even when a receiver already
held that exact generation staged — from the chooser’s own prepare-ahead
preview, or a prior identical activate — because nothing tracked which
receiver was prepared for which generation across calls.
exchange() now records per-endpoint prepared state:
_prepared_state: dict[Path, str] = {}
def _remember(endpoint: Path, phase: str, generation: Path | None) -> None:
if phase == "prepare" and generation is not None:
_prepared_state[endpoint] = generation.name
elif phase in ("commit", "rollback"):
_prepared_state.pop(endpoint, None)
activate_generation() partitions targets into warm (state says already
prepared for this exact generation) and to_prepare, sends prepare only
to to_prepare, then commits every target unconditionally. If a “warm”
receiver’s commit is rejected anyway — the tracking was stale, e.g. a
concurrent caller reset that receiver outside this process’s view — the
code transparently retries with a real prepare then commit for that one
receiver before re-raising on any further failure. A stale assumption
therefore degrades to exactly today’s behaviour (a real prepare, then
commit) rather than ever risking a wrong or partial commit; full
all-or-nothing rollback correctness across both receivers is unchanged.
Verify:
python3 -m unittest tests.test_omarchy_theme_transaction
Five new cases: test_activate_skips_a_prepare_already_warm_for_both_ receivers, test_activate_prepares_only_the_receiver_that_was_not_already_ warm, test_stale_warm_assumption_retries_with_a_real_prepare_and_still_ succeeds, test_a_genuine_commit_failure_for_a_warm_receiver_still_rolls_ back_fully, test_exchange_tracks_prepared_state_across_prepare_commit_ rollback.
6.4: the optimistic tap-frame UI — considered, not implemented
The coordinator’s “better still” suggestion was to show the new theme in the chooser UI on the tap frame itself, since the receivers already hold the prepared resources, letting the durable commit finish asynchronously. This was not implemented, for three reasons:
- Correctness risk. The chooser’s displayed state would diverge from the receivers’ actual committed state for exactly the window between the optimistic repaint and the real commit ack. If that commit then failed and rolled back — a real, handled case today (a receiver restarted, a generation removed from under it, a second commit failure after the first succeeded) — the person would see a theme applied that the system then reverted, with no visible cue why. This is the precise invariant task 1’s own instrumentation and this whole change’s proposal both treat as load-bearing: “ack only after a real frame,” never a result the two-phase protocol has not yet confirmed.
- Unverifiable without the board. The entire point of the change is perceived, on-glass latency; a host or QEMU run cannot show whether an optimistic repaint actually looks instantaneous versus merely racy, and this task is explicitly not to touch the board.
- 6.1-6.3 already remove the avoidable cost this was aimed at. A warm
Apply (the common case once 6.5’s neighbour-warming is in place) no
longer re-prepares either receiver at all — the remaining
activate_ generationcost for a warm generation is just thecommitround trip itself, not a secondprepare. The board re-check in 6.6 is what tells us whether that remaining cost still needs the optimistic-UI idea as a follow-up, rather than guessing at it here.
Left as an explicit candidate follow-up, to be revisited only with a board re-check in hand and, if pursued, a UI-level rollback affordance designed alongside it.
6.5: warm the active theme’s neighbours as soon as the list loads
Task 3.2 only ever warms the centred theme, and only after a 220 ms
dwell — so the very first theme a person looks at when the chooser opens
(the board’s cold 4071 ms preview) is never warmed ahead of time, and a
quick first swipe to an adjacent theme still pays the full cost.
ThemeView gains pending_neighbor_warms: VecDeque<usize>, populated with
the active theme’s immediate list neighbours (one, at either end of the
list, or two) every time a list reply is accepted — covering chooser-open
and the return from Preview/Cancel back to List alike. poll_prepare_ahead
still prioritises a real dwell-driven warm-up every tick (a person actively
browsing is never delayed behind a neighbour warm-up); only when nothing
dwell-driven is due does it drain the neighbour queue, one request per idle
tick, still bounded to a single request in flight, still discarded before
it can reach ThemeView::accept. Verify:
cd nix/rust-shell-client && cargo test --offline --lib theme_ui
Five new cases: a_list_reply_queues_both_neighbours_of_a_mid_list_active_ theme, a_list_reply_queues_only_the_one_neighbour_at_each_end_of_the_ list, a_single_theme_list_queues_no_neighbours, the_neighbour_queue_ drains_when_nothing_is_dwell_driven, a_dwell_driven_request_takes_ priority_over_the_neighbour_queue.
Test-suite proof
cargo test --offline(nix/rust-shell-client): all suites pass —--lib16theme_uicases (11 pre-existing + 5 new) plus the crate’s other lib tests, and thetheme_catalog_module/background_decode_ module/service_data_moduleintegration suites, 0 failures.python3 -m unittest tests.test_omarchy_theme_transaction tests.test_theme_helper_daemon tests.test_omarchy_theme_activation tests.test_omarchy_theme_sources tests.test_theme_catalog tests.test_theme_timing: 73 cases, all passing (run 11 of 12 times clean; one run hit ashutil.rmtree“Directory not empty” race during an unrelated test’s teardown underuptimeload 14-24 from concurrent agents on this shared host — reproduced 8/8 clean immediately after, consistent with the same host-contention flake class already documented indocs/evidence/omarchy-themes/instant-theme-swap/README.md’s own section 6; not touched by this task’s diff, which adds no threading).python3 tools/blob-scan.py: exit 0 (“every binary is accounted for”).nix build --no-link --print-out-paths --max-jobs 1 --cores 6 .#handheld-shell-rust .#handheld-theme-command .#handheld-theme-default .#card-shell: all built cleanly for riscv64;theme_timing.py,theme_sources.py, andtheme_transaction.pyconfirmed present under.#handheld-theme-command’s ownlibexec/handheld-theme/.nix eval .#nixosConfigurations.k230-coherent-shell.config.system.build.toplevel.drvPath: evaluates to a.drvpath, no eval error.openspec validate the-shell-swaps-themes-without-a-python-stall --strict: valid.
Board commands for the coordinator (task 6.6)
Same shape as board-chooser-2026-09-25.md’s own run: Settings, tap Themes,
wait long enough for the carousel’s own neighbour-warm to land (no dwell
needed with 6.5 in place), tap a theme, wait past the 220 ms dwell so the
chooser’s own prepare-ahead has time to run, then tap Apply.
journalctl -u theme-helper -u shell-ui -u shell -o json --since "-2min" \
| grep -E "THEME_TIMING|theme-command|appearance-(prepare|commit)-accepted"
What to look for that would confirm this task’s fixes landed:
parse=well under 65 ms (parser built once, at daemon start).prepare_entry=lower for an already-warm generation (no full source re-hash).- For an Apply of a theme the chooser already prepared (dwelled-on,
neighbour-warmed, or a repeat of the same theme): no second
appearance-prepare-acceptedline between the chooser’s own warm-up and the Apply’s own commit — only a commit.activate_generation’s own loggedms=should drop close to just thecommitround trip’s cost. - For the second theme previewed after opening the chooser (not the
first, not one dwelled on): a
previewms=close to the warm ~200 ms figure, not the cold ~4 s figure, proving the neighbour queue warmed it ahead of time. - Whether the resulting Apply-to-visible time is now under the ~150 ms target from task 5, and how close it comes to the user’s original ~100 ms goal — the honest answer only the board can give.
Task 6.6’s board run has not been executed by this worktree; /dev/ttyACM0
was not opened here.