8184693b
fix: no layout save at the exit, so two terminals stop clobbering each other
a73x 2026-09-03 05:20
Commit message
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:" |