evidence · 12.7 KiB

apply-skip-redundant-prepare-2026-09-24.md

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

Open raw source

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 numberCause foundFix
parse=65.0mstheme_helperd.py built a fresh argparse.ArgumentParser on every request instead of once at daemon start6.1: build the parser once in Helperd.__init__()
part of prepare_entry=90.6mstheme_activate.prepare() re-hashed the theme’s own source tree (full content read) on every call, even for an unchanged theme6.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-ahead6.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 opensonly the centred theme is ever warmed, and only after a 220 ms dwell — the first theme a person looks at pays the full cold cost6.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:

  1. 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.
  2. 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.
  3. 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_ generation cost for a warm generation is just the commit round trip itself, not a second prepare. 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

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:

Task 6.6’s board run has not been executed by this worktree; /dev/ttyACM0 was not opened here.