a73x

60e26d29

feat: the wall's first paint is the saved cut, and the settle owes one re-cut

a73x   2026-08-30 16:50

Commit message
feat: the wall's first paint is the saved cut, and the settle owes one re-cut

`run` seeds the tree from the sidecar before the first flatten: the
host table moves above it (pollers still spawn after the first paint),
pending panes take their saved rects, and the entry tile stands in its
remembered pane. The restore-at-2s and its full-screen re-cut are gone;
the gate remains as the SETTLE, whose only job is to collapse the panes
no host claimed — so a wall whose sessions all came back re-cuts zero
times. A down daemon's remembered pane may wait one poll round; the
hosts-restart leg now judges the settled grid through the render
oracle, which is the claim the wall actually makes.

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

docscheck.blocks
Old New
@@ -45,7 +45,7 @@ term.zig 0
45 testtmp.zig 1 45 testtmp.zig 1
46 upgrade.zig 2 46 upgrade.zig 2
47 wall_host.zig 7 47 wall_host.zig 7
48 wall_layout.zig 4 48 wall_layout.zig 2
49 wall_picker.zig 6 49 wall_picker.zig 6
50 wall_pump.zig 45 50 wall_pump.zig 45
51 wall_test_harness.zig 1 51 wall_test_harness.zig 1
@@ -54,7 +54,7 @@ wall_test_layout.zig 2
54 wall_test_picker.zig 4 54 wall_test_picker.zig 4
55 wall_test_pump.zig 9 55 wall_test_pump.zig 9
56 wall_test_wall.zig 7 56 wall_test_wall.zig 7
57 wallview.zig 64 57 wallview.zig 63
58 wasm_core.zig 5 58 wasm_core.zig 5
59 webhub_main.zig 4 59 webhub_main.zig 4
60 webhub.zig 29 60 webhub.zig 29
docscheck.budget
Old New
@@ -50,7 +50,7 @@ server_sessions.zig 0
50 wall_host.zig 0 50 wall_host.zig 0
51 wall_picker.zig 0 51 wall_picker.zig 0
52 wall_pump.zig 0 52 wall_pump.zig 0
53 wall_layout.zig 1432 53 wall_layout.zig 890
54 wall_test_harness.zig 0 54 wall_test_harness.zig 0
55 wall_test_host.zig 0 55 wall_test_host.zig 0
56 wall_test_picker.zig 0 56 wall_test_picker.zig 0
src/tui/wall_layout.zig
Old New
@@ -190,99 +190,28 @@ pub fn relayout(w: Wall, sel: usize) void {
190 wv.paintDeadBarsLocked(w.liveTiles()); 190 wv.paintDeadBarsLocked(w.liveTiles());
191 } 191 }
192 192
193 /// Rebuild the wall's tree from a saved sidecar, healing per leaf: each 193 /// The file half of the seed: null on a pipe, a missing sidecar, or bytes
194 /// saved leaf id takes the first wall tile whose label matches its 194 /// `seedLayout` refuses — and the wall then boots on today's default cut.
195 /// spelling, unmatched saved leaves collapse out, and unmatched wall tiles 195 pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Shared, entry_spelling: ?[]const u8) ?SeedPlan {
196 /// insert beside the first matched one. The saved root orientation wins 196 if (!shared.is_tty) return null;
197 /// over the aspect heuristic — `setRootOrient` is never called here. 197 const path = hosts.layoutPath(alloc) catch return null;
198 /// 198 defer alloc.free(path);
199 /// Pure of file I/O: bytes come in, the caller decides whether to feed 199 const bytes = loadLayout(alloc, path) orelse return null;
200 /// them. False on any malformation, zero matches, or allocation failure — 200 defer alloc.free(bytes);
201 /// the caller builds today's default tree and the sidecar is ignored. 201 return seedLayout(alloc, table, shared, bytes, entry_spelling);
202 pub fn restoreLayout( 202 }
203 alloc: std.mem.Allocator,
204 resolved: []const Resolved,
205 shared: *Shared,
206 bytes: []const u8,
207 focus_out: *?u8,
208 ) bool {
209 var parsed = layout.parse(alloc, bytes) orelse return false;
210 // Every early-false path deinits parsed; the success path moves the
211 // tree out and deinits only the spellings.
212
213 const n_saved = parsed.spellings.items.len;
214 const map = alloc.alloc(?u8, n_saved) catch {
215 parsed.deinit(alloc);
216 return false;
217 };
218 defer alloc.free(map);
219
220 var taken = alloc.alloc(bool, resolved.len) catch {
221 parsed.deinit(alloc);
222 return false;
223 };
224 defer alloc.free(taken);
225 @memset(taken, false);
226
227 var any_match = false;
228 for (0..n_saved) |i| {
229 const spelling = parsed.spellings.items[i];
230 map[i] = null;
231 for (resolved, 0..) |r, j| {
232 if (!taken[j] and std.mem.eql(u8, r.label, spelling)) {
233 taken[j] = true;
234 map[i] = @intCast(j);
235 any_match = true;
236 break;
237 }
238 }
239 }
240
241 // Zero matches: the sidecar describes a different wall entirely.
242 // Deinit parsed and return false so the caller builds the default tree.
243 if (!any_match) {
244 parsed.deinit(alloc);
245 return false;
246 }
247
248 // Map the saved focus through the spelling match before remapLeaves
249 // consumes map. A null here means the focused leaf did not survive
250 // (no wall tile matched its spelling) — the caller falls back to 0.
251 if (parsed.focus) |k| {
252 if (k < map.len) focus_out.* = map[k];
253 }
254
255 parsed.tree.remapLeaves(map);
256
257 // Unmatched wall indices, ascending, insert beside the first matched
258 // tile — the same beside-focus placement the initial build uses.
259 const first_tile: u8 = if (map.len > 0) blk: {
260 var k: usize = 0;
261 while (k < map.len) : (k += 1) {
262 if (map[k] != null) break :blk map[k].?;
263 }
264 break :blk 0;
265 } else 0;
266 203
267 var next_tile: u8 = first_tile; 204 /// Every pane still waiting when the wall settles goes at once, in the
268 for (taken, 0..) |is_taken, j| { 205 /// ONE re-cut the caller owes when this answers true: after it, no tile
269 if (!is_taken) { 206 /// on the wall came from the file.
270 const new_id: u8 = @intCast(j); 207 pub fn collapsePending(w: Wall) bool {
271 parsed.tree.insert(next_tile, new_id) catch { 208 var took = false;
272 parsed.deinit(alloc); 209 for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
273 return false; 210 if (!p or !t.pending) continue;
274 }; 211 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null);
275 next_tile = new_id; 212 took = true;
276 }
277 } 213 }
278 214 return took;
279 // Move the parsed tree into shared, deinit the old one first.
280 shared.tree.deinit();
281 shared.tree = parsed.tree;
282 // The spellings are no longer needed — the tree holds only ids now.
283 for (parsed.spellings.items) |s| alloc.free(s);
284 parsed.spellings.deinit(alloc);
285 return true;
286 } 215 }
287 216
288 /// Every failure is the same null: a caller degrades the same way whatever 217 /// Every failure is the same null: a caller degrades the same way whatever
@@ -323,8 +252,8 @@ pub fn saveLayoutTo(
323 /// Resolves the sidecar path from env and delegates to `saveLayoutTo`. 252 /// Resolves the sidecar path from env and delegates to `saveLayoutTo`.
324 pub fn saveSidecar(w: Wall) void { 253 pub fn saveSidecar(w: Wall) void {
325 // A pipe has no stripes, so it has no layout worth remembering — and 254 // A pipe has no stripes, so it has no layout worth remembering — and
326 // a tree it saved would be a tree the next TERMINAL restores over the 255 // a tree it saved would be a tree the next TERMINAL seeds over the
327 // aspect rule. See `restoreSidecar` for the cost of the other half. 256 // aspect rule. `seedSidecar` refuses a pipe for the same reason.
328 if (!w.shared.is_tty) return; 257 if (!w.shared.is_tty) return;
329 const path = hosts.layoutPath(w.alloc) catch |err| { 258 const path = hosts.layoutPath(w.alloc) catch |err| {
330 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); 259 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
@@ -523,59 +452,6 @@ fn seedAttempt(
523 return plan; 452 return plan;
524 } 453 }
525 454
526 /// The saved layout over the tiles the hosts turned out to have; null
527 /// when nothing matched.
528 pub fn restoreSidecar(w: Wall, focus_out: *?usize) ?void {
529 // Not on a pipe, and the cost is why. A restored tree is applied by
530 // `focusAnswer(recut = true)`, whose `relayout` flags `resize_pending`
531 // on EVERY present tile whether or not that tile's rect moved; each
532 // pump then sends `.resize`, and `Server.onResize` answers every one of
533 // them with `resyncSnapshot` — which has no "nothing changed" guard.
534 // Measured on a scripted `mux quic://...` whose state home already held
535 // a sidecar: a second full snapshot on the wire for a wall of one tile
536 // that was already the right shape. The CLAIM is innocent; it stopped
537 // sending a resize (see the pump's focus-claim block).
538 if (!w.shared.is_tty) return null;
539 const path = hosts.layoutPath(w.alloc) catch return null;
540 defer w.alloc.free(path);
541 const bytes = loadLayout(w.alloc, path) orelse return null;
542 defer w.alloc.free(bytes);
543 return restoreLayoutFrom(w, bytes, focus_out);
544 }
545
546 /// Pure of file I/O like `restoreLayout`: the chained id translations —
547 /// saved→dense, then dense→real — are what a test must reach.
548 pub fn restoreLayoutFrom(w: Wall, bytes: []const u8, focus_out: *?usize) ?void {
549 const n = wv.presentCount(w.livePresent());
550 if (n == 0) return null;
551 const dense = w.alloc.alloc(Resolved, n) catch return null;
552 defer w.alloc.free(dense);
553 const remap = w.alloc.alloc(?u8, n) catch return null;
554 defer w.alloc.free(remap);
555 var di: usize = 0;
556 for (w.liveTiles(), w.livePresent(), 0..) |*t, p, ti| {
557 if (p) {
558 dense[di] = t.r;
559 remap[di] = @intCast(ti);
560 di += 1;
561 }
562 }
563 var dense_focus: ?u8 = null;
564 if (restoreLayout(w.alloc, dense, w.shared, bytes, &dense_focus)) {
565 // The tree's leaf ids are dense indices into `dense`; remap them
566 // to the real tile indices relayout reads, and the saved focus
567 // with them.
568 w.shared.tree.remapLeaves(remap);
569 if (dense_focus) |d| {
570 if (d < remap.len) {
571 if (remap[d]) |real| focus_out.* = real;
572 }
573 }
574 return {};
575 }
576 return null;
577 }
578
579 /// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and 455 /// The leaf a birth sits beside: `birthTile` inserts against a LEAF, and
580 /// the focus can be a hole. 456 /// the focus can be a hole.
581 pub fn anchorTile(present: []const bool, sel: usize) usize { 457 pub fn anchorTile(present: []const bool, sel: usize) usize {
src/tui/wall_test_layout.zig
Old New
@@ -705,216 +705,6 @@ test "saveLayoutTo handles a vanished middle tile without panicking" {
705 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null); 705 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b") == null);
706 } 706 }
707 707
708 test "restore: a saved beside pair comes back verbatim on a tall terminal" {
709 // Aspect at 40x30 would say stacked; the sidecar says beside and wins.
710 const alloc = std.testing.allocator;
711 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 40, .rows = 30 }, .is_tty = false };
712 shared.tree = layout.Tree.init(alloc);
713 shared.flat_alloc = alloc;
714 defer shared.tree.deinit();
715 defer if (shared.last_flat) |*f| f.deinit(alloc);
716 defer if (shared.base_flat) |*f| f.deinit(alloc);
717 const resolved = [_]Resolved{
718 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
719 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
720 };
721 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\n";
722 var focus_out: ?u8 = null;
723 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
724 const tiles = try alloc.alloc(Tile, 2);
725 defer alloc.free(tiles);
726 var present = [_]bool{ true, true };
727 for (tiles, 0..) |*t, i| {
728 t.* = Tile{
729 .r = resolved[i],
730 .rect = .{ .top = 0, .left = 0, .rows = 30, .cols = 40 },
731 .shared = &shared,
732 .idx = i,
733 .wake_r = -1,
734 .wake_w = -1,
735 };
736 }
737 wall_layout.relayout(fixture.wallAll(alloc, tiles, &present, &shared), 0);
738 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.top);
739 try std.testing.expect(tiles[1].rect.left > 0);
740 }
741
742 test "restore: heals — unknown wall line inserted, lost leaf dropped" {
743 // Sidecar knows a, GONE and c side by side; the wall has a, c and NEW.
744 // Three saved leaves, not two: with two, dropping one collapses the
745 // container and every heal lands on the default cut, so the claim
746 // ("inserted BESIDE the match") could not be seen.
747 const alloc = std.testing.allocator;
748 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
749 shared.tree = layout.Tree.init(alloc);
750 shared.flat_alloc = alloc;
751 defer shared.tree.deinit();
752 defer if (shared.last_flat) |*f| f.deinit(alloc);
753 defer if (shared.base_flat) |*f| f.deinit(alloc);
754 const resolved = [_]Resolved{
755 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
756 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#c", .session = "c" },
757 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" },
758 };
759 const bytes = "mux-layout 1\nbeside 0\n leaf 26 --sock /tmp/x#a\n" ++
760 " leaf 26 --sock /tmp/x#gone\n leaf 26 --sock /tmp/x#c\n";
761 var focus_out: ?u8 = null;
762 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
763 try std.testing.expectEqual(@as(usize, 3), shared.tree.count());
764 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null);
765 defer flat.deinit(alloc);
766 const a = flat.rectOf(0) orelse return error.TestUnexpectedResult;
767 const c = flat.rectOf(1) orelse return error.TestUnexpectedResult;
768 const new = flat.rectOf(2) orelse return error.TestUnexpectedResult;
769 // The saved axis survives the drop — a heal that rebuilt the default
770 // cut would stack these, giving them one left and three tops.
771 try std.testing.expectEqual(a.top, c.top);
772 try std.testing.expectEqual(a.top, new.top);
773 // NEW landed next to the match (a), not at either end by accident.
774 try std.testing.expect(a.left < new.left);
775 try std.testing.expect(new.left < c.left);
776 }
777
778 test "restore: zero matches or garbage degrade to false" {
779 const alloc = std.testing.allocator;
780 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
781 shared.tree = layout.Tree.init(alloc);
782 shared.flat_alloc = alloc;
783 defer shared.tree.deinit();
784 defer if (shared.last_flat) |*f| f.deinit(alloc);
785 defer if (shared.base_flat) |*f| f.deinit(alloc);
786 const resolved = [_]Resolved{
787 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
788 };
789 var focus_out: ?u8 = null;
790 try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, "not a sidecar", &focus_out));
791 const nomatch = "mux-layout 1\nleaf 0 --sock /nowhere#z\n";
792 try std.testing.expect(!wall_layout.restoreLayout(alloc, &resolved, &shared, nomatch, &focus_out));
793 }
794
795 test "restore: duplicate spellings pair positionally" {
796 // Wall shows one host twice: same spelling on both lines. Saved
797 // leaves 0 and 1 share it; leaf 0 takes wall 0, leaf 1 takes wall 1.
798 const alloc = std.testing.allocator;
799 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
800 shared.tree = layout.Tree.init(alloc);
801 shared.flat_alloc = alloc;
802 defer shared.tree.deinit();
803 defer if (shared.last_flat) |*f| f.deinit(alloc);
804 defer if (shared.base_flat) |*f| f.deinit(alloc);
805 const resolved = [_]Resolved{
806 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" },
807 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#dup", .session = "dup" },
808 };
809 // Lopsided on purpose: with near-equal weights a mispairing produces
810 // near-equal rects and hides in the leaf count.
811 const bytes = "mux-layout 1\nbeside 0\n leaf 59 --sock /tmp/x#dup\n leaf 20 --sock /tmp/x#dup\n";
812 var focus_out: ?u8 = null;
813 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
814 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
815 // Pairing is the whole claim and it IS observable: saved leaf 0 owns
816 // the left, wide slot, so it must be wall tile 0. Paired the other
817 // way round the count is still 2 and the tiles have swapped screens.
818 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(2), null);
819 defer flat.deinit(alloc);
820 const t0 = flat.rectOf(0) orelse return error.TestUnexpectedResult;
821 const t1 = flat.rectOf(1) orelse return error.TestUnexpectedResult;
822 try std.testing.expect(t0.left < t1.left);
823 try std.testing.expect(t0.cols > t1.cols);
824 }
825
826 test "restoreLayoutFrom: dense sidecar indices land on the real tile indices" {
827 // Tile 1 was forgotten, so the dense array is 0,2,3 and the two index
828 // spaces disagree. With no hole they coincide and the second remap is
829 // untestable by construction — which is how a tile could be handed the
830 // rect flattened for its neighbour and no test would say so.
831 const alloc = std.testing.allocator;
832 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
833 shared.tree = layout.Tree.init(alloc);
834 shared.flat_alloc = alloc;
835 defer shared.tree.deinit();
836 defer if (shared.last_flat) |*f| f.deinit(alloc);
837 defer if (shared.base_flat) |*f| f.deinit(alloc);
838 const labels = [_][]const u8{
839 "--sock /tmp/x#a",
840 "--sock /tmp/x#hole",
841 "--sock /tmp/x#c",
842 "--sock /tmp/x#d",
843 };
844 var tiles: [4]Tile = undefined;
845 for (&tiles, 0..) |*t, i| {
846 t.* = .{
847 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = labels[i], .session = "" },
848 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
849 .shared = &shared,
850 .idx = i,
851 .wake_r = -1,
852 .wake_w = -1,
853 };
854 }
855 var present = [_]bool{ true, false, true, true };
856 // Three unequal weights over 80 columns less two rails: 40+20+18 = 78,
857 // so each tile's width names which saved leaf it was given.
858 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n" ++
859 " leaf 20 --sock /tmp/x#c\n leaf 18 --sock /tmp/x#d\n";
860 var restored_focus: ?usize = null;
861 wall_layout.restoreLayoutFrom(fixture.wallLive(alloc, &tiles, &present, 4, &shared), bytes, &restored_focus) orelse
862 return error.TestUnexpectedResult;
863 const flat = try shared.tree.flatten(alloc, 24, 80, wall_layout.wallFloors(3), null);
864 defer flat.deinit(alloc);
865 try std.testing.expectEqual(@as(?layout.Rect, null), flat.rectOf(1));
866 const a = flat.rectOf(0) orelse return error.TestUnexpectedResult;
867 const c = flat.rectOf(2) orelse return error.TestUnexpectedResult;
868 const d = flat.rectOf(3) orelse return error.TestUnexpectedResult;
869 try std.testing.expectEqual(@as(u16, 40), a.cols);
870 try std.testing.expectEqual(@as(u16, 20), c.cols);
871 try std.testing.expectEqual(@as(u16, 18), d.cols);
872 try std.testing.expect(a.left < c.left);
873 try std.testing.expect(c.left < d.left);
874 }
875
876 test "restore: focus_out maps the saved focus through the spelling match" {
877 // Saved leaf 1 matches wall tile 1 (b) → focus_out == 1. Saved leaf 1
878 // matches NO wall tile (gone) → focus_out stays null.
879 const alloc = std.testing.allocator;
880
881 // Matched: focus 1 → tile 1.
882 {
883 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
884 shared.tree = layout.Tree.init(alloc);
885 shared.flat_alloc = alloc;
886 defer shared.tree.deinit();
887 defer if (shared.last_flat) |*f| f.deinit(alloc);
888 defer if (shared.base_flat) |*f| f.deinit(alloc);
889 const resolved = [_]Resolved{
890 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
891 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
892 };
893 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\nfocus 1\n";
894 var focus_out: ?u8 = null;
895 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
896 try std.testing.expectEqual(@as(?u8, 1), focus_out);
897 }
898
899 // Unmatched: saved leaf 1 (gone) has no wall tile → focus_out null.
900 {
901 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
902 shared.tree = layout.Tree.init(alloc);
903 shared.flat_alloc = alloc;
904 defer shared.tree.deinit();
905 defer if (shared.last_flat) |*f| f.deinit(alloc);
906 defer if (shared.base_flat) |*f| f.deinit(alloc);
907 const resolved = [_]Resolved{
908 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
909 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#new", .session = "new" },
910 };
911 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#gone\nfocus 1\n";
912 var focus_out: ?u8 = null;
913 try std.testing.expect(wall_layout.restoreLayout(alloc, &resolved, &shared, bytes, &focus_out));
914 try std.testing.expectEqual(@as(?u8, null), focus_out);
915 }
916 }
917
918 test "layout sidecar: save round-trips through load; a missing file is null" { 708 test "layout sidecar: save round-trips through load; a missing file is null" {
919 const alloc = std.testing.allocator; 709 const alloc = std.testing.allocator;
920 var tmp = try TmpDir.make(); 710 var tmp = try TmpDir.make();
@@ -1171,3 +961,118 @@ test "seed: the entry tile's own pane is not pending; a sidecar that does not kn
1171 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan2.panes[0]); 961 try std.testing.expectEqual(@as(?wall_layout.SeedPane, null), plan2.panes[0]);
1172 try std.testing.expectEqual(@as(usize, 4), shared.tree.count()); 962 try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
1173 } 963 }
964
965 test "collapse: every pane no host claimed goes in ONE re-cut, and the panes that bound keep theirs" {
966 const alloc = std.testing.allocator;
967 var shared: Shared = undefined;
968 seedShared(alloc, &shared);
969 defer shared.tree.deinit();
970 defer if (shared.last_flat) |*f| f.deinit(alloc);
971 defer if (shared.base_flat) |*f| f.deinit(alloc);
972 var tiles: [4]Tile = undefined;
973 var present = [_]bool{false} ** 4;
974 try shared.tree.addFirst(0);
975 try shared.tree.insert(0, 1);
976 try shared.tree.insert(1, 2);
977 // Three seeded panes; pane 1 bound (a live session now), 0 and 2
978 // never claimed. Off-origin rects arrive from the relayout below.
979 for (0..3) |i| {
980 try wv.seedTile(&tiles[i], .{
981 .target = .{ .sock = "/tmp/h0.sock" },
982 .label = try alloc.dupe(u8, "--sock /tmp/h0.sock#x"),
983 .session = try alloc.dupe(u8, "x"),
984 }, .{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, &shared, i, 0);
985 present[i] = true;
986 }
987 defer for (tiles[0..3]) |*t| {
988 alloc.free(t.r.label);
989 alloc.free(t.r.session);
990 if (t.wake_r >= 0) std.posix.close(t.wake_r);
991 if (t.wake_w >= 0) std.posix.close(t.wake_w);
992 };
993 tiles[1].pending = false;
994 const w = fixture.wallLive(alloc, &tiles, &present, 3, &shared);
995 wall_layout.relayout(w, 1);
996 const kept = tiles[1].rect;
997 const gen = shared.repaint_gen.load(.acquire);
998
999 try std.testing.expect(wall_layout.collapsePending(w));
1000 wall_layout.relayout(w, 1);
1001
1002 // One relayout, both unclaimed panes gone, the bound pane grew to the
1003 // whole screen and after the settle no tile came from the file.
1004 try std.testing.expectEqual(gen + 1, shared.repaint_gen.load(.acquire));
1005 try std.testing.expect(!present[0] and present[1] and !present[2]);
1006 try std.testing.expect(tiles[1].rect.rows > kept.rows);
1007 for (tiles[0..3], present[0..3]) |t, p| {
1008 if (p) try std.testing.expect(!t.pending);
1009 }
1010 // Nothing left waiting: a second collapse has nothing to take.
1011 try std.testing.expect(!wall_layout.collapsePending(w));
1012 }
1013
1014 test "collapse: a collapsed pane hands its digit straight back" {
1015 const alloc = std.testing.allocator;
1016 var shared: Shared = undefined;
1017 seedShared(alloc, &shared);
1018 defer shared.tree.deinit();
1019 var tiles: [2]Tile = undefined;
1020 var present = [_]bool{false} ** 2;
1021 try shared.tree.addFirst(0);
1022 try wv.seedTile(&tiles[0], .{
1023 .target = .{ .sock = "/tmp/h0.sock" },
1024 .label = try alloc.dupe(u8, "--sock /tmp/h0.sock#gone"),
1025 .session = try alloc.dupe(u8, "gone"),
1026 }, .{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, &shared, 0, 0);
1027 present[0] = true;
1028 defer {
1029 alloc.free(tiles[0].r.label);
1030 alloc.free(tiles[0].r.session);
1031 if (tiles[0].wake_r >= 0) std.posix.close(tiles[0].wake_r);
1032 if (tiles[0].wake_w >= 0) std.posix.close(tiles[0].wake_w);
1033 }
1034 const w = fixture.wallLive(alloc, &tiles, &present, 1, &shared);
1035 try std.testing.expect(wall_layout.collapsePending(w));
1036 // `freeSlot` wants `!present and pump_done`: the seed stored the
1037 // second half precisely so this digit is not burned for the wall's
1038 // life - no pump ever held the slot.
1039 try std.testing.expectEqual(@as(?usize, 0), wv.freeSlot(&tiles, &present));
1040 }
1041
1042 test "a wall left before its hosts answered saves the shape it was given" {
1043 const alloc = std.testing.allocator;
1044 var shared: Shared = undefined;
1045 seedShared(alloc, &shared);
1046 defer shared.tree.deinit();
1047 var tiles: [2]Tile = undefined;
1048 var present = [_]bool{ true, true };
1049 try shared.tree.addFirst(0);
1050 try shared.tree.insert(0, 1);
1051 for (0..2) |i| {
1052 try wv.seedTile(&tiles[i], .{
1053 .target = .{ .sock = "/tmp/h0.sock" },
1054 .label = if (i == 0)
1055 try alloc.dupe(u8, "--sock /tmp/h0.sock#a")
1056 else
1057 try alloc.dupe(u8, "--sock /tmp/h0.sock#b"),
1058 .session = try alloc.dupe(u8, if (i == 0) "a" else "b"),
1059 }, .{ .top = 0, .left = 0, .rows = 12, .cols = 100 }, &shared, i, 0);
1060 }
1061 defer for (tiles[0..2]) |*t| {
1062 alloc.free(t.r.label);
1063 alloc.free(t.r.session);
1064 if (t.wake_r >= 0) std.posix.close(t.wake_r);
1065 if (t.wake_w >= 0) std.posix.close(t.wake_w);
1066 };
1067 var tmp = try TmpDir.make();
1068 defer tmp.cleanup();
1069 var pbuf: [256]u8 = undefined;
1070 const path = try std.fmt.bufPrint(&pbuf, "{s}/layout", .{tmp.path()});
1071 // Detaching inside the settle window: the pending panes' spellings go
1072 // back out verbatim, so a quick in-and-out does not erode the sidecar.
1073 wall_layout.saveLayoutTo(alloc, path, &tiles, &present, &shared);
1074 const bytes = wall_layout.loadLayout(alloc, path) orelse return error.NothingSaved;
1075 defer alloc.free(bytes);
1076 try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#a") != null);
1077 try std.testing.expect(std.mem.indexOf(u8, bytes, "--sock /tmp/h0.sock#b") != null);
1078 }
src/tui/wallview.zig
Old New
@@ -963,7 +963,7 @@ pub fn presentCount(present: []const bool) usize {
963 963
964 /// The lowest slot a new tile may take back: off the wall AND its pump 964 /// The lowest slot a new tile may take back: off the wall AND its pump
965 /// returned. `Tile.pump_done` is why both halves are needed. 965 /// returned. `Tile.pump_done` is why both halves are needed.
966 fn freeSlot(tiles: []const Tile, present: []const bool) ?usize { 966 pub fn freeSlot(tiles: []const Tile, present: []const bool) ?usize {
967 for (tiles, present, 0..) |*t, p, i| { 967 for (tiles, present, 0..) |*t, p, i| {
968 if (!p and t.pump_done.load(.acquire)) return i; 968 if (!p and t.pump_done.load(.acquire)) return i;
969 } 969 }
@@ -1644,16 +1644,45 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1644 var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty }; 1644 var shared = Shared{ .out_fd = stdout_fd, .size = size, .is_tty = is_tty };
1645 shared.tree = layout.Tree.init(alloc); 1645 shared.tree = layout.Tree.init(alloc);
1646 shared.flat_alloc = alloc; 1646 shared.flat_alloc = alloc;
1647 // The wall starts with the tile the user asked for and NOTHING else: 1647 const env_sock = std.posix.getenv(proto.sock_env);
1648 // every other tile arrives from a host's own list, so the sidecar is 1648 const env_session = std.posix.getenv(proto.session_env);
1649 // restored later, over the tiles the hosts turn out to have. 1649 // The host table before the first flatten, because the seed matches
1650 // saved leaves against these spellings. Allocated at CAPACITY: a
1651 // poller thread holds its `*Host` for the wall's whole life, so the
1652 // array may never move when the picker's `a` adds a host to it.
1653 const host_table = try alloc.alloc(Host, max_tiles);
1654 var hosts_live: usize = @min(host_specs.len, max_tiles);
1655 for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| {
1656 h.* = .{
1657 .spec = spec,
1658 .shared = &shared,
1659 .self_name = selfSession(spec.target, env_sock, env_session),
1660 };
1661 }
1650 const has_entry = entry.pre != null and entry.entry_host != null; 1662 const has_entry = entry.pre != null and entry.entry_host != null;
1651 if (has_entry) shared.tree.addFirst(0) catch return 2; 1663 var entry_name: []const u8 = "";
1664 var entry_label: ?[]const u8 = null;
1665 if (has_entry) {
1666 // Duped, though argv outlives the process: a tile that ends frees
1667 // these two when its digit is taken back.
1668 entry_name = try alloc.dupe(u8, proto.resolveName(entry.entry_session));
1669 entry_label = try tileLabel(alloc, host_specs[entry.entry_host.?].target, entry_name);
1670 }
1671 // The wall starts with the cut the sidecar remembers: its leaves are
1672 // pending panes the polls bind in place or the settle collapses, so
1673 // the first paint is the saved shape, not a placeholder to re-cut.
1674 var seed_plan: ?wall_layout.SeedPlan = if (measured != null)
1675 wall_layout.seedSidecar(alloc, host_table[0..hosts_live], &shared, entry_label)
1676 else
1677 null;
1678 const seeded = seed_plan != null;
1679 if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2;
1680 const boot_tiles: usize = if (seed_plan) |pl| pl.panes.len else @intFromBool(has_entry);
1652 const init_flat = shared.tree.flatten( 1681 const init_flat = shared.tree.flatten(
1653 alloc, 1682 alloc,
1654 size.rows, 1683 size.rows,
1655 size.cols, 1684 size.cols,
1656 wall_layout.wallFloors(@intFromBool(has_entry)), 1685 wall_layout.wallFloors(boot_tiles),
1657 null, 1686 null,
1658 ) catch { 1687 ) catch {
1659 std.debug.print("mux: terminal too small\n", .{}); 1688 std.debug.print("mux: terminal too small\n", .{});
@@ -1761,21 +1790,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1761 // re-sliced wherever `hosts_live` grows — the picker's `a` is the only 1790 // re-sliced wherever `hosts_live` grows — the picker's `a` is the only
1762 // thing that does. 1791 // thing that does.
1763 var w: Wall = .{ .alloc = alloc, .tiles = tiles, .present = present, .live = &live, .shared = &shared }; 1792 var w: Wall = .{ .alloc = alloc, .tiles = tiles, .present = present, .live = &live, .shared = &shared };
1764 const env_sock = std.posix.getenv(proto.sock_env); 1793 w.hosts = host_table[0..hosts_live];
1765 const env_session = std.posix.getenv(proto.session_env);
1766 if (has_entry) { 1794 if (has_entry) {
1767 const hi = entry.entry_host.?; 1795 const hi = entry.entry_host.?;
1768 const target = host_specs[hi].target; 1796 const target = host_specs[hi].target;
1769 // Duped, though argv outlives the process: a tile that ends frees
1770 // these two when its digit is taken back, and a free of argv would
1771 // be the birth after the entry tile's exit, not this line, that
1772 // crashed.
1773 const name = try alloc.dupe(u8, proto.resolveName(entry.entry_session));
1774 const rect = init_flat.rectOf(0) orelse return 2; 1797 const rect = init_flat.rectOf(0) orelse return 2;
1775 try initTile(&tiles[0], .{ 1798 try initTile(&tiles[0], .{
1776 .target = target, 1799 .target = target,
1777 .label = try tileLabel(alloc, target, name), 1800 .label = entry_label.?,
1778 .session = name, 1801 .session = entry_name,
1779 .agent = entry.agent, 1802 .agent = entry.agent,
1780 }, rect, &shared, 0, .fresh); 1803 }, rect, &shared, 0, .fresh);
1781 tiles[0].host = hi; 1804 tiles[0].host = hi;
@@ -1799,34 +1822,41 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1799 // the dial, and the attach already carries the terminal's size. 1822 // the dial, and the attach already carries the terminal's size.
1800 tiles[0].claim_pending.store(true, .release); 1823 tiles[0].claim_pending.store(true, .release);
1801 } 1824 }
1825 if (seed_plan) |*pl| {
1826 for (pl.panes, 0..) |mp, i| {
1827 const pane = mp orelse continue;
1828 const rect = init_flat.rectOf(@intCast(i)) orelse layout.Rect{ .top = 0, .left = 0, .rows = 0, .cols = 0 };
1829 try seedTile(&tiles[i], .{
1830 .target = host_table[pane.host].spec.target,
1831 .label = pane.label,
1832 .session = pane.session,
1833 }, rect, &shared, i, pane.host);
1834 present[i] = true;
1835 }
1836 live = pl.panes.len;
1837 // The saved focus, unless the user is already typing into the
1838 // entry tile - a focus record must not move them off it.
1839 if (!has_entry) shared.sel = pl.focus orelse (wall_layout.firstPresent(present[0..live]) orelse 0);
1840 // The names moved into the tiles above; only the array goes.
1841 alloc.free(pl.panes);
1842 seed_plan = null;
1843 }
1802 // A one-tile wall owns every row and draws no label bar; two or more 1844 // A one-tile wall owns every row and draws no label bar; two or more
1803 // tiles each lose their top row to one. Set before the pumps start so 1845 // tiles each lose their top row to one. Set before the pumps start so
1804 // `viewRows` is right on the first attach. 1846 // `viewRows` is right on the first attach.
1805 shared.label_rows = if (w.live.* > 1) 1 else 0; 1847 shared.label_rows = if (w.live.* > 1) 1 else 0;
1806 for (tiles[0..live]) |*t| spawnPump(t); 1848 for (tiles[0..live]) |*t| spawnPump(t);
1807 // A wall with no tile yet paints its one line rather than nothing: a 1849 // A wall with no tile yet paints its one line rather than nothing (a
1808 // blank terminal with no cursor reads as hung, and the hosts are up to 1850 // blank terminal with no cursor reads as hung), and a seeded wall
1809 // a poll away from having anything to show. 1851 // paints its remembered cut: rails and waiting bars have no pump.
1810 if (w.live.* == 0) wall_layout.relayout(w, 0); 1852 if (w.live.* == 0 or seeded) wall_layout.relayout(w, shared.sel);
1811 1853
1812 // One poller per host, all of them at once and none of them on this 1854 // One poller per host, all of them at once and none of them on this
1813 // thread: the user asked for one session and must not be held on 1855 // thread: the user asked for one session and must not be held on
1814 // another host's ssh to see it. 1856 // another host's ssh to see it.
1815 //
1816 // Allocated at CAPACITY for the tiles' reason: a poller thread holds
1817 // its `*Host` for the wall's whole life, so the array may never move
1818 // when the picker's `a` adds a host to it.
1819 const host_table = try alloc.alloc(Host, max_tiles);
1820 var hosts_live: usize = @min(host_specs.len, max_tiles);
1821 w.hosts = host_table[0..hosts_live];
1822 var over_buf: [64]u8 = undefined; 1857 var over_buf: [64]u8 = undefined;
1823 if (wall_host.hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said); 1858 if (wall_host.hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said);
1824 for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| { 1859 for (host_table[0..hosts_live]) |*h| {
1825 h.* = .{
1826 .spec = spec,
1827 .shared = &shared,
1828 .self_name = selfSession(spec.target, env_sock, env_session),
1829 };
1830 // Not without a terminal (`headless`). A poller here would turn 1860 // Not without a terminal (`headless`). A poller here would turn
1831 // every scripted `mux --sock S` into a wall of whatever that daemon 1861 // every scripted `mux --sock S` into a wall of whatever that daemon
1832 // happens to be running: measured on a piped attach to a daemon 1862 // happens to be running: measured on a piped attach to a daemon
@@ -1883,16 +1913,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1883 // on the normal screen once the terminal is back. 1913 // on the normal screen once the terminal is back.
1884 var exit_code: u8 = 0; 1914 var exit_code: u8 = 0;
1885 var exit_msg: ?[]const u8 = null; 1915 var exit_msg: ?[]const u8 = null;
1886 // The sidecar is restored ONCE, over the tiles the hosts turned out to 1916 // The wall SETTLES once: every opening host has answered, or 2s - the
1887 // have: at the latest 2s in, so a host that never answers cannot hold 1917 // poll's own dial budget - so a host that never answers cannot hold
1888 // the user on an unlaid-out wall, and as soon as every host has 1918 // the seeded panes on screen forever. The settle owes one re-cut, for
1889 // reported when they are quick. 1919 // the panes nobody claimed.
1890 const restore_due: i64 = std.time.milliTimestamp() + 2000; 1920 const settle_due: i64 = std.time.milliTimestamp() + 2000;
1891 var restore_tried = false; 1921 var settled = false;
1892 var restore_ok = false;
1893 // Only the hosts the wall OPENED with are waited for. One added in the 1922 // Only the hosts the wall OPENED with are waited for. One added in the
1894 // picker arrives long after this, on a wall the user is already 1923 // picker arrives long after this, on a wall the user is already
1895 // looking at, and holding the restore for it would relay their panes 1924 // looking at, and holding the settle for it would relay their panes
1896 // under their hands. 1925 // under their hands.
1897 const opening_hosts = hosts_live; 1926 const opening_hosts = hosts_live;
1898 // The aspect heuristic is the fallback for a wall with no saved tree, 1927 // The aspect heuristic is the fallback for a wall with no saved tree,
@@ -2015,7 +2044,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2015 // news and arrives at once, while a list is up to a poll behind. 2044 // news and arrives at once, while a list is up to a poll behind.
2016 // Reading the list first would vanish the tile the exit code is on. 2045 // Reading the list first would vanish the tile the exit code is on.
2017 const host_news = wall_host.applyReadyLists(w); 2046 const host_news = wall_host.applyReadyLists(w);
2018 if (!restore_tried) { 2047 if (!settled) {
2019 var all_reported = true; 2048 var all_reported = true;
2020 for (host_table[0..opening_hosts]) |*h| { 2049 for (host_table[0..opening_hosts]) |*h| {
2021 if (!h.applied) { 2050 if (!h.applied) {
@@ -2023,19 +2052,18 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2023 break; 2052 break;
2024 } 2053 }
2025 } 2054 }
2026 if (all_reported or std.time.milliTimestamp() >= restore_due) { 2055 if (all_reported or std.time.milliTimestamp() >= settle_due) {
2027 restore_tried = true; 2056 settled = true;
2028 var saved_focus: ?usize = null; 2057 if (wall_layout.collapsePending(w)) {
2029 if (w.live.* > 0 and wall_layout.restoreSidecar(w, &saved_focus) != null) { 2058 // The focus may have sat on a collapsed pane; the wall
2030 restore_ok = true; 2059 // owes the tile it ends up with a `setFocus`.
2031 // The entry tile is the one the user is already typing 2060 if ((shared.sel >= live or !present[shared.sel]) and presentCount(present[0..live]) > 0)
2032 // into; a saved focus record must not move them off it. 2061 setFocus(tiles[0..live], &shared, wall_layout.firstPresent(present[0..live]) orelse 0);
2033 const to = if (has_entry) shared.sel else (saved_focus orelse shared.sel); 2062 wall_layout.relayout(w, shared.sel);
2034 focusAnswer(w, true, to);
2035 } 2063 }
2036 } 2064 }
2037 } 2065 }
2038 if (restore_tried and !restore_ok and !oriented and presentCount(present[0..live]) > 1) { 2066 if (!seeded and !oriented and presentCount(present[0..live]) > 1) {
2039 oriented = true; 2067 oriented = true;
2040 shared.paint_mu.lock(); 2068 shared.paint_mu.lock();
2041 shared.tree.setRootOrient(wall_layout.rootOrient(shared.size)); 2069 shared.tree.setRootOrient(wall_layout.rootOrient(shared.size));
@@ -2047,12 +2075,12 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2047 // list of machines. Once, so the Esc that closes it leaves the 2075 // list of machines. Once, so the Esc that closes it leaves the
2048 // one-line empty-wall text standing. 2076 // one-line empty-wall text standing.
2049 var picker_opened = false; 2077 var picker_opened = false;
2050 // `restore_tried` is the gate, not emptiness alone: it is the moment 2078 // `settled` is the gate, not emptiness alone: it is the moment
2051 // every opening host has answered (or 2s), and a wall still dialling 2079 // every opening host has answered (or 2s), and a wall still dialling
2052 // is not known to be empty. A popup flashed over a wall that is 2080 // is not known to be empty. A popup flashed over a wall that is
2053 // about to have tiles is one the user never asked for. 2081 // about to have tiles is one the user never asked for.
2054 switch (picker_auto.step( 2082 switch (picker_auto.step(
2055 shared.is_tty and restore_tried, 2083 shared.is_tty and settled,
2056 presentCount(present[0..live]) == 0, 2084 presentCount(present[0..live]) == 0,
2057 input.prefix.picking, 2085 input.prefix.picking,
2058 input.prefix.prompting, 2086 input.prefix.prompting,
test/e2e_09_hosts.sh
Old New
@@ -511,11 +511,14 @@ ok "x on a wall ends the focused tile's session and no other, and that tile leav
511 # 511 #
512 # Two states of one host, each with its own witness: 512 # Two states of one host, each with its own witness:
513 # 513 #
514 # * down: NOTHING. The wall shows live sessions and nothing else, so a 514 # * down: NOTHING on the SETTLED screen. The wall shows live sessions
515 # daemon that is not answering contributes no bar of its own and not 515 # and nothing else — the layout seed may paint a remembered pane as
516 # one dead tile per session it used to have. Daemon 1 is up throughout 516 # [waiting] for the poll round it takes every opening host to answer,
517 # and its tiles are what the leg waits on, so "the poll has not landed 517 # and the settle collapses it, so the claim is judged on the final
518 # yet" and "daemon 2 has no bar" cannot be confused. 518 # grid (render), not on every transient frame the capture holds.
519 # Daemon 1 is up throughout and its tiles are what the leg waits on,
520 # so "the poll has not landed yet" and "daemon 2 has no bar" cannot
521 # be confused.
519 # * back: its own default session and nothing else. `c`, which was live 522 # * back: its own default session and nothing else. `c`, which was live
520 # when the daemon died, is not there — asked of `mux d stats` on the 523 # when the daemon died, is not there — asked of `mux d stats` on the
521 # real daemon, not of the wall that would be reporting on its own 524 # real daemon, not of the wall that would be reporting on its own
@@ -534,12 +537,15 @@ RC=$?
534 set -e 537 set -e
535 [ "$RC" -eq 0 ] || { 538 [ "$RC" -eq 0 ] || {
536 echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; } 539 echo "e2e FAIL: hosts restart: the down-host wall exited $RC:"; cat "$OUT.hdpc"; exit 1; }
537 # The down daemon's spelling, ANYWHERE on the screen: a placeholder bar 540 # The down daemon's spelling, anywhere on the SETTLED screen: a lingering
538 # would carry `--sock $SOCKH2` and a dead tile `--sock $SOCKH2#c`, so one 541 # bar would carry `--sock $SOCKH2` and a dead tile `--sock $SOCKH2#c`, so
539 # count of the host's own path catches both. The `expect` above is the 542 # one count of the host's own path catches both. The `expect` above is
540 # control: it waited on daemon 1's bar, so a capture this grep finds 543 # the control: it waited on daemon 1's bar, so a grid this grep finds
541 # nothing in is a PAINTED screen and not a blank one. 544 # nothing in is a PAINTED screen and not a blank one. The settle above
542 HDN=$(grep -c -- "--sock $SOCKH2" "$OUT.hdcap" || true) 545 # (past every opening host's first answer) is what makes the final grid
546 # the settled one.
547 "$RENDER" --cols 80 --rows 44 < "$OUT.hdcap" > "$OUT.hdcap.final"
548 HDN=$(grep -c -- "--sock $SOCKH2" "$OUT.hdcap.final" || true)
543 [ "$HDN" -eq 0 ] || { 549 [ "$HDN" -eq 0 ] || {
544 echo "e2e FAIL: hosts restart: a down daemon named itself on the wall," 550 echo "e2e FAIL: hosts restart: a down daemon named itself on the wall,"
545 echo " which shows live sessions and nothing else:" 551 echo " which shows live sessions and nothing else:"
@@ -565,7 +571,8 @@ set -e
565 grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrcap" || { 571 grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrcap" || {
566 echo "e2e FAIL: hosts restart: the reborn daemon's own session never became a tile:" 572 echo "e2e FAIL: hosts restart: the reborn daemon's own session never became a tile:"
567 cat "$OUT.hrpc"; exit 1; } 573 cat "$OUT.hrpc"; exit 1; }
568 if grep -q -- "--sock $SOCKH2#c" "$OUT.hrcap"; then 574 "$RENDER" --cols 80 --rows 44 < "$OUT.hrcap" > "$OUT.hrcap.final"
575 if grep -q -- "--sock $SOCKH2#c" "$OUT.hrcap.final"; then
569 echo "e2e FAIL: hosts restart: the wall resurrected the session the dead daemon held:" 576 echo "e2e FAIL: hosts restart: the wall resurrected the session the dead daemon held:"
570 cat "$OUT.hrpc"; exit 1 577 cat "$OUT.hrpc"; exit 1
571 fi 578 fi