b5dc1836
feat: a reachable host's missing sessions dress their panes gone
a73x 2026-09-01 10:06
Commit message
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)) { |