a73x

02d20ce8

fix: a refused layout file is never written back

a73x   2026-09-03 05:20

Commit message
fix: a refused layout file is never written back

A run with an entry pane seeded nothing from a refused file, built the
default cut, and then saved its one-leaf tree over it on start-up: the
line the printed refusal told the user to fix was gone before they could
read it, and every other pane the file named went with it. mux hosts rm,
an upgrade over a --via leaf, and a hand-edited typo all reach it.

seedSidecar now returns the verdict instead of a plan-or-nothing, and run
stops saving for the whole run on a refused file, saying so on the notice
line as well as on stderr. seedLayout tells a file that named only this
shell's own sessions from an empty one for the same reason: the file is
good and this wall simply cannot show it.

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

src/tui/wall_layout.zig
Old New
@@ -1,4 +1,4 @@
1 //! The pane tree's operations and the layout sidecar. `relayout` is the 1 //! The pane tree's operations and the layout file. `relayout` is the
2 //! single flatten point that turns the tree into tile rects and paints the 2 //! single flatten point that turns the tree into tile rects and paints the
3 //! rails; `persist` writes that tree after every change to it and 3 //! rails; `persist` writes that tree after every change to it and
4 //! `seedLayout` seats it back verbatim, refusing a file it cannot seat 4 //! `seedLayout` seats it back verbatim, refusing a file it cannot seat
@@ -186,25 +186,42 @@ pub fn relayout(w: Wall, sel: usize) void {
186 wv.paintDeadBarsLocked(w.liveTiles()); 186 wv.paintDeadBarsLocked(w.liveTiles());
187 } 187 }
188 188
189 /// The file half of the seed: null on a pipe, a missing sidecar, or bytes 189 /// The file half of the seed: `.none` on a pipe or a missing file, and
190 /// `seedLayout` refuses — and the wall then boots on today's default cut. 190 /// otherwise whatever `seedLayout` made of the bytes. The VERDICT is
191 /// returned, not flattened to a plan-or-nothing, because the caller has to
192 /// tell a file it refused from a file that was not there: a refused file is
193 /// still the user's wall, and a run that wrote its own one-leaf tree over
194 /// it would destroy the thing the printed line asked them to fix.
191 /// `layout_path` is the gate, the same one `persist` reads: a wall that 195 /// `layout_path` is the gate, the same one `persist` reads: a wall that
192 /// writes no file must not seed from one either, or the next start would 196 /// writes no file must not seed from one either, or the next start would
193 /// restore a shape this run has already stopped recording. 197 /// restore a shape this run has already stopped recording.
194 pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan { 198 ///
195 const path = shared.layout_path orelse return null; 199 /// The refused line is COPIED into `line_buf`: it points into bytes this
196 const bytes = loadLayout(alloc, path) orelse return null; 200 /// frees, and the caller says it again on the notice line much later.
201 pub fn seedSidecar(
202 alloc: std.mem.Allocator,
203 table: []const Host,
204 shared: *Shared,
205 entry_spelling: ?[]const u8,
206 line_buf: *[96]u8,
207 ) SeedResult {
208 const path = shared.layout_path orelse return .none;
209 const bytes = loadLayout(alloc, path) orelse return .none;
197 defer alloc.free(bytes); 210 defer alloc.free(bytes);
198 return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) { 211 return switch (seedLayout(alloc, table, shared, bytes, entry_spelling)) {
199 .plan => |p| p, 212 .plan => |p| .{ .plan = p },
200 .refused => |line| blk: { 213 .refused => |line| blk: {
201 // Said once, on stderr, before the alternate screen: the wall 214 // Said once, on stderr, before the alternate screen: the line is
202 // then starts as if the file were missing, and the line is the 215 // the thing to fix or delete, and stderr is the only place a
203 // thing to fix or delete. 216 // whole one fits. The notice the caller sets from the copy is
217 // the same sentence cut to the wall's 96 bytes.
204 std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line }); 218 std.debug.print("mux: layout ignored ({s}): {s}\n", .{ path, line });
205 break :blk null; 219 const n = @min(line.len, line_buf.len);
220 @memcpy(line_buf[0..n], line[0..n]);
221 break :blk .{ .refused = line_buf[0..n] };
206 }, 222 },
207 .none => null, 223 .self_only => .self_only,
224 .none => .none,
208 }; 225 };
209 } 226 }
210 227
@@ -296,7 +313,14 @@ pub const SeedResult = union(enum) {
296 /// `bytes`, for the caller to print. The wall then starts as if the 313 /// `bytes`, for the caller to print. The wall then starts as if the
297 /// file were missing. 314 /// file were missing.
298 refused: []const u8, 315 refused: []const u8,
299 /// No file, or a file with no leaves this wall can seat at this size. 316 /// Every leaf named the session this `mux` runs inside, and a wall may
317 /// not attach to itself. Apart from `.none` because the file is good
318 /// and the user authored it: the run degrades to the default cut like
319 /// any other, but writing this run's tree back would replace their wall
320 /// with one leaf.
321 self_only,
322 /// No file, no leaves at all, or a file with no leaves this wall can
323 /// seat at this size.
300 none, 324 none,
301 }; 325 };
302 326
@@ -363,7 +387,10 @@ pub fn seedLayout(
363 const base: usize = if (entry_spelling != null) 1 else 0; 387 const base: usize = if (entry_spelling != null) 1 else 0;
364 if (base + keeps.items.len > wv.max_tiles) 388 if (base + keeps.items.len > wv.max_tiles)
365 return .{ .refused = inFile(bytes, probe.spellings.items[keeps.items[wv.max_tiles - base].saved]) }; 389 return .{ .refused = inFile(bytes, probe.spellings.items[keeps.items[wv.max_tiles - base].saved]) };
366 if (keeps.items.len == 0 and entry_at == null) return .none; 390 // Nothing to seat and no entry tile: `.self_only` when the shell's own
391 // session is the reason, so the caller can stop saving over a file that
392 // is not wrong, just unseatable from inside one of its own panes.
393 if (keeps.items.len == 0 and entry_at == null) return if (dropped > 0) .self_only else .none;
367 // A tree cut on a big screen must not refuse the boot on a laptop: 394 // A tree cut on a big screen must not refuse the boot on a laptop:
368 // drop the last pane and retry until the terminal can hold what is 395 // drop the last pane and retry until the terminal can hold what is
369 // left. The floor is a wall of ONE — the entry tile's leaf when the 396 // left. The floor is a wall of ONE — the entry tile's leaf when the
src/tui/wall_test_layout.zig
Old New
@@ -1,4 +1,4 @@
1 //! The pane tree, the rects it cuts and the layout sidecar (wall_layout.zig). 1 //! The pane tree, the rects it cuts and the layout file (wall_layout.zig).
2 const std = @import("std"); 2 const std = @import("std");
3 const proto = @import("term").protocol; 3 const proto = @import("term").protocol;
4 const hosts = @import("client").hosts; 4 const hosts = @import("client").hosts;
@@ -841,6 +841,46 @@ test "seed: the session this shell is standing in is never seeded" {
841 try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label); 841 try std.testing.expectEqualStrings("--sock /tmp/h1.sock#a", plan.panes[1].?.label);
842 } 842 }
843 843
844 test "seed: a file naming nothing but this shell's own sessions is self_only, not the silence of an empty one" {
845 const alloc = std.testing.allocator;
846 var shared: Shared = undefined;
847 seedShared(alloc, &shared);
848 defer shared.tree.deinit();
849 var table = [_]wall_host.Host{
850 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
851 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
852 };
853 // Plural and off-origin: BOTH hosts are the shell's own, so nothing is
854 // left after the drops and the answer cannot come from a single leaf.
855 table[0].self_name = "a";
856 table[1].self_name = "a";
857 const all_self = "mux-layout 1\nbeside 0\n leaf 50 --sock /tmp/h0.sock#a\n leaf 50 --sock /tmp/h1.sock#a\n";
858 // Told apart from `.none` because the caller acts differently: this file
859 // is a wall the user authored and this run must not write over it, while
860 // an empty file has nothing to lose.
861 try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, all_self, null) == .self_only);
862 try std.testing.expect(wall_layout.seedLayout(alloc, &table, &shared, " \n\t\n", null) == .none);
863 // One leaf that is not this shell's is a plan again, with the other
864 // counted as dropped - the distinction is "none seated", not "any self".
865 const one_free = "mux-layout 1\nbeside 0\n leaf 50 --sock /tmp/h0.sock#a\n leaf 50 --sock /tmp/h1.sock#b\n";
866 var res = wall_layout.seedLayout(alloc, &table, &shared, one_free, null);
867 defer if (res == .plan) res.plan.deinit(alloc);
868 try std.testing.expect(res == .plan);
869 try std.testing.expectEqual(@as(usize, 1), res.plan.dropped_self);
870 }
871
872 test "refusalNotice: the sentence names the line and is cut to what a notice holds" {
873 var buf: [96]u8 = undefined;
874 const short = wv.refusalNotice(&buf, "box#0");
875 try std.testing.expectEqualStrings("[layout not saved: the layout file was refused - fix box#0]", short);
876 // A leaf spelling can be longer than the whole notice; the head is what
877 // says what happened, so the tail of the line is what goes.
878 const long = wv.refusalNotice(&buf, "--sock /tmp/a/very/long/socket/path/that/nobody/would/type.sock#work");
879 try std.testing.expect(long.len <= buf.len);
880 try std.testing.expect(std.mem.startsWith(u8, long, "[layout not saved: the layout file was refused - fix --sock /tmp/a"));
881 try std.testing.expect(std.mem.endsWith(u8, long, "]"));
882 }
883
844 test "seed: garbage and a leaf no host on this wall can seat refuse the file; an empty file is silence - and the tree survives all three" { 884 test "seed: garbage and a leaf no host on this wall can seat refuse the file; an empty file is silence - and the tree survives all three" {
845 const alloc = std.testing.allocator; 885 const alloc = std.testing.allocator;
846 var shared: Shared = undefined; 886 var shared: Shared = undefined;
src/tui/wallview.zig
Old New
@@ -1401,6 +1401,30 @@ pub fn noticeText(buf: *[128]u8, msg: []const u8) []const u8 {
1401 return std.fmt.bufPrint(buf, "[{s}]", .{bare}) catch bare; 1401 return std.fmt.bufPrint(buf, "[{s}]", .{bare}) catch bare;
1402 } 1402 }
1403 1403
1404 /// This run stops writing the layout file, for good, and says why. Nulling
1405 /// the path is the whole mechanism: `wall_layout.persist` reads it before
1406 /// every save, so one call here covers every later change to the wall. The
1407 /// three callers are the three ways a run can end up showing something the
1408 /// file does not describe — a `--via` wall, a file this wall refused, and a
1409 /// file it could only seat part of.
1410 fn stopSaving(shared: *Shared, alloc: std.mem.Allocator, why: []const u8) void {
1411 if (shared.layout_path) |p| alloc.free(p);
1412 shared.layout_path = null;
1413 setNotice(shared, why);
1414 }
1415
1416 /// The refusal, cut to what a notice holds. stderr already carried the whole
1417 /// line before the alternate screen opened; this is the reminder that
1418 /// survives onto the wall, so the head is what matters and the tail of a
1419 /// long spelling is the part to lose.
1420 pub fn refusalNotice(buf: *[96]u8, line: []const u8) []const u8 {
1421 const head = "[layout not saved: the layout file was refused - fix ";
1422 const room = buf.len - head.len - 1;
1423 const n = @min(line.len, room);
1424 return std.fmt.bufPrint(buf, "{s}{s}]", .{ head, line[0..n] }) catch
1425 "[layout not saved: the layout file was refused]";
1426 }
1427
1404 /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user. 1428 /// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user.
1405 pub fn endAction( 1429 pub fn endAction(
1406 tiles: []Tile, 1430 tiles: []Tile,
@@ -1749,31 +1773,50 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1749 // as well as before it writes one, and says so where the wall says 1773 // as well as before it writes one, and says so where the wall says
1750 // everything else. 1774 // everything else.
1751 if (has_entry and host_specs[entry.entry_host.?].target == .via) { 1775 if (has_entry and host_specs[entry.entry_host.?].target == .via) {
1752 if (shared.layout_path) |p| alloc.free(p); 1776 stopSaving(&shared, alloc, "[layout not saved: a --via wall is not recorded]");
1753 shared.layout_path = null;
1754 setNotice(&shared, "[layout not saved: a --via wall is not recorded]");
1755 } 1777 }
1756 // The wall starts with the cut the sidecar remembers: its leaves are 1778 // The wall starts with the cut the layout file remembers: its leaves are
1757 // pending panes the polls bind in place or the settle collapses, so 1779 // pending panes the polls bind in place or the settle collapses, so
1758 // the first paint is the saved shape, not a placeholder to re-cut. 1780 // the first paint is the saved shape, not a placeholder to re-cut.
1759 var seed_plan: ?wall_layout.SeedPlan = if (measured != null) 1781 var refused_line: [96]u8 = undefined;
1760 wall_layout.seedSidecar(alloc, host_table[0..hosts_live], &shared, entry_label) 1782 var refused_notice: [96]u8 = undefined;
1783 // The plan is MOVED out of this below and owned by `seed_plan` from
1784 // there on; nothing reads the verdict again.
1785 const seed_res: wall_layout.SeedResult = if (measured != null)
1786 wall_layout.seedSidecar(alloc, host_table[0..hosts_live], &shared, entry_label, &refused_line)
1761 else 1787 else
1762 null; 1788 .none;
1789 var seed_plan: ?wall_layout.SeedPlan = switch (seed_res) {
1790 .plan => |pl| pl,
1791 else => null,
1792 };
1763 const seeded = seed_plan != null; 1793 const seeded = seed_plan != null;
1794 switch (seed_res) {
1795 // A file this run could not read is a file this run must not write.
1796 // The wall boots on the default cut, and the entry tile's start-up
1797 // `persist` would then replace the user's whole authored wall with
1798 // the one leaf it has — the panes gone before a key was pressed,
1799 // and the printed line pointing at a file that no longer holds the
1800 // mistake it names. Nothing is written for the rest of the run; the
1801 // next start reads the same file and refuses it the same way.
1802 .refused => |line| stopSaving(&shared, alloc, refusalNotice(&refused_notice, line)),
1803 // The file is not wrong — this wall simply cannot seat any of it,
1804 // because every leaf is the session this `mux` is running inside.
1805 // Same rule: a run that shows none of the file does not rewrite it.
1806 .self_only => stopSaving(&shared, alloc, "[layout not saved: it names only this shell's own session]"),
1807 else => {},
1808 }
1764 // A seed that could not seat everything the file named is not this 1809 // A seed that could not seat everything the file named is not this
1765 // run's to write back. `persist` serializes the tree it HAS, so the 1810 // run's to write back either. `persist` serializes the tree it HAS, so
1766 // first save would drop the leaves this wall left out — the user's 1811 // the first save would drop the leaves this wall left out — the user's
1767 // other panes gone before they touched a key, and no undo. The whole 1812 // other panes gone before they touched a key, and no undo. The whole
1768 // run stops saving instead, and says so once; the next start on a 1813 // run stops saving instead, and says so once; the next start on a
1769 // terminal that fits them saves again. 1814 // terminal that fits them saves again.
1770 if (seed_plan) |pl| { 1815 if (seed_plan) |pl| {
1771 if (pl.dropped > 0) { 1816 if (pl.dropped > 0) {
1772 if (shared.layout_path) |p| alloc.free(p);
1773 shared.layout_path = null;
1774 var why: [96]u8 = undefined; 1817 var why: [96]u8 = undefined;
1775 const fit = pl.dropped - pl.dropped_self; 1818 const fit = pl.dropped - pl.dropped_self;
1776 setNotice(&shared, if (fit > 0) 1819 stopSaving(&shared, alloc, if (fit > 0)
1777 std.fmt.bufPrint(&why, "[layout not saved: terminal too small for {d} of its panes]", .{fit}) catch 1820 std.fmt.bufPrint(&why, "[layout not saved: terminal too small for {d} of its panes]", .{fit}) catch
1778 "[layout not saved: terminal too small for it]" 1821 "[layout not saved: terminal too small for it]"
1779 else 1822 else
test/e2e_12_panes.sh
Old New
@@ -848,13 +848,14 @@ D61PID=""
848 rm -rf "$LPHSTATE" 848 rm -rf "$LPHSTATE"
849 ok "a poll changes nothing on the wall: a newcomer stays off it, the survivor keeps its pane, the ended one's pane stands gone" 849 ok "a poll changes nothing on the wall: a newcomer stays off it, the survivor keeps its pane, the ended one's pane stands gone"
850 850
851 # ---- layout sidecar: silent degrade on garbage ----------------------- 851 # ---- layout file: a refused file degrades loudly and is NOT rewritten -
852 # 852 #
853 # A corrupted sidecar (`bogus 9`) is not a fatal error: restoreLayout 853 # A corrupted layout (`bogus 9`) is not a fatal error: the seed refuses it,
854 # returns null, the default tree is built, and the wall comes up. The 854 # the default tree is built, and the wall comes up. The assertions are that
855 # assertion is that a marker typed into the wall reaches the focused 855 # a marker typed into the wall reaches the focused session (the wall
856 # session (the wall works), and that the refusal NAMES the line it gave up 856 # works), that the refusal NAMES the line it gave up on so there is
857 # on so there is something to fix. 857 # something to fix, and that the file still holds those bytes when the wall
858 # leaves.
858 # 859 #
859 # Loud and not silent, which is the change: the layout used to be derived 860 # Loud and not silent, which is the change: the layout used to be derived
860 # convenience — a stale cut was a convenience to forget — and it is the 861 # convenience — a stale cut was a convenience to forget — and it is the
@@ -862,6 +863,12 @@ ok "a poll changes nothing on the wall: a newcomer stays off it, the survivor ke
862 # panes gone with no undo and no sentence to search for, so the refusal is 863 # panes gone with no undo and no sentence to search for, so the refusal is
863 # printed once, before the alternate screen, with the offending line in it. 864 # printed once, before the alternate screen, with the offending line in it.
864 # 865 #
866 # And NOT rewritten, which is the second change: this run has an entry pane
867 # and used to save its one-leaf tree over the refused file on start-up, so
868 # the line the user was told to fix was gone by the time they looked for
869 # it, and with it every other pane the file named. A run that refuses the
870 # file stops writing it for good.
871 #
865 # `mux --sock S` and not a bare `mux`, because a refused file leaves the 872 # `mux --sock S` and not a bare `mux`, because a refused file leaves the
866 # wall with no panes at all: the entry attach is what gives this leg a 873 # wall with no panes at all: the entry attach is what gives this leg a
867 # stripe to type into, and session b — live on the same daemon, named by 874 # stripe to type into, and session b — live on the same daemon, named by
@@ -931,26 +938,99 @@ grep -qF "bogus 9" "$OUT.lpdcap.err" || {
931 echo "e2e FAIL: layout-degrade: session b painted on a wall that refused its" 938 echo "e2e FAIL: layout-degrade: session b painted on a wall that refused its"
932 echo " file and named nothing else:" 939 echo " file and named nothing else:"
933 cat "$OUT.lpdpc"; exit 1; } 940 cat "$OUT.lpdpc"; exit 1; }
934 _lpd_rail=$(rail_cols "$OUT.lpdcap" | awk '$1 > 1' | tail -1) 941 # One PANE bar and no other. Counted off the label bars themselves rather
935 [ -z "$_lpd_rail" ] || { 942 # than off `rail_cols`, which sees every reverse-video run and so also sees
936 echo "e2e FAIL: layout-degrade: a rail at column $_lpd_rail — the refused wall" 943 # the notice this wall now carries — a right-aligned banner at column 19.
944 # A tile bar is ` N> spelling`; two panes are two of them, wherever the cut
945 # put their rows.
946 _lpd_bars=$(grep -ao $'\x1b\\[[0-9][0-9]*;[0-9][0-9]*H\x1b\\[7m [0-9][0-9]*>' "$OUT.lpdcap" |
947 sed 's/.*\x1b\[\([0-9]*;[0-9]*\)H.*/\1/' | sort -u | wc -l)
948 [ "$_lpd_bars" -eq 1 ] || {
949 echo "e2e FAIL: layout-degrade: $_lpd_bars pane bars — the refused wall"
937 echo " came up with more than the entry pane:" 950 echo " came up with more than the entry pane:"
938 cat "$OUT.lpdpc"; exit 1; } 951 cat "$OUT.lpdpc"; exit 1; }
939 # ...and what the detach wrote is a wall of one: the file the user has to 952 # The refusal is on the WALL too, not only on the stderr the alternate
940 # fix is replaced by the wall they actually got, not left as the garbage 953 # screen covered up: a user who ran `mux` from a prompt they have since
941 # that was refused. 954 # scrolled past has this line to search for.
942 _lpd_saved=$(sed -n 's/^ *leaf [0-9][0-9]* //p' "$LPDLAYOUT" | tr '\n' ' ') 955 grep -aqF '[layout not saved: the layout file was refused - fix bogus 9]' "$OUT.lpdcap" || {
943 [ "$_lpd_saved" = "--sock $SOCK61#0 " ] || { 956 echo "e2e FAIL: layout-degrade: the wall never said the file was refused:"
944 echo "e2e FAIL: layout-degrade: the detach saved leaves '$_lpd_saved'," 957 cat "$OUT.lpdpc"; exit 1; }
945 echo " want the entry pane alone; the file holds:" 958 # ...and the file is UNTOUCHED. The entry pane's start-up save and the
946 cat "$LPDLAYOUT"; exit 1; } 959 # detach's both ran on a wall that had one leaf; either would have replaced
947 head -1 "$LPDLAYOUT" | grep -qxF 'mux-layout 1' || { 960 # the bytes the user has to fix with a wall of one.
948 echo "e2e FAIL: layout-degrade: the file the detach wrote is not a layout:" 961 printf 'bogus 9\n' > "$OUT.lpdwant"
962 cmp -s "$LPDLAYOUT" "$OUT.lpdwant" || {
963 echo "e2e FAIL: layout-degrade: the refused file was rewritten; it now holds:"
949 cat "$LPDLAYOUT"; exit 1; } 964 cat "$LPDLAYOUT"; exit 1; }
965 ok "a corrupted layout is reported with its line, the wall starts as if it were missing, and the file is left alone"
966
967 # ---- layout file: a GOOD file refused for one leaf keeps all of them ---
968 #
969 # The refusal that costs most is not garbage: it is a well-formed wall with
970 # one leaf naming a host the hosts file no longer lists — what
971 # `mux hosts rm` used to leave behind, and what a hand-edited hosts line
972 # leaves behind still. The whole file is refused, because seating part of a
973 # wall silently is how a user loses one, and the run must therefore leave
974 # every leaf where it is: three leaves in, three leaves out, byte for byte.
975 #
976 # Three leaves and two hosts, not one of each: a file that survived because
977 # nothing in it was seatable would prove nothing, so two of these leaves
978 # name sessions that are live on the listed daemon right now.
979 LPKSTATE="${TMPDIR:-/tmp}/mux-e2e-lpkeep-state-$$"
980 defer_rm "$LPKSTATE"
981 LPKLAYOUT="$LPKSTATE/mux/layout"
982 LPKGHOST="${TMPDIR:-/tmp}/mux-e2e-lpkeep-absent-$$.sock"
983 mkdir -p "$LPKSTATE/mux"
984 printf -- '--sock %s\n' "$SOCK61" > "$LPKSTATE/mux/hosts"
985 cat > "$LPKLAYOUT" <<EOF
986 mux-layout 1
987 beside 0
988 leaf 40 --sock $SOCK61#0
989 stacked 30
990 leaf 50 --sock $SOCK61#b
991 leaf 50 --sock $LPKGHOST#z
992 focus 0
993 EOF
994 cp "$LPKLAYOUT" "$OUT.lpkwant"
995
996 set +e
997 XDG_STATE_HOME="$LPKSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
998 --out "$OUT.lpkcap" --err "$OUT.lpkcap.err" -- \
999 "$MUX" --sock "$SOCK61" > "$OUT.lpkpc" 2>&1 <<'EOF'
1000 expect lpd-origin 20000
1001 settle 2000 25000
1002 send printf 'lpd-keep-%s\n' marker\n
1003 expect lpd-keep-marker 10000
1004 settle 500 15000
1005 send \x1cd
1006 waitexit 10000
1007 EOF
1008 RC=$?
1009 set -e
1010 rc0 "layout-keep: ptyclient exited $RC:" "$OUT.lpkpc" "$OUT.lpkcap.err"
1011 # The refusal named the leaf it could not seat, not the file's first line:
1012 # every line here parses, and the one that is wrong is wrong about the
1013 # hosts file.
1014 grep -qF "mux: layout ignored (" "$OUT.lpkcap.err" || {
1015 echo "e2e FAIL: layout-keep: a refused layout said nothing; stderr holds:"
1016 cat "$OUT.lpkcap.err"; exit 1; }
1017 grep -qF "$LPKGHOST#z" "$OUT.lpkcap.err" || {
1018 echo "e2e FAIL: layout-keep: the refusal never named the unlisted host's leaf:"
1019 cat "$OUT.lpkcap.err"; exit 1; }
1020 # The wall came up on the entry pane alone, which is what makes the file
1021 # below a file this run could have overwritten and did not.
1022 grep -aqF "lpd-keep-marker" "$OUT.lpkcap" || {
1023 echo "e2e FAIL: layout-keep: the marker never painted; the wall did not come up:"
1024 cat "$OUT.lpkpc"; exit 1; }
1025 cmp -s "$LPKLAYOUT" "$OUT.lpkwant" || {
1026 echo "e2e FAIL: layout-keep: the refused file changed. Want:"
1027 cat "$OUT.lpkwant"; echo "got:"; cat "$LPKLAYOUT"; exit 1; }
1028 rm -rf "$LPKSTATE"
1029 ok "a layout refused for one unlisted host keeps all three of its leaves byte for byte"
1030
950 assert_stopped "$SOCK61" "$D62PID" "layout-degrade" "$OUT.lpdstop" 1031 assert_stopped "$SOCK61" "$D62PID" "layout-degrade" "$OUT.lpdstop"
951 D62PID="" 1032 D62PID=""
952 rm -rf "$LPDSTATE" 1033 rm -rf "$LPDSTATE"
953 ok "a corrupted layout is reported with its line and the wall starts as if it were missing"
954 1034
955 # ---- gone panes: a rebooted daemon keeps the cut ---------------------- 1035 # ---- gone panes: a rebooted daemon keeps the cut ----------------------
956 # 1036 #