a73x

005be213

feat: an unreachable pane says it is retrying, and Enter brings it back

a73x   2026-09-02 08:41

Commit message
feat: an unreachable pane says it is retrying, and Enter brings it back

The two pending states read the same because the wall was silent about
one: focusing a gone pane popped a notice, an unreachable pane popped
nothing, so Enter (the gone reflex) fell into nowhere. Now both narrate
and both answer Enter through ONE primitive.

revivePane({creates, asked}) is the single wake every pending pane goes
through — a poll bind is {false,false}, a gone-pane Enter {true,false},
an unreachable-pane Enter {true,true} — and the struct is the whole of
how they differ. `asked` starts the remote daemon (`mux d endpoint
--start`), which is the one thing a rebooted box needs and a re-poll of
the read-only `endpoint` could never do: an unreachable pane's daemon
may be dead, so Enter boots it and then re-creates the session in place.

Labels follow: gone -> 'session ended', unreachable gains a retrying
notice. The e2e bar assertions move with the words.

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

src/tui/wall_test_host.zig
Old New
@@ -1013,31 +1013,77 @@ test "setFocus: landing on a gone pane says what the two keys are" {
1013 try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]); 1013 try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]);
1014 } 1014 }
1015 1015
1016 test "armRevive: Enter turns a gone pane into a creating attach in the same rect" { 1016 test "setFocus: landing on an unreachable pane says it is retrying and Enter retries" {
1017 // The pane the wall used to be SILENT about: no notice on focus, so the
1018 // user pressed Enter (the gone reflex) into nothing. It now says the
1019 // host is retrying and that Enter hurries it, the mirror of the gone
1020 // notice one state over.
1017 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1021 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1018 defer arena.deinit(); 1022 defer arena.deinit();
1019 const alloc = arena.allocator(); 1023 const alloc = arena.allocator();
1020 var shared: Shared = undefined; 1024 var shared: Shared = undefined;
1021 fixture.stoppedWall(alloc, &shared); 1025 fixture.stoppedWall(alloc, &shared);
1022 var tiles: [2]Tile = undefined; 1026 var tiles: [4]Tile = undefined;
1023 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 0, 0); 1027 var present = [_]bool{false} ** 4;
1024 tiles[0].state = .gone; 1028 var live: usize = 0;
1029 defer fixture.endPumps(tiles[0..live]);
1030 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
1031 try wv.seedTile(&tiles[1], .{ .target = .{ .sock = "/tmp/dark.sock" }, .label = "dark#b", .session = "b" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 1, 1);
1032 present[0] = true;
1033 present[1] = true;
1034 live = 2;
1035 tiles[1].state = .@"unreachable";
1025 1036
1026 wv.armRevive(&tiles[0]); 1037 wv.setFocus(tiles[0..live], &shared, 1);
1038 try std.testing.expectEqualStrings(wv.unreachable_notice, shared.notice[0..shared.notice_len]);
1039 }
1027 1040
1028 // The tile is no longer a placeholder: it CREATES (the daemon has no 1041 test "armPane: the three wakes differ only in create and start" {
1029 // such session - that is what gone means), and it does so exactly 1042 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1030 // where it stood. 1043 defer arena.deinit();
1031 try std.testing.expect(!tiles[0].pending); 1044 const alloc = arena.allocator();
1032 try std.testing.expect(tiles[0].creates); 1045 var shared: Shared = undefined;
1033 try std.testing.expectEqual(wv.State.connecting, tiles[0].state); 1046 fixture.stoppedWall(alloc, &shared);
1034 try std.testing.expect(tiles[0].alive.load(.acquire)); 1047
1035 try std.testing.expect(!tiles[0].pump_done.load(.acquire)); 1048 // A gone pane on a HAND host, so `asked` has somewhere to land. Gone
1036 try std.testing.expectEqual(@as(u16, 41), tiles[0].rect.left); 1049 // means the host is up, so Enter re-creates ({create, no start}) and
1037 try std.testing.expectEqualStrings("a", tiles[0].r.session); 1050 // does so exactly where the pane stood.
1051 var gone: Tile = undefined;
1052 const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
1053 try wv.seedTile(&gone, .{ .target = hand, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 0, 0);
1054 gone.state = .gone;
1055 wv.armPane(&gone, .{ .creates = true, .asked = false });
1056 try std.testing.expect(!gone.pending);
1057 try std.testing.expect(gone.creates);
1058 try std.testing.expect(!gone.r.target.hand.asked);
1059 try std.testing.expectEqual(wv.State.connecting, gone.state);
1060 try std.testing.expect(gone.alive.load(.acquire));
1061 try std.testing.expect(!gone.pump_done.load(.acquire));
1062 try std.testing.expectEqual(@as(u16, 41), gone.rect.left);
1063 try std.testing.expectEqualStrings("a", gone.r.session);
1064
1065 // An unreachable pane on the same kind of host: its daemon may be dark,
1066 // so Enter STARTS it first ({create, start}) — the one field that
1067 // differs from the gone wake.
1068 var unreach: Tile = undefined;
1069 const hand2 = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
1070 try wv.seedTile(&unreach, .{ .target = hand2, .label = "box#b", .session = "b" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 1, 0);
1071 unreach.state = .@"unreachable";
1072 wv.armPane(&unreach, .{ .creates = true, .asked = true });
1073 try std.testing.expect(unreach.creates);
1074 try std.testing.expect(unreach.r.target.hand.asked);
1075
1076 // A poll bind: attach only, neither create nor start.
1077 var bind: Tile = undefined;
1078 const hand3 = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
1079 try wv.seedTile(&bind, .{ .target = hand3, .label = "box#c", .session = "c" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 2, 0);
1080 bind.state = .waiting;
1081 wv.armPane(&bind, .{ .creates = false, .asked = false });
1082 try std.testing.expect(!bind.creates);
1083 try std.testing.expect(!bind.r.target.hand.asked);
1038 } 1084 }
1039 1085
1040 test "keysToFocused: a gone pane eats every byte except Enter" { 1086 test "keysToFocused: a gone pane eats every byte except Enter, which revives without starting" {
1041 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 1087 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1042 defer arena.deinit(); 1088 defer arena.deinit();
1043 const alloc = arena.allocator(); 1089 const alloc = arena.allocator();
@@ -1048,7 +1094,8 @@ test "keysToFocused: a gone pane eats every byte except Enter" {
1048 // `shared` is this frame too: the test may not return until that thread 1094 // `shared` is this frame too: the test may not return until that thread
1049 // has stored `alive = false`, or the next test's stack is what it reads. 1095 // has stored `alive = false`, or the next test's stack is what it reads.
1050 defer fixture.endPumps(tiles[0..1]); 1096 defer fixture.endPumps(tiles[0..1]);
1051 try wv.seedTile(&tiles[0], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0); 1097 const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
1098 try wv.seedTile(&tiles[0], .{ .target = hand, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
1052 tiles[0].state = .gone; 1099 tiles[0].state = .gone;
1053 1100
1054 // Ordinary typing reaches no session and queues nowhere. 1101 // Ordinary typing reaches no session and queues nowhere.
@@ -1056,10 +1103,40 @@ test "keysToFocused: a gone pane eats every byte except Enter" {
1056 try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len); 1103 try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len);
1057 try std.testing.expect(tiles[0].pending); 1104 try std.testing.expect(tiles[0].pending);
1058 1105
1059 // Enter revives. (`running` is false in this fixture, so the spawned 1106 // Enter revives, and does NOT start the daemon — a gone host is up.
1060 // pump exits at its gate; the transitions are armRevive's, pinned above.) 1107 // (`running` is false in this fixture, so the spawned pump exits at its
1108 // gate; the transitions are armPane's, pinned above.)
1061 wv.keysToFocused(&tiles[0], "\r"); 1109 wv.keysToFocused(&tiles[0], "\r");
1062 try std.testing.expect(!tiles[0].pending); 1110 try std.testing.expect(!tiles[0].pending);
1111 try std.testing.expect(!tiles[0].r.target.hand.asked);
1112 tiles[0].pump_done.store(true, .release);
1113 }
1114
1115 test "keysToFocused: Enter on an unreachable pane revives it AND starts the daemon" {
1116 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
1117 defer arena.deinit();
1118 const alloc = arena.allocator();
1119 var shared: Shared = undefined;
1120 fixture.stoppedWall(alloc, &shared);
1121 var tiles: [2]Tile = undefined;
1122 defer fixture.endPumps(tiles[0..1]);
1123 // A dark host's saved pane. The old behaviour re-polled and could never
1124 // start a rebooted box's daemon; Enter now wakes a pump that dials
1125 // `--start`, the one thing that boots it.
1126 const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
1127 try wv.seedTile(&tiles[0], .{ .target = hand, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
1128 tiles[0].state = .@"unreachable";
1129
1130 // Ordinary typing is not the verb: the pane stays a pending drawing.
1131 wv.keysToFocused(&tiles[0], "ls -la");
1132 try std.testing.expect(tiles[0].pending);
1133 try std.testing.expect(!tiles[0].r.target.hand.asked);
1134
1135 // Enter wakes it with the start armed.
1136 wv.keysToFocused(&tiles[0], "\r");
1137 try std.testing.expect(!tiles[0].pending);
1138 try std.testing.expect(tiles[0].creates);
1139 try std.testing.expect(tiles[0].r.target.hand.asked);
1063 tiles[0].pump_done.store(true, .release); 1140 tiles[0].pump_done.store(true, .release);
1064 } 1141 }
1065 1142
src/tui/wallview.zig
Old New
@@ -68,7 +68,7 @@ pub const State = enum {
68 return switch (s) { 68 return switch (s) {
69 .waiting => "waiting", 69 .waiting => "waiting",
70 .@"unreachable" => "unreachable", 70 .@"unreachable" => "unreachable",
71 .gone => "gone", 71 .gone => "session ended",
72 .connecting => "connecting", 72 .connecting => "connecting",
73 .up => "up", 73 .up => "up",
74 .reconnecting => "reconnecting", 74 .reconnecting => "reconnecting",
@@ -730,7 +730,13 @@ pub fn setNoticeIdle(shared: *Shared, text: []const u8) void {
730 730
731 /// What a gone pane can do, said where the user is looking. Under the 731 /// What a gone pane can do, said where the user is looking. Under the
732 /// 96-byte notice buffer; `setNoticeIdle` truncates silently past it. 732 /// 96-byte notice buffer; `setNoticeIdle` truncates silently past it.
733 pub const gone_notice: []const u8 = "[session gone - Enter starts it anew, x closes]"; 733 pub const gone_notice: []const u8 = "[session ended - Enter restarts it, x closes]";
734
735 /// The same, for an unreachable pane — the one the wall was silent about, so
736 /// the user pressed Enter (the gone reflex) and nothing answered. It says
737 /// the host is retrying on its own AND that Enter hurries it, because both
738 /// are true: the shared poller retries every cycle, and Enter pokes it now.
739 pub const unreachable_notice: []const u8 = "[host unreachable, retrying - Enter retries now, x closes]";
734 740
735 /// Take it, once. 741 /// Take it, once.
736 pub fn takeNotice(shared: *Shared, out: []u8) []const u8 { 742 pub fn takeNotice(shared: *Shared, out: []u8) []const u8 {
@@ -772,18 +778,22 @@ pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
772 // Scoped rather than deferred to the end of the function: the notice at 778 // Scoped rather than deferred to the end of the function: the notice at
773 // the tail goes through `setNoticeIdle`, which takes `paint_mu` itself, 779 // the tail goes through `setNoticeIdle`, which takes `paint_mu` itself,
774 // and the mutex is not reentrant. 780 // and the mutex is not reentrant.
775 var landed_gone = false; 781 var landed: State = .waiting;
776 { 782 {
777 shared.paint_mu.lock(); 783 shared.paint_mu.lock();
778 defer shared.paint_mu.unlock(); 784 defer shared.paint_mu.unlock();
779 moveFocusLocked(tiles, shared, prev, next); 785 moveFocusLocked(tiles, shared, prev, next);
780 // Read HERE because `state` is a `paint_mu` field a pump writes 786 // Read HERE because `state` is a `paint_mu` field a pump writes
781 // through `paintLabel`, and the keyboard is not its owner. 787 // through `paintLabel`, and the keyboard is not its owner.
782 landed_gone = next < tiles.len and tiles[next].state == .gone; 788 if (next < tiles.len) landed = tiles[next].state;
783 } 789 }
784 // A gone pane's bar says "gone"; this says what to do about it. Idle 790 // The pane's bar names the state; this says what to do about it. Idle
785 // only: a refusal or an exit sentence already on screen outranks a hint. 791 // only: a refusal or an exit sentence already on screen outranks a hint.
786 if (landed_gone) setNoticeIdle(shared, gone_notice); 792 switch (landed) {
793 .gone => setNoticeIdle(shared, gone_notice),
794 .@"unreachable" => setNoticeIdle(shared, unreachable_notice),
795 else => {},
796 }
787 } 797 }
788 798
789 /// The focus move itself. Caller holds `paint_mu`. 799 /// The focus move itself. Caller holds `paint_mu`.
@@ -1058,39 +1068,59 @@ pub fn wakePending(t: *Tile, creates: bool) void {
1058 t.alive.store(true, .release); 1068 t.alive.store(true, .release);
1059 } 1069 }
1060 1070
1061 /// A pending pane the host's own list confirmed: wake it in place. The 1071 /// How a pending pane comes to life — the two axes every wake differs on,
1062 /// rect was cut at seed time, so binding moves nothing and re-cuts 1072 /// and nothing else. `creates`: the daemon MAKES the session (a nonzero
1063 /// nothing — which is the whole point of seeding. 1073 /// attach size) rather than only attaching to one it already lists.
1064 pub fn bindTile(w: Wall, i: usize) void { 1074 /// `asked`: the dial STARTS the daemon (`mux d endpoint --start`) rather
1065 const t = &w.tiles[i]; 1075 /// than reading what is already there — the field the entry attach and a
1066 wakePending(t, false); 1076 /// picker birth set, worn on a `.hand` target. Every road onto a pending
1067 spawnPump(t); 1077 /// pane — a poll bind, a gone-pane Enter, an unreachable-pane Enter — is one
1078 /// of these three combos, so they share one primitive and diverge only in
1079 /// the struct.
1080 pub const ReviveOpts = struct { creates: bool, asked: bool };
1081
1082 /// Set the dial's start-the-daemon flag where the target can carry one. A
1083 /// `.sock`/`.quic`/`.via` target has no daemon to start over ssh, so the
1084 /// flag has nowhere to live and the ask is a no-op — a local daemon that is
1085 /// down is `mux d start`, not a wall pane's to boot.
1086 fn armAsked(t: *Tile, asked: bool) void {
1087 if (t.r.target == .hand) t.r.target.hand.asked = asked;
1068 } 1088 }
1069 1089
1070 /// The state half of a revive, split from the thread so a test can pin the 1090 /// The state half of a wake, split from the thread so a test can pin the
1071 /// transitions without racing a pump. `reviveTile` is the only production 1091 /// transitions without racing a pump.
1072 /// caller. 1092 pub fn armPane(t: *Tile, opts: ReviveOpts) void {
1073 pub fn armRevive(t: *Tile) void { 1093 armAsked(t, opts.asked);
1074 wakePending(t, true); 1094 wakePending(t, opts.creates);
1075 } 1095 }
1076 1096
1077 /// A gone pane, revived on the user's Enter: the same tile re-arms as a 1097 /// The one wake: arm the pane, then give it its pump. Every caller names its
1078 /// CREATING attach — same host, same session name, same rect — so the 1098 /// intent in the struct — a bind attaches ({false,false}), a gone-pane Enter
1079 /// daemon makes the session anew where the old one stood. No tree edit and 1099 /// re-creates on a live host ({true,false}), an unreachable-pane Enter starts
1080 /// no flatten: reviving re-cuts nothing, which is the promise gone panes 1100 /// the daemon first and then re-creates ({true,true}). No tree edit and no
1081 /// exist to keep. 1101 /// flatten: waking re-cuts nothing, which is the promise a seeded rect keeps.
1082 pub fn reviveTile(t: *Tile) void { 1102 pub fn revivePane(t: *Tile, opts: ReviveOpts) void {
1083 armRevive(t); 1103 armPane(t, opts);
1084 spawnPump(t); 1104 spawnPump(t);
1085 } 1105 }
1086 1106
1087 /// The one door for bytes aimed at the focused tile. A gone pane has no 1107 /// A pending pane the host's own list confirmed: wake it in place, attaching
1108 /// to the session the poll just saw — never creating, never starting.
1109 pub fn bindTile(w: Wall, i: usize) void {
1110 revivePane(&w.tiles[i], .{ .creates = false, .asked = false });
1111 }
1112
1113 /// The one door for bytes aimed at the focused tile. A pending pane has no
1088 /// pump, so no byte could reach a session — eaten here rather than queued 1114 /// pump, so no byte could reach a session — eaten here rather than queued
1089 /// into `in` where they would greet the NEXT session this tile carries. 1115 /// into `in` where they would greet the NEXT session this tile carries.
1090 /// Enter is the pane's one verb. 1116 /// Enter is the pane's one verb: it wakes the pane through `revivePane`, and
1117 /// the only thing that differs by WHY the pane was pending is whether the
1118 /// daemon must be started first — a gone pane's host is up, an unreachable
1119 /// pane's may be down.
1091 pub fn keysToFocused(t: *Tile, keys: []const u8) void { 1120 pub fn keysToFocused(t: *Tile, keys: []const u8) void {
1092 if (t.pending and t.state == .gone) { 1121 if (t.pending and (t.state == .gone or t.state == .@"unreachable")) {
1093 if (std.mem.indexOfAny(u8, keys, "\r\n") != null) reviveTile(t); 1122 if (std.mem.indexOfAny(u8, keys, "\r\n") != null)
1123 revivePane(t, .{ .creates = true, .asked = t.state == .@"unreachable" });
1094 return; 1124 return;
1095 } 1125 }
1096 sendKeys(t, keys); 1126 sendKeys(t, keys);
test/e2e_09_hosts.sh
Old New
@@ -513,7 +513,7 @@ ok "x on a wall ends the focused tile's session and no other, and that tile leav
513 # when the daemon died, is not there — asked of `mux d stats` on the 513 # when the daemon died, is not there — asked of `mux d stats` on the
514 # real daemon, not of the wall that would be reporting on its own 514 # real daemon, not of the wall that would be reporting on its own
515 # resurrection. The PANE for `c` is still on the wall, and that is not 515 # resurrection. The PANE for `c` is still on the wall, and that is not
516 # a contradiction: it stands wearing [gone] because the host answered 516 # a contradiction: it stands wearing [session ended] because the host answered
517 # and did not name the session (gone panes, 2026-09-01), which is a 517 # and did not name the session (gone panes, 2026-09-01), which is a
518 # rect the user saved and an offer to start it anew, not a session. 518 # rect the user saved and an offer to start it anew, not a session.
519 # `x` closes it and Enter creates it; until then the daemon holds one 519 # `x` closes it and Enter creates it; until then the daemon holds one
@@ -570,10 +570,10 @@ grep -q -- "--sock $SOCKH2#0 \[up\]" "$OUT.hrcap" || {
570 cat "$OUT.hrpc"; exit 1; } 570 cat "$OUT.hrpc"; exit 1; }
571 "$RENDER" --cols 80 --rows 44 < "$OUT.hrcap" > "$OUT.hrcap.final" 571 "$RENDER" --cols 80 --rows 44 < "$OUT.hrcap" > "$OUT.hrcap.final"
572 # The pane the dead session left behind stands, and says why: the host 572 # The pane the dead session left behind stands, and says why: the host
573 # ANSWERED and did not name `c`, which is [gone] and not [unreachable]. 573 # ANSWERED and did not name `c`, which is [session ended] and not [unreachable].
574 grep -q -- "--sock $SOCKH2#c \[gone\]" "$OUT.hrcap.final" || { 574 grep -q -- "--sock $SOCKH2#c \[session ended\]" "$OUT.hrcap.final" || {
575 echo "e2e FAIL: hosts restart: the pane for the session the dead daemon held did" 575 echo "e2e FAIL: hosts restart: the pane for the session the dead daemon held did"
576 echo " not stand wearing [gone] on the reborn daemon:" 576 echo " not stand wearing [session ended] on the reborn daemon:"
577 cat "$OUT.hrcap.final"; exit 1; } 577 cat "$OUT.hrcap.final"; exit 1; }
578 # ...and it is a rect, not a session: nothing attached to a `c` that does 578 # ...and it is a rect, not a session: nothing attached to a `c` that does
579 # not exist. A pane the wall had resurrected would read [up]. 579 # not exist. A pane the wall had resurrected would read [up].
test/e2e_12_panes.sh
Old New
@@ -818,8 +818,8 @@ grep -q -- "^ leaf 43 .*#0\$" "$LPHLAYOUT" || {
818 # ...and the leaf the list disowned says on screen why it is still there. 818 # ...and the leaf the list disowned says on screen why it is still there.
819 # The state word survives a bar's truncation where the socket path does 819 # The state word survives a bar's truncation where the socket path does
820 # not, so this is the needle that reads at any pane width. 820 # not, so this is the needle that reads at any pane width.
821 grep -qa '\[gone\]' "$OUT.lphcap2" || { 821 grep -qa '\[session ended\]' "$OUT.lphcap2" || {
822 echo "e2e FAIL: layout-heal: the ended session's pane wore no [gone]:" 822 echo "e2e FAIL: layout-heal: the ended session's pane wore no [session ended]:"
823 cat "$OUT.lphpc2"; exit 1; } 823 cat "$OUT.lphpc2"; exit 1; }
824 timeout 20 "$MUX" a capture --sock "$SOCK60" > "$OUT.lphfa" 2>&1 824 timeout 20 "$MUX" a capture --sock "$SOCK60" > "$OUT.lphfa" 2>&1
825 grep -q "lph-heal-marker" "$OUT.lphfa" || { 825 grep -q "lph-heal-marker" "$OUT.lphfa" || {
@@ -992,10 +992,10 @@ RC=$?
992 set -e 992 set -e
993 rc0 "gone-panes: run 2's ptyclient exited $RC:" "$OUT.gppc2" "$OUT.gpcap2.err" 993 rc0 "gone-panes: run 2's ptyclient exited $RC:" "$OUT.gppc2" "$OUT.gpcap2.err"
994 # The word reached the grid. A bar is byte-truncated from the LABEL end and 994 # The word reached the grid. A bar is byte-truncated from the LABEL end and
995 # the state survives that cut (`labelText`), so `[gone]` is on screen for 995 # the state survives that cut (`labelText`), so `[session ended]` is on screen for
996 # any pane width this leg could produce. 996 # any pane width this leg could produce.
997 grep -qa '\[gone\]' "$OUT.gpcap2" || { 997 grep -qa '\[session ended\]' "$OUT.gpcap2" || {
998 echo "e2e FAIL: gone-panes: no pane wore [gone] after the reboot:" 998 echo "e2e FAIL: gone-panes: no pane wore [session ended] after the reboot:"
999 cat "$OUT.gppc2"; exit 1; } 999 cat "$OUT.gppc2"; exit 1; }
1000 # The rails did not move. A capture is a stream and holds every rail the 1000 # The rails did not move. A capture is a stream and holds every rail the
1001 # run ever painted, so this catches a re-cut that MOVES a boundary; the 1001 # run ever painted, so this catches a re-cut that MOVES a boundary; the