06756d09
feat: a silent host keeps its panes, and only its own list may take one
a73x 2026-08-30 18:16
Commit message
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 | # |