02d20ce8
fix: a refused layout file is never written back
a73x 2026-09-03 05:20
Commit message
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 | # |