src/server/server_test_modes.zig
Ref: Size: 34.9 KiB History
const std = @import("std");
const proto = @import("term").protocol;
const h = @import("server_test_harness.zig");
const dial = h.dial;
const srv_mod = @import("server.zig");
const Server = srv_mod.Server;
const awaitFrame = h.awaitFrame;
const connectedPair = h.connectedPair;
fn pumpAndCollectModes(
alloc: std.mem.Allocator,
srv: *Server,
fd: std.posix.fd_t,
out: *std.ArrayList(proto.PtyModeFlags),
) !void {
try srv.pumpOnce(20);
try drainModes(alloc, fd, out);
}
/// Pump until `out` holds `want` mode frames, keeping every one of them in
/// order. A sink rather than a `pumpUntil`, because the condition is
/// counted out of the frames themselves: the predicate would have to read
/// the socket to answer, and a predicate with a side effect is a worse
/// thing to read than this.
fn collectModes(
alloc: std.mem.Allocator,
srv: *Server,
fd: std.posix.fd_t,
out: *std.ArrayList(proto.PtyModeFlags),
want: usize,
iters: usize,
) !bool {
const Collect = struct {
alloc: std.mem.Allocator,
out: *std.ArrayList(proto.PtyModeFlags),
want: usize,
fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void {
if (frame.type != .pty_mode) return;
const self: *@This() = @ptrCast(@alignCast(ctx.?));
try self.out.append(self.alloc, try proto.decodePtyMode(frame.payload));
if (self.out.items.len >= self.want) return error.ModesCollected;
}
};
var collect: Collect = .{ .alloc = alloc, .out = out, .want = want };
_ = h.awaitFrameSink(alloc, srv, fd, h.never_from_daemon, iters, .{
.ctx = &collect,
.on = Collect.on,
}) catch |err| switch (err) {
error.ModesCollected => return true,
else => return err,
};
return false;
}
/// No pump: for paths that answer synchronously, where pumping would blur what
/// caused the frame. Its own poll, and not a `Link.awaitFrame`, because it
/// does not WAIT at all — it takes what is already readable and returns.
fn drainModes(
alloc: std.mem.Allocator,
fd: std.posix.fd_t,
out: *std.ArrayList(proto.PtyModeFlags),
) !void {
while (true) {
var pfd = [_]std.posix.pollfd{
.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
};
if ((try std.posix.poll(&pfd, 0)) == 0) return;
const frame = (try proto.readFrame(alloc, fd)) orelse return;
defer frame.deinit(alloc);
if (frame.type == .pty_mode) {
try out.append(alloc, try proto.decodePtyMode(frame.payload));
}
}
}
test "Server: an attach arriving on an established connection is answered with the mode" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "reattach");
defer td.deinit();
// /bin/cat: a session that produces no output of its own, so the only
// frames on this socket are the ones the attach caused.
try td.start(.{ .shell = "/bin/cat" });
const c = try connectedPair();
defer std.posix.close(c.peer);
// Already a client before it has said a word — how a QUIC client starts life,
// since its slot is created at handshake and its FIRST attach reaches
// `handleFrame` rather than `serviceObserver`. The two attach paths are
// separate arms, each needing its own send. No session on purpose.
td.srv.clients[0] = .{ .sink = .{ .socket = c.daemon } };
var modes: std.ArrayList(proto.PtyModeFlags) = .empty;
defer modes.deinit(alloc);
var frame: std.ArrayList(u8) = .empty;
defer frame.deinit(alloc);
try proto.appendFrame(&frame, alloc, .attach, &proto.encodeAttach(80, 24, 0, 0));
// First attach, before any pump has run: this is the branch that reads
// the pty for itself because nothing has been broadcast yet.
td.srv.pushInbound(0, frame.items);
try drainModes(alloc, c.peer, &modes);
try std.testing.expectEqual(@as(usize, 1), modes.items.len);
try std.testing.expect(modes.items[0].icanon);
try std.testing.expect(modes.items[0].echo);
// And again once a pump has been round, where the daemon already knows
// the mode and the fallback is not what is being exercised. A reattach
// over a live connection is a real client's reconnect path.
try td.srv.pumpOnce(20);
modes.clearRetainingCapacity();
td.srv.pushInbound(0, frame.items);
try drainModes(alloc, c.peer, &modes);
try std.testing.expectEqual(@as(usize, 1), modes.items.len);
try std.testing.expect(modes.items[0].icanon);
try std.testing.expect(modes.items[0].echo);
}
test "Server: the pty's mode bits reach a client on attach, and again only when they change" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "mode");
defer td.deinit();
// A SCRIPT, because readline takes the tty out of canonical mode per line
// and puts it back, so the bits flap continuously and "changed exactly once"
// could not be asserted. It ends on a blocking read, so `deinit`'s SIGTERM
// lands on the shell itself.
try td.tmp.dir.writeFile(.{
.sub_path = "session.sh",
.data =
\\#!/bin/sh
\\read -r start
\\stty -echo
\\read -r second
\\stty -icanon
\\read -r stop
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/session.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
var modes: std.ArrayList(proto.PtyModeFlags) = .empty;
defer modes.deinit(alloc);
// Attaching is enough on its own: nothing about the session has changed,
// so a client that only heard about changes would never be told what the
// terminal is doing.
try std.testing.expect(try collectModes(alloc, &td.srv, c.handle, &modes, 1, 250));
try std.testing.expectEqual(@as(usize, 1), modes.items.len);
try std.testing.expect(modes.items[0].icanon);
try std.testing.expect(modes.items[0].echo);
// Release the script into `stty -echo`. Note what the daemon gets out of
// this: no output at all. The mode change is silent, which is why the
// poll cannot be folded into the "the pty printed something" arm.
try proto.writeFrame(c.handle, .input, "go\n");
try std.testing.expect(try collectModes(alloc, &td.srv, c.handle, &modes, 2, 500));
try std.testing.expectEqual(@as(usize, 2), modes.items.len);
try std.testing.expect(!modes.items[1].echo);
// -echo alone: the line discipline is still canonical, and reporting
// otherwise would tell a client to earn its predictions in the one
// context where it must make none at all.
try std.testing.expect(modes.items[1].icanon);
// The transition that pins where the poll lives: echo is off, so between
// here and the frame below the pty emits NOT ONE BYTE. A daemon that checked
// the mode only after reading pty output sits here forever.
try proto.writeFrame(c.handle, .input, "silent\n");
try std.testing.expect(try collectModes(alloc, &td.srv, c.handle, &modes, 3, 500));
try std.testing.expectEqual(@as(usize, 3), modes.items.len);
try std.testing.expect(!modes.items[2].icanon);
try std.testing.expect(!modes.items[2].echo);
// And then nothing: the pty is in a blocking read with its mode unchanged,
// so a daemon that re-sent what it already said shows up as a fourth frame
// on the very NEXT pump. 25 rounds, because round one catches it — a
// fixed count on purpose, since the verdict is that a FOURTH frame never
// came and there is no arrival to wait for.
var rounds: usize = 0;
while (rounds < 25) : (rounds += 1) {
try pumpAndCollectModes(alloc, &td.srv, c.handle, &modes);
}
try std.testing.expectEqual(@as(usize, 3), modes.items.len);
}
test "Server: a BEL from the session reaches its client as a bell term_event" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "bell");
defer td.deinit();
// A sibling of the clipboard test rather than a leg inside it, which is named
// for clipboard SCOPING. What is untested is the drain's `.bell` arm:
// `encodeBellEvent` and the client's rendering are each pinned alone, and
// neither runs this function.
try td.start(.{ .shell = "/bin/cat" });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
(try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse
return error.NoSnapshot).deinit(alloc);
// The newline flushes the line to cat in canonical mode. cat writes the
// raw BEL back whatever ECHOCTL does to the echo alongside it — mangled to
// `^G` the echo is not a bell at all, unmangled it is a second one — so
// the first term_event is a bell either way.
try proto.writeFrame(c.handle, .input, "\x07\n");
const ev = (try awaitFrame(alloc, &td.srv, c.handle, .term_event, 400)) orelse
return error.NoTermEvent;
defer ev.deinit(alloc);
switch (try proto.decodeTermEvent(ev.payload)) {
.bell => {},
.clipboard => return error.ExpectedBellGotClipboard,
}
}
test "Server: a burst of bells in one chunk is coalesced into one frame" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "bellburst");
defer td.deinit();
// A script rather than `cat`, and the COUNT is the reason: typed input is
// echoed, so five typed BELs can reach the engine twice in two chunks and
// therefore two honest drains. A file writes them once.
//
// The `read` is not decoration: a script that rings at once races the attach,
// and the drain then has nobody to queue to — the event is recorded pending,
// but a fresh attach takes the snapshot branch and replays nothing.
try td.tmp.dir.writeFile(.{
.sub_path = "bellburst.sh",
.data =
\\#!/bin/sh
\\read -r go
\\printf '\007\007\007\007\007'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/bellburst.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
// Awaited, not assumed: this is what makes the ring below strictly later
// than the attach, and so what removes the race described above.
(try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse
return error.NoSnapshot).deinit(alloc);
// `go`, never a BEL: with a bell typed here the tty's own echo could
// arrive as a separate event in a separate chunk, and this test would be
// counting ECHOCTL rather than coalescing.
try proto.writeFrame(c.handle, .input, "go\n");
const ev = (try awaitFrame(alloc, &td.srv, c.handle, .term_event, 400)) orelse
return error.NoTermEvent;
defer ev.deinit(alloc);
switch (try proto.decodeTermEvent(ev.payload)) {
.bell => {},
.clipboard => return error.ExpectedBellGotClipboard,
}
// The boundary, with the positive already in hand so it cannot pass
// vacuously: the other four rings must not have become four more frames.
// The budget keeps pumping long past the drain that produced the first,
// which is the very pump the rest would have been queued in.
if (try awaitFrame(alloc, &td.srv, c.handle, .term_event, 60)) |extra| {
extra.deinit(alloc);
return error.BellsNotCoalesced;
}
}
test "Server: a bell in a later chunk is its own frame, not folded into the first" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "belltwice");
defer td.deinit();
// Coalescing is PER DRAIN, and this is the half the burst test cannot see:
// hoisting the flag to session state would pass it and silence every ring
// after the first for the session's life.
//
// Each ring is released by its own `read`, which puts the two in separate
// chunks and both strictly after the attach. What is typed to release them
// is `go`, never a BEL: an echoed bell could arrive as a second event and
// pass this test on the leftovers of the first.
try td.tmp.dir.writeFile(.{
.sub_path = "belltwice.sh",
.data =
\\#!/bin/sh
\\read -r one
\\printf '\007'
\\read -r two
\\printf '\007'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/belltwice.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
(try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse
return error.NoSnapshot).deinit(alloc);
try proto.writeFrame(c.handle, .input, "one\n");
const first = (try awaitFrame(alloc, &td.srv, c.handle, .term_event, 400)) orelse
return error.NoFirstBell;
first.deinit(alloc);
try proto.writeFrame(c.handle, .input, "two\n");
const second = (try awaitFrame(alloc, &td.srv, c.handle, .term_event, 400)) orelse
return error.SecondBellSwallowed;
defer second.deinit(alloc);
switch (try proto.decodeTermEvent(second.payload)) {
.bell => {},
.clipboard => return error.ExpectedBellGotClipboard,
}
}
test "Server: a session enabling bracketed paste tells its clients, and not again per pump" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "modes");
defer td.deinit();
// Two pty chunks with one mode change between them: the DECSET, then —
// once the test asks for it — output that changes nothing about the
// modes. The second chunk is what makes "sent on change" falsifiable;
// without it a per-pump send would pass just as well.
try td.tmp.dir.writeFile(.{
.sub_path = "modes.sh",
.data =
\\#!/bin/sh
\\printf '\033[?2004h'
\\read -r go
\\printf 'same modes as before'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/modes.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
// attach answers with the modes as they stand, which is before the shell
// has run: keep pumping until one says the DECSET landed. Asserting on
// the FIRST term_modes would be asserting on the attach-time one.
const on = while (try awaitFrame(alloc, &td.srv, c.handle, .term_modes, 400)) |f| {
defer f.deinit(alloc);
if ((try proto.decodeTermModes(f.payload)).bracketed_paste) break true;
} else false;
try std.testing.expect(on);
// Now more pty output with the modes unchanged. A delta proves a chunk
// was digested — without it the "no second frame" half would pass
// vacuously on a session that simply never spoke again — and a second
// term_modes would prove the send is per-pump rather than on change.
try proto.writeFrame(c.handle, .input, "go\n");
// A round-counting loop on purpose: the wait ends a fixed number of
// pumps AFTER the content frame, not when some frame arrives, and the
// verdict is what did NOT come in that window.
var saw_content = false;
var resent: usize = 0;
var after: usize = 0;
var i: usize = 0;
while (i < 400 and after < 60) : (i += 1) {
if (saw_content) after += 1;
try td.srv.pumpOnce(5);
var pfd = [_]std.posix.pollfd{
.{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
};
if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
const f = (try proto.readFrame(alloc, c.handle)) orelse break;
defer f.deinit(alloc);
switch (f.type) {
.delta, .snapshot => saw_content = true,
.term_modes => resent += 1,
else => {},
}
}
try std.testing.expect(saw_content);
try std.testing.expectEqual(@as(usize, 0), resent);
}
test "Server: a session entering and leaving the alternate screen tells its clients both times" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "altmodes");
defer td.deinit();
// The pair a pager sets and clears together: the alternate screen and
// DECCKM. They are asserted as a pair because a client that read one
// and guessed the other would send `less` arrows it ignores.
try td.tmp.dir.writeFile(.{
.sub_path = "altmodes.sh",
.data =
\\#!/bin/sh
\\printf '\033[?1049h\033[?1h'
\\read -r go
\\printf '\033[?1l\033[?1049l'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/altmodes.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
// The attach-time frame reports the modes as they stood before the
// shell ran, so the loop pumps until one carries the pager's pair.
const entered = while (try awaitFrame(alloc, &td.srv, c.handle, .term_modes, 400)) |f| {
defer f.deinit(alloc);
const m = try proto.decodeTermModes(f.payload);
if (m.alt_screen and m.cursor_keys) break true;
} else false;
try std.testing.expect(entered);
// Leaving must be reported too. A client told only about entering
// would keep turning the wheel into arrows after the pager quit.
try proto.writeFrame(c.handle, .input, "go\n");
const left = while (try awaitFrame(alloc, &td.srv, c.handle, .term_modes, 400)) |f| {
defer f.deinit(alloc);
const m = try proto.decodeTermModes(f.payload);
if (!m.alt_screen and !m.cursor_keys) break true;
} else false;
try std.testing.expect(left);
}
test "Server: a client attaching to a session already in bracketed paste is told, on either resync branch" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "modesjoin");
defer td.deinit();
try td.tmp.dir.writeFile(.{
.sub_path = "modesjoin.sh",
.data =
\\#!/bin/sh
\\printf '\033[?2004h'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/modesjoin.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
// The first client is here to get the change SENT, not to observe it:
// once term_modes_sent holds true, sampleTermModes will never fire again
// this session, so everything below can only be the resync's doing.
const a = try dial.dialAttach(td.sock_path, 80, 24);
defer a.close();
const enabled = while (try awaitFrame(alloc, &td.srv, a.handle, .term_modes, 400)) |f| {
defer f.deinit(alloc);
if ((try proto.decodeTermModes(f.payload)).bracketed_paste) break true;
} else false;
try std.testing.expect(enabled);
// Snapshot branch: a joiner holding nothing. It must be told what is
// true now, or it would paint a session whose application wants
// bracketed paste while its host terminal has never heard of it.
const b = try dial.dialAttach(td.sock_path, 80, 24);
defer b.close();
var snap = try modesWithResync(alloc, &td.srv, b.handle);
try std.testing.expectEqual(proto.MsgType.snapshot, snap.content orelse
return error.NoContentFrameOnJoin);
try std.testing.expect(snap.modes orelse return error.NoTermModesOnSnapshotBranch);
// Delta branch: the same client re-attaching current, at the same size.
// Seq and epoch read off the session rather than parsed back out of the
// snapshot — the branch under test is chosen by exactly these two values,
// so handing them over verbatim is what makes the branch certain.
const s = &td.srv.sessions.table[0].?;
try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, s.tracker.seq, s.epoch));
snap = try modesWithResync(alloc, &td.srv, b.handle);
try std.testing.expectEqual(proto.MsgType.delta, snap.content orelse
return error.NoContentFrameOnReattach);
try std.testing.expect(snap.modes orelse return error.NoTermModesOnDeltaBranch);
}
test "Server: a joiner that resizes the grid is still told the session's modes" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "modesresize");
defer td.deinit();
try td.tmp.dir.writeFile(.{
.sub_path = "modesresize.sh",
.data =
\\#!/bin/sh
\\printf '\033[?2004h'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/modesresize.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
// As in the resync-branch test above: the first client is here to latch
// term_modes_sent, after which sampleTermModes can never fire again this
// session and everything below is the resync's doing alone.
const a = try dial.dialAttach(td.sock_path, 80, 24);
defer a.close();
const enabled = while (try awaitFrame(alloc, &td.srv, a.handle, .term_modes, 400)) |f| {
defer f.deinit(alloc);
if ((try proto.decodeTermModes(f.payload)).bracketed_paste) break true;
} else false;
try std.testing.expect(enabled);
// The third arm of `sendResync`, which the other two tests cannot reach: a
// joiner at a DIFFERENT size returns early through `resyncSnapshot`. Left
// bare, bracketed paste stops being mirrored after any window resize, and
// nothing connects a shell echoing a literal `200~` to the resize.
const b = try dial.dialAttach(td.sock_path, 100, 30);
defer b.close();
// A's re-snapshot at B's size is the witness that this arm ran at all:
// resyncSnapshot broadcasts, and the other two arms send to the joiner
// alone. Without it the test could pass down the snapshot branch and
// pin nothing the resync-branch test does not already cover.
// Two connections at once, which is why this keeps its own poll:
// `Link.awaitFrame` waits on ONE link, and the verdict here is a fact
// about A and a fact about B arriving in the same window.
var a_resnapshotted = false;
var b_modes: ?bool = null;
var i: usize = 0;
while (i < 400 and !(a_resnapshotted and b_modes != null)) : (i += 1) {
try td.srv.pumpOnce(5);
var pfds = [_]std.posix.pollfd{
.{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 },
};
if ((std.posix.poll(&pfds, 1) catch 0) == 0) continue;
for (pfds) |pfd| {
if (pfd.revents & std.posix.POLL.IN == 0) continue;
const f = (try proto.readFrame(alloc, pfd.fd)) orelse continue;
defer f.deinit(alloc);
if (pfd.fd == a.handle and f.type == .snapshot) {
const p = try proto.readSnapshotPrefix(f.payload);
if (p.cols == 100 and p.rows == 30) a_resnapshotted = true;
}
if (pfd.fd == b.handle and f.type == .term_modes) {
b_modes = (try proto.decodeTermModes(f.payload)).bracketed_paste;
}
}
}
try std.testing.expect(a_resnapshotted);
try std.testing.expect(b_modes orelse return error.NoTermModesOnResizingJoin);
}
test "Server: a window title reaches clients on change, and only on change" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "title");
defer td.deinit();
try td.tmp.dir.writeFile(.{
.sub_path = "title.sh",
// The leading `read` is what makes this about the SAMPLER: a shell that
// printed its title at once would race the attach, and the frame this
// waits for could be the resync's.
.data =
\\#!/bin/sh
\\read -r go
\\printf '\033]0;first\007'
\\read -r go2
\\printf 'output that sets no title'
\\read -r go3
\\printf '\033]0;second\007'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/title.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
// Queued behind the attach on the same socket, so the daemon has taken
// the attach before it can forward this — see the script's leading read.
try proto.writeFrame(c.handle, .input, "go\n");
const first = (try awaitFrame(alloc, &td.srv, c.handle, .term_title, 400)) orelse
return error.NoTitleFrame;
defer first.deinit(alloc);
try std.testing.expectEqualStrings("first", first.payload);
// More pty output with the title unchanged. The delta proves a chunk was
// digested — without it the "no second frame" half would pass vacuously
// on a session that simply never spoke again.
try proto.writeFrame(c.handle, .input, "go\n");
// A round-counting loop on purpose: the wait ends a fixed number of
// pumps AFTER the content frame, not when some frame arrives, and the
// verdict is what did NOT come in that window.
var saw_content = false;
var resent: usize = 0;
var after: usize = 0;
var i: usize = 0;
while (i < 400 and after < 60) : (i += 1) {
if (saw_content) after += 1;
try td.srv.pumpOnce(5);
var pfd = [_]std.posix.pollfd{
.{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
};
if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
const f = (try proto.readFrame(alloc, c.handle)) orelse break;
defer f.deinit(alloc);
switch (f.type) {
.delta, .snapshot => saw_content = true,
.term_title => resent += 1,
else => {},
}
}
try std.testing.expect(saw_content);
try std.testing.expectEqual(@as(usize, 0), resent);
// A DIFFERENT title does travel: the early return is a change filter,
// not a one-title-per-session latch.
try proto.writeFrame(c.handle, .input, "go\n");
const second = (try awaitFrame(alloc, &td.srv, c.handle, .term_title, 400)) orelse
return error.NoSecondTitleFrame;
defer second.deinit(alloc);
try std.testing.expectEqualStrings("second", second.payload);
}
test "Server: a session that never set a title has none sent for it" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "notitle");
defer td.deinit();
try td.tmp.dir.writeFile(.{
.sub_path = "notitle.sh",
.data =
\\#!/bin/sh
\\printf 'plain output, no OSC 0 anywhere'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/notitle.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
// Two attaches, because the two paths that could send an empty title are
// different code: the SAMPLER and the RESYNC. An empty title on either makes
// the client wipe the title bar of a terminal whose session said nothing.
const a = try dial.dialAttach(td.sock_path, 80, 24);
defer a.close();
var saw_content = false;
var titles: usize = 0;
var after: usize = 0;
var joined = false;
var b: ?std.net.Stream = null;
defer if (b) |s| s.close();
// Both reasons at once: two connections, and a round-counting settle
// whose verdict is `titles == 0`.
var i: usize = 0;
while (i < 500 and after < 80) : (i += 1) {
if (saw_content) {
// The joiner goes in only once the shell's output has landed, so
// its resync reads a session that has genuinely run.
if (!joined) {
b = try dial.dialAttach(td.sock_path, 80, 24);
joined = true;
}
after += 1;
}
try td.srv.pumpOnce(5);
// Sliced to what exists rather than padded with a placeholder. The
// padding this replaces put A's fd in slot 1 until B joined and
// relied on a `continue` to skip it — and in a test whose verdict is
// `titles == 0`, double-reading A's frames fails toward PASSING.
var pfds = [_]std.posix.pollfd{
.{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = if (b) |s| s.handle else -1, .events = std.posix.POLL.IN, .revents = 0 },
};
const live = pfds[0..if (b == null) @as(usize, 1) else 2];
if ((std.posix.poll(live, 1) catch 0) == 0) continue;
for (live) |pfd| {
if (pfd.revents & std.posix.POLL.IN == 0) continue;
const f = (try proto.readFrame(alloc, pfd.fd)) orelse continue;
defer f.deinit(alloc);
switch (f.type) {
.delta, .snapshot => saw_content = true,
.term_title => titles += 1,
else => {},
}
}
}
try std.testing.expect(saw_content);
try std.testing.expect(joined);
try std.testing.expectEqual(@as(usize, 0), titles);
}
test "Server: a joiner that resizes the grid is still told the session's title" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "titleresize");
defer td.deinit();
try td.tmp.dir.writeFile(.{
.sub_path = "titleresize.sh",
.data =
\\#!/bin/sh
\\printf '\033]0;vim\007'
\\exec sleep 30
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/titleresize.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
// The first client latches title_sent, after which sampleTermTitle can
// never fire again for this title and everything below is the resync's
// doing alone.
const a = try dial.dialAttach(td.sock_path, 80, 24);
defer a.close();
const latched = (try awaitFrame(alloc, &td.srv, a.handle, .term_title, 400)) orelse
return error.NoTitleFrame;
defer latched.deinit(alloc);
try std.testing.expectEqualStrings("vim", latched.payload);
// The third arm of sendResync: a joiner at a DIFFERENT size returns early
// through resyncSnapshot, before the delta/snapshot split. Left bare it
// is a real regression and a quiet one — the title bar would silently
// stop matching the session after any window resize.
const b = try dial.dialAttach(td.sock_path, 100, 30);
defer b.close();
// A's re-snapshot at B's size is the witness that this arm ran at all:
// resyncSnapshot broadcasts, and the other two arms send to the joiner
// alone.
// Two connections again; see the bracketed-paste test above for why the
// poll stays.
var a_resnapshotted = false;
var b_title: ?[]const u8 = null;
defer if (b_title) |t| alloc.free(t);
var i: usize = 0;
while (i < 400 and !(a_resnapshotted and b_title != null)) : (i += 1) {
try td.srv.pumpOnce(5);
var pfds = [_]std.posix.pollfd{
.{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 },
};
if ((std.posix.poll(&pfds, 1) catch 0) == 0) continue;
for (pfds) |pfd| {
if (pfd.revents & std.posix.POLL.IN == 0) continue;
const f = (try proto.readFrame(alloc, pfd.fd)) orelse continue;
defer f.deinit(alloc);
if (pfd.fd == a.handle and f.type == .snapshot) {
const p = try proto.readSnapshotPrefix(f.payload);
if (p.cols == 100 and p.rows == 30) a_resnapshotted = true;
}
if (pfd.fd == b.handle and f.type == .term_title and b_title == null) {
b_title = try alloc.dupe(u8, f.payload);
}
}
}
try std.testing.expect(a_resnapshotted);
try std.testing.expectEqualStrings("vim", b_title orelse return error.NoTitleOnResizingJoin);
}
/// Which resync branch answered the join, and what it said about the modes.
/// A sink rather than a bare `awaitFrame`, because the branch is named by
/// the frame BEFORE the answer and awaitFrame drops those.
fn modesWithResync(
alloc: std.mem.Allocator,
srv: *Server,
fd: std.posix.fd_t,
) !struct { content: ?proto.MsgType, modes: ?bool } {
const Branch = struct {
content: ?proto.MsgType = null,
fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void {
if (frame.type != .snapshot and frame.type != .delta) return;
const self: *@This() = @ptrCast(@alignCast(ctx.?));
self.content = frame.type;
}
};
var branch: Branch = .{};
var modes: ?bool = null;
if (try h.awaitFrameSink(alloc, srv, fd, .term_modes, 400, .{
.ctx = &branch,
.on = Branch.on,
})) |frame| {
defer frame.deinit(alloc);
modes = (try proto.decodeTermModes(frame.payload)).bracketed_paste;
}
return .{ .content = branch.content, .modes = modes };
}
test "Server: a resize reaches the pty as an in-band size report when the app asked for one" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "inband");
defer td.deinit();
// `cat`, not a shell: the oracle below is the line discipline's own echo, and
// a shell's line editor turns ECHO off and reads the report as editing keys —
// `ESC [` then digits is a numeric argument to readline.
try td.start(.{ .shell = "/bin/cat" });
const s = &td.srv.sessions.table[0].?;
// The app opts in. The engine queues the immediate report; drop it so
// only the resize's own report is on trial below.
s.eng.feed("\x1b[?2048h");
s.eng.clearPtyOutput();
try std.testing.expect(td.srv.applySize(0, 100, 30));
// The report is INPUT to the child, so the only place to see it from
// here is the tty's own echo of it back on the master. Canonical mode
// may spell the ESC as ^[, so the match starts after it.
// Raw bytes off the pty master, not frames on a connection, so there is
// no Link here to await on.
var got: std.ArrayList(u8) = .empty;
defer got.deinit(alloc);
var tries: usize = 0;
while (tries < 50) : (tries += 1) {
var pfd = [_]std.posix.pollfd{.{ .fd = s.pty.master, .events = std.posix.POLL.IN, .revents = 0 }};
if ((try std.posix.poll(&pfd, 100)) > 0) {
var buf: [512]u8 = undefined;
const n = std.posix.read(s.pty.master, &buf) catch 0;
try got.appendSlice(alloc, buf[0..n]);
}
if (std.mem.indexOf(u8, got.items, "[48;30;100;0;0t") != null) break;
}
try std.testing.expect(std.mem.indexOf(u8, got.items, "[48;30;100;0;0t") != null);
}