a73x

src/tui/wall_test_pump.zig

Ref:   Size: 29.5 KiB   History

//! One tile's pump thread (wall_pump.zig).
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const interact = @import("interact.zig");
const TmpDir = @import("testtmp").TmpDir;
const askpass = @import("client").askpass;
const fixture = @import("wall_test_harness.zig");
const wall_pump = @import("wall_pump.zig");
const wv = @import("wallview.zig");
const AgentLocal = wall_pump.AgentLocal;
const ClaimStep = wall_pump.ClaimStep;
const Shared = wv.Shared;
const SwallowTransport = fixture.SwallowTransport;
const Tile = wv.Tile;

test "claimAllowed: a stale arm does not claim even when the sink would now admit it" {
    const alloc = std.testing.allocator;
    const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(pipe[0]);
    defer std.posix.close(pipe[1]);
    var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    // The focus has moved to tile 1; tile 2 is still holding the arm it was
    // given before the popup went up.
    shared.sel = 1;
    var t = fixture.claimBench(&shared, 2);
    t.claim_pending.store(true, .release);
    var core = try interact.Core.initSized(alloc, -1, pipe[1], .{ .cols = 80, .rows = 24 });
    defer core.deinit();
    core.is_tty = true;

    // The whole of this test: the picker has CLOSED, so the default sink
    // admits every paint and `claimTerminal` would SUCCEED. Judging the
    // claim's refusal cannot see that — there is no refusal left to judge —
    // so the focus test has to run first, and `claimFocus` is the one door
    // in this file that puts the two in that order.
    const rect: proto.Size = .{ .cols = 80, .rows = 24 };
    _ = t.claim_pending.swap(false, .acq_rel);
    try std.testing.expectEqual(ClaimStep.dropped, wall_pump.claimFocus(&t, &core, rect));

    // Nothing claimed, and nothing on the terminal: `session_claim` is the
    // mouse modes, and a second tile setting them is what leaves one
    // session's modes on the screen with nothing left to take them off.
    try std.testing.expectEqual(interact.Claim.none, core.claim);
    var buf: [256]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 0), std.posix.read(pipe[0], &buf) catch 0);

    // The control: the same call on the tile that DOES hold the focus takes
    // it, so the guard is a focus test and not a blanket refusal.
    shared.sel = 2;
    try std.testing.expectEqual(ClaimStep.done, wall_pump.claimFocus(&t, &core, rect));
    try std.testing.expectEqual(interact.Claim.session, core.claim);
    try std.testing.expect((std.posix.read(pipe[0], &buf) catch 0) > 0);
}

test "afterClaim: a claim the picker refused is armed again for this tile" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    // Off-origin: tile 2 of a wall, not tile 0, so an index compared
    // against a hard-coded 0 would pass here and nowhere else.
    shared.sel = 2;
    var t = fixture.claimBench(&shared, 2);
    try std.testing.expectEqual(ClaimStep.rearmed, wall_pump.afterClaim(&t, false, false, true));
    try std.testing.expect(t.claim_pending.load(.acquire));
}

test "afterClaim: an arm does not outlive the focus that earned it" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    var t = fixture.claimBench(&shared, 2);
    // The keyboard moved the focus while the popup was up — a poller's tile
    // arriving, or the birth the Enter made. A re-arm that survived it
    // fires after the close: two tiles' modes on one terminal, the cursor
    // resting on the loser, and the release already consumed so nothing
    // takes either off.
    shared.sel = 1;
    try std.testing.expectEqual(ClaimStep.dropped, wall_pump.afterClaim(&t, false, false, true));
    try std.testing.expect(!t.claim_pending.load(.acquire));
}

test "afterClaim: only the SINK's refusal is worth another pass" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    shared.sel = 2;
    var t = fixture.claimBench(&shared, 2);
    // A claim that landed owes nothing.
    try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, true, true, true));
    // A Core that ALREADY holds it answers false too, and re-arming that
    // would re-take the focus notice on every pass, forever.
    try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, true, true));
    // No terminal, nothing to hold: a piped `mux` is one tile and no claim.
    try std.testing.expectEqual(ClaimStep.done, wall_pump.afterClaim(&t, false, false, false));
    // None of the three armed anything.
    try std.testing.expect(!t.claim_pending.load(.acquire));
}

test "tilePaintBegin: no tile paints while the picker owns the screen" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    var t = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
        .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    // The gate the whole popup rests on, and the gate that makes a focus
    // CLAIM fail under it (`Core.claimTerminal` writes through the same
    // sink) — which is why the pump re-arms `claim_pending` instead of
    // treating a false return as "already claimed".
    try std.testing.expect(wall_pump.tilePaintBegin(&t));
    wall_pump.tilePaintEnd(&t);
    shared.picker_open.store(true, .release);
    try std.testing.expect(!wall_pump.tilePaintBegin(&t));
    // Refused WITHOUT holding the lock: a begin that returned false and
    // kept `paint_mu` would wedge the keyboard on its next paint.
    try std.testing.expect(shared.paint_mu.tryLock());
    shared.paint_mu.unlock();
}

test "tilePaintBegin: a pass a relayout has superseded paints nothing; the next pass paints" {
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 44 }, .is_tty = true };
    var t = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" },
        .rect = .{ .top = 10, .left = 0, .rows = 8, .cols = 80 },
        .shared = &shared,
        .idx = 3,
        .wake_r = -1,
        .wake_w = -1,
    };
    // The pump paints from the pass it took at the top of its loop. Seen
    // on a wall of five when `x` ended a neighbour: the pump took its pass,
    // the keyboard's relayout cleared the screen and moved every rect, and
    // the pump's repaint then put this session's rows at the OLD offsets —
    // over the bar its own label had just painted at the NEW row. Nothing
    // repaints a bar after that, so the tile is gone from the final grid.
    const before = wall_pump.takePass(&t);
    try std.testing.expect(wall_pump.tilePaintBegin(&t));
    wall_pump.tilePaintEnd(&t);
    // What relayout does after re-cutting: the generation moves.
    t.shared.paint_mu.lock();
    t.rect = .{ .top = 12, .left = 0, .rows = 10, .cols = 80 };
    t.resize_pending = true;
    _ = shared.repaint_gen.fetchAdd(1, .release);
    t.shared.paint_mu.unlock();
    try std.testing.expect(!wall_pump.tilePaintBegin(&t));
    // ...and, as above, refused without the lock held.
    try std.testing.expect(shared.paint_mu.tryLock());
    shared.paint_mu.unlock();
    // The next pass is the relayout's: new rect, the resize it owes, and a
    // generation the pump has not painted yet — the repaint that puts the
    // tile back happens against THIS pass, which the sink admits.
    const after = wall_pump.takePass(&t);
    try std.testing.expect(after.gen != before.gen);
    try std.testing.expect(after.resize);
    try std.testing.expectEqual(@as(u16, 12), after.top);
    try std.testing.expect(wall_pump.tilePaintBegin(&t));
    wall_pump.tilePaintEnd(&t);
}

test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" {
    try std.testing.expectEqualStrings(
        "[no such session on that daemon]",
        wall_pump.endRefusal(proto.end_reason.no_session),
    );
    try std.testing.expectEqualStrings(
        "[the daemon could not read the end request]",
        wall_pump.endRefusal(proto.end_reason.bad_frame),
    );
    // Anything the daemon does not say: an OSC title set, a CSI, or 260
    // bytes of nothing. All three used to reach the terminal verbatim.
    try std.testing.expectEqualStrings(
        "[the daemon refused to end this session]",
        wall_pump.endRefusal("\x1b]0;pwned\x07"),
    );
    try std.testing.expectEqualStrings(
        "[the daemon refused to end this session]",
        wall_pump.endRefusal("\x1b[2J"),
    );
    try std.testing.expectEqualStrings("[the daemon refused to end this session]", wall_pump.endRefusal(""));
}

test "a copy too big for OSC 52 is said out loud rather than dropped" {
    // The notice path is the whole claim. A reply that rounds to ok on the
    // wire but outruns OSC 52 is neither trimmed nor dropped: a half-copied
    // selection is worse than none, because the user finds out when they
    // paste it. The drag is per-tile now, on the Core the pump owns, so the
    // copy goes through that Core — no claim is taken, the copy reads the
    // drag and not the terminal's modes.
    const alloc = std.testing.allocator;
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    var t = Tile{
        .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
        .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    var core = try interact.Core.initSized(alloc, -1, p[1], shared.size);
    defer core.deinit();
    core.rep.history_rows = 0;
    core.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
    core.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
    _ = core.drag.release();
    try std.testing.expect(core.drag.range() != null);
    var tr: SwallowTransport = .{};
    const held = core.drag.range() orelse return error.NoDrag;
    try core.requestSelection(&tr, held);

    // Base64 of this length overruns clipboard_base64_max: the daemon sends
    // it as ok, and OSC 52 cannot carry it.
    const fits = proto.clipboard_base64_max / 4 * 3;
    const big = try alloc.alloc(u8, fits + 1);
    defer alloc.free(big);
    @memset(big, 'x');
    const rbuf = try alloc.alloc(u8, proto.selection_reply_prefix_len + big.len);
    defer alloc.free(rbuf);
    const payload = fixture.replyBytes(rbuf, core.sel_id, .ok, 0, big);

    wall_pump.copySelection(&t, alloc, &core, payload);
    var buf: [8192]u8 = undefined;
    const said = fixture.readAvail(p[0], &buf);
    try std.testing.expect(std.mem.indexOf(u8, said, "too large") != null);
    // Refused, not trimmed: a half-copied selection is worse than none,
    // because the user finds out when they paste it.
    try std.testing.expect(std.mem.indexOf(u8, said, "\x1b]52;") == null);
}

test "a reconnect drops the highlight over its own tile, and only its own" {
    // Core.reattached clears that Core's drag — a resync renames the
    // absolute row space, so a highlight kept across one would invert rows
    // nobody selected. The drag is per-tile, so one tile's reconnect says
    // nothing about another's selection: the neighbour's highlight stays.
    const alloc = std.testing.allocator;
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    var tiles: [2]Tile = undefined;
    tiles[0] = Tile{
        .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
        .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    tiles[1] = Tile{
        .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
        .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 1,
        .wake_r = -1,
        .wake_w = -1,
    };
    // Each tile's pump owns its own Core, and each Core its own drag. A
    // drag over tile 0 and one over tile 1, anchored in their own tiles.
    var core0 = try interact.Core.initSized(alloc, -1, -1, shared.size);
    defer core0.deinit();
    var core1 = try interact.Core.initSized(alloc, -1, -1, shared.size);
    defer core1.deinit();
    core0.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 0, .row = 0, .col = 4 });
    core0.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 0, .row = 2, .col = 9 });
    core1.drag.press(.{ .row = 3, .col = 4 }, .{ .tile = 1, .row = 0, .col = 4 });
    core1.drag.motion(.{ .row = 5, .col = 9 }, .{ .tile = 1, .row = 2, .col = 9 });
    try std.testing.expectEqual(@as(?usize, 0), core0.drag.on());
    try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());

    // Tile 0 reconnects: its row space is about to be renamed, so its
    // highlight comes off. Tile 1's never went away.
    core0.reattached();
    try std.testing.expect(core0.drag.range() == null);
    try std.testing.expect(core0.drag.on() == null);
    try std.testing.expect(core1.drag.range() != null);
    try std.testing.expectEqual(@as(?usize, 1), core1.drag.on());
}

test "agent channels: slots fill in order, a full table refuses, and a close is announced" {
    // The pump's table at the size its refusal path needs. The e2e proves
    // one channel end to end and will never fill eight of them.
    var locals: [3]?AgentLocal = @splat(null);

    // Real fds, not stand-ins: a channel here is the write end of a pipe, so
    // "the table closed it" is answered by the READER seeing EOF rather than
    // by trusting the table's own bookkeeping. Non-blocking, so a channel
    // that was NOT closed reports EAGAIN and fails the assertion instead of
    // parking the suite on a read that never returns.
    var peers: [4]std.posix.fd_t = undefined;
    var chans: [4]std.posix.fd_t = undefined;
    for (0..4) |i| {
        const p = try std.posix.pipe2(.{ .NONBLOCK = true });
        peers[i] = p[0];
        chans[i] = p[1];
    }
    defer for (peers) |fd| std.posix.close(fd);

    for (0..3) |i| try std.testing.expectEqual(
        @as(?usize, i),
        wall_pump.storeLocal(&locals, @intCast(10 + i), chans[i]),
    );
    // The full table the pump answers with `agent_close`. The fourth fd stays
    // this test's to close: a refused store never took ownership of it.
    try std.testing.expectEqual(@as(?usize, null), wall_pump.storeLocal(&locals, 99, chans[3]));
    std.posix.close(chans[3]);

    try std.testing.expectEqual(@as(?usize, 1), wall_pump.findLocal(&locals, 11));
    try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 99));

    // A pipe for the link: the transport holds the WRITE end, since these
    // tests only ever send, and reads the pipe themselves. The frame that
    // comes back out of it is the daemon's only notice that this end hung
    // up on the channel.
    const link = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(link[0]);
    defer std.posix.close(link[1]);
    var transport: client.Transport = .{ .link = .{ .fd = link[1] } };

    wall_pump.closeLocal(&locals, 1, &transport);
    try std.testing.expectEqual(@as(?AgentLocal, null), locals[1]);
    try std.testing.expect(fixture.peerClosed(peers[1]));

    const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAgentCloseOnLocalClose;
    defer frame.deinit(std.testing.allocator);
    try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
    try std.testing.expectEqual(@as(u32, 11), try proto.decodeAgentId(frame.payload));

    // The redial's sweep: every channel still open goes, and nothing is said
    // — the connection that owned them is the one that just died.
    wall_pump.dropLocals(&locals);
    for (locals) |c| try std.testing.expectEqual(@as(?AgentLocal, null), c);
    try std.testing.expect(fixture.peerClosed(peers[0]));
    try std.testing.expect(fixture.peerClosed(peers[2]));
}

test "agent channels: an oversize frame hangs the channel up, a full one lands" {
    var locals: [2]?AgentLocal = @splat(null);
    const chan = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(chan[0]);
    _ = wall_pump.storeLocal(&locals, 5, chan[1]);

    const link = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(link[0]);
    defer std.posix.close(link[1]);
    var transport: client.Transport = .{ .link = .{ .fd = link[1] } };

    // The ordinary case first, so the refusal below is a refusal and not a
    // delivery path that never worked.
    var small: [proto.agent_id_len + 2]u8 = undefined;
    small[0..proto.agent_id_len].* = proto.encodeAgentId(5);
    @memcpy(small[proto.agent_id_len..], "hi");
    try std.testing.expect(wall_pump.deliverAgentData(&locals, &small, &transport));
    var got: [8]u8 = undefined;
    try std.testing.expectEqual(@as(usize, 2), try std.posix.read(chan[0], &got));
    try std.testing.expectEqualSlices(u8, "hi", got[0..2]);

    // One byte past the cap. The daemon never sends this; a peer that is not
    // the daemon we shipped can, and `writeAllFd` would block on it.
    const over = try std.testing.allocator.alloc(u8, proto.agent_id_len + proto.agent_data_max + 1);
    defer std.testing.allocator.free(over);
    @memset(over, 'x');
    over[0..proto.agent_id_len].* = proto.encodeAgentId(5);
    try std.testing.expect(wall_pump.deliverAgentData(&locals, over, &transport));
    try std.testing.expectEqual(@as(?AgentLocal, null), locals[0]);
    try std.testing.expect(fixture.peerClosed(chan[0]));

    // And the daemon is told, because it is holding the far socket open for
    // an answer this end will never write.
    // A payload too short to name a channel: false, and the pump abandons the
    // batch on it rather than skipping one frame, because a stream that has
    // lost frame alignment is not one to keep reading.
    try std.testing.expect(!wall_pump.deliverAgentData(&locals, "ab", &transport));

    const frame = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAgentCloseOnOversize;
    defer frame.deinit(std.testing.allocator);
    try std.testing.expectEqual(proto.MsgType.agent_close, frame.type);
    try std.testing.expectEqual(@as(u32, 5), try proto.decodeAgentId(frame.payload));
}

test "agent forwarding is per tile: no -A offers nothing and opens nothing" {
    // A REAL agent socket on this machine, listening. Without one, a tile
    // that never asked for forwarding and a tile that did both refuse — for
    // different reasons — and the consent gate could be deleted under a
    // green suite.
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [128]u8 = undefined;
    const sock = try std.fmt.bufPrintZ(&buf, "{s}/agent.sock", .{tmp.path()});
    const addr = try std.net.Address.initUnix(sock);
    var listener = try addr.listen(.{});
    defer listener.deinit();

    var locals: [2]?AgentLocal = @splat(null);
    defer wall_pump.dropLocals(&locals);

    // The gate, both ways round, against the same reachable agent.
    try std.testing.expect(!wall_pump.openAgentChan(&locals, 1, false, sock));
    try std.testing.expectEqual(@as(?usize, null), wall_pump.findLocal(&locals, 1));
    try std.testing.expect(wall_pump.openAgentChan(&locals, 1, true, sock));
    try std.testing.expect(wall_pump.findLocal(&locals, 1) != null);

    // And the daemon does not get told about an agent the tile never
    // offered — the offer is what the daemon routes on, so a stray one
    // makes this client the answerer for a session it never armed.
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    var t = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
        .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    const link = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(link[0]);
    defer std.posix.close(link[1]);
    var transport: client.Transport = .{ .link = .{ .fd = link[1] } };

    try wall_pump.sendAttach(&t, &transport, 0, 0);
    const first = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAttach;
    defer first.deinit(std.testing.allocator);
    try std.testing.expectEqual(proto.MsgType.attach, first.type);
    // Nothing else on the pipe at all: an empty non-blocking read is the
    // only proof that no offer followed, since a frame reader would just
    // block waiting for one.
    var spare: [1]u8 = undefined;
    try std.testing.expectError(error.WouldBlock, std.posix.read(link[0], &spare));

    // The positive control on the same pipe: with `-A` the offer follows the
    // attach, so the silence above is the gate and not an unwritten frame.
    t.r.agent = true;
    try wall_pump.sendAttach(&t, &transport, 0, 0);
    const attach = (try proto.readFrame(std.testing.allocator, link[0])).?;
    defer attach.deinit(std.testing.allocator);
    const offer = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAgentOffer;
    defer offer.deinit(std.testing.allocator);
    try std.testing.expectEqual(proto.MsgType.agent_offer, offer.type);
}

test "a view tile's attach makes no size claim; a tile the user asked for does" {
    // The daemon reads attach-or-create off the attach's size claim: 0x0
    // joins, a real size creates. So a view tile (creates = false) must
    // put 0x0 on the wire and leave its rect to the resize doorbell, while
    // a tile the user asked for (creates = true) claims its rect in the
    // attach itself.
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
    // A piped wall draws no bar, so viewRows is the whole stripe.
    var t = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
        .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    const link = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(link[0]);
    defer std.posix.close(link[1]);
    var transport: client.Transport = .{ .link = .{ .fd = link[1] } };

    // A view tile: 0x0 on the wire, and the rect owed to the doorbell.
    t.creates = false;
    t.resize_pending = false;
    try wall_pump.sendAttach(&t, &transport, 0, 0);
    const view = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAttach;
    defer view.deinit(std.testing.allocator);
    try std.testing.expectEqual(proto.MsgType.attach, view.type);
    const view_req = try proto.decodeAttach(view.payload);
    try std.testing.expectEqual(@as(u16, 0), view_req.cols);
    try std.testing.expectEqual(@as(u16, 0), view_req.rows);
    try std.testing.expect(t.resize_pending);

    // The same tile, now one the user asked for: the rect is in the attach,
    // and no doorbell is owed.
    t.creates = true;
    t.resize_pending = false;
    try wall_pump.sendAttach(&t, &transport, 0, 0);
    const entry = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAttach;
    defer entry.deinit(std.testing.allocator);
    try std.testing.expectEqual(proto.MsgType.attach, entry.type);
    const entry_req = try proto.decodeAttach(entry.payload);
    try std.testing.expectEqual(@as(u16, 80), entry_req.cols);
    try std.testing.expectEqual(@as(u16, 24), entry_req.rows);
    try std.testing.expect(!t.resize_pending);
}

test "a newborn tile's first claim is its real stripe, not the placeholder" {
    // A chord-born tile (creates = true) puts its rect on the attach frame,
    // and the daemon refuses creates under min_session_rows. A 2-row
    // stripe under a label bar is 1 content row — below the floor — so a
    // placeholder stripe silently freezes the session on a stale grid.
    // The tile's stripe must be what relayout would give it BEFORE the
    // pump's first sendAttach.
    var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    // Real stripe for the second tile of a 2-tile wall on 24 rows.
    var t = Tile{
        .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "", .agent = false },
        .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 1,
        .wake_r = -1,
        .wake_w = -1,
    };
    t.creates = true;
    const link = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(link[0]);
    defer std.posix.close(link[1]);
    var transport: client.Transport = .{ .link = .{ .fd = link[1] } };
    try wall_pump.sendAttach(&t, &transport, 0, 0);
    const fr = (try proto.readFrame(std.testing.allocator, link[0])) orelse
        return error.NoAttach;
    defer fr.deinit(std.testing.allocator);
    const req = try proto.decodeAttach(fr.payload);
    // The real stripe's viewRows is 11 (12 - 1 bar), never the 1 a
    // placeholder rows=2 stripe would put on the wire.
    try std.testing.expectEqual(@as(u16, 80), req.cols);
    try std.testing.expectEqual(@as(u16, 11), req.rows);
    try std.testing.expect(req.rows >= proto.min_session_rows);
}

test "the cursor sleeps in the focused tile, whoever painted last" {
    // Every painter ends by showing the cursor where its own stripe sits, so
    // without an owner the visible cursor lands on whichever pump painted
    // last. The fix: a focused paint records its screen cursor in Shared, and
    // an unfocused paint's last act is to put the cursor back there. A
    // focused tile with no core (the unit-test fixture) records nothing and
    // restores nothing — the core is what knows the cursor.
    const p = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(p[0]);
    defer std.posix.close(p[1]);
    var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    var tiles: [2]Tile = undefined;
    tiles[0] = Tile{
        .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
        .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 0,
        .wake_r = -1,
        .wake_w = -1,
    };
    tiles[1] = Tile{
        .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
        .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
        .shared = &shared,
        .idx = 1,
        .wake_r = -1,
        .wake_w = -1,
    };
    shared.sel = 0;
    shared.cursor = .{ .x = 3, .y = 5 };
    var buf: [512]u8 = undefined;

    // An UNFOCUSED paint (tile 1, core null) ends by restoring the shared
    // cursor: hide, CUP to the focused tile's stored position, then show
    // again so the cursor rests visible in the focused tile until its own
    // next paint — without the trailing show the cursor stays hidden.
    _ = wall_pump.tilePaintBegin(&tiles[1]);
    wall_pump.tilePaintEnd(&tiles[1]);
    try std.testing.expectEqualStrings("\x1b[?25l\x1b[6;4H\x1b[?25h", fixture.readAvail(p[0], &buf));

    // A FOCUSED paint (tile 0, core null) records nothing and restores
    // nothing: no core means the shared cursor is left untouched, and the
    // pipe receives no new bytes.
    _ = wall_pump.tilePaintBegin(&tiles[0]);
    wall_pump.tilePaintEnd(&tiles[0]);
    try std.testing.expectEqual(@as(usize, 0), fixture.readAvail(p[0], &buf).len);
}

test "askOn: a wall dial carries the popup's socket, and every other target is untouched" {
    const alloc = std.testing.allocator;
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    const pipe = try std.posix.pipe2(.{ .NONBLOCK = true });
    defer std.posix.close(pipe[0]);
    defer std.posix.close(pipe[1]);
    var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
    shared.kb_w = pipe[1];

    const hand = client.Target{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
    // No listener: the OLD behaviour, and the visible one — ssh reads
    // /dev/tty. A piped `mux` and a box with no runtime dir are both here.
    const bare = wall_pump.askOn(hand, &shared);
    try std.testing.expect(bare.hand.ask_sock == null);
    try std.testing.expect(bare.hand.askpassFor() == null);

    const l = try askpass.Listener.start(alloc, tmp.path(), .{
        .ctx = &shared,
        .wake = struct {
            fn nop(_: *anyopaque) void {}
        }.nop,
    });
    defer l.stop();
    shared.prompts = l;
    shared.prompt_exe = "/opt/mux";
    const armed = wall_pump.askOn(hand, &shared);
    const ask = armed.hand.askpassFor() orelse return error.TestUnexpectedResult;
    try std.testing.expectEqualStrings(l.path, ask.sock);
    try std.testing.expectEqualStrings("/opt/mux", ask.exe);

    // A socket or a `--via` dial spawns no ssh, so there is nothing to
    // point anywhere — and a target arm added later must not silently
    // acquire a field this one cannot use.
    const sock = wall_pump.askOn(.{ .sock = "/tmp/d.sock" }, &shared);
    try std.testing.expect(sock == .sock);
}

test "askpassFor: a socket with no binary to carry it is no askpass at all" {
    // SSH_ASKPASS pointing at nothing makes ssh FAIL every prompt rather
    // than ask one, which is worse than the tty read this replaces.
    const half = client.HandoffTarget{
        .host = "box",
        .ssh_argv = &.{"ssh"},
        .cache_path = null,
        .ask_sock = "/run/ask.sock",
    };
    try std.testing.expect(half.askpassFor() == null);
}