a73x

d3ff0d67

fix: mux hosts rm takes the daemon's panes out of the layout too

a73x   2026-09-03 05:20

Commit message
fix: mux hosts rm takes the daemon's panes out of the layout too

A layout leaf is HOST#SESSION and its host part must be a line of the
hosts file, so a rm that edited one file and not the other left leaves
the next start refuses the WHOLE layout for: one 'mux hosts rm box' and
every other pane the user had authored was gone, with a printed line and
no undo.

client.layoutfile is the read-modify-write, a child of the client row
because it knows the layout grammar that hosts.zig deliberately does not,
and reachable from both fronts that edit the file without a wall on
screen. The picker's own forget says 'layout not updated' when the run
saves no layout, since there it took the panes off the screen only.

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

src/cli/mux_main.zig
Old New
@@ -16,6 +16,7 @@ const handoff = @import("client").handoff;
16 const sockpath = @import("sockpath"); 16 const sockpath = @import("sockpath");
17 const wall = @import("wall"); 17 const wall = @import("wall");
18 const hosts = @import("client").hosts; 18 const hosts = @import("client").hosts;
19 const layoutfile = @import("client").layoutfile;
19 const cliflags = @import("cliflags"); 20 const cliflags = @import("cliflags");
20 const TmpDir = @import("testtmp").TmpDir; 21 const TmpDir = @import("testtmp").TmpDir;
21 22
@@ -620,12 +621,36 @@ fn hostsEdit(
620 rc = 1; 621 rc = 1;
621 } 622 }
622 } 623 }
624 // The panes go with the daemon. A layout leaf is `HOST#SESSION` and its
625 // host part must be a line of the hosts file, so a leaf left behind here
626 // refuses the WHOLE layout at the next start — one `mux hosts rm box`
627 // and the user's other four panes are gone with a printed line they did
628 // not ask for. The sessions themselves keep running; this is the file,
629 // not the daemon. `wall_picker.pickForget` is the same edit typed from
630 // inside a wall, made there by the wall's own save.
631 if (layoutBeside(arena, path)) |lp| {
632 if (layoutfile.forgetHosts(arena, lp, spellings.items, "mux hosts")) |_| {} else |err| {
633 // Not a failure of the removal: the hosts file is already
634 // written and the daemon is already off the wall. Said, because
635 // the next `mux` will refuse a layout this could not repair.
636 std.debug.print("mux hosts rm: {s}: {s}\n", .{ lp, hosts.reason(err) });
637 }
638 }
623 // `forgetMany` matches exact lines. On failure, print the file so a 639 // `forgetMany` matches exact lines. On failure, print the file so a
624 // hand-edited entry can be copied byte for byte. 640 // hand-edited entry can be copied byte for byte.
625 if (rc != 0) showFile(arena, path); 641 if (rc != 0) showFile(arena, path);
626 return rc; 642 return rc;
627 } 643 }
628 644
645 /// The layout file beside a given hosts file. Both are `xdg.statePath` names
646 /// in one directory (`hosts.statePath`, `hosts.layoutPath`), so the sibling
647 /// is the layout for THIS hosts file — including the tmp-directory pair a
648 /// test drives, which an `XDG_STATE_HOME` lookup here would miss.
649 fn layoutBeside(arena: std.mem.Allocator, hosts_path: []const u8) ?[]const u8 {
650 const dir = std.fs.path.dirname(hosts_path) orelse return null;
651 return std.fs.path.join(arena, &.{ dir, "layout" }) catch null;
652 }
653
629 /// Print the hosts file verbatim for use with an exact-match removal. 654 /// Print the hosts file verbatim for use with an exact-match removal.
630 fn showFile(arena: std.mem.Allocator, path: []const u8) void { 655 fn showFile(arena: std.mem.Allocator, path: []const u8) void {
631 const lines = hosts.loadLines(arena, path) catch return; 656 const lines = hosts.loadLines(arena, path) catch return;
@@ -778,6 +803,34 @@ test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
778 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path)); 803 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "add", "other" }, path));
779 } 804 }
780 805
806 test "hosts rm: the removed daemon's panes leave the layout beside the hosts file" {
807 const alloc = std.testing.allocator;
808 var tmp = try TmpDir.make();
809 defer tmp.cleanup();
810 var buf: [256]u8 = undefined;
811 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
812 var lbuf: [256]u8 = undefined;
813 const lpath = try std.fmt.bufPrint(&lbuf, "{s}/layout", .{tmp.path()});
814 try hosts.saveBytes(path, "box\n--sock /tmp/a.sock\n");
815 // Two hosts, three panes: the removal must take the two that name `box`
816 // and leave the third exactly where it was.
817 try hosts.saveBytes(lpath,
818 \\mux-layout 1
819 \\beside 0
820 \\ leaf 60 box#0
821 \\ stacked 40
822 \\ leaf 50 --sock /tmp/a.sock#work
823 \\ leaf 50 box#two
824 \\
825 );
826 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &[_][:0]const u8{ "rm", "box" }, path));
827 const after = try std.fs.cwd().readFileAlloc(alloc, lpath, 4096);
828 defer alloc.free(after);
829 // What the next `mux` reads: one leaf, and a file it does not refuse.
830 try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/a.sock#work") != null);
831 try std.testing.expect(std.mem.indexOf(u8, after, "box#") == null);
832 }
833
781 test "hosts list: a line the grammar refuses is named, not a refusal of the listing" { 834 test "hosts list: a line the grammar refuses is named, not a refusal of the listing" {
782 const alloc = std.testing.allocator; 835 const alloc = std.testing.allocator;
783 var tmp = try TmpDir.make(); 836 var tmp = try TmpDir.make();
src/client/client.zig
Old New
@@ -27,6 +27,7 @@ const link_mod = @import("link");
27 pub const hosts = @import("hosts.zig"); 27 pub const hosts = @import("hosts.zig");
28 pub const handoff = @import("handoff.zig"); 28 pub const handoff = @import("handoff.zig");
29 pub const layout = @import("layout.zig"); 29 pub const layout = @import("layout.zig");
30 pub const layoutfile = @import("layoutfile.zig");
30 // The seam carries `keymap.detach_key` and nothing else: the dial here and 31 // The seam carries `keymap.detach_key` and nothing else: the dial here and
31 // the prefix filter in interact.zig both watch for that one abort byte. It 32 // the prefix filter in interact.zig both watch for that one abort byte. It
32 // is keymap's rather than the filter's because a dial has no session to 33 // is keymap's rather than the filter's because a dial has no session to
src/client/layoutfile.zig
Old New
@@ -0,0 +1,186 @@
1 //! The layout file edited from OUTSIDE a wall: a read-modify-write over
2 //! `hosts.saveBytes`' atomic rename, for the fronts that change what the
3 //! wall will hold without one on the screen.
4 //!
5 //! The wall's own saves are `wall_layout.persist`, which serializes the tree
6 //! it is painting; the hub's own add is `webhub.appendLeaf`. What lives here
7 //! is the edit neither of those can make, because it is made by a command
8 //! that never opens a wall — `mux hosts rm`, which takes a daemon off the
9 //! device and must take that daemon's panes with it. A leaf naming a host
10 //! the hosts file does not list refuses the WHOLE layout at the next start
11 //! (`wall_layout.seedLayout`), so a `rm` that edited one file and not the
12 //! other cost the user every other pane they had authored.
13 //!
14 //! A `client`-row child rather than part of `hosts.zig`: this knows the
15 //! layout grammar, and `hosts.zig` deliberately does not — it owns the
16 //! hosts file and the path beside it, and says so. Reachable from
17 //! `src/cli/mux_main.zig` and from `webhub.zig`, which are the two fronts
18 //! that edit the file without a wall.
19 const std = @import("std");
20 const hosts = @import("hosts.zig");
21 const layout = @import("layout.zig");
22 const TmpDir = @import("testtmp").TmpDir;
23
24 /// What a removal did to the file.
25 pub const Outcome = union(enum) {
26 /// No file, or no leaf named any of those hosts: nothing was written.
27 /// A save that changes nothing is still a write another writer can lose
28 /// an update to, which is `hosts.forgetMany`'s rule as well.
29 unchanged,
30 /// How many leaves went. The file was rewritten, or DELETED when the
31 /// removal took the last one: an empty tree serializes to a header with
32 /// no root, and `layout.parseReporting` refuses that file — so writing
33 /// one would leave behind exactly the refusal this whole module exists
34 /// to prevent. No panes is no layout.
35 rewrote: usize,
36 /// The file is not a layout. Nothing is written and the line it gave up
37 /// on has been printed: a file mux cannot read is a file mux must not
38 /// rewrite, and the user's own editor is the tool for it.
39 refused,
40 };
41
42 /// Every leaf whose HOST part is one of `spellings`, gone from the tree.
43 /// The match is byte for byte against the hosts-file spelling, the same key
44 /// `seedLayout` matches a leaf by, so a `rm` removes exactly the leaves the
45 /// next start would have refused the file for.
46 ///
47 /// `who` is the command saying so, for the one line this prints.
48 pub fn forgetHosts(
49 alloc: std.mem.Allocator,
50 path: []const u8,
51 spellings: []const []const u8,
52 who: []const u8,
53 ) !Outcome {
54 const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |e| switch (e) {
55 // A device that has never opened a wall has no layout, and removing
56 // a host from its hosts file is not the moment to invent one.
57 error.FileNotFound => return .unchanged,
58 else => return e,
59 };
60 defer alloc.free(bytes);
61 var bad_line: []const u8 = "";
62 var parsed = layout.parseReporting(alloc, bytes, &bad_line) orelse {
63 std.debug.print("{s}: layout ignored ({s}): {s}\n", .{ who, path, bad_line });
64 return .refused;
65 };
66 defer parsed.deinit(alloc);
67
68 var removed: usize = 0;
69 for (parsed.spellings.items, 0..) |sp, id| {
70 const cut = std.mem.lastIndexOfScalar(u8, sp, '#') orelse continue;
71 for (spellings) |want| {
72 if (!std.mem.eql(u8, sp[0..cut], want)) continue;
73 // The leaf id IS its index here: `parseReporting` hands ids out
74 // in encounter order. The spelling stays in the array — the
75 // tree no longer names it, and `serialize` walks the tree.
76 parsed.tree.remove(@intCast(id));
77 if (parsed.focus) |f| {
78 if (f == id) parsed.focus = null;
79 }
80 removed += 1;
81 break;
82 }
83 }
84 if (removed == 0) return .unchanged;
85 if (parsed.tree.root == null) {
86 std.fs.cwd().deleteFile(path) catch |e| switch (e) {
87 error.FileNotFound => {},
88 else => return e,
89 };
90 return .{ .rewrote = removed };
91 }
92 var buf: std.ArrayListUnmanaged(u8) = .{};
93 defer buf.deinit(alloc);
94 try parsed.tree.serialize(parsed.spellings.items, parsed.focus, buf.writer(alloc));
95 try hosts.saveBytes(path, buf.items);
96 return .{ .rewrote = removed };
97 }
98
99 test "forgetHosts: one host's leaves go and the other host's keep their shape" {
100 const alloc = std.testing.allocator;
101 var tmp = try TmpDir.make();
102 defer tmp.cleanup();
103 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
104 defer alloc.free(path);
105
106 // Two hosts and three leaves, the plural default: a fixture with one
107 // leaf per host could not tell "removed the right leaves" from
108 // "removed a host". The cut is a `beside` over a `stacked`, and the
109 // survivors must come out in the same shape.
110 try hosts.saveBytes(path,
111 \\mux-layout 1
112 \\beside 0
113 \\ leaf 60 box#0
114 \\ stacked 40
115 \\ leaf 50 --sock /tmp/a.sock#work
116 \\ leaf 50 box#two
117 \\focus 2
118 \\
119 );
120 const out = try forgetHosts(alloc, path, &.{"box"}, "test");
121 try std.testing.expectEqual(@as(usize, 2), out.rewrote);
122
123 const after = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
124 defer alloc.free(after);
125 var line: []const u8 = "";
126 var re = layout.parseReporting(alloc, after, &line) orelse return error.Unparsable;
127 defer re.deinit(alloc);
128 try std.testing.expectEqual(@as(usize, 1), re.spellings.items.len);
129 try std.testing.expectEqualStrings("--sock /tmp/a.sock#work", re.spellings.items[0]);
130 // The removed leaf held the focus record; it may not survive as an
131 // index into a file that no longer has that many leaves.
132 try std.testing.expect(re.focus == null or re.focus.? == 0);
133 }
134
135 test "forgetHosts: a host no leaf names writes nothing, and a file that is not a layout is left alone" {
136 const alloc = std.testing.allocator;
137 var tmp = try TmpDir.make();
138 defer tmp.cleanup();
139 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
140 defer alloc.free(path);
141
142 const good =
143 \\mux-layout 1
144 \\beside 0
145 \\ leaf 60 box#0
146 \\ leaf 40 --sock /tmp/a.sock#work
147 \\
148 ;
149 try hosts.saveBytes(path, good);
150 try std.testing.expect(try forgetHosts(alloc, path, &.{"elsewhere"}, "test") == .unchanged);
151 const same = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
152 defer alloc.free(same);
153 try std.testing.expectEqualStrings(good, same);
154
155 // Not a layout: the file is the user's to repair, and a rewrite here
156 // would destroy the line they have to find.
157 try hosts.saveBytes(path, "bogus 9\n");
158 try std.testing.expect(try forgetHosts(alloc, path, &.{"box"}, "test") == .refused);
159 const junk = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
160 defer alloc.free(junk);
161 try std.testing.expectEqualStrings("bogus 9\n", junk);
162
163 // A missing file is a no-op, not an error: a device that never opened a
164 // wall still runs `mux hosts rm`.
165 try std.fs.cwd().deleteFile(path);
166 try std.testing.expect(try forgetHosts(alloc, path, &.{"box"}, "test") == .unchanged);
167 }
168
169 test "forgetHosts: taking the last leaf deletes the file rather than writing one nothing can read" {
170 const alloc = std.testing.allocator;
171 var tmp = try TmpDir.make();
172 defer tmp.cleanup();
173 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
174 defer alloc.free(path);
175
176 try hosts.saveBytes(path,
177 \\mux-layout 1
178 \\beside 0
179 \\ leaf 50 box#0
180 \\ leaf 50 box#two
181 \\
182 );
183 const out = try forgetHosts(alloc, path, &.{"box"}, "test");
184 try std.testing.expectEqual(@as(usize, 2), out.rewrote);
185 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(path, .{}));
186 }
src/tui/wall_picker.zig
Old New
@@ -655,12 +655,18 @@ pub fn pickForget(w: Wall, sel: usize, path: ?[]const u8) void {
655 // record would put every one of them back on the next start. 655 // record would put every one of them back on the next start.
656 wall_layout.persist(w); 656 wall_layout.persist(w);
657 var buf: [96]u8 = undefined; 657 var buf: [96]u8 = undefined;
658 // A run that saves no layout (`Shared.layout_path` null — a pipe, a
659 // `--via` wall, a file this run refused) has just taken the panes off
660 // the SCREEN and nothing off the file, so the next start puts every one
661 // of them back. Said here, where the forget is, rather than left for
662 // the user to discover on a wall they thought they had changed.
663 const tail: []const u8 = if (w.shared.layout_path == null) " - layout not updated" else "";
658 const said = if (why) |e| 664 const said = if (why) |e|
659 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]" 665 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}{s}]", .{ hosts.reason(e), tail }) catch "[hosts file not updated]"
660 else if (!gone_from_file) 666 else if (!gone_from_file)
661 std.fmt.bufPrint(&buf, "[{s} was not on the wall]", .{h.spec.spelling}) catch "[that host was not on the wall]" 667 std.fmt.bufPrint(&buf, "[{s} was not on the wall{s}]", .{ h.spec.spelling, tail }) catch "[that host was not on the wall]"
662 else 668 else
663 std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]"; 669 std.fmt.bufPrint(&buf, "[forgot {s}{s}]", .{ h.spec.spelling, tail }) catch "[forgot the host]";
664 wv.setNotice(w.shared, said); 670 wv.setNotice(w.shared, said);
665 } 671 }
666 672
test/e2e_09_hosts.sh
Old New
@@ -798,6 +798,14 @@ for _hc in "$SOCKH1:2" "$SOCKH2:1"; do
798 echo "e2e FAIL: hosts: 'mux hosts' does not say $_hcs holds $_hcn sessions:" 798 echo "e2e FAIL: hosts: 'mux hosts' does not say $_hcs holds $_hcn sessions:"
799 cat "$OUT.hlist"; exit 1; } 799 cat "$OUT.hlist"; exit 1; }
800 done 800 done
801 # The layout is the wall, so the removal has a second file to keep true.
802 # A leaf's host part must be a line of the hosts file — `seedLayout`
803 # refuses the WHOLE layout over one that is not — so a `rm` that edited the
804 # hosts file alone left the user's OTHER panes to be thrown away at the
805 # next start, with a printed line and no undo. Two hosts and three leaves:
806 # the removal has to take two of them and leave the third exactly as it is.
807 seed_layout "$HSTATE" stacked \
808 "--sock $SOCKH1#0" "--sock $SOCKH1#b" "--sock $SOCKH2#0"
801 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts rm "--sock $SOCKH1" > "$OUT.hrm" 2>&1 || { 809 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts rm "--sock $SOCKH1" > "$OUT.hrm" 2>&1 || {
802 echo "e2e FAIL: hosts rm: removing a listed daemon failed:"; cat "$OUT.hrm"; exit 1; } 810 echo "e2e FAIL: hosts rm: removing a listed daemon failed:"; cat "$OUT.hrm"; exit 1; }
803 if grep -qF -- "$SOCKH1" "$HSTATE/mux/hosts"; then 811 if grep -qF -- "$SOCKH1" "$HSTATE/mux/hosts"; then
@@ -818,6 +826,36 @@ kill -0 "$BSHELL" 2>/dev/null || {
818 wait_sessions "$SOCKH1" 2 "hosts rm: daemon 1's sessions must survive being forgotten" 826 wait_sessions "$SOCKH1" 2 "hosts rm: daemon 1's sessions must survive being forgotten"
819 wait_grid "$SOCKH1" "hb-pin" "hosts rm: session b's grid outlived the wall line" b 827 wait_grid "$SOCKH1" "hb-pin" "hosts rm: session b's grid outlived the wall line" b
820 828
829 # What the NEXT `mux` on this device gets — the assertion the file edit is
830 # for. The surviving host's pane, on a wall that read its layout without a
831 # word: a `rm` that left the removed daemon's leaves behind would have this
832 # run print `layout ignored` and open the empty wall's picker instead.
833 set +e
834 XDG_STATE_HOME="$HSTATE" timeout 60 "$PTYCLIENT" --cols 80 --rows 44 \
835 --out "$OUT.hrmcap" --err "$OUT.hrmcap.err" -- "$MUX" > "$OUT.hrmpc" 2>&1 <<EOF
836 expect --sock $SOCKH2#0 [up] 25000
837 settle 800 25000
838 send \x1cd
839 waitexit 15000
840 EOF
841 RC=$?
842 set -e
843 rc0 "hosts rm: the wall after the removal exited $RC:" "$OUT.hrmpc" "$OUT.hrmcap.err"
844 if grep -qF "layout ignored" "$OUT.hrmcap.err"; then
845 echo "e2e FAIL: hosts rm: the layout was refused after the removal, so the"
846 echo " removal left leaves naming the daemon it took off the wall:"
847 cat "$OUT.hrmcap.err"; exit 1
848 fi
849 "$RENDER" --cols 80 --rows 44 < "$OUT.hrmcap" > "$OUT.hrmcap.final"
850 grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrmcap.final" || {
851 echo "e2e FAIL: hosts rm: the surviving host's pane is not on the wall the"
852 echo " removal left behind:"
853 cat "$OUT.hrmcap.final"; exit 1; }
854 if grep -q -- "--sock $SOCKH1" "$OUT.hrmcap.final"; then
855 echo "e2e FAIL: hosts rm: a pane on the removed daemon is still on the wall:"
856 cat "$OUT.hrmcap.final"; exit 1
857 fi
858
821 set +e 859 set +e
822 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts rm "--sock $SOCKH1" > "$OUT.hrm2" 2>&1 860 env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" hosts rm "--sock $SOCKH1" > "$OUT.hrm2" 2>&1
823 RC=$? 861 RC=$?