fix(tmux-restore): trigger on the tmux socket appearing, not a 45s timer - #1376
Open
ZacxDev wants to merge 2 commits into
Open
fix(tmux-restore): trigger on the tmux socket appearing, not a 45s timer#1376ZacxDev wants to merge 2 commits into
ZacxDev wants to merge 2 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Trigger the boot restore unit on the tmux socket appearing, instead of a fixed
OnActiveSec=45stimer.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-restorewithPathChanged=%t/tmux-%U/default,WantedBy=default.target; theOnActiveSec=45stimer is gone.ConditionPathExists=on the same socket path.TMUX_TMPDIR=%tpinned on the service (its two siblings already pin it; this unit didnot, and worked only because the user-manager environment happened to carry it).
RemainAfterExit.graphical-session.targetordering 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 reachingstart-limit-hit. Under this unit'sOnFailure=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 socketDELETION, not only creation. That second fact is what keeps the exit-0 decision below
honest.
ConditionPathExistsfiresOnFailureZERO times, against a positivecontrol that fires once. So the condition genuinely suppresses the failure path rather
than merely appearing to.
Why not
RemainAfterExit=yesIt is measured to work, and it is not needed here.
RemainAfterExitwould only berequired under
PathExists=— so choosing the event-type watch removes the need ratherthan 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, andsystemctl --user stopwould 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;
ConditionPathExistsabsorbs the ordinary case; and what actually reachesthe 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 newscripts/testlib/nix_units.py) plustest_nix_units.pyfor 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 ·PathChanged→PathExists· triggering a different service · droppingWantedBy·dropping
TMUX_TMPDIR·TMUX_TMPDIR=/tmp(the seam mutant) · droppingConditionPathExists· guarding a different path than is watched · hardcoding the uid ·watching the directory not the socket · adding
RemainAfterExit· three on the advice textin
tmux-restore-observe.sh· three onnix_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(containsorigin/maine9e9075f):gate.sh --tier bothchecks.pytestschecks.nodetestsThe two nix derivations were built one at a time (a combined invocation causes store
contention and false failures), from a
git archiveextract rather than the worktreedirectly (a worktree's
.gitis a FILE and defeats the sandbox's git-config isolation,failing
exit=2before any test runs), and every verdict was read fromnix log— anix buildprints 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 thebehaviour it fixes is only observable across a real reboot, which is the one thing that has
overturned two successive hypotheses in this arc.