src/tui/wall_test_host.zig
Ref: Size: 54.6 KiB History
//! The hosts file, the host table and the poller (wall_host.zig).
const std = @import("std");
const client = @import("client");
const hosts = @import("client").hosts;
const TmpDir = @import("testtmp").TmpDir;
const fixture = @import("wall_test_harness.zig");
const wall_host = @import("wall_host.zig");
const wall_layout = @import("wall_layout.zig");
const wall_pump = @import("wall_pump.zig");
const wv = @import("wallview.zig");
const AddHost = wall_host.AddHost;
const Host = wall_host.Host;
const HostSpec = client.HostSpec;
const Shared = wv.Shared;
const Tile = wv.Tile;
const TileIdxs = wall_host.TileIdxs;
/// Panes the layout seated and a poll already bound: live tiles, in the
/// tree, whose pump never ran — so `alive` is whatever the test states and
/// there is no thread to join. The wall's tiles come from the layout now,
/// so a poll test that wants tiles has to seat them itself.
fn seatBound(
tiles: []Tile,
present: []bool,
live: *usize,
shared: *Shared,
rs: []const wall_host.Resolved,
hostof: []const usize,
) !void {
for (rs, hostof, 0..) |r, h, i| {
tiles[i] = .{
.r = r,
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = shared,
.idx = i,
.wake_r = -1,
.wake_w = -1,
.host = h,
.alive = std.atomic.Value(bool).init(false),
};
present[i] = true;
if (i == 0) {
try shared.tree.addFirst(0);
} else {
try shared.tree.insert(@intCast(i - 1), @intCast(i));
}
}
live.* = rs.len;
}
test "planHostDiff: tiles the daemon dropped vanish, and another host's same name is untouched" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, true, true };
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
// The vanish waits for a second list to agree (see the grace test
// below), so this list is asked twice. "c" is a session of host 0's
// that no pane spells, and it stays nothing to this wall.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nc\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), vanish.len);
try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
// Host 1's "a" survives host 0's list saying nothing about it.
vanish.len = 0;
wall_host.planHostDiff(&tiles, &present, 3, 1, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
// An empty list vanishes everything the host had, once every tile has
// spent the grace.
wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &binds, &vanish, &gones);
vanish.len = 0;
wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 2), vanish.len);
}
test "planHostDiff: a live pump's tile is not vanished by ONE list that lacks it" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, true, true };
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
// A poll can overtake a session's exit: the daemon has already dropped
// the name while the pump has not yet read its `exit_status`. Vanishing
// on that list loses the exit code — on a wall of one, `endedTile` skips
// a tile that is no longer present and mux never leaves.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
try std.testing.expect(tiles[1].missed_once);
// A list that names it again forgives it: a tile does not accumulate
// misses across the seconds it is legitimately live.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
try std.testing.expect(!tiles[1].missed_once);
// Two consecutive lists without it, and it goes.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), vanish.len);
try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
// A pump that has ENDED has no exit code left to lose, so its tile
// needs no grace at all.
tiles[0].alive.store(false, .release);
vanish.len = 0;
const only0 = [_]bool{ true, false, false };
wall_host.planHostDiff(&tiles, &only0, 3, 0, "", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), vanish.len);
try std.testing.expectEqual(@as(usize, 0), vanish.get(0));
}
test "planHostDiff: a pending pane the reachable list disowns is gone, not vanished — and a later list still binds it" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, true, true };
// Panes 0 and 1 are sidecar restores: no pump ever ran, nothing dialed.
for (tiles[0..2]) |*t| {
t.pending = true;
t.state = .waiting;
t.alive.store(false, .release);
}
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
// The reachable host answers with NEITHER saved session: both panes are
// gone-fodder, neither is vanish-fodder, and host 1's tile is untouched.
wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
try std.testing.expectEqual(@as(usize, 2), gones.len);
try std.testing.expectEqual(@as(usize, 0), gones.get(0));
try std.testing.expectEqual(@as(usize, 1), gones.get(1));
// Once dressed, a pane is not re-listed every second: the poll comes
// back every 1s and a repaint per poll would flicker the bar.
tiles[0].state = .gone;
tiles[1].state = .gone;
gones.len = 0;
wall_host.planHostDiff(&tiles, &present, 3, 0, "", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), gones.len);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
// The dressing is reversible: a list that names session "a" binds pane 0
// (it stayed pending), in the rect it never left.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), binds.len);
try std.testing.expectEqual(@as(usize, 0), binds.get(0));
}
test "planHostDiff: a vanished tile is not present, so the next list does not vanish it twice" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, false, true };
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
}
test "planHostDiff: the session this shell is inside never wakes a pane" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, true, true };
// Two pending panes, one of them spelling the session mux is running
// inside. `seedLayout` refuses such a leaf, so this is the poll's own
// half of the same refusal: a pane it woke would attach a replica to
// the grid it is painting into.
for (tiles[0..2]) |*t| {
t.pending = true;
t.state = .waiting;
t.alive.store(false, .release);
}
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", "a", &binds, &vanish, &gones);
// Only "b" is woken; the pane named for this shell stays as it was,
// and is not dressed gone either — the daemon does have that session.
try std.testing.expectEqual(@as(usize, 1), binds.len);
try std.testing.expectEqual(@as(usize, 1), binds.get(0));
try std.testing.expect(tiles[0].pending);
try std.testing.expectEqual(@as(usize, 0), gones.len);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
}
test "applyReadyLists: only the host that reported is marked, and its sessions are not tiles" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [8]Tile = undefined;
var present = [_]bool{false} ** 8;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
// TWO hosts open the wall and a THIRD arrives from the picker. A fixture
// whose table never grows cannot see a per-host flag sized at the count
// the wall opened with — which is the whole of this.
var table = [_]Host{
fixture.testHost(&shared, "box", "/tmp/box.sock"),
fixture.testHost(&shared, "vm", "/tmp/vm.sock"),
fixture.testHost(&shared, "--sock /tmp/added.sock", "/tmp/added.sock"),
};
fixture.setList(&table[2], "late\n");
table[2].poll.list_ready.store(true, .release);
_ = wall_host.applyReadyLists(fixture.wallOf(alloc, &tiles, &present, &live, &shared, table[0..3]));
// Only the host that reported is marked: the two that opened the wall
// are still owed a first list.
try std.testing.expect(table[2].applied);
try std.testing.expect(!table[0].applied);
try std.testing.expect(!table[1].applied);
// ...and the session it reported is on nobody's wall: this one was
// authored with no panes, and a poll does not author one.
try std.testing.expectEqual(@as(usize, 0), live);
try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
}
test "recordHost: a file it cannot write comes BACK — the wall may be on the alternate screen" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var bad_buf: [256]u8 = undefined;
var ok_buf: [256]u8 = undefined;
var blocker_buf: [256]u8 = undefined;
// A path whose parent is a FILE. `saveBytes` makes missing directories,
// so an absent one is not a failure — this is. The caller has to say so
// out loud, and WHERE it may say it depends on which caller it is:
// stderr before the screen is taken, a notice after.
const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()});
try hosts.saveBytes(blocker, "x");
const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()});
const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()});
try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null);
try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", ok) == null);
// `--via` has no form in the host grammar, and writing nothing down for
// it is not a failure to report.
try std.testing.expect(wall_host.recordHost(alloc, .{ .via = "ssh h mux d proxy" }, "x", bad) == null);
}
test "applyHostList: drift dresses every tile of its host, and repaints only on change" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
shared.own_version = "1.0";
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
// Two drifted hosts side by side — one behind us, one stale on its own
// box with TWO sessions, so "every tile of the host" is plural where it
// can be.
var table = [_]Host{
fixture.testHost(&shared, "behind", "/tmp/behind.sock"),
fixture.testHost(&shared, "stale", "/tmp/stale.sock"),
};
fixture.setList(&table[0], "a\n# mux 0.9");
fixture.setList(&table[1], "b\nc\n# mux 1.0 stale");
try seatBound(&tiles, &present, &live, &shared, &.{
.{ .target = .{ .sock = "/tmp/behind.sock" }, .label = "behind#a", .session = "a" },
.{ .target = .{ .sock = "/tmp/stale.sock" }, .label = "stale#b", .session = "b" },
.{ .target = .{ .sock = "/tmp/stale.sock" }, .label = "stale#c", .session = "c" },
}, &.{ 0, 1, 1 });
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1);
try std.testing.expectEqual(@as(usize, 3), wv.presentCount(present[0..live]));
for (tiles[0..live], present[0..live]) |*t, p| {
if (!p) continue;
const want: []const u8 = if (t.host == @as(?usize, 0)) "daemon 0.9" else "daemon stale";
try std.testing.expectEqualStrings(want, t.drift[0..t.drift_len]);
}
// The same lists again: same words, and NO repaint asked for — a wall
// repainted once a second per host would flicker for no news.
const gen = shared.repaint_gen.load(.acquire);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1);
try std.testing.expectEqual(gen, shared.repaint_gen.load(.acquire));
// The behind daemon upgrades under us: its word leaves, and that IS news.
fixture.setList(&table[0], "a\n# mux 1.0");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
for (tiles[0..live], present[0..live]) |*t, p| {
if (!p or t.host != @as(?usize, 0)) continue;
try std.testing.expectEqualStrings("", t.drift[0..t.drift_len]);
}
try std.testing.expect(shared.repaint_gen.load(.acquire) != gen);
}
test "applyHostList: a host makes no tile — not when it is silent, not when it lists a session" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
// The two ways a host has nothing to show, side by side: one that never
// answered and one that answered with an empty list.
var table = [_]Host{
fixture.testHost(&shared, "down", "/tmp/nobody.sock"),
fixture.testHost(&shared, "empty", "/tmp/empty.sock"),
};
table[0].poll.reachable.store(false, .release);
fixture.setList(&table[1], "");
// Twice, because a placeholder that is born once is still a placeholder.
for (0..2) |_| {
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1);
}
try std.testing.expectEqual(@as(usize, 0), live);
try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
// The third way, and the one the layout changed: the host answers with
// a session, and this wall — which names no pane — still shows nothing.
// A session somebody else started is not this user's authored wall.
fixture.setList(&table[1], "a\n");
for (0..2) |_|
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 1);
try std.testing.expectEqual(@as(usize, 0), live);
try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
}
test "applyHostList: a live tile survives one list that lost its session, and goes on the next" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
// Two panes, because the tile that keeps its name is what shows the
// grace is per tile and not a wall-wide pause. No pump ever runs here,
// so `endPumps` has nothing to wait for and the liveness the rule turns
// on is stated rather than raced for.
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
try seatBound(&tiles, &present, &live, &shared, &.{
.{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#a", .session = "a" },
.{ .target = .{ .sock = "/tmp/box.sock" }, .label = "box#b", .session = "b" },
}, &.{ 0, 0 });
tiles[0].alive.store(true, .release);
tiles[1].alive.store(true, .release);
fixture.setList(&table[0], "a\n");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
// Still there: this list may have overtaken an `exit_status` the pump
// has not read yet, and that code is the run's own on a wall of one.
try std.testing.expect(present[1]);
try std.testing.expect(present[0]);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expect(!present[1]);
try std.testing.expect(present[0]);
}
test "applyHostList: a busy daemon leaves an empty wall empty — a list is not a source of tiles" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
// THREE sessions, none of them a pane: a wall the user authored empty
// stays empty however busy the box is, and the picker is the door onto
// it. Twice, because a tile born once is still a tile.
fixture.setList(&table[0], "a\nb\nc\n");
for (0..2) |_|
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(@as(usize, 0), live);
try std.testing.expectEqual(@as(usize, 0), shared.tree.count());
try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
// Not even a word about them: a wall that shows the panes it was given
// is not a wall hiding the rest of the daemon.
var buf: [96]u8 = undefined;
try std.testing.expectEqualStrings("", wv.takeNotice(&shared, &buf));
}
test "applyHostList: when a host's last session goes the wall empties, and nothing stands in for the host" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 1;
defer fixture.endPumps(tiles[0..live]);
tiles[0] = .{
.r = .{ .target = .{ .sock = "/tmp/box.sock" }, .label = "--sock /tmp/box.sock#a", .session = "a" },
.rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
.shared = &shared,
.idx = 0,
.wake_r = -1,
.wake_w = -1,
.host = 0,
.alive = std.atomic.Value(bool).init(false),
};
present[0] = true;
try shared.tree.addFirst(0);
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
// The daemon has nothing left: a restart, or another client ended it.
fixture.setList(&table[0], "");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
// The host is up with nothing on it, which the wall says by showing
// nothing: no placeholder tile takes the gone session's place.
try std.testing.expect(!present[0]);
try std.testing.expectEqual(@as(usize, 0), wv.presentCount(present[0..]));
}
test "planHostDiff: a creating tile whose attach has not landed yet is not vanished by the list that raced it" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
// `Ctrl-\ c` picked "b" off the daemon's free list and the tile is
// dialling; `pokeHost` asks for the list at exactly this moment, and
// the daemon answers before the attach that makes "b" lands.
tiles[1].creates = true;
var present = [_]bool{ true, true, true };
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
// ...and once its session exists, the same list does drop it — after
// the one list of grace a live pump gets: a session the daemon HAD and
// no longer lists is gone.
tiles[1].ever_up.store(true, .release);
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), vanish.len);
try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
}
test "hostsOverCapacity: a hosts file past the wall's room is counted out loud, not cut in silence" {
var buf: [64]u8 = undefined;
// Exactly full is not over: the boundary is where a silent cut starts.
try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, wv.max_tiles));
try std.testing.expectEqual(@as(?[]const u8, null), wall_host.hostsOverCapacity(&buf, 0));
try std.testing.expectEqualStrings(
"[+1 host in the file not shown]",
wall_host.hostsOverCapacity(&buf, wv.max_tiles + 1) orelse "",
);
try std.testing.expectEqualStrings(
"[+9 hosts in the file not shown]",
wall_host.hostsOverCapacity(&buf, wv.max_tiles + 9) orelse "",
);
}
test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var path_buf: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "{s}/hosts", .{tmp.path()});
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
// A FULL table is the only one that can see a forgotten slot refused:
// `hosts_live` only ever grew, so 32 adds spent the wall for good even
// with two rows left in the picker.
var table: [2]Host = undefined;
table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared };
table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
var n: usize = 2;
var buf: [96]u8 = undefined;
try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
// `x` asks the poller to leave; until it has, the slot is still its own.
// A spec overwritten under a live poller is a thread dialling a target
// that has been replaced.
table[0].forgotten.store(true, .release);
try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path) == .refused);
try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
// The poller's last act, and the slot is the wall's again.
table[0].poller_done.store(true, .release);
const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
.added => |i| i,
else => return error.HostWasNotAdded,
};
// `run`'s allocator is an ARENA that never returns, so a reused slot's
// old spec is deliberately not freed there; a test outlives its own.
// The target owns its own path: `Target.fromSpec` copies every slice,
// because the spelling a caller parsed may be a scratch buffer.
defer alloc.free(table[at].spec.spelling);
defer alloc.free(table[at].spec.target.sock);
try std.testing.expectEqual(@as(usize, 0), at);
try std.testing.expectEqual(@as(usize, 2), n);
try std.testing.expectEqualStrings("/c", table[0].spec.target.sock);
try std.testing.expect(!table[0].forgotten.load(.acquire));
try std.testing.expect(!table[0].poller_done.load(.acquire));
}
test "addHost: the prompt adds a DAEMON — a flag, a session and a host already listed are all refused" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var path_buf: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "{s}/hosts", .{tmp.path()});
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
defer shared.tree.deinit();
// Two hosts on the wall before the prompt is ever opened: a table that
// only ever held one cannot see an append landing on the wrong row.
var table: [4]Host = undefined;
table[0] = .{ .spec = .{ .spelling = "--sock /a", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } }, .shared = &shared };
table[1] = .{ .spec = .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }, .shared = &shared };
var n: usize = 2;
var buf: [96]u8 = undefined;
// The typo `mux hosts add -A host` dies at, typed at the prompt
// instead: a host named `-A` is nobody's intent at either mouth.
try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "-A nosuchhost.invalid", null, 30_000, path) == .refused);
try std.testing.expectEqual(@as(usize, 2), n);
try std.testing.expectEqualStrings("[bad host: FlagLikeTarget]", wv.takeNotice(&shared, &buf));
// `#` is the old wall grammar's session split. The prompt names a
// daemon now, and the refusal says which half was one too many.
try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /c#work", null, 30_000, path) == .refused);
try std.testing.expectEqual(@as(usize, 2), n);
try std.testing.expect(std.mem.indexOf(u8, wv.takeNotice(&shared, &buf), "names a daemon") != null);
// A real one: it lands in the table AND in the file, because the wall
// the user is looking at and the wall they get back are the same wall.
const at = switch (wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path)) {
.added => |i| i,
else => return error.HostWasNotAdded,
};
// `run`'s allocator never returns, so the table's copy is deliberately
// never freed there; a test outlives its allocator's bookkeeping.
defer alloc.free(table[at].spec.spelling);
defer alloc.free(table[at].spec.target.sock);
try std.testing.expectEqual(@as(usize, 2), at);
try std.testing.expectEqual(@as(usize, 3), n);
try std.testing.expectEqualStrings("/c", table[2].spec.target.sock);
var h = try hosts.load(alloc, path);
defer h.deinit(alloc);
try std.testing.expect(h.has("--sock /c"));
// Twice is once: the poller is already asking that daemon for its
// sessions, and a second one would tile every one of them again. It
// says so and names the row, because an unchanged popup and an unmoved
// selection is the prompt answering a real host with nothing at all.
try std.testing.expectEqual(
AddHost{ .listed = 2 },
wall_host.addHost(alloc, &shared, &table, &n, "--sock /c", null, 30_000, path),
);
try std.testing.expectEqual(@as(usize, 3), n);
try std.testing.expectEqualStrings("[that host is already on the wall]", wv.takeNotice(&shared, &buf));
// Full is said out loud, not swallowed.
n = table.len;
try std.testing.expect(wall_host.addHost(alloc, &shared, &table, &n, "--sock /d", null, 30_000, path) == .refused);
try std.testing.expectEqualStrings("[no room on the wall for another host]", wv.takeNotice(&shared, &buf));
}
test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cannot narrate" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
// Two spellings, because the poll target is built per host and a shared
// one would point every stripe at the first host's ssh argv.
for ([_][]const u8{ "box", "user@gate" }) |spelling| {
const spec = try client.resolveHost(alloc, spelling, null, 30_000);
try std.testing.expect(hasWord(spec.poll_target.hand.ssh_argv, "BatchMode=yes"));
try std.testing.expectEqualStrings(spelling, spec.poll_target.hand.host);
// The attach the user sees must still be able to ask for a password.
try std.testing.expect(!hasWord(spec.target.hand.ssh_argv, "BatchMode=yes"));
// Neither door narrates a fallback: both are dialled under a wall
// that owns the alternate screen.
try std.testing.expect(!spec.target.hand.asked);
try std.testing.expect(!spec.poll_target.hand.asked);
}
// A transport that cannot prompt has one target, not a copy of one.
const s = try client.resolveHost(alloc, "--sock /tmp/a.sock", null, 30_000);
try std.testing.expectEqualStrings(s.target.sock, s.poll_target.sock);
}
test "otherHosts: the dialled host is not tiled twice, and the rest follow in file order" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
var buf: [256]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
// Off-origin: the host the user dialled is the file's SECOND line, so a
// skip that only ever worked on line 0 is caught here.
try hosts.saveBytes(path, "--sock /a\n--sock /b\n");
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
var specs: std.ArrayList(HostSpec) = .empty;
try specs.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
wall_host.otherHosts(arena, &specs, "--sock /b", path, null, 0);
try std.testing.expectEqual(@as(usize, 2), specs.items.len);
try std.testing.expectEqualStrings("--sock /b", specs.items[0].spelling);
try std.testing.expectEqualStrings("--sock /a", specs.items[1].spelling);
// A file the grammar refuses costs the user the WALL, never the session
// they asked for: the entry host stands alone and the attach goes on.
try hosts.saveBytes(path, "--sock /a\nbox#old\n");
var one: std.ArrayList(HostSpec) = .empty;
try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0);
try std.testing.expectEqual(@as(usize, 1), one.items.len);
}
fn hasWord(argv: []const []const u8, want: []const u8) bool {
for (argv) |w| if (std.mem.eql(u8, w, want)) return true;
return false;
}
/// Three pending panes off one sidecar: "a" and "b" on host 0 and an
/// aliasing "a" on host 1, so a bind keyed on name alone is caught.
/// Dupes the names: every wall tile owns its copies, because a birth that
/// takes a freed digit frees the departed tile's with the wall's allocator.
fn seedBench(alloc: std.mem.Allocator, tiles: []Tile, present: []bool, live: *usize, shared: *Shared) !void {
try shared.tree.addFirst(0);
try shared.tree.insert(0, 1);
try shared.tree.insert(1, 2);
const rs = [_]wall_host.Resolved{
.{ .target = .{ .sock = "/tmp/h0.sock" }, .label = "--sock /tmp/h0.sock#a", .session = "a" },
.{ .target = .{ .sock = "/tmp/h0.sock" }, .label = "--sock /tmp/h0.sock#b", .session = "b" },
.{ .target = .{ .sock = "/tmp/h1.sock" }, .label = "--sock /tmp/h1.sock#a", .session = "a" },
};
const hostof = [_]usize{ 0, 0, 1 };
for (rs, hostof, 0..) |r, h, i| {
const owned: wall_host.Resolved = .{
.target = r.target,
.label = try alloc.dupe(u8, r.label),
.session = try alloc.dupe(u8, r.session),
};
try wv.seedTile(&tiles[i], owned, .{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, shared, i, h);
present[i] = true;
}
live.* = 3;
}
test "planHostDiff: a pending pane the list names is a bind, and only its own host's list may wake it" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
defer shared.tree.deinit();
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
try seedBench(alloc, &tiles, &present, &live, &shared);
defer fixture.endPumps(tiles[0..live]);
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
// Host 0's whole list is pending panes: two binds, and no pane of
// host 1's is touched by a name host 0 spelled.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
try std.testing.expectEqual(@as(usize, 2), binds.len);
try std.testing.expectEqual(@as(usize, 0), binds.get(0));
try std.testing.expectEqual(@as(usize, 1), binds.get(1));
// Host 1's "a" is its own pane, not host 0's; its "x" is a session no
// pane spells, and the wall has nothing to say about it.
binds.len = 0;
wall_host.planHostDiff(&tiles, &present, 3, 1, "a\nx\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 1), binds.len);
try std.testing.expectEqual(@as(usize, 2), binds.get(0));
try std.testing.expectEqual(@as(usize, 3), live);
}
test "applyHostList: binding a saved pane moves no rect and re-cuts nothing" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
defer shared.tree.deinit();
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
try seedBench(alloc, &tiles, &present, &live, &shared);
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
// The seed's own cut, then quiet: what the list must not disturb.
wall_layout.relayout(w, 0);
for (tiles[0..3]) |*t| t.resize_pending = false;
const gen = shared.repaint_gen.load(.acquire);
var rects: [3]@TypeOf(tiles[0].rect) = undefined;
for (tiles[0..3], 0..) |t, i| rects[i] = t.rect;
fixture.setList(&table[0], "a\nb\n");
wall_host.applyHostList(w, 0);
try std.testing.expectEqual(gen, shared.repaint_gen.load(.acquire));
for (tiles[0..3], 0..) |t, i| {
try std.testing.expectEqual(rects[i], t.rect);
try std.testing.expect(!t.resize_pending);
}
// Bound panes joined - the daemon already has these sessions, and a
// sized attach on a live one would resize somebody.
try std.testing.expect(!tiles[0].pending and !tiles[1].pending);
try std.testing.expect(!tiles[0].creates and !tiles[1].creates);
// Host 1's aliasing "a" is still waiting for ITS host.
try std.testing.expect(tiles[2].pending);
}
test "a silent host's saved panes survive its failed poll and wear unreachable" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
defer shared.tree.deinit();
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
try seedBench(alloc, &tiles, &present, &live, &shared);
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
// Off-origin rects, so a pane that IS taken shows up as a re-cut.
wall_layout.relayout(w, 0);
var rects: [3]@TypeOf(tiles[0].rect) = undefined;
for (tiles[0..3], 0..) |t, i| rects[i] = t.rect;
// Host 0 is dark; twice, because a pane that rides out one poll and
// goes on the next has not ridden anything out.
table[0].poll.reachable.store(false, .release);
for (0..2) |_| wall_host.applyHostList(w, 0);
for (tiles[0..3], present[0..3], 0..) |t, p, i| {
if (!p) return error.SilentHostTookThePanesTheUserSaved;
if (!t.pending) return error.SilenceBoundAPaneNoHostNamed;
if (!std.meta.eql(rects[i], t.rect)) return error.SilenceRecutTheWall;
}
// Only host 0's panes are dressed: a quiet box says nothing about
// anyone else's, and the word is the picker row's own.
if (tiles[0].state != .@"unreachable" or tiles[1].state != .@"unreachable")
return error.DarkHostsPanesDoNotSayWhichSetupIsDark;
if (tiles[2].state != .waiting) return error.OneHostsSilenceSpreadToAnother;
try std.testing.expectEqualStrings("unreachable", tiles[0].state.word());
}
test "bindTile: the pane under the saved focus claims the terminal when it binds, its neighbour does not" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
defer shared.tree.deinit();
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
try seedBench(alloc, &tiles, &present, &live, &shared);
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
// The saved focus record put the user on pane 1 before any host spoke.
shared.sel = 1;
fixture.setList(&table[0], "a\nb\n");
wall_host.applyHostList(w, 0);
try std.testing.expect(tiles[1].claim_pending.load(.acquire));
try std.testing.expect(!tiles[0].claim_pending.load(.acquire));
}
test "applyHostList: one list binds a pane, dresses the pane it disowns gone, and adds nothing for the name it never saw" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
defer shared.tree.deinit();
var tiles: [8]Tile = undefined;
var present = [_]bool{false} ** 8;
var live: usize = 0;
try seedBench(alloc, &tiles, &present, &live, &shared);
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/h0.sock", "/tmp/h0.sock"),
fixture.testHost(&shared, "--sock /tmp/h1.sock", "/tmp/h1.sock"),
};
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
// The seed's own cut, then quiet: what this list must not disturb.
wall_layout.relayout(w, 0);
const gen = shared.repaint_gen.load(.acquire);
var rects: [3]@TypeOf(tiles[0].rect) = undefined;
for (tiles[0..3], 0..) |t, i| rects[i] = t.rect;
// One list, all three answers in it: "a" binds, "b" is disowned, and
// "c" is a session of this daemon's that no pane spells.
fixture.setList(&table[0], "a\nc\n");
wall_host.applyHostList(w, 0);
try std.testing.expect(!tiles[0].pending);
// The disowned pane keeps its digit and its rect: the host said no, and
// a pane the user authored is not the daemon's to collapse.
try std.testing.expect(present[1] and tiles[1].pending);
try std.testing.expectEqual(wv.State.gone, tiles[1].state);
// "c" is nobody's business: the wall the user wrote down is three panes
// wide before this list and three after it.
try std.testing.expectEqual(@as(usize, 3), live);
try std.testing.expectEqual(@as(usize, 3), wv.presentCount(present[0..live]));
// Nothing was re-cut: a bind wakes a pane in place and a gone pane is
// dressed where it stands, so no rect moved and no repaint was asked for.
try std.testing.expectEqual(gen, shared.repaint_gen.load(.acquire));
for (tiles[0..3], 0..) |t, i| try std.testing.expectEqual(rects[i], t.rect);
}
test "sendKeys: the mailbox of a pending pane holds what was typed for the pump the bind will start" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
defer shared.tree.deinit();
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
try seedBench(alloc, &tiles, &present, &live, &shared);
defer fixture.endPumps(tiles[0..live]);
wv.sendKeys(&tiles[1], "typed-early");
var buf: [64]u8 = undefined;
try std.testing.expectEqualStrings("typed-early", wall_pump.takeKeys(&tiles[1], &buf));
}
test "applyHostList: a reachable host that lost its sessions dresses seeded panes gone and keeps them standing" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
var table = [_]Host{fixture.testHost(&shared, "box", "/tmp/box.sock")};
// Two seeded panes, off-origin, distinct rects: the saved cut of a
// two-pane wall whose daemon rebooted overnight.
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);
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);
present[0] = true;
present[1] = true;
live = 2;
// The host is REACHABLE and answers empty: both panes dress gone, both
// stay present — the wall re-cuts nothing.
fixture.setList(&table[0], "");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(wv.State.gone, tiles[0].state);
try std.testing.expectEqual(wv.State.gone, tiles[1].state);
try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
// The poll FAILS next: gone yields to unreachable (the host cannot
// answer, which is a different sentence than "the host said no")...
table[0].poll.reachable.store(false, .release);
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(wv.State.@"unreachable", tiles[0].state);
// ...and recovery without the session dresses gone again. Flap over,
// both panes still standing.
fixture.setList(&table[0], "");
wall_host.applyHostList(fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table), 0);
try std.testing.expectEqual(wv.State.gone, tiles[0].state);
try std.testing.expectEqual(wv.State.gone, tiles[1].state);
try std.testing.expectEqual(@as(usize, 2), wv.presentCount(present[0..live]));
}
test "setFocus: landing on a gone pane says what the two keys are" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
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);
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);
present[0] = true;
present[1] = true;
live = 2;
tiles[1].state = .gone;
wv.setFocus(tiles[0..live], &shared, 1);
try std.testing.expectEqualStrings(wv.gone_notice, shared.notice[0..shared.notice_len]);
}
test "setFocus: landing on an unreachable pane says it is retrying and Enter retries" {
// The pane the wall used to be SILENT about: no notice on focus, so the
// user pressed Enter (the gone reflex) into nothing. It now says the
// host is retrying and that Enter hurries it, the mirror of the gone
// notice one state over.
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
defer fixture.endPumps(tiles[0..live]);
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);
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);
present[0] = true;
present[1] = true;
live = 2;
tiles[1].state = .@"unreachable";
wv.setFocus(tiles[0..live], &shared, 1);
try std.testing.expectEqualStrings(wv.unreachable_notice, shared.notice[0..shared.notice_len]);
}
test "armPane: the three wakes differ only in create and start" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
// A gone pane on a HAND host, so `asked` has somewhere to land. Gone
// means the host is up, so Enter re-creates ({create, no start}) and
// does so exactly where the pane stood.
var gone: Tile = undefined;
const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
try wv.seedTile(&gone, .{ .target = hand, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 41, .rows = 23, .cols = 39 }, &shared, 0, 0);
gone.state = .gone;
wv.armPane(&gone, .{ .creates = true, .asked = false });
try std.testing.expect(!gone.pending);
try std.testing.expect(gone.creates);
try std.testing.expect(!gone.r.target.hand.asked);
try std.testing.expectEqual(wv.State.connecting, gone.state);
try std.testing.expect(gone.alive.load(.acquire));
try std.testing.expect(!gone.pump_done.load(.acquire));
try std.testing.expectEqual(@as(u16, 41), gone.rect.left);
try std.testing.expectEqualStrings("a", gone.r.session);
// An unreachable pane on the same kind of host: its daemon may be dark,
// so Enter STARTS it first ({create, start}) — the one field that
// differs from the gone wake.
var unreach: Tile = undefined;
const hand2 = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
try wv.seedTile(&unreach, .{ .target = hand2, .label = "box#b", .session = "b" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 1, 0);
unreach.state = .@"unreachable";
wv.armPane(&unreach, .{ .creates = true, .asked = true });
try std.testing.expect(unreach.creates);
try std.testing.expect(unreach.r.target.hand.asked);
// A poll bind: attach only, neither create nor start.
var bind: Tile = undefined;
const hand3 = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
try wv.seedTile(&bind, .{ .target = hand3, .label = "box#c", .session = "c" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 2, 0);
bind.state = .waiting;
wv.armPane(&bind, .{ .creates = false, .asked = false });
try std.testing.expect(!bind.creates);
try std.testing.expect(!bind.r.target.hand.asked);
}
test "keysToFocused: a gone pane eats every byte except Enter, which revives without starting" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [2]Tile = undefined;
// The pump the Enter below spawns holds `*Tile` into this frame, and
// `shared` is this frame too: the test may not return until that thread
// has stored `alive = false`, or the next test's stack is what it reads.
defer fixture.endPumps(tiles[0..1]);
const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
try wv.seedTile(&tiles[0], .{ .target = hand, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
tiles[0].state = .gone;
// Ordinary typing reaches no session and queues nowhere.
wv.keysToFocused(&tiles[0], "ls -la");
try std.testing.expectEqual(@as(usize, 0), tiles[0].in_len);
try std.testing.expect(tiles[0].pending);
// Enter revives, and does NOT start the daemon — a gone host is up.
// (`running` is false in this fixture, so the spawned pump exits at its
// gate; the transitions are armPane's, pinned above.)
wv.keysToFocused(&tiles[0], "\r");
try std.testing.expect(!tiles[0].pending);
try std.testing.expect(!tiles[0].r.target.hand.asked);
tiles[0].pump_done.store(true, .release);
}
test "keysToFocused: Enter on an unreachable pane revives it AND starts the daemon" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tiles: [2]Tile = undefined;
defer fixture.endPumps(tiles[0..1]);
// A dark host's saved pane. The old behaviour re-polled and could never
// start a rebooted box's daemon; Enter now wakes a pump that dials
// `--start`, the one thing that boots it.
const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
try wv.seedTile(&tiles[0], .{ .target = hand, .label = "box#a", .session = "a" }, .{ .top = 1, .left = 0, .rows = 23, .cols = 40 }, &shared, 0, 0);
tiles[0].state = .@"unreachable";
// Ordinary typing is not the verb: the pane stays a pending drawing.
wv.keysToFocused(&tiles[0], "ls -la");
try std.testing.expect(tiles[0].pending);
try std.testing.expect(!tiles[0].r.target.hand.asked);
// Enter wakes it with the start armed.
wv.keysToFocused(&tiles[0], "\r");
try std.testing.expect(!tiles[0].pending);
try std.testing.expect(tiles[0].creates);
try std.testing.expect(tiles[0].r.target.hand.asked);
tiles[0].pump_done.store(true, .release);
}
test "planHostDiff: a session the daemon has and the wall does not is nobody's business: no birth, no tile" {
var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
var tiles = fixture.diffFixture(&shared);
var present = [_]bool{ true, true, true };
var binds = TileIdxs{};
var vanish = TileIdxs{};
var gones = TileIdxs{};
// Host 0 answers with its two panes' sessions and three the wall never
// asked for. Twice, so the grace has been spent and a vanish would show.
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones);
wall_host.planHostDiff(&tiles, &present, 3, 0, "a\nb\nx\ny\nz\n", null, &binds, &vanish, &gones);
try std.testing.expectEqual(@as(usize, 0), vanish.len);
try std.testing.expectEqual(@as(usize, 0), gones.len);
try std.testing.expectEqual(@as(usize, 3), wv.presentCount(&present));
}
test "applyHostList: a pane the daemon stopped listing leaves the layout file too" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var shared: Shared = undefined;
fixture.stoppedWall(alloc, &shared);
var tmp = try TmpDir.make();
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "{s}/layout", .{tmp.path()});
shared.layout_path = path;
var tiles: [4]Tile = undefined;
var present = [_]bool{false} ** 4;
var live: usize = 0;
// Two panes on two HOSTS: the survivor has to be a pane the vanishing
// one's host does not own, or a save that wrote the wrong slice would
// still look right.
var table = [_]Host{
fixture.testHost(&shared, "--sock /tmp/box.sock", "/tmp/box.sock"),
fixture.testHost(&shared, "--sock /tmp/vm.sock", "/tmp/vm.sock"),
};
try seatBound(&tiles, &present, &live, &shared, &.{
.{ .target = .{ .sock = "/tmp/box.sock" }, .label = "--sock /tmp/box.sock#a", .session = "a" },
.{ .target = .{ .sock = "/tmp/vm.sock" }, .label = "--sock /tmp/vm.sock#b", .session = "b" },
}, &.{ 0, 1 });
tiles[0].alive.store(true, .release);
tiles[1].alive.store(true, .release);
const w = fixture.wallOf(alloc, &tiles, &present, &live, &shared, &table);
// The wall as seated, on disk: what the poll is about to change.
wall_layout.persist(w);
const seated = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
try std.testing.expect(std.mem.indexOf(u8, seated, "--sock /tmp/box.sock#a") != null);
// Twice: the first list that loses a session is a grace, the second
// vanishes the pane.
fixture.setList(&table[0], "");
wall_host.applyHostList(w, 0);
wall_host.applyHostList(w, 0);
try std.testing.expect(!present[0]);
const after = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/box.sock#a") == null);
// The other host's pane is still the wall's, and still in the file.
try std.testing.expect(std.mem.indexOf(u8, after, "--sock /tmp/vm.sock#b") != null);
}