Optimistic Apply: show an already-prepared theme ahead of the durable commit
Implements OpenSpec task 7 from
openspec/changes/archive/2026-10-12-the-shell-swaps-themes-without-a-python-stall/tasks.md,
against the user’s explicit approval (2026-09-25 coordinator message: “The
user has explicitly approved the optimistic theme apply”) of the design the
change’s design.md records in full. This is a host-only result: no
board, glass, or real-finger observation. Worktree
perf/optimistic-theme-apply, base master 600171bc.
Starting point
Board re-check after skipping the redundant prepare
(docs/evidence/omarchy-themes/instant-theme-swap/board-chooser-2026-09-25.md’s
own “Re-check after skip-redundant-prepare” section, system
v84gna5s1qnabg5mqhwa55anml2q8amh..., master bc0cf82f): tap Apply -> Rust
appearance-commit-accepted 397 ms, helper parse=2.2ms prepare_entry= 63.0ms activate_generation=226.2ms. Still short of the user’s original
~100 ms target (“theme swaps should be instant”); the remaining cost is
activate_generation’s own durable pointer-swap/preference/app-sync work,
none of which can be skipped without risking the transaction’s own
correctness.
What changed
nix/rust-shell-client/src/main.rs:should_apply_optimistically(request, prepared_generation)— pure: true only for anActivaterequest whoseexpected_generationexactly matches the localAppearanceReceiver’s ownpreparedsnapshot.optimistic_apply_due(already_attempted_for, pending_id)— pure: true exactly once per freshpending_id, so a still-pending Apply is never re-checked tick after tick and a rapid second Apply (its own fresh, higher id) is never suppressed by the first’s bookkeeping.show_theme_optimistically— renders an already-prepared snapshot through the samedraw_wallpaper/draw/flush calls the real commit path already uses, skipping only for a video-backed selection, an unrenderable snapshot (defensive), or an overlay not immediately ready for a new frame. Logsrust-shell <ms>ms optimistic-apply shown ms=<tap-to-frame>. Never touchesAppearanceReceiver’s ownprepared/activebookkeeping or theActivaterequest already dispatched toThemeWorker— both are left entirely to the real, unmodifiedAppearancePhase::Commit/Rollbackhandling to settle authoritatively (including reverting the display and surfacing an error viaThemeView::accept’s existingErrhandling on failure).show_appearance_optimistically— a best-effort, fire-and-forget “show” message to the card-shell compositor’s own appearance socket (K230_CARD_APPEARANCE_SOCKET, defaulting tonix/shell.nix’s existingSWAY_K230_CARD_APPEARANCE_SOCKETproduction path), sent after the local render/log so a slow or unreachable compositor never inflates the logged latency.ShellClientgainstheme_apply_tapped_at,theme_optimistic_shown_ for, andcard_appearance_socket;submit_themerecords the tap instant and resets the per-Apply attempted-marker whenever a freshActivateis submitted;serve’s own loop checksoptimistic_apply_ dueonce, right after Wayland event dispatch (the same tick a touch-up already rantheme_action(ThemeIntent::Apply)in).
nix/card-shell/appearance.c: an additive"show"protocol phase. Rendersservice.candidateimmediately when it is alreadyservice. preparedand matches the requested id/path, with the same best-effort restore-on-apply-failure as the existingcommitbranch, but never touchesservice.prepared/service.candidate_path/service.current— a subsequent realcommitorrollbackfor the same candidate is unaffected by whethershowwas ever sent, was late, or was duplicated.
Why this is safe (see design.md’s own “Optimistic Apply” section for the full reasoning)
A receiver only ever holds a prepared candidate after it has already
validated that candidate byte-for-byte via the same checks a real prepare
exchange runs. Rendering it early therefore risks nothing a real prepare
ack would not already have accepted. The optimistic render is stateless
with respect to the durable transaction — it reads prepared, renders,
and is done — so there is nothing to reconcile if the two ever disagree:
the real commit/rollback event, unaware optimism ever ran, always wins
and settles the receiver’s actual state and the visible appearance
correctly, including on a durable failure.
What was deliberately not done
- The wire acknowledgement contract is unchanged. No
prepare/commit/rollbackexchange’s own success criteria, or the “ack only after a real, flushed frame” rule, changed in any way.proposal.md’s own “What This Does Not Do” section is revised to say this precisely (its earlier wording read as ruling this whole feature out permanently). - The compositor fanout is not wired into production here.
theme- helper.service’s--rust-socket/--deck-socketflags are not passed innix/shell.nixon this branch’s base (master600171bc) — that is a separate, not-yet-landed integration (see this repository’s ownimpl/card-appearance-endpointworktree). The compositor’sshowphase andmain.rs’s sender are therefore correct and tested in isolation, but dormant in production until that fanout lands; sendingshowto an unreachable/not-listening compositor is silently ignored, by design. - A genuine touch-driven QEMU proof was not built. The existing
tests/rust_theme_chooser_qemu.pyharness stands in a syntheticK230_THEME_COMMANDscript fork230-theme, which never runs the real two-phase protocol against the realAppearanceReceiversocket — soappearance.prepared()is never populated there and this feature’s own code is structurally unreachable in that harness (every Apply in it is effectively “cold” from the receiver’s point of view, which is also why running it unmodified is still a valid regression check — see below). A harness combining real touch injection with the realtheme- helper/two-phase backend (closer totests/test_theme_commit_under_ occlusion_runtime.py’s own real-CLI approach) would be needed to exerciseshould_apply_optimisticallyend to end under QEMU, and was not built in this task given the time this stage already spent; this is named here as an explicit gap, not a claimed pass.
Test-suite proof
cargo test --offline(nix/rust-shell-client): 159+9+14+8+8+5 = 203 cases, all passing. New:route_tests::optimistic_apply_fires_only_for_ an_activate_matching_the_prepared_generation(warm/cold/mismatched/ non-Activate-request cases forshould_apply_optimistically),route_tests::optimistic_apply_due_is_scoped_to_each_fresh_pending_id_ for_a_rapid_double_apply(the rapid-double-Apply scoping property),theme_ui::tests::a_failed_activate_clears_pending_and_surfaces_a_ visible_error(the existing, unmodifiedErr-reply handling proven specifically for an Activate reply, not just Preview/background).python3 -m unittest tests.test_card_shell_appearance: 10 cases (7 pre-existing + 3 new), all passing against the real, compiledappearance.cover a real Unix socket (built with-Werror, so the new C branch itself is warning-clean). New:test_show_renders_a_prepared_candidate_without_disturbing_the_two_ phase_state(show renders, twice, then a real commit for the same candidate still succeeds — provingshownever clearedprepared),test_show_is_rejected_for_a_generation_that_was_never_prepared(cold and mismatched-id cases both rejected, connection never blocks),test_show_never_blocks_a_later_rollback_from_restoring_the_previous_ generation(show, then a real rollback still restores the previous generation’s own pixels/palette).python3 tools/blob-scan.py: exit 0 (“every binary is accounted for”).openspec validate the-shell-swaps-themes-without-a-python-stall --strict: valid.nix build --no-link --print-out-paths --max-jobs 1 --cores 6 .#handheld-shell-rust .#card-shell .#handheld-theme-command .#handheld-theme-default .#card-composition-probe: all five built cleanly for riscv64 (exit 0), includingcard-shell’s own build ofsway-unwrapped/swayagainst the new"show"branch inappearance.c— the real C cross-compile check for that change, not just the hostcc -Werrorbuild the unit tests above already ran. Output paths:/nix/store/j8fnc6jr4hhsvp3hff9k2hjjy7k2d9il-k230-shell-rust-riscv64-unknown-linux-gnu-0.1.0,/nix/store/1va4angandva4xbm5i0v7367p0nd9jry-k230-card-shell,/nix/store/nh386rn5qkdbcl11lqyxx538s4mc2niv-handheld-theme-command-0.1,/nix/store/1dqfry706fj780v5byiklmaawakwphid-handheld-theme-default-28ceaae7,/nix/store/ih8mq5lq53w7p8mcv6aarz6z55il0f4s-k230-card-composition-probe.nix eval .#nixosConfigurations.k230-coherent-shell.config.system.build.toplevel.drvPath: evaluates to/nix/store/2j8yam3w04lhj3qhhssi0hyp8m1awip0-nixos-system-nixos-26.11.20260919.20b1ddd.drv, no eval error.
Board commands for the coordinator (task 7.4)
journalctl -u shell-ui -u shell -o json --since "-2min" \
| grep -E "optimistic-apply|appearance-(prepare|commit)-accepted|appearance-commit-rejected"
Steps: Settings, Themes, dwell on (or swipe to a neighbour-warmed) a
non-active theme long enough for its own prepare-ahead to land, then tap
Apply. Expected on a warm Apply: an optimistic-apply shown ms=<N> line
appears before (or very close to) the durable activate’s own reply, and
<N> is the number to compare against the ~100 ms target. Expected on a
cold Apply (a theme the chooser never had time to prepare ahead): no
optimistic-apply shown line at all, and the pre-existing busy-state
behaviour. For the failure/rollback scenario, the coordinator’s own
existing failure-injection approach (see
docs/evidence/omarchy-themes/instant-theme-swap/board-diagnosis-2026-09-24.md/
tests/test_theme_commit_under_occlusion_runtime.py for the shape of a
reproducible commit failure) should show a subsequent appearance-commit- rejected and the wallpaper/chooser visibly reverting to the previous
theme with an error message, never settling on the optimistically-shown
one.