a73x

8184693b

fix: no layout save at the exit, so two terminals stop clobbering each other

a73x   2026-09-03 05:20

Commit message
fix: no layout save at the exit, so two terminals stop clobbering each other

The detach and finish arms persisted unconditionally. A second terminal
opened on one device holds the tree as it was when it started, so its exit
wrote that stale copy back over everything the first one had changed since
- panes added, splits, resizes, all undone by a wall that touched nothing.
Every change already writes the file; the focus record is the only thing
these two saved that nothing else does, and a focus is not authored.

The restore leg now moves focus BEFORE the resize, so the save that
carries the focus record is the resize's own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

README.md
Old New
@@ -85,9 +85,11 @@ is at least twice as wide as it is tall, and stacks them otherwise — after
85 that the tree is yours. Fullscreen is a layout where the focused pane takes 85 that the tree is yours. Fullscreen is a layout where the focused pane takes
86 the whole terminal; resize mode trades cells between the focus and its 86 the whole terminal; resize mode trades cells between the focus and its
87 neighbors, one per keystroke. Every change to the panes or the tree — a 87 neighbors, one per keystroke. Every change to the panes or the tree — a
88 birth, a split, a resize, an `x`, a detach — writes the layout file, so a 88 birth, a split, a resize, an `x` — writes the layout file, so a wall that
89 second terminal on this machine opens what you last arranged and a wall 89 is killed loses nothing it had committed and the next `mux` opens what you
90 that is killed loses nothing it had committed. 90 last arranged. Each terminal writes on its OWN changes and the last writer
91 wins: two walls open at once on one machine do not merge, and the one that
92 changed something last is the one the next start sees.
91 93
92 The focused tile has the terminal, and only the prefix is held back: 94 The focused tile has the terminal, and only the prefix is held back:
93 95
docs/decisions.md
Old New
@@ -7916,6 +7916,26 @@ same spirit a save FAILURE is a notice, not a `std.debug.print`: every save
7916 runs under the alternate screen, so a stderr line would land in the middle 7916 runs under the alternate screen, so a stderr line would land in the middle
7917 of whichever pane the cursor was in and stay there until a repaint. 7917 of whichever pane the cursor was in and stay there until a repaint.
7918 7918
7919 **A refused file is never written, and no save happens at the exit.** Two
7920 holes in the ruling above, both found by review on 2026-09-02 and both the
7921 same shape: a run whose tree is not what the file says, writing that tree
7922 back. First, a REFUSED file left `Shared.layout_path` set, so a run with an
7923 entry pane (`mux HOST`, `mux --sock S`) did `addFirst(0)` and the start-up
7924 `persist` replaced the user's whole wall with one leaf — the printed line
7925 telling them what to fix pointed at a file that no longer held the mistake.
7926 `seedSidecar` returns the verdict now and `run` stops saving on `.refused`
7927 and on the new `.self_only` (every leaf was this shell's own session),
7928 saying so on the notice line as well as on stderr. Second, the two
7929 exit-time saves — `.detach` and `.finish` — were unconditional, so a second
7930 terminal opened on the same device, showing a tree from before the first
7931 one's edits, wrote its stale copy back on the way out. They are gone: every
7932 change already saves, and the FOCUS is the only thing they recorded that
7933 nothing else does. Two walls open at once therefore do not merge; the last
7934 writer wins, which is what the hosts file has always done. A
7935 changed-underneath guard — re-read before write, refuse or merge on a
7936 mismatch — is the real fix for concurrent walls and is deferred, because it
7937 needs a rule for what a merge means and neither front has one.
7938
7919 **`# holds NAME N`, and why a `#` line.** The picker's session rows need to 7939 **`# holds NAME N`, and why a `#` line.** The picker's session rows need to
7920 say who else is on a session before you end it, and that is the only wire 7940 say who else is on a session before you end it, and that is the only wire
7921 addition in the change. `sessions_reply` was names one per line plus a 7941 addition in the change. `sessions_reply` was names one per line plus a
src/tui/wallview.zig
Old New
@@ -2154,7 +2154,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2154 wall_layout.persist(w); 2154 wall_layout.persist(w);
2155 }, 2155 },
2156 .finish => |how| { 2156 .finish => |how| {
2157 wall_layout.persist(w);
2158 exit_code = how.code; 2157 exit_code = how.code;
2159 exit_msg = how.msg; 2158 exit_msg = how.msg;
2160 break :keys; 2159 break :keys;
@@ -2479,7 +2478,14 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2479 tiles[z].detach_req.store(true, .release); 2478 tiles[z].detach_req.store(true, .release);
2480 ring(&tiles[z]); 2479 ring(&tiles[z]);
2481 awaitDetach(&tiles[z], &shared); 2480 awaitDetach(&tiles[z], &shared);
2482 wall_layout.persist(w); 2481 // No save here, and none on `.finish` either. Every change
2482 // to the pane set or the tree has already written the file,
2483 // so an exit has nothing left to record — and two terminals
2484 // on one device made the exit-time save actively wrong: the
2485 // passive one, showing a tree from before the other's edits,
2486 // wrote its stale copy back on the way out and undid them.
2487 // The focus is the only thing that goes unsaved by this, and
2488 // a focus is not authored.
2483 exit_code = 0; 2489 exit_code = 0;
2484 exit_msg = "mux: detached (session still running; run mux to reattach)"; 2490 exit_msg = "mux: detached (session still running; run mux to reattach)";
2485 break :keys; 2491 break :keys;
test/e2e_09_hosts.sh
Old New
@@ -199,9 +199,9 @@ HESH_PID=$("$MUX" d dump --sock "$HESOCK" | sed -n 's/.*hepid=\([0-9]*\).*/\1/p'
199 [ "$(ps -o ppid= -p "$HESH_PID" 2>/dev/null | tr -d ' ')" = "$(real_pid "$HEPID")" ] || { 199 [ "$(ps -o ppid= -p "$HESH_PID" 2>/dev/null | tr -d ' ')" = "$(real_pid "$HEPID")" ] || {
200 echo "e2e FAIL: hosts: the shell on the grid is not the auto-started daemon's child" 200 echo "e2e FAIL: hosts: the shell on the grid is not the auto-started daemon's child"
201 ps -o pid,ppid,args -p "$HESH_PID" 2>&1; exit 1; } 201 ps -o pid,ppid,args -p "$HESH_PID" 2>&1; exit 1; }
202 # ...and the LAYOUT the detach wrote: one leaf, the session the entry 202 # ...and the LAYOUT the entry pane's own save wrote: one leaf, the session
203 # attach opened on. Two files, both written by a `mux` that was handed 203 # the entry attach opened on — written when the pane was added and not at
204 # neither — the hosts file says which daemons, the layout says which panes, 204 # the exit. Two files, both written by a `mux` that was handed neither — the hosts file says which daemons, the layout says which panes,
205 # and a machine with no files at all ends up with a wall of one either way. 205 # and a machine with no files at all ends up with a wall of one either way.
206 _he_leaves=$(sed -n 's/^ *leaf [0-9][0-9]* //p' "$HESTATE/mux/layout" | tr '\n' ' ') 206 _he_leaves=$(sed -n 's/^ *leaf [0-9][0-9]* //p' "$HESTATE/mux/layout" | tr '\n' ' ')
207 [ "$_he_leaves" = "--sock $HESOCK#0 " ] || { 207 [ "$_he_leaves" = "--sock $HESOCK#0 " ] || {
@@ -351,10 +351,13 @@ done
351 [ "$HFHASH" = "$(sha256sum "$HSTATE/mux/hosts" | cut -d' ' -f1)" ] || { 351 [ "$HFHASH" = "$(sha256sum "$HSTATE/mux/hosts" | cut -d' ' -f1)" ] || {
352 echo "e2e FAIL: hosts: the wall edited the hosts file:" 352 echo "e2e FAIL: hosts: the wall edited the hosts file:"
353 cat "$HSTATE/mux/hosts"; exit 1; } 353 cat "$HSTATE/mux/hosts"; exit 1; }
354 # ...and the layout it wrote back is the three leaves it was given. 354 # ...and the layout still holds the three leaves it was opened on. This run
355 # changed no pane and no weight, so it wrote nothing at all — the file is
356 # the one the leg seeded, and what is asserted is that a wall which opens a
357 # layout and walks it does not disturb it.
355 _hw_leaves=$(sed -n 's/^ *leaf [0-9][0-9]* //p' "$HSTATE/mux/layout" | tr '\n' ' ') 358 _hw_leaves=$(sed -n 's/^ *leaf [0-9][0-9]* //p' "$HSTATE/mux/layout" | tr '\n' ' ')
356 [ "$_hw_leaves" = "--sock $SOCKH1#0 --sock $SOCKH1#b --sock $SOCKH2#0 " ] || { 359 [ "$_hw_leaves" = "--sock $SOCKH1#0 --sock $SOCKH1#b --sock $SOCKH2#0 " ] || {
357 echo "e2e FAIL: hosts: the wall saved leaves '$_hw_leaves', want the three it opened on:" 360 echo "e2e FAIL: hosts: the layout holds leaves '$_hw_leaves', want the three it opened on:"
358 cat "$HSTATE/mux/layout"; exit 1; } 361 cat "$HSTATE/mux/layout"; exit 1; }
359 362
360 # A session born ELSEWHERE, while nothing was looking. Its own state home, 363 # A session born ELSEWHERE, while nothing was looking. Its own state home,
test/e2e_12_panes.sh
Old New
@@ -544,8 +544,8 @@ ok "Ctrl-\\ | births a session beside; its label bar sits right of the rail"
544 544
545 # ---- layout sidecar: restore round-trip ------------------------------ 545 # ---- layout sidecar: restore round-trip ------------------------------
546 # 546 #
547 # A no-argv `mux` opens the layout, and on Ctrl-\ d the resized tree is 547 # A no-argv `mux` opens the layout, and the resize writes the tree back to
548 # saved back to it. Reattaching with the same state home loads it verbatim 548 # it. Reattaching with the same state home loads it verbatim
549 # — the resize survived the round trip. The rail's CUP column in the second 549 # — the resize survived the round trip. The rail's CUP column in the second
550 # capture is >= 43 (three right-resizes from a 40|40 split), and a marker 550 # capture is >= 43 (three right-resizes from a 40|40 split), and a marker
551 # typed into the restored wall lands in the focused session. 551 # typed into the restored wall lands in the focused session.
@@ -576,25 +576,29 @@ printf -- '--sock %s\n' "$SOCK59" > "$LPHOSTS"
576 seed_layout "$LPSTATE" beside "--sock $SOCK59#0" "--sock $SOCK59#b" 576 seed_layout "$LPSTATE" beside "--sock $SOCK59#0" "--sock $SOCK59#b"
577 577
578 set +e 578 set +e
579 # First ptyclient: hydrate, resize three right, detach. The detach saves 579 # First ptyclient: hydrate, focus pane b, move the boundary three cells,
580 # the sidecar. 580 # detach. The RESIZE is what saves the file — every change to the tree
581 # does, and the exit does not — so the focus move comes first, and the
582 # record the resize writes is the focus it finds. Shrinking the right
583 # pane's width grows its left neighbour, so the boundary still ends up
584 # three cells right of the seed's 40|40 and pane 0 still weighs 43.
581 XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \ 585 XDG_STATE_HOME="$LPSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
582 --out "$OUT.lprcap1" --err "$OUT.lprcap1.err" -- \ 586 --out "$OUT.lprcap1" --err "$OUT.lprcap1.err" -- \
583 "$MUX" > "$OUT.lprpc1" 2>&1 <<'EOF' 587 "$MUX" > "$OUT.lprpc1" 2>&1 <<'EOF'
584 expect lpr-origin-0 20000 588 expect lpr-origin-0 20000
585 settle 700 20000 589 settle 700 20000
590 send \x1cl
591 settle 500 5000
586 send \x1cr 592 send \x1cr
587 settle 300 5000 593 settle 300 5000
588 send l 594 send h
589 settle 300 5000 595 settle 300 5000
590 send l 596 send h
591 settle 300 5000 597 settle 300 5000
592 send l 598 send h
593 settle 500 5000 599 settle 500 5000
594 send \x1b 600 send \x1b
595 settle 500 5000 601 settle 500 5000
596 send \x1cl
597 settle 500 5000
598 send \x1cd 602 send \x1cd
599 waitexit 10000 603 waitexit 10000
600 EOF 604 EOF
@@ -617,7 +621,7 @@ case "$_head2" in
617 *) echo "e2e FAIL: layout-restore: sidecar root is '$_head2', want 'beside 0'" 621 *) echo "e2e FAIL: layout-restore: sidecar root is '$_head2', want 'beside 0'"
618 cat "$LPLAYOUT"; exit 1 ;; 622 cat "$LPLAYOUT"; exit 1 ;;
619 esac 623 esac
620 # The focus record: focus-right moved focus to pane b (leaf 1). 624 # The focus record: the focus was on pane b (leaf 1) when the resize saved.
621 grep -q '^focus 1$' "$LPLAYOUT" || { 625 grep -q '^focus 1$' "$LPLAYOUT" || {
622 echo "e2e FAIL: layout-restore: sidecar carries no focus record" 626 echo "e2e FAIL: layout-restore: sidecar carries no focus record"
623 cat "$LPLAYOUT"; exit 1; } 627 cat "$LPLAYOUT"; exit 1; }
@@ -794,27 +798,35 @@ _lph_r1=$(rail_cols "$OUT.lphcap1" | awk '$1 > 1' | tail -1)
794 echo " — the resize this leg saves a tree for did not happen" 798 echo " — the resize this leg saves a tree for did not happen"
795 cat "$OUT.lphpc1"; exit 1; } 799 cat "$OUT.lphpc1"; exit 1; }
796 # The money assertion is that run 2's cut came from the FILE and not from 800 # The money assertion is that run 2's cut came from the FILE and not from
797 # the aspect rule, and the tree run 2 SAVES is where that reads exactly. A 801 # the aspect rule, and run 2's own SCREEN is where that reads: the default
798 # seed that refused falls through to `setRootOrient(rootOrient(size))` and 802 # cut of two panes at 80x24 is `beside` with equal weights and a rail at
799 # builds default weights of 1, so a root that is still `beside` over a 803 # column 40, while the tree run 1 saved puts it where run 1's `lll` left
800 # survivor still weighing 43 is the seed having run AND won. Measured 804 # it. Same terminal, same tree, same flatten — so a rail as far right as
801 # 2026-09-02, layout read either side: 805 # run 1's is the seed having run AND won, and a rail at 40 would be the
806 # aspect rule having cut this wall instead.
807 _lph_r2=$(rail_cols "$OUT.lphcap2" | awk '$1 > 1' | tail -1)
808 [ -n "$_lph_r2" ] && [ "$_lph_r2" -ge 43 ] || {
809 echo "e2e FAIL: layout-heal: run 2's rail ended at column ${_lph_r2:-none}, want >= 43"
810 echo " — the aspect rule cut this wall, so the saved tree never reached it"
811 cat "$OUT.lphpc2"; exit 1; }
812 # ...and the FILE is still run 1's bytes. Run 2 changed no pane and no
813 # weight, and the exit-time save is gone (a second terminal showing a
814 # stale tree used to write it back over the first's edits), so a run that
815 # changes nothing writes nothing. Measured 2026-09-02:
802 # 816 #
803 # saved by run 1: beside 0 / leaf 43 #0 / leaf 36 #b / focus 0 817 # saved by run 1: beside 0 / leaf 43 #0 / leaf 36 #b / focus 0
804 # saved by run 2: beside 0 / leaf 43 #0 / leaf 36 #b
805 # 818 #
806 # Two leaves, unchanged: the newcomer is not one of them, and the leaf the 819 # Two leaves, and the poll that ran under run 2 for two and a half seconds
807 # list disowned keeps its own 36 where it stood. Nothing was added and 820 # added neither the newcomer nor a collapse: the ended session's leaf keeps
808 # nothing collapsed, so nothing re-wrapped — which is why the orientation 821 # its own 36 where it stood.
809 # and the weights survive a poll that used to rewrite both.
810 _lph_root=$(sed -n '2p' "$LPHLAYOUT") 822 _lph_root=$(sed -n '2p' "$LPHLAYOUT")
811 [ "$_lph_root" = "beside 0" ] || { 823 [ "$_lph_root" = "beside 0" ] || {
812 echo "e2e FAIL: layout-heal: run 2 saved root '$_lph_root', want 'beside 0'" 824 echo "e2e FAIL: layout-heal: the layout's root is '$_lph_root', want 'beside 0'"
813 echo " — the aspect rule cut this wall, so the saved tree never reached it" 825 echo " — something rewrote the tree run 1 saved"
814 cat "$LPHLAYOUT"; exit 1; } 826 cat "$LPHLAYOUT"; exit 1; }
815 _lph_leaves=$(sed -n 's/^ *leaf [0-9][0-9]* .*#\(.*\)$/\1/p' "$LPHLAYOUT" | tr '\n' ' ') 827 _lph_leaves=$(sed -n 's/^ *leaf [0-9][0-9]* .*#\(.*\)$/\1/p' "$LPHLAYOUT" | tr '\n' ' ')
816 [ "$_lph_leaves" = "0 b " ] || { 828 [ "$_lph_leaves" = "0 b " ] || {
817 echo "e2e FAIL: layout-heal: run 2 saved leaves '$_lph_leaves', want '0 b ':" 829 echo "e2e FAIL: layout-heal: the layout holds leaves '$_lph_leaves', want '0 b ':"
818 cat "$LPHLAYOUT"; exit 1; } 830 cat "$LPHLAYOUT"; exit 1; }
819 grep -q -- "^ leaf 43 .*#0\$" "$LPHLAYOUT" || { 831 grep -q -- "^ leaf 43 .*#0\$" "$LPHLAYOUT" || {
820 echo "e2e FAIL: layout-heal: the survivor's weight did not survive the poll:" 832 echo "e2e FAIL: layout-heal: the survivor's weight did not survive the poll:"