Skip to content

fix(tmux-restore): trigger on the tmux socket appearing, not a 45s timer - #1376

Open
ZacxDev wants to merge 2 commits into
mainfrom
feat/tmux-restore-socket-activation
Open

fix(tmux-restore): trigger on the tmux socket appearing, not a 45s timer#1376
ZacxDev wants to merge 2 commits into
mainfrom
feat/tmux-restore-socket-activation

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 8, 2026

Copy link
Copy Markdown
Member

Trigger the boot restore unit on the tmux socket appearing, instead of a fixed OnActiveSec=45s timer.

This is the half that makes cold-boot restore actually work. #1351 (squash 9353d958)
stopped the unit from manufacturing a tmux server that systemd immediately killed — but
after it, a cold boot refuses and the operator re-runs by hand. This makes the unit fire
when there is a server to restore into.

The change

  • systemd.user.paths.tmux-session-restore with PathChanged=%t/tmux-%U/default,
    WantedBy=default.target; the OnActiveSec=45s timer is gone.
  • The service is gated by ConditionPathExists= on the same socket path.
  • TMUX_TMPDIR=%t pinned on the service (its two siblings already pin it; this unit did
    not, and worked only because the user-manager environment happened to carry it).
  • No RemainAfterExit.
  • graphical-session.target ordering dropped — it never ordered against anything tmux-related.

Twelve controlled systemd experiments, each with positive AND negative controls

The design is measured, not reasoned. The load-bearing results:

  • 🔴 PathExists= BUSY-LOOPS. Measured: 5 runs in 8 seconds, both units reaching
    start-limit-hit. Under this unit's OnFailure=notify-failure@%n — which bypasses DND —
    that is an alarm on every boot, forever. This is the obvious implementation and it is
    wrong.
  • PathChanged= fires exactly once, no loop — but it also fires on socket
    DELETION
    , not only creation. That second fact is what keeps the exit-0 decision below
    honest.
  • A failing ConditionPathExists fires OnFailure ZERO times, against a positive
    control that fires once. So the condition genuinely suppresses the failure path rather
    than merely appearing to.

Why not RemainAfterExit=yes

It is measured to work, and it is not needed here. RemainAfterExit would only be
required under PathExists= — so choosing the event-type watch removes the need rather
than trading one hazard for another. Keeping it would be strictly worse: with
KillMode=control-group, the unit would then own the operator's tmux server, and
systemctl --user stop would take the whole workspace with it.

The exit-0 refusal: KEPT, but its reason is REPLACED

#1351's refusal returns 0 deliberately, and its stated justification was "no-server is the
standing cold-boot state because the unit fires on a 45s timer"
. This change removes that
precondition
, so leaving the comment would have shipped a claim the code contradicts.

Re-derived, the answer still holds, for a different reason: the path unit re-fires on server
shutdown, so "no server" moves from every boot to every shutdown rather than
becoming rare; ConditionPathExists absorbs the ordinary case; and what actually reaches
the refusal is a race, which must not raise a DND-bypassing alarm. The comment, the test
docstring and the test's assertion message were all rewritten together, so the justification
cannot rot out of step with the code.

Verification

Tests: new scripts/tests/test_tmux_restore_trigger.py (structural, via a new
scripts/testlib/nix_units.py) plus test_nix_units.py for the helper — 57 tests.
Red-at-base / green-at-HEAD confirmed, with the base tree carrying the test files so nothing
goes red merely for a missing import.

Mutation: 18/18 killed, each by its own guard's own assertion text, both controls green,
zero survivors, zero no-op edits, zero killed-by-a-neighbour. Run under
PYTHONDONTWRITEBYTECODE=1. Covers: re-adding the timer · deleting the path unit ·
PathChangedPathExists · triggering a different service · dropping WantedBy ·
dropping TMUX_TMPDIR · TMUX_TMPDIR=/tmp (the seam mutant) · dropping
ConditionPathExists · guarding a different path than is watched · hardcoding the uid ·
watching the directory not the socket · adding RemainAfterExit · three on the advice text
in tmux-restore-observe.sh · three on nix_units.section().
One mutant survived on the first pass and produced a real fix to a prose guard.

Gate — all three tier/tree combinations green on 4fbe8440 (contains origin/main
e9e9075f):

tier verdict
dev host gate.sh --tier both PASS — pytest 22056 passed / 0 failed; node 1449 / 0 fail
nix sandbox checks.pytests PASS — 22059 collected / 22056 passed / 0 failed
nix sandbox checks.nodetests PASS — 5 suites / 41 files / 1449 tests / 0 fail

The two nix derivations were built one at a time (a combined invocation causes store
contention and false failures), from a git archive extract rather than the worktree
directly (a worktree's .git is a FILE and defeats the sandbox's git-config isolation,
failing exit=2 before any test runs), and every verdict was read from nix log — a
nix build prints no test output and a derivation that ran zero tests exits 0 identically.

Blast radius

Reversible — one commit revert restores the timer. It changes WHEN the unit runs, not
what it does. Worth knowing: this only takes effect after a home-manager switch, and the
behaviour it fixes is only observable across a real reboot, which is the one thing that has
overturned two successive hypotheses in this arc.

ZacxDev and others added 2 commits September 7, 2026 19:29
…imer

The unit destroyed the workspace it existed to restore. Measured 2026-09-06:
43 `claude --resume` conversations, silently. It was started by an
`OnActiveSec=45s` timer; on a cold boot nothing else has started tmux by
second 45, so tmux-session-restore.py's own `tmux new-session -d` created the
server INSIDE the unit's cgroup. The sends were delivered SUCCESSFULLY into
it. Then ExecStart returned and Type=oneshot + RemainAfterExit=no +
KillMode=control-group tore the cgroup down, taking the server and every
claude process with it. The unit reported Result=success.

A duration was never the variable, so this replaces the timer with the
observable the restore actually depends on: a tmux server that this unit did
not create, and therefore cannot destroy.

  * systemd.user.paths.tmux-session-restore watches %t/tmux-%U/default with
    PathChanged=, wanted by default.target.
  * The service is gated on ConditionPathExists= for the SAME socket, because
    PathChanged also fires on deletion (the operator's server exiting).
  * The service pins TMUX_TMPDIR=%t, so the socket the path unit WATCHES and
    the socket `tmux` QUERIES are one declaration rather than two that happen
    to agree. tmux's compiled-in default is /tmp, not %t.
  * No RemainAfterExit=yes: it is measured to work and is the wrong fix, since
    with KillMode=control-group the unit would then own the operator's server.

PathChanged rather than PathExists is not a style choice. systemd re-checks a
path unit's condition the moment the triggered unit terminates, so a STATE
that goes on being true re-fires forever: measured on this host, PathExists=
ran the oneshot 5 times in 8 seconds and left both units
Result=start-limit-hit — which under OnFailure=notify-failure@%n is a
DND-bypassing toast on every boot, strictly worse than the bug.

Also updates the operator-facing not-run advice in tmux-restore-observe.sh,
which still told the reader to wait for a 45s timer that no longer exists.

Adds nix_units.section() — a nested-section reader — because `Unit` is both a
section name and a directive name in a path unit, and directive() correctly
refuses rather than answering about the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQvw3A9KbFEUXVjtTAk4j
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
…ew trigger

The exit-0 justification named a precondition this branch removes — "until the
unit is triggered on the tmux socket appearing rather than a fixed
OnActiveSec=45s, no server is the normal COLD-BOOT state". That is now false,
so the comment and the test docstring are re-derived rather than left asserting
it. The DECISION is unchanged (still exit 0); only the reason is new:
PathChanged fires on socket DELETION too, ConditionPathExists skips the
ordinary case, and what reaches the refusal is a race — which must not raise a
DND-bypassing alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQvw3A9KbFEUXVjtTAk4j
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant