a73x

06756d09

feat: a silent host keeps its panes, and only its own list may take one

a73x   2026-08-30 18:16

Commit message
feat: a silent host keeps its panes, and only its own list may take one

A poll that fails is not evidence. It dresses the host's saved panes
`unreachable` and leaves them standing; only a host's own reachable list
binds a pane or, by not naming it, takes it. The settle collapses
nothing now and gates the picker's auto-open alone: an eight-pane setup
across four machines is not worth one quiet box.

`x` on a pending pane closes the tile locally, like every other tile
that has never come up - a pane the user closed that turns out to exist
births back on the next list, and a pane on a dark host was otherwise
unremovable for the wall's life.

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

CLAUDE.md
Old New
@@ -144,7 +144,10 @@ own. Test fixtures in `test/`:
144 anyone appears and an exit disappears, and `n`/`p`/digits walk the one 144 anyone appears and an exit disappears, and `n`/`p`/digits walk the one
145 list. A host with no live session contributes NOTHING — the wall shows 145 list. A host with no live session contributes NOTHING — the wall shows
146 sessions and nothing else, and a wall with none says so; tiles 146 sessions and nothing else, and a wall with none says so; tiles
147 ride out a blip rather than vanishing. `--via` and `mux a` record no host. 147 ride out a blip rather than vanishing. Only a host's OWN list may take a
148 pane: a saved pane on a host that answers nothing stays, wearing
149 `unreachable`, for as long as the box is dark — an eight-pane setup is
150 not worth one quiet machine. `--via` and `mux a` record no host.
148 Nothing re-creates a session: a daemon restart heals to what that daemon 151 Nothing re-creates a session: a daemon restart heals to what that daemon
149 now has, which for a fresh daemon is session `0`. 152 now has, which for a fresh daemon is session `0`.
150 `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints 153 `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints
@@ -181,9 +184,10 @@ own. Test fixtures in `test/`:
181 a masterless pty across the exec. A daemon with no arm answers nothing 184 a masterless pty across the exec. A daemon with no arm answers nothing
182 and the client banners `[daemon too old to end a session]`. The tile 185 and the client banners `[daemon too old to end a session]`. The tile
183 leaves on the next list, not on the keypress — EXCEPT a tile that has 186 leaves on the next list, not on the keypress — EXCEPT a tile that has
184 never come up, which names no session to end: `x` there closes the tile 187 never come up, a saved pane on a dark host included: `x` there closes the
185 locally, because a pump parked in `dial` reads no ask and birthing onto 188 tile locally, because a pump parked in `dial` reads no ask, a pending pane
186 an `unreachable` row is a designed path. 189 has no pump at all, and birthing onto an `unreachable` row is a designed
190 path.
187 - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a 191 - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a
188 session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code 192 session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code
189 reaches that shell's own clients as `exit_status`, and an emptied daemon 193 reaches that shell's own clients as `exit_status`, and an emptied daemon
src/tui/wall_host.zig
Old New
@@ -313,6 +313,22 @@ pub fn applyReadyLists(w: Wall) bool {
313 return news; 313 return news;
314 } 314 }
315 315
316 /// A silent host DRESSES its saved panes rather than taking them: losing an
317 /// eight-pane setup to one quiet box is worse than reading the word.
318 fn dressSilent(w: Wall, hi: usize) void {
319 w.shared.paint_mu.lock();
320 defer w.shared.paint_mu.unlock();
321 var dressed = false;
322 for (w.liveTiles(), w.livePresent()) |*t, p| {
323 if (!p or !t.pending or !ownedBy(t, hi) or t.state == .@"unreachable") continue;
324 t.state = .@"unreachable";
325 dressed = true;
326 }
327 // A pending pane has no pump, so no doorbell reaches its bar: the
328 // keyboard is the only thread that can repaint one.
329 if (dressed and w.shared.label_rows != 0) wv.paintDeadBarsLocked(w.liveTiles());
330 }
331
316 /// One host's list, applied to the wall. The keyboard thread only: it is 332 /// One host's list, applied to the wall. The keyboard thread only: it is
317 /// the single writer of the tile array and the layout tree. 333 /// the single writer of the tile array and the layout tree.
318 pub fn applyHostList(w: Wall, hi: usize) void { 334 pub fn applyHostList(w: Wall, hi: usize) void {
@@ -379,7 +395,7 @@ pub fn applyHostList(w: Wall, hi: usize) void {
379 var buf: [48]u8 = undefined; 395 var buf: [48]u8 = undefined;
380 wv.setNoticeIdle(w.shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]"); 396 wv.setNoticeIdle(w.shared, std.fmt.bufPrint(&buf, "[+{d} not shown]", .{unplaced}) catch "[not shown]");
381 } 397 }
382 } 398 } else dressSilent(w, hi);
383 if ((!had_focus or w.shared.sel >= w.live.* or !w.present[w.shared.sel]) and 399 if ((!had_focus or w.shared.sel >= w.live.* or !w.present[w.shared.sel]) and
384 wv.presentCount(w.livePresent()) > 0) 400 wv.presentCount(w.livePresent()) > 0)
385 wv.setFocus(w.liveTiles(), w.shared, wall_layout.firstPresent(w.livePresent()) orelse 0); 401 wv.setFocus(w.liveTiles(), w.shared, wall_layout.firstPresent(w.livePresent()) orelse 0);
src/tui/wall_layout.zig
Old New
@@ -201,19 +201,6 @@ pub fn seedSidecar(alloc: std.mem.Allocator, table: []const Host, shared: *Share
201 return seedLayout(alloc, table, shared, bytes, entry_spelling); 201 return seedLayout(alloc, table, shared, bytes, entry_spelling);
202 } 202 }
203 203
204 /// Every pane still waiting when the wall settles goes at once, in the
205 /// ONE re-cut the caller owes when this answers true: after it, no tile
206 /// on the wall came from the file.
207 pub fn collapsePending(w: Wall) bool {
208 var took = false;
209 for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
210 if (!p or !t.pending) continue;
211 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, i, null);
212 took = true;
213 }
214 return took;
215 }
216
217 /// Every failure is the same null: a caller degrades the same way whatever 204 /// Every failure is the same null: a caller degrades the same way whatever
218 /// kept the layout from arriving. 205 /// kept the layout from arriving.
219 pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 { 206 pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 {
src/tui/wall_test_host.zig
Old New
@@ -756,6 +756,46 @@ test "applyHostList: binding a saved pane moves no rect and re-cuts nothing" {
756 try std.testing.expect(tiles[2].pending); 756 try std.testing.expect(tiles[2].pending);
757 } 757 }
758 758
759 test "a silent host's saved panes survive its failed poll and wear unreachable" {
760 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
761 defer arena.deinit();
762 const alloc = arena.allocator();
763 var shared: Shared = undefined;
764 fixture.stoppedWall(alloc, &shared);
765 defer shared.tree.deinit();
766 var tiles: [4]Tile = undefined;
767 var present = [_]bool{false} ** 4;
768 var live: usize = 0;
769 try seedBench(alloc, &tiles, &present, &live, &shared);
770 defer fixture.endPumps(tiles[0..live]);
771 var table = [_]Host{
772 fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
773 fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
774 };
775 const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
776 // Off-origin rects, so a pane that IS taken shows up as a re-cut.
777 wall_layout.relayout(w, 0);
778 var rects: [3]@TypeOf(tiles[0].rect) = undefined;
779 for (tiles[0..3], 0..) |t, i| rects[i] = t.rect;
780
781 // Host 0 is dark; twice, because a pane that rides out one poll and
782 // goes on the next has not ridden anything out.
783 table[0].poll.reachable.store(false, .release);
784 for (0..2) |_| wall_host.applyHostList(w, 0);
785
786 for (tiles[0..3], present[0..3], 0..) |t, p, i| {
787 if (!p) return error.SilentHostTookThePanesTheUserSaved;
788 if (!t.pending) return error.SilenceBoundAPaneNoHostNamed;
789 if (!std.meta.eql(rects[i], t.rect)) return error.SilenceRecutTheWall;
790 }
791 // Only host 0's panes are dressed: a quiet box says nothing about
792 // anyone else's, and the word is the picker row's own.
793 if (tiles[0].state != .@"unreachable" or tiles[1].state != .@"unreachable")
794 return error.DarkHostsPanesDoNotSayWhichSetupIsDark;
795 if (tiles[2].state != .waiting) return error.OneHostsSilenceSpreadToAnother;
796 try std.testing.expectEqualStrings("unreachable", tiles[0].state.word());
797 }
798
759 test "bindTile: the pane under the saved focus claims the terminal when it binds, its neighbour does not" { 799 test "bindTile: the pane under the saved focus claims the terminal when it binds, its neighbour does not" {
760 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 800 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
761 defer arena.deinit(); 801 defer arena.deinit();
src/tui/wall_test_layout.zig
Old New
@@ -962,83 +962,6 @@ test "seed: the entry tile's own pane is not pending; a sidecar that does not kn
962 try std.testing.expectEqual(@as(usize, 4), shared.tree.count()); 962 try std.testing.expectEqual(@as(usize, 4), shared.tree.count());
963 } 963 }
964 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" { 965 test "a wall left before its hosts answered saves the shape it was given" {
1043 const alloc = std.testing.allocator; 966 const alloc = std.testing.allocator;
1044 var shared: Shared = undefined; 967 var shared: Shared = undefined;
src/tui/wall_test_wall.zig
Old New
@@ -1231,16 +1231,19 @@ test "State.waiting has a word of its own, and truncation drops the label before
1231 if (bar.len > 30) return error.WaitingBarOverranTheTerminal; 1231 if (bar.len > 30) return error.WaitingBarOverranTheTerminal;
1232 } 1232 }
1233 1233
1234 test "endKey: x on a pane that has not bound yet is refused - a drop would last one poll" { 1234 test "endKey: x on a pane that has not bound yet closes it locally, silent host or not" {
1235 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 1235 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1236 var tiles = fixture.diffFixture(&shared); 1236 var tiles = fixture.diffFixture(&shared);
1237 tiles[1].pending = true; 1237 tiles[1].pending = true;
1238 tiles[1].alive.store(false, .release); 1238 tiles[1].alive.store(false, .release);
1239 tiles[1].state = .waiting; 1239 tiles[1].state = .waiting;
1240 // The refusal, not the local drop the same fields would earn a tile 1240 // A pending pane names no session to end, so `x` takes the TILE. The
1241 // whose dial never landed: this pane's session is real, and the 1241 // pane the user closed that turns out to exist births back on the next
1242 // daemon's next list would birth a dropped tile straight back. 1242 // list; a pane on a silent host is otherwise unremovable for the wall's
1243 try std.testing.expectEqual(EndKey.waiting, wv.endKey(&tiles[1], 0)); 1243 // life, which is what makes `unreachable` a place to press a key.
1244 if (wv.endKey(&tiles[1], 0) != .drop) return error.WaitingPaneRefusedTheKeyThatCloseIt;
1245 tiles[1].state = .@"unreachable";
1246 if (wv.endKey(&tiles[1], 0) != .drop) return error.SilentHostsPaneCannotBeClosed;
1244 tiles[1].pending = false; 1247 tiles[1].pending = false;
1245 try std.testing.expectEqual(EndKey.drop, wv.endKey(&tiles[1], 0)); 1248 try std.testing.expectEqual(EndKey.drop, wv.endKey(&tiles[1], 0));
1246 } 1249 }
src/tui/wallview.zig
Old New
@@ -62,6 +62,9 @@ pub const State = enum {
62 /// A pending pane: nothing has dialed and nothing will until the 62 /// A pending pane: nothing has dialed and nothing will until the
63 /// host's own list names its session. `.connecting` would be a lie. 63 /// host's own list names its session. `.connecting` would be a lie.
64 waiting, 64 waiting,
65 /// A pending pane whose host's poll failed: the picker row's word,
66 /// worn on the pane, so the wall itself says which setup is dark.
67 @"unreachable",
65 connecting, 68 connecting,
66 up, 69 up,
67 reconnecting, 70 reconnecting,
@@ -75,6 +78,7 @@ pub const State = enum {
75 pub fn word(s: State) []const u8 { 78 pub fn word(s: State) []const u8 {
76 return switch (s) { 79 return switch (s) {
77 .waiting => "waiting", 80 .waiting => "waiting",
81 .@"unreachable" => "unreachable",
78 .connecting => "connecting", 82 .connecting => "connecting",
79 .up => "up", 83 .up => "up",
80 .reconnecting => "reconnecting", 84 .reconnecting => "reconnecting",
@@ -536,9 +540,6 @@ pub const EndKey = union(enum) {
536 ask: client.SwitchIntent, 540 ask: client.SwitchIntent,
537 /// No session yet: the TILE goes, locally. 541 /// No session yet: the TILE goes, locally.
538 drop, 542 drop,
539 /// A pending pane. Its session is real — the daemon's next list would
540 /// birth a dropped tile straight back — and its bind is a poll away.
541 waiting,
542 /// Nothing to do, and a sentence owed. 543 /// Nothing to do, and a sentence owed.
543 none, 544 none,
544 }; 545 };
@@ -552,7 +553,8 @@ pub fn endKey(t: *Tile, now: i64) EndKey {
552 // back — but a tile whose host no poll can reach (a declined prompt, a 553 // back — but a tile whose host no poll can reach (a declined prompt, a
553 // box that is down) was otherwise on the wall for the wall's life with 554 // box that is down) was otherwise on the wall for the wall's life with
554 // no key that could remove it. 555 // no key that could remove it.
555 if (t.pending) return .waiting; 556 // A pending pane has no pump at all, so it goes the same way — and a
557 // pane closed that turns out to exist births back on the next list.
556 if (!t.alive.load(.acquire)) return .drop; 558 if (!t.alive.load(.acquire)) return .drop;
557 // A pump still on its FIRST dial is alive and parked in `dial`, which 559 // A pump still on its FIRST dial is alive and parked in `dial`, which
558 // polls `gone` and never `ask`: the key would be swallowed for as long 560 // polls `gone` and never `ask`: the key would be swallowed for as long
@@ -1918,9 +1920,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1918 var exit_code: u8 = 0; 1920 var exit_code: u8 = 0;
1919 var exit_msg: ?[]const u8 = null; 1921 var exit_msg: ?[]const u8 = null;
1920 // The wall SETTLES once: every opening host has answered, or 2s - the 1922 // The wall SETTLES once: every opening host has answered, or 2s - the
1921 // poll's own dial budget - so a host that never answers cannot hold 1923 // poll's own dial budget. It gates the picker's auto-open and nothing
1922 // the seeded panes on screen forever. The settle owes one re-cut, for 1924 // else; only a host's own list may take a pane (`dressSilent`).
1923 // the panes nobody claimed.
1924 const settle_due: i64 = std.time.milliTimestamp() + 2000; 1925 const settle_due: i64 = std.time.milliTimestamp() + 2000;
1925 var settled = false; 1926 var settled = false;
1926 // Only the hosts the wall OPENED with are waited for. One added in the 1927 // Only the hosts the wall OPENED with are waited for. One added in the
@@ -2056,16 +2057,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2056 break; 2057 break;
2057 } 2058 }
2058 } 2059 }
2059 if (all_reported or std.time.milliTimestamp() >= settle_due) { 2060 if (all_reported or std.time.milliTimestamp() >= settle_due)
2060 settled = true; 2061 settled = true;
2061 if (wall_layout.collapsePending(w)) {
2062 // The focus may have sat on a collapsed pane; the wall
2063 // owes the tile it ends up with a `setFocus`.
2064 if ((shared.sel >= live or !present[shared.sel]) and presentCount(present[0..live]) > 0)
2065 setFocus(tiles[0..live], &shared, wall_layout.firstPresent(present[0..live]) orelse 0);
2066 wall_layout.relayout(w, shared.sel);
2067 }
2068 }
2069 } 2062 }
2070 if (!seeded and !oriented and presentCount(present[0..live]) > 1) { 2063 if (!seeded and !oriented and presentCount(present[0..live]) > 1) {
2071 oriented = true; 2064 oriented = true;
@@ -2443,11 +2436,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2443 "[nothing attached there yet - tile closed]"); 2436 "[nothing attached there yet - tile closed]");
2444 wall_layout.relayout(w, shared.sel); 2437 wall_layout.relayout(w, shared.sel);
2445 }, 2438 },
2446 .waiting => {
2447 asked = true;
2448 setNotice(&shared, "[waiting for its host - nothing attached to end yet]");
2449 showRefusal(tiles[0..live], &shared, z);
2450 },
2451 .none => {}, 2439 .none => {},
2452 } 2440 }
2453 if (!asked) { 2441 if (!asked) {
test/e2e_09_hosts.sh
Old New
@@ -511,14 +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 on the SETTLED screen. The wall shows live sessions 514 # * down: its remembered panes STAY, wearing [unreachable], and not one
515 # and nothing else — the layout seed may paint a remembered pane as 515 # of them is [up]. Only a host's own list may take a pane, and a quiet
516 # [waiting] for the poll round it takes every opening host to answer, 516 # box has no list — losing an eight-pane setup to one dark machine is
517 # and the settle collapses it, so the claim is judged on the final 517 # worse than reading the word until the user decides. Judged on the
518 # grid (render), not on every transient frame the capture holds. 518 # final grid (render) after the settle, not on transient frames.
519 # Daemon 1 is up throughout and its tiles are what the leg waits on, 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 520 # so "the poll has not landed yet" and "daemon 2 is dark" cannot be
521 # be confused. 521 # confused.
522 # * 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
523 # 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
524 # 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
@@ -537,19 +537,23 @@ RC=$?
537 set -e 537 set -e
538 [ "$RC" -eq 0 ] || { 538 [ "$RC" -eq 0 ] || {
539 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; }
540 # The down daemon's spelling, anywhere on the SETTLED screen: a lingering 540 # The down daemon's two remembered panes on the SETTLED screen. The
541 # bar would carry `--sock $SOCKH2` and a dead tile `--sock $SOCKH2#c`, so 541 # `expect` above is the control: it waited on daemon 1's bar, so this grid
542 # one count of the host's own path catches both. The `expect` above is 542 # is a PAINTED screen and not a blank one, and the settle past every
543 # the control: it waited on daemon 1's bar, so a grid this grep finds 543 # opening host's first answer is what makes it the settled one.
544 # nothing in is a PAINTED screen and not a blank one. The settle above
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" 544 "$RENDER" --cols 80 --rows 44 < "$OUT.hdcap" > "$OUT.hdcap.final"
548 HDN=$(grep -c -- "--sock $SOCKH2" "$OUT.hdcap.final" || true) 545 for _hd in "$SOCKH2#0" "$SOCKH2#c"; do
549 [ "$HDN" -eq 0 ] || { 546 grep -q -- "--sock $_hd \[unreachable\]" "$OUT.hdcap.final" || {
550 echo "e2e FAIL: hosts restart: a down daemon named itself on the wall," 547 echo "e2e FAIL: hosts restart: the pane the user saved on a dark daemon is"
551 echo " which shows live sessions and nothing else:" 548 echo " gone, or does not say the machine is: expected $_hd [unreachable]"
552 cat "$OUT.hdpc"; exit 1; } 549 cat "$OUT.hdcap.final"; exit 1; }
550 done
551 # ...and none of them claims to be attached: nothing dialled a daemon that
552 # is not there, so no pane on it may read [up].
553 if grep -- "--sock $SOCKH2" "$OUT.hdcap.final" | grep -q '\[up\]'; then
554 echo "e2e FAIL: hosts restart: a pane on a stopped daemon read [up]:"
555 cat "$OUT.hdcap.final"; exit 1
556 fi
553 557
554 start_daemon "$SOCKH2" "$OUT.h2b.d" "hosts daemon 2 never came back" --shell /bin/sh 558 start_daemon "$SOCKH2" "$OUT.h2b.d" "hosts daemon 2 never came back" --shell /bin/sh
555 DH2PID=$DPID 559 DH2PID=$DPID
@@ -581,7 +585,7 @@ fi
581 [ "$("$MUX" d stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "1" ] || { 585 [ "$("$MUX" d stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "1" ] || {
582 echo "e2e FAIL: hosts restart: the reborn daemon holds more than its own default session:" 586 echo "e2e FAIL: hosts restart: the reborn daemon holds more than its own default session:"
583 "$MUX" d stats --sock "$SOCKH2"; exit 1; } 587 "$MUX" d stats --sock "$SOCKH2"; exit 1; }
584 ok "a daemon that goes down leaves no bar behind, and comes back re-creating nothing" 588 ok "a daemon that goes down keeps its panes wearing unreachable, and comes back re-creating nothing"
585 589
586 # ---- hosts rm takes a daemon off the wall and ends nothing -------------- 590 # ---- hosts rm takes a daemon off the wall and ends nothing --------------
587 # 591 #