a73x

b5dc1836

feat: a reachable host's missing sessions dress their panes gone

a73x   2026-09-01 10:06

Commit message
feat: a reachable host's missing sessions dress their panes gone

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

src/tui/wall_host.zig
Old New
@@ -340,14 +340,23 @@ pub fn applyHostList(w: Wall, hi: usize) void {
340 var births = BirthNames{}; 340 var births = BirthNames{};
341 var binds = TileIdxs{}; 341 var binds = TileIdxs{};
342 var vanish = TileIdxs{}; 342 var vanish = TileIdxs{};
343 // Where the seeded panes this list disowns land. Nothing reads them 343 // Where the seeded panes this list disowns land.
344 // yet: taking them off the vanish path is what makes such a pane
345 // stand, and dressing its bar is a separate change.
346 var gones = TileIdxs{}; 344 var gones = TileIdxs{};
347 planHostDiff(w.liveTiles(), w.livePresent(), w.live.*, hi, list, h.self_name, &births, &binds, &vanish, &gones); 345 planHostDiff(w.liveTiles(), w.livePresent(), w.live.*, hi, list, h.self_name, &births, &binds, &vanish, &gones);
348 // Binds first, and they do not count as change: a pane whose rect 346 // Binds first, and they do not count as change: a pane whose rect
349 // was cut at seed time wakes in place, and re-cuts nothing. 347 // was cut at seed time wakes in place, and re-cuts nothing.
350 for (binds.items[0..binds.len]) |bi| wv.bindTile(w, bi); 348 for (binds.items[0..binds.len]) |bi| wv.bindTile(w, bi);
349 // Dressed, never re-cut: `changed` stays as it was, because the whole
350 // point of a gone pane is that its rect survives the daemon that
351 // forgot its session. Same shape as `dressSilent`, one state along.
352 if (gones.len > 0) {
353 w.shared.paint_mu.lock();
354 defer w.shared.paint_mu.unlock();
355 for (gones.items[0..gones.len]) |gi| w.liveTiles()[gi].state = .gone;
356 // A gone pane has no pump, so no doorbell can repaint its bar:
357 // the keyboard paints it here, the same hand that dressed it.
358 if (w.shared.labelRows() != 0) wv.paintDeadBarsLocked(w.liveTiles());
359 }
351 for (vanish.items[0..vanish.len]) |v| { 360 for (vanish.items[0..vanish.len]) |v| {
352 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null); 361 wv.vanishTile(w.liveTiles(), w.livePresent(), w.shared, v, null);
353 changed = true; 362 changed = true;
src/tui/wall_test_host.zig
Old New
@@ -901,3 +901,67 @@ test "sendKeys: the mailbox of a pending pane holds what was typed for the pump
901 var buf: [64]u8 = undefined; 901 var buf: [64]u8 = undefined;
902 try std.testing.expectEqualStrings("typed-early", wall_pump.takeKeys(&tiles[1], &buf)); 902 try std.testing.expectEqualStrings("typed-early", wall_pump.takeKeys(&tiles[1], &buf));
903 } 903 }
904
905 test "applyHostList: a reachable host that lost its sessions dresses seeded panes gone and keeps them standing" {
906 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
907 defer arena.deinit();
908 const alloc = arena.allocator();
909 var shared: Shared = undefined;
910 fixture.stoppedWall(alloc, &shared);
911 var tiles: [4]Tile = undefined;
912 var present = [_]bool{false} ** 4;
913 var live: usize = 0;
914 defer fixture.endPumps(tiles[0..live]);
915 var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
916
917 // Two seeded panes, off-origin, distinct rects: the saved cut of a
918 // two-pane wall whose daemon rebooted overnight.
919 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);
920 try wv.seedTile(&tiles[1], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#b", .session = "b" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 1, 0);
921 present[0] = true;
922 present[1] = true;
923 live = 2;
924
925 // The host is REACHABLE and answers empty: both panes dress gone, both
926 // stay present — the wall re-cuts nothing.
927 fixture.setList(&table[0], "");
928 wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
929 try std.testing.expectEqual(wv.State.gone, tiles[0].state);
930 try std.testing.expectEqual(wv.State.gone, tiles[1].state);
931 try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
932
933 // The poll FAILS next: gone yields to unreachable (the host cannot
934 // answer, which is a different sentence than "the host said no")...
935 table[0].poll.reachable.store(false, .release);
936 wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
937 try std.testing.expectEqual(wv.State.@"unreachable", tiles[0].state);
938
939 // ...and recovery without the session dresses gone again. Flap over,
940 // both panes still standing.
941 fixture.setList(&table[0], "");
942 wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
943 try std.testing.expectEqual(wv.State.gone, tiles[0].state);
944 try std.testing.expectEqual(wv.State.gone, tiles[1].state);
945 try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
946 }
947
948 test "setFocus: landing on a gone pane says what the two keys are" {
949 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
950 defer arena.deinit();
951 const alloc = arena.allocator();
952 var shared: Shared = undefined;
953 fixture.stoppedWall(alloc, &shared);
954 var tiles: [4]Tile = undefined;
955 var present = [_]bool{false} ** 4;
956 var live: usize = 0;
957 defer fixture.endPumps(tiles[0..live]);
958 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);
959 try wv.seedTile(&tiles[1], .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#b", .session = "b" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 1, 0);
960 present[0] = true;
961 present[1] = true;
962 live = 2;
963 tiles[1].state = .gone;
964
965 wv.setFocus(tiles[0..live], &shared, 1);
966 try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]);
967 }
src/tui/wallview.zig
Old New
@@ -651,6 +651,10 @@ pub fn setNoticeIdle(shared: *Shared, text: []const u8) void {
651 if (!held) setNotice(shared, text); 651 if (!held) setNotice(shared, text);
652 } 652 }
653 653
654 /// What a gone pane can do, said where the user is looking. Under the
655 /// 96-byte notice buffer; `setNoticeIdle` truncates silently past it.
656 pub const gone_notice: []const u8 = "[session gone - Enter starts it anew, x closes]";
657
654 /// Take it, once. 658 /// Take it, once.
655 pub fn takeNotice(shared: *Shared, out: []u8) []const u8 { 659 pub fn takeNotice(shared: *Shared, out: []u8) []const u8 {
656 // Copied out because the caller paints it after releasing the lock: 660 // Copied out because the caller paints it after releasing the lock:
@@ -688,8 +692,25 @@ pub fn sendKeys(t: *Tile, keys: []const u8) void {
688 /// pumps are two threads and A's release races B's claim. 692 /// pumps are two threads and A's release races B's claim.
689 pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void { 693 pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
690 const prev = shared.sel; 694 const prev = shared.sel;
691 shared.paint_mu.lock(); 695 // Scoped rather than deferred to the end of the function: the notice at
692 defer shared.paint_mu.unlock(); 696 // the tail goes through `setNoticeIdle`, which takes `paint_mu` itself,
697 // and the mutex is not reentrant.
698 var landed_gone = false;
699 {
700 shared.paint_mu.lock();
701 defer shared.paint_mu.unlock();
702 moveFocusLocked(tiles, shared, prev, next);
703 // Read HERE because `state` is a `paint_mu` field a pump writes
704 // through `paintLabel`, and the keyboard is not its owner.
705 landed_gone = next < tiles.len and tiles[next].state == .gone;
706 }
707 // A gone pane's bar says "gone"; this says what to do about it. Idle
708 // only: a refusal or an exit sentence already on screen outranks a hint.
709 if (landed_gone) setNoticeIdle(shared, gone_notice);
710 }
711
712 /// The focus move itself. Caller holds `paint_mu`.
713 fn moveFocusLocked(tiles: []Tile, shared: *Shared, prev: usize, next: usize) void {
693 // The outgoing session's modes come off HERE, ahead of the doorbell that 714 // The outgoing session's modes come off HERE, ahead of the doorbell that
694 // lets the next pump claim — and only when there IS a terminal to change. 715 // lets the next pump claim — and only when there IS a terminal to change.
695 if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) { 716 if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) {