a73x

60c95173

fix: a --via wall records no layout, so one throwaway run cannot refuse the next

a73x   2026-09-03 05:20

Commit message
fix: a --via wall records no layout, so one throwaway run cannot refuse the next

`mux --via CMD` on a terminal took the entry-pane road like any other
target, so the start-up persist wrote the leaf tileLabel spells for it —
`--via CMD#NAME` — and the NEXT start's seedLayout found no hosts-file row
for `--via CMD` and refused the WHOLE file. One throwaway --via run cost
the user every pane they had authored. hosts.zig has always refused to
write a --via line; the layout had no matching rule.

Closed in the family of the trimmed-seed rule rather than by teaching the
loader about --via: run nulls Shared.layout_path when the entry target is
.via, before the seed and before the start-up save, and says
[layout not saved: a --via wall is not recorded]. A --via wall now neither
reads the file nor writes one, which is what it already did with hosts.

Pinned twice: a unit test that a via-spelled leaf refuses the whole file
(and that tileLabel is what spells it), and an assertion inside the boot
group's existing --via leg that a pty run under a fresh state dir leaves no
layout behind. Reproduced against the pre-fix binary on a real pty first —
the suite's piped --via run could never have seen it, because the layout
path is opened only when stdin is a terminal.

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

src/tui/wall_test_layout.zig
Old New
@@ -1077,6 +1077,34 @@ test "seedLayout: a leaf with no session, a bad name, or a repeat refuses; garba
1077 try std.testing.expectEqualStrings("not a layout", on_garbage.refused); 1077 try std.testing.expectEqualStrings("not a layout", on_garbage.refused);
1078 } 1078 }
1079 1079
1080 test "seedLayout: a --via leaf refuses the whole file, which is why a --via wall records nothing" {
1081 const alloc = std.testing.allocator;
1082 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1083 defer shared.tree.deinit();
1084 // The leaf a `--via` entry would be saved as. `tileLabel` is the one
1085 // speller of a leaf, so the file below is byte for byte the file the
1086 // start-up `persist` used to produce for `mux --via CMD`.
1087 const label = try wv.tileLabel(alloc, .{ .via = "ssh box mux d proxy" }, "0");
1088 defer alloc.free(label);
1089 try std.testing.expectEqualStrings("--via ssh box mux d proxy#0", label);
1090 // `hosts.zig` refuses to write a `--via` line, so no hosts table can
1091 // ever have a row for one: this leaf names a host that cannot be there.
1092 var table = [_]Host{fixture.testHost(&shared, "--sock /a", "/a")};
1093 var file: std.ArrayListUnmanaged(u8) = .{};
1094 defer file.deinit(alloc);
1095 try file.appendSlice(alloc, "mux-layout 1\nbeside 0\n leaf 1 --sock /a#0\n leaf 1 ");
1096 try file.appendSlice(alloc, label);
1097 try file.append(alloc, '\n');
1098 const res = wall_layout.seedLayout(alloc, &table, &shared, file.items, null);
1099 try std.testing.expect(res == .refused);
1100 try std.testing.expectEqualStrings(label, res.refused);
1101 // The refusal is the WHOLE file: the pane on `--sock /a`, and every
1102 // other pane the user authored, goes with it. That is the cost `run`
1103 // avoids by nulling `Shared.layout_path` for a `--via` entry — it
1104 // neither reads the file nor writes one.
1105 try std.testing.expect(shared.tree.root == null);
1106 }
1107
1080 test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" { 1108 test "seedLayout: the entry spelling takes leaf 0 when the file has it, and is inserted beside the focus when it does not" {
1081 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true }; 1109 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 120, .rows = 40 }, .is_tty = true };
1082 defer shared.tree.deinit(); 1110 defer shared.tree.deinit();
src/tui/wallview.zig
Old New
@@ -1737,6 +1737,22 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1737 entry_name = try alloc.dupe(u8, proto.resolveName(entry.entry_session)); 1737 entry_name = try alloc.dupe(u8, proto.resolveName(entry.entry_session));
1738 entry_label = try tileLabel(alloc, host_specs[entry.entry_host.?].target, entry_name); 1738 entry_label = try tileLabel(alloc, host_specs[entry.entry_host.?].target, entry_name);
1739 } 1739 }
1740 // A `--via CMD` wall records nothing, and that includes its own pane.
1741 // `hosts.zig` refuses to write a `--via` line — an arbitrary command is
1742 // not an address — so no hosts table can ever hold a row for one, while
1743 // `tileLabel` still spells the tile `--via CMD#NAME` for its bar. Left
1744 // alone, the start-up `persist` writes that spelling as a leaf and the
1745 // NEXT start's `seedLayout` finds no host for it and refuses the WHOLE
1746 // file: one throwaway `--via` run costs the user every pane they
1747 // authored. Same rule as the trimmed seed below — a run that cannot
1748 // record what it is showing stops recording, before it reads the file
1749 // as well as before it writes one, and says so where the wall says
1750 // everything else.
1751 if (has_entry and host_specs[entry.entry_host.?].target == .via) {
1752 if (shared.layout_path) |p| alloc.free(p);
1753 shared.layout_path = null;
1754 setNotice(&shared, "[layout not saved: a --via wall is not recorded]");
1755 }
1740 // The wall starts with the cut the sidecar remembers: its leaves are 1756 // The wall starts with the cut the sidecar remembers: its leaves are
1741 // pending panes the polls bind in place or the settle collapses, so 1757 // pending panes the polls bind in place or the settle collapses, so
1742 // the first paint is the saved shape, not a placeholder to re-cut. 1758 // the first paint is the saved shape, not a placeholder to re-cut.
test/e2e_01_boot.sh
Old New
@@ -187,6 +187,43 @@ kill -0 "$D1PID" || { echo "e2e FAIL: daemon died in --via scenario"; exit 1; }
187 assert_converged "$OUT.via" "$SOCK" "via transport" 187 assert_converged "$OUT.via" "$SOCK" "via transport"
188 rm_swept "$OUT.via" 188 rm_swept "$OUT.via"
189 189
190 # Same transport, on a TERMINAL, for what the wall must NOT do with it: a
191 # `--via` run records no layout. `hosts.zig` refuses to write a `--via`
192 # line, so no hosts table can ever hold a row for one, while a tile of that
193 # transport still spells itself `--via CMD#NAME` — the only spelling it
194 # has. Saved, that leaf is one the next start's loader finds no host for,
195 # and it refuses the WHOLE file: one throwaway `--via` run would cost the
196 # user every pane they had authored. A pty and a state dir of its own are
197 # both required to see it — the layout path is opened only when stdin is a
198 # terminal, so the piped run above cannot show this either way.
199 VIASTATE="${TMPDIR:-/tmp}/mux-e2e-vias-$$"
200 defer_rm "$VIASTATE"
201 mkdir -p "$VIASTATE"
202 set +e
203 env XDG_STATE_HOME="$VIASTATE" XDG_RUNTIME_DIR=/nonexistent-mux-e2e SHELL=/bin/sh \
204 timeout 60 "$PTYCLIENT" --cols 80 --rows 24 \
205 --out "$OUT.viapty" --err "$OUT.viapty.err" -- \
206 "$MUX" --via "$MUX d proxy --sock $SOCK" \
207 > "$OUT.viapty.log" 2>&1 <<'VIAEOF'
208 settle 700 20000
209 send printf 'm6-%s\n' via-tty\n
210 expect m6-via-tty 20000
211 settle 500 15000
212 send \x1cd
213 waitexit 10000
214 VIAEOF
215 RC=$?
216 set -e
217 rc0 "--via on a terminal exited $RC:" "$OUT.viapty.log" "$OUT.viapty.err"
218 grep -q 'layout not saved: a --via wall is not recorded' "$OUT.viapty" || {
219 echo "e2e FAIL: a --via wall on a terminal did not say it records nothing:"
220 cat "$OUT.viapty"; exit 1; }
221 [ ! -e "$VIASTATE/mux/layout" ] || {
222 echo "e2e FAIL: a --via run wrote a layout; the next start's loader refuses"
223 echo " the whole file over its leaf, taking every other pane with it:"
224 cat "$VIASTATE/mux/layout"; exit 1; }
225 rm_swept "$OUT.viapty" "$OUT.viapty.err" "$OUT.viapty.log"
226
190 # --- M10: a --via command that dies before the first frame stops claiming 227 # --- M10: a --via command that dies before the first frame stops claiming
191 # a connection existed. ssh's own stderr still passes through untouched. 228 # a connection existed. ssh's own stderr still passes through untouched.
192 # No convergence: no session was ever established, so there is no grid to match. 229 # No convergence: no session was ever established, so there is no grid to match.