src/server/server_test_session.zig
Ref: Size: 82.5 KiB History
const std = @import("std");
const builtin = @import("builtin");
const Grid = @import("term").grid.Grid;
const proto = @import("term").protocol;
const quic = @import("quic");
const quic_server = @import("quic_server.zig");
const xdg = @import("xdg");
const TmpDir = @import("testtmp").TmpDir;
const h = @import("server_test_harness.zig");
const dial = h.dial;
const Pty = h.Pty;
const srv_mod = @import("server.zig");
const SessionTable = @import("server_sessions.zig").SessionTable;
const Server = srv_mod.Server;
const boundUdpPort = srv_mod.boundUdpPort;
const max_sessions = srv_mod.max_sessions;
const shutdown_flag = &srv_mod.shutdown_flag;
const awaitFrame = h.awaitFrame;
const awaitFrameOn = h.awaitFrameOn;
const connectedPair = h.connectedPair;
const firstStateFrame = h.firstStateFrame;
// Rationale: a test's daemon needs a shell to spawn, and this
// file's fixture helpers sit outside the `test` blocks the rule skips.
/// The teardown on the failing branch is not tidiness: a Server that got built
/// owns a live shell on a pty, and discarding it leaves that shell holding the
/// test runner's stdout — the build never sees EOF and hangs instead of
/// printing a failure.
fn expectInitRefused(alloc: std.mem.Allocator, path: []const u8, want: anyerror) !void {
// folder rule 5 exemption: This fixture invokes a shell to exercise session behavior.
if (Server.init(alloc, .{ .sock_path = path, .shell = "/bin/sh" })) |built| {
var stolen = built;
stolen.deinit();
return error.InitShouldHaveRefused;
} else |err| {
try std.testing.expectEqual(want, err);
}
}
test "Server: a second daemon refuses a live socket instead of stealing it" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "live", .{ .shell = "/bin/sh" });
defer td.deinit();
try td.threaded();
// The incident: this used to unlink the path and bind over it, leaving
// the first daemon running and unreachable.
try expectInitRefused(alloc, td.sock_path, error.DaemonAlreadyRunning);
// The loser touched nothing: the socket file is still there...
const st = try std.posix.fstatat(std.posix.AT.FDCWD, td.sock_path, 0);
try std.testing.expect(std.posix.S.ISSOCK(st.mode));
// ...and it still reaches the daemon that was already there. The epoch
// is what makes that precise: it names one daemon *instance*, so
// matching it rules out having been handed a replacement.
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
const first = try firstStateFrame(alloc, c.handle, 10_000);
try std.testing.expect(first != null);
try std.testing.expectEqual(proto.MsgType.snapshot, first.?.type);
try std.testing.expectEqual(td.srv.sessions.table[0].?.epoch, first.?.epoch);
}
test "Server: a dead daemon's leftover socket file is cleared and rebound" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "stale");
defer td.deinit();
// What a daemon killed with SIGKILL leaves behind: a bound socket file
// whose listener is gone, since deinit's unlink never ran.
{
const addr = try std.net.Address.initUnix(td.sock_path);
var dead = try addr.listen(.{});
dead.deinit(); // closes the fd; the file stays on disk
}
const before = try std.posix.fstatat(std.posix.AT.FDCWD, td.sock_path, 0);
try std.testing.expect(std.posix.S.ISSOCK(before.mode));
// Nobody answers there, so the path is ours: recovery, not refusal.
try td.start(.{ .shell = "/bin/sh" });
try td.threaded();
// And the rebind is real, not just a file that reappeared: it serves.
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
const first = try firstStateFrame(alloc, c.handle, 10_000);
try std.testing.expect(first != null);
try std.testing.expectEqual(td.srv.sessions.table[0].?.epoch, first.?.epoch);
}
test "Server: a path that cannot be bound fails as AddressInUse" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const dir_path = tmp.path();
const sock_path = try std.fmt.allocPrint(alloc, "{s}/dangling.sock", .{dir_path});
defer alloc.free(sock_path);
// A dangling symlink reaches `bind()` the way a lost start-up race does, but
// deterministically: the stat gets ENOENT so the probe reads the path as
// free, while the bind gets EADDRINUSE off the symlink's own entry.
try tmp.dir.symLink("no-such-target", "dangling.sock", .{});
// Linux only, and not because of a spelling: Darwin's bind FOLLOWS a
// dangling symlink and creates the socket at the name it points to, so
// there is no unbindable path to grade there. Measured 2026-09-03 on
// macOS 26 with a plain C bind, which returned 0.
if (builtin.os.tag != .linux) return error.SkipZigTest;
try expectInitRefused(alloc, sock_path, error.AddressInUse);
// And the path is left alone: we could not identify it as a dead
// daemon's socket, so it was never ours to delete.
const st = try std.posix.fstatat(
std.posix.AT.FDCWD,
sock_path,
std.posix.AT.SYMLINK_NOFOLLOW,
);
try std.testing.expect(std.posix.S.ISLNK(st.mode));
}
test "Server: a non-socket at the path is refused, not deleted" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const dir_path = tmp.path();
const file_path = try std.fmt.allocPrint(alloc, "{s}/notes.txt", .{dir_path});
defer alloc.free(file_path);
const contents = "mux must not eat this";
try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = contents });
// Nothing but the stat separates this file from a dead daemon's socket:
// Linux answers a connect to a regular file with ECONNREFUSED, the very
// same errno a dead socket gives, so identifying stale sockets by the
// connect result alone would delete this file. `claim` stats first and
// never connects to a non-socket at all, and this test is what pins that.
try expectInitRefused(alloc, file_path, error.SockPathNotASocket);
const after = try tmp.dir.readFileAlloc(alloc, "notes.txt", 1024);
defer alloc.free(after);
try std.testing.expectEqualStrings(contents, after);
}
// BEFORE the QUIC integration tests, and not cosmetically: widening `deinit`'s
// `.owned` arm to free a `.borrowed` listener makes the daemon free one it does
// not own, and every test down there hands one in and frees it itself — so the
// regression double-frees and WEDGES in wolfSSL teardown, printing nothing at
// all. Here it fails first and says what broke.
//
// Worth knowing before moving it: a FAILURE here returns before its own
// `l.deinit()`, so the process-global latch stays held and every QUIC test
// below refuses its bind rather than reaching the free.
test "Server: a listener attached by the caller survives srv.deinit — ownership stays with whoever bound it" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "ep3");
defer td.deinit();
var kbuf: [128]u8 = undefined;
const key_path = try std.fmt.bufPrint(&kbuf, "{s}/key", .{td.tmp.path()});
try xdg.writeNewKey(key_path);
const key = try quic.Key.load(key_path);
// Bound out here, as main.zig's explicit `--quic` path does. Deliberately NOT
// deferred: which side frees this IS the claim, so the frees are written in
// the order the claim is about — a deferred free would be a double free the
// moment the guard stopped guarding.
const addr = try std.net.Address.parseIp("0.0.0.0", 0);
const l = try quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms);
const port = boundUdpPort(l);
try std.testing.expect(port != 0);
{
try td.start(.{ .shell = "/bin/sh" });
defer td.shutdown();
// The two lines main.zig's run() uses, in its order.
l.setHandler(td.srv.quicHandler());
td.srv.attachQuic(l);
// attachQuic adopts the reference and nothing else: the tag that
// decides who frees says borrowed, which is what the switch in
// deinit reads.
try std.testing.expect(td.srv.quic == .borrowed);
// The reporting path answers from the listener it was handed, with
// no env and no key resolution — the same short circuit the lazy
// path takes once it has bound.
try std.testing.expectEqual(port, td.srv.endpointPortFrom(null, null, null));
}
// The daemon's `deinit` has run and the listener must have survived it: the
// process-global latch is still held, so a fresh bind is refused. Without
// this, a deinit that freed somebody else's listener shows up only as a hang.
try std.testing.expectError(
error.ListenerAlreadyRunning,
quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms),
);
// And it is still ours to free, which is the other half of "survived".
l.deinit();
const l2 = try quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms);
l2.deinit();
}
test "Server: a clean exit takes its socket file with it" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "unlink", .{ .shell = "/bin/sh" });
defer td.deinit();
try std.fs.cwd().access(td.sock_path, .{});
td.shutdown();
// The EFFECT, not the reasoning: a guard comparing a sockfs inode against a
// filesystem one is false every time, and the leftover socket is
// indistinguishable from a correct refusal to delete someone else's.
try std.testing.expectError(
error.FileNotFound,
std.fs.cwd().access(td.sock_path, .{}),
);
}
test "Server: a socket that replaced ours is not ours to delete" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "replaced", .{ .shell = "/bin/sh" });
defer td.deinit();
// A second daemon takes the path over while the first is still up —
// the shape of the field incident behind the socket-steal fix, and the
// whole reason the unlink is guarded rather than unconditional.
try std.fs.cwd().deleteFile(td.sock_path);
const addr = try std.net.Address.initUnix(td.sock_path);
var survivor = try addr.listen(.{});
defer survivor.deinit();
td.shutdown();
// The survivor's socket is still there. Deleting it would leave the
// daemon that owns it listening on a path nothing can reach.
try std.fs.cwd().access(td.sock_path, .{});
const st = try std.posix.fstatat(std.posix.AT.FDCWD, td.sock_path, 0);
try std.testing.expect(std.posix.S.ISSOCK(st.mode));
std.fs.cwd().deleteFile(td.sock_path) catch {};
}
test "Server: stats reports live client slots, and the number comes down again" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "gauge", .{ .shell = "/bin/sh" });
defer td.deinit();
var buf: [Server.stats_text_len]u8 = undefined;
// indexOf rather than endsWith: the text no longer ends on the
// global gauge — a per-session tail follows it — so the assertion has
// to name the field it means rather than lean on it being last.
try std.testing.expect(std.mem.indexOf(u8, try td.srv.statsText(&buf), "clients=0") != null);
// Slots filled directly: what is under test is the gauge, not the
// machinery that fills them. Emptied by defer so that a failed
// assertion below still leaves teardown a valid Server — otherwise the
// real failure is buried under an abort from closing fd -1.
defer {
td.srv.clients[0] = null;
td.srv.clients[3] = null;
}
td.srv.clients[0] = .{ .sink = .{ .socket = -1 } };
td.srv.clients[3] = .{ .sink = .{ .socket = -1 } };
// Filled with no session (session: null, the promoted-but-unattached
// shape), so this raises the daemon-wide gauge on the main line
// without moving the default session's own per-session count — that
// is the boundary the next assertion pins.
try std.testing.expect(std.mem.indexOf(u8, try td.srv.statsText(&buf), "clients=2") != null);
// A gauge, not a counter: the whole reason for adding it is watching
// occupancy clear, so it has to be able to go down.
td.srv.clients[0] = null;
try std.testing.expect(std.mem.indexOf(u8, try td.srv.statsText(&buf), "clients=1") != null);
td.srv.clients[3] = null;
try std.testing.expect(std.mem.indexOf(u8, try td.srv.statsText(&buf), "clients=0") != null);
// ...and `attaches=` is the COUNTER beside it, which is why both exist:
// occupancy went 0 → 2 → 0 above without an attach frame ever arriving, so
// this is still 0. It answers "did anyone attach since I last looked".
try std.testing.expect(std.mem.indexOf(u8, try td.srv.statsText(&buf), "attaches=0") != null);
td.srv.stats.attaches += 1;
try std.testing.expect(std.mem.indexOf(u8, try td.srv.statsText(&buf), "attaches=1") != null);
td.srv.clients[0] = .{ .sink = .{ .socket = -1 } };
// A second client seating itself does not move it either — only the
// attach handler does, and a gauge moving is not an attach.
const both = try td.srv.statsText(&buf);
try std.testing.expect(std.mem.indexOf(u8, both, "clients=1") != null);
try std.testing.expect(std.mem.indexOf(u8, both, "attaches=1") != null);
td.srv.clients[0] = null;
// The fields the harnesses parse are still where they were: appended,
// never reordered. The old leading `seq=` was one session's tracker
// and moved into the per-session tail; the main line now starts
// with the counters that were always daemon-global.
const text = try td.srv.statsText(&buf);
try std.testing.expect(std.mem.startsWith(u8, text, "snapshots="));
try std.testing.expect(std.mem.indexOf(u8, text, " snapshot_equiv_bytes=") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " sessions=1") != null);
try std.testing.expect(std.mem.indexOf(u8, text, " session 0 clients=0 seq=0") != null);
}
// A hand-counted bound is the failure this pins: `statsText` writes into a fixed
// buffer, so an under-count is `error.WriteFailed` and the `.stats_req` arm
// drops the client — `mux d stats` reports the daemon gone rather than a short
// line. A new counter is what makes these values reachable.
test "Server: the stats buffer holds the widest reply its format can print" {
var buf: [Server.stats_text_len]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const widest = std.math.maxInt(u64);
// A wrong count here is a compile error, not a silent pass: `print`
// checks the tuple against the format string.
try w.print(Server.stats_main_fmt, .{widest} ** 11);
const name = "n" ** proto.session_name_max;
for (0..max_sessions) |_| {
try w.print(Server.stats_session_fmt, .{ name, widest, widest });
}
}
test "Server: stop_req from a bare connection requests shutdown; run returns 0" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "stop");
defer td.deinit();
// The flag is global by necessity (a signal handler shares it); reset
// so this test neither inherits a stale request nor leaves one behind.
shutdown_flag.store(false, .release);
defer shutdown_flag.store(false, .release);
try td.start(.{ .shell = "/bin/sh" });
const c = try dial.dial(td.sock_path);
defer c.close();
// No attach first: the frame must be honored from the OBSERVER
// dispatch, which is where a bare `mux d stop` connection lives.
try proto.writeFrame(c.handle, .stop_req, "");
// Bounded, so the mutation run FAILS here instead of hanging the
// suite: 100 iterations x 50ms is the deadline, the flag is the exit.
var i: usize = 0;
while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) {
try td.srv.pumpOnce(50);
}
try std.testing.expect(shutdown_flag.load(.acquire));
// `run()` re-checks the flag before its first poll, so this returns without
// pumping. It is the one unbounded call here, safe only because the expect
// above guarantees the flag. 0: a daemon asked to stop did its job, and a
// supervisor reads nonzero as a crash-loop.
try std.testing.expectEqual(@as(u8, 0), try td.srv.run());
}
test "Server: stop_req from an attached client is honored too" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "stop2");
defer td.deinit();
shutdown_flag.store(false, .release);
defer shutdown_flag.store(false, .release);
try td.start(.{ .shell = "/bin/sh" });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
// Both frames go out before any pump and the stop still lands in
// `handleFrame`: one observer read takes both into the buffer, the attach
// promotes the connection with that buffer, and the stop drains as the
// client's through `pushInbound`.
try proto.writeFrame(c.handle, .stop_req, "");
var i: usize = 0;
while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) {
try td.srv.pumpOnce(50);
}
try std.testing.expect(shutdown_flag.load(.acquire));
}
test "Server: endpoint_req binds a listener lazily, answers the same port on both dispatches, and deinit hands it back" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "ep");
defer td.deinit();
var kbuf: [128]u8 = undefined;
const key_path = try std.fmt.bufPrint(&kbuf, "{s}/key", .{td.tmp.path()});
try xdg.writeNewKey(key_path);
const key = try quic.Key.load(key_path);
// Scoped so the daemon's deinit runs before the release check below, and still
// runs if an assertion inside fails.
var first_port: u16 = 0;
{
try td.start(.{ .shell = "/bin/sh" });
defer td.shutdown();
// Key resolution first, through the env seam: MUX_KEY_FILE's value
// handed in rather than set, since tests cannot setenv.
first_port = td.srv.endpointPortFrom(key_path, null, null);
try std.testing.expect(first_port != 0);
try std.testing.expect(td.srv.quicListener() != null);
// Now the wire, on a listener that already exists — which is what
// keeps this half from depending on the test machine having a real
// ~/.config/mux/key. The observer arm is the load-bearing one:
// `mux d endpoint` never attaches.
const obs = try dial.dial(td.sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .endpoint_req, "");
const reply = (try awaitFrame(alloc, &td.srv, obs.handle, .endpoint_reply, 200)) orelse
return error.NoEndpointReply;
defer reply.deinit(alloc);
try std.testing.expectEqual(first_port, try proto.decodeEndpointReply(reply.payload));
// Asked twice, answered the same: a second bind attempt would be
// refused by the process-global latch and surface as port 0, so
// equality is the stronger claim than "nonzero again".
try proto.writeFrame(obs.handle, .endpoint_req, "");
const reply2 = (try awaitFrame(alloc, &td.srv, obs.handle, .endpoint_reply, 200)) orelse
return error.NoSecondEndpointReply;
defer reply2.deinit(alloc);
try std.testing.expectEqual(first_port, try proto.decodeEndpointReply(reply2.payload));
// The attached-client arm answers the same verb the same way, on a
// separate connection that attaches first.
const cl = try dial.dialAttach(td.sock_path, 80, 24);
defer cl.close();
try proto.writeFrame(cl.handle, .endpoint_req, "");
const reply3 = (try awaitFrame(alloc, &td.srv, cl.handle, .endpoint_reply, 400)) orelse
return error.NoAttachedEndpointReply;
defer reply3.deinit(alloc);
try std.testing.expectEqual(first_port, try proto.decodeEndpointReply(reply3.payload));
}
// A lazily bound listener is the server's to free, and this is the
// observable for that: the latch is only released by a deinit that
// happened, so a fresh bind succeeding proves the old one is gone.
const addr = try std.net.Address.parseIp("0.0.0.0", 0);
const l2 = try quic_server.Listener.bind(alloc, addr, key, quic.default_idle_ms);
l2.deinit();
}
test "Server: endpointPortFrom refuses without a key, survives it, and prefers a listener already bound" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "ep2");
defer td.deinit();
var kbuf: [128]u8 = undefined;
const key_path = try std.fmt.bufPrint(&kbuf, "{s}/key", .{td.tmp.path()});
try xdg.writeNewKey(key_path);
const key = try quic.Key.load(key_path);
try td.start(.{ .shell = "/bin/sh" });
// No env at all: keyPathFrom has nothing to build a default from, so
// there is no key and the answer is 0 rather than a crash.
try std.testing.expectEqual(@as(u16, 0), td.srv.endpointPortFrom(null, null, null));
// Named but absent: Key.load fails, same answer.
var mbuf: [128]u8 = undefined;
const missing = try std.fmt.bufPrint(&mbuf, "{s}/absent-key", .{td.tmp.path()});
try std.testing.expectEqual(@as(u16, 0), td.srv.endpointPortFrom(missing, null, null));
// A default path pointing at a directory with no key in it: resolvable,
// not present, so the access check refuses before Key.load is asked.
try std.testing.expectEqual(@as(u16, 0), td.srv.endpointPortFrom(null, td.tmp.path(), null));
// Refusing left no half-state behind: nothing was bound, and the daemon
// is still able to bind when a real key does turn up.
try std.testing.expect(td.srv.quic == .none);
const port = try td.srv.lazyBindQuic(key);
try std.testing.expect(port != 0);
// With a listener in hand the answer is the bound port, and key
// resolution is never reached — all-null env would otherwise return 0.
try std.testing.expectEqual(port, td.srv.endpointPortFrom(null, null, null));
}
test "Server: status_req is answered on an attached client and on a bare observer" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "status");
defer td.deinit();
// /bin/cat: a session that produces no output of its own, so nothing
// moves the state these assertions describe.
try td.start(.{ .shell = "/bin/cat" });
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
try proto.writeFrame(c.handle, .status_req, "");
const f = (try awaitFrame(alloc, &td.srv, c.handle, .status_reply, 400)) orelse
return error.NoAttachedStatusReply;
defer f.deinit(alloc);
const st = try proto.decodeStatusReply(f.payload);
try std.testing.expectEqual(@as(u16, 80), st.cols);
try std.testing.expectEqual(@as(u16, 24), st.rows);
try std.testing.expect(!st.alt_screen);
try std.testing.expectEqual(proto.CmdPhase.at_prompt, st.cmd.phase);
// No C has ever been seen on this session, so the reply reports the
// regime it is actually in — pgid, not marks. That field is a report of
// which mechanism would decide, not a claim that one just did.
try std.testing.expectEqual(proto.Mechanism.pgid, st.cmd.mechanism);
// `mux a status` never attaches, so the observer arm is the load-bearing
// one — same reasoning as endpoint_req's, and the same failure if it is
// missing: the frame falls into `else => {}` and the caller hangs.
const obs = try dial.dial(td.sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .status_req, "");
const f2 = (try awaitFrame(alloc, &td.srv, obs.handle, .status_reply, 400)) orelse
return error.NoObserverStatusReply;
defer f2.deinit(alloc);
const st2 = try proto.decodeStatusReply(f2.payload);
try std.testing.expectEqual(@as(u16, 80), st2.cols);
try std.testing.expectEqual(@as(u16, 24), st2.rows);
try std.testing.expectEqual(proto.CmdPhase.at_prompt, st2.cmd.phase);
}
/// Doubles as an absence probe: with the positives asserted, a bounded `false`
/// means they never crossed onto this connection.
fn pumpUntilReplicaSees(
alloc: std.mem.Allocator,
srv: *Server,
fd: std.posix.fd_t,
rep: *Grid,
needle: []const u8,
iters: usize,
) !bool {
// The pumping twin of `h.awaitReplicaText`: same sink, and a wait that
// drives the daemon because this test's daemon has no thread.
var feed: h.ReplicaFeed = .{ .alloc = alloc, .w = .{ .replica = rep, .needle = needle } };
_ = h.awaitFrameSink(alloc, srv, fd, h.never_from_daemon, iters, feed.sink()) catch |err| switch (err) {
error.TextArrived => return true,
else => return err,
};
return false;
}
test "Server: two named sessions hold two shells with independent content" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "twonames");
defer td.deinit();
// /bin/cat sessions: what is typed comes straight back and nothing else
// ever prints, so each grid holds exactly its own client's marker.
try td.start(.{ .shell = "/bin/cat" });
const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer ca.close();
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer cb.close();
try proto.writeFrame(ca.handle, .input, "MARKER-ALPHA\n");
try proto.writeFrame(cb.handle, .input, "MARKER-BETA\n");
var rep_a = try Grid.init(alloc, 80, 24);
defer rep_a.deinit();
var rep_b = try Grid.init(alloc, 80, 24);
defer rep_b.deinit();
// Liveness first: each client converges on its own marker.
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, ca.handle, rep_a, "MARKER-ALPHA", 400),
);
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, cb.handle, rep_b, "MARKER-BETA", 400),
);
// The boundary: keep pumping so any misdirected broadcast has every
// chance to arrive, then assert the other session's marker never does.
try std.testing.expect(
!try pumpUntilReplicaSees(alloc, &td.srv, ca.handle, rep_a, "MARKER-BETA", 30),
);
try std.testing.expect(
!try pumpUntilReplicaSees(alloc, &td.srv, cb.handle, rep_b, "MARKER-ALPHA", 30),
);
// Two real sessions stood up beside the default, each under its name.
try std.testing.expect(td.srv.sessions.table[1] != null);
try std.testing.expect(td.srv.sessions.table[2] != null);
const si_a = td.srv.sessions.find("a") orelse return error.SessionAMissing;
const si_b = td.srv.sessions.find("b") orelse return error.SessionBMissing;
try std.testing.expect(si_a != si_b);
try std.testing.expect(si_a != 0 and si_b != 0);
try std.testing.expectEqualStrings("a", td.srv.sessions.table[si_a].?.name());
try std.testing.expectEqualStrings("b", td.srv.sessions.table[si_b].?.name());
}
test "SessionTable: a name read from the table is that session's own, after the table changes again" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "ownname", .{ .shell = "/bin/sh" });
defer td.deinit();
// Four slots, no two names the same length. The escape this pins gave
// every session the LAST one's bytes truncated to its own length, so a
// table of equal-length names sees nothing, and a table of one sees less.
td.srv.sessions.table[1] = try SessionTable.create(alloc, td.srv.spawn_plan, "aaa", 80, 24, null);
td.srv.sessions.table[2] = try SessionTable.create(alloc, td.srv.spawn_plan, "bb", 80, 24, null);
// Read out and HELD, which is the shape that broke: a name is a slice,
// and the question is whose bytes it points at once the walk that
// produced it is over. Inline storage per slot is what makes this safe;
// one buffer shared by the table would not be.
const held = [_][]const u8{ td.srv.ses(0).name(), td.srv.ses(1).name(), td.srv.ses(2).name() };
// The table changes under the slices, and the newest name is READ —
// the read is the half that matters, because that is what would write
// over a buffer the table shared.
td.srv.sessions.table[3] = try SessionTable.create(alloc, td.srv.spawn_plan, "zzzzz", 80, 24, null);
try std.testing.expectEqualStrings("zzzzz", td.srv.ses(3).name());
try std.testing.expectEqualStrings(proto.default_session, held[0]);
try std.testing.expectEqualStrings("aaa", held[1]);
try std.testing.expectEqualStrings("bb", held[2]);
}
test "Server: every session shell is told the socket it lives on and its own name" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "selfenv", .{ .shell = "/bin/sh" });
defer td.deinit();
// The shell does the comparing, so a pass means the CHILD saw the
// daemon's own path — not that this test can rebuild the string. The
// marker is assembled by printf from pieces, never typed whole, so the
// grid's echo of the command line can never satisfy the grep.
const probe = try std.fmt.allocPrint(
alloc,
"[ \"$MUX_SOCK\" = \"{s}\" ] && printf 'SOCK%s-%s\\n' OK \"$MUX_SESSION\"\n",
.{td.sock_path},
);
defer alloc.free(probe);
const c0 = try dial.dialAttachNamed(td.sock_path, 80, 24, "");
defer c0.close();
const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer ca.close();
try proto.writeFrame(c0.handle, .input, probe);
try proto.writeFrame(ca.handle, .input, probe);
var rep_0 = try Grid.init(alloc, 80, 24);
defer rep_0.deinit();
var rep_a = try Grid.init(alloc, 80, 24);
defer rep_a.deinit();
// The default session's name on the wire is empty; what its shell is
// told is the RESOLVED name, because that is what a client comparing
// `--session` against it will have resolved too.
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, c0.handle, rep_0, "SOCKOK-0", 600),
);
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, ca.handle, rep_a, "SOCKOK-a", 600),
);
}
test "Server: a bare 20-byte attach lands in the default session" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "bare", .{ .shell = "/bin/cat" });
defer td.deinit();
// The wire spelling from before named sessions, byte for byte: no name
// tail at all. An
// old client must land in the default session, not create a nameless
// one beside it.
const c = try dial.dialAttach(td.sock_path, 80, 24);
defer c.close();
const f = (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400)) orelse
return error.NoSnapshotOnBareAttach;
f.deinit(alloc);
const si = td.srv.clients[0].?.session orelse return error.SlotHoldsNoSession;
try std.testing.expectEqualStrings(proto.default_session, td.srv.sessions.table[si].?.name());
// And nothing was created for it: the default was already there.
for (td.srv.sessions.table[1..]) |slot| try std.testing.expect(slot == null);
}
test "Server: an attach past max_sessions is refused with exit_status, sessions intact" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "tablefull", .{ .shell = "/bin/cat" });
defer td.deinit();
// The default session holds slot 0, so `max_sessions - 1` more fill the
// table. Each attach is confirmed before the next, so the refusal below is
// unambiguously "no session slot" — and each connection CLOSES, because
// the two tables are the same size: holding all of them open would leave
// the client table one attach from full, and the refusal under test could
// then be either table's. The session outlives its client, which makes
// closing safe.
for (1..max_sessions) |i| {
var nb: [8]u8 = undefined;
const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i});
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, nm);
defer c.close();
const f = (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400)) orelse
return error.NoSnapshotFillingTable;
f.deinit(alloc);
}
// The closes above are only visible to the daemon once it polls, and a
// client slot it still believes is live would refuse the probe below
// before the session table ever got asked.
for (0..8) |_| try td.srv.pumpOnce(1);
// One name past the table gets the same honest no a full client table
// gives.
const extra = try dial.dialAttachNamed(td.sock_path, 80, 24, "extra");
defer extra.close();
const f = (try awaitFrame(alloc, &td.srv, extra.handle, .exit_status, 400)) orelse
return error.NoRefusal;
defer f.deinit(alloc);
try std.testing.expect(f.payload.len == 1 and f.payload[0] == 1);
// And it cost nobody anything: every slot's shell is live, none exited.
for (0..max_sessions) |si| {
try std.testing.expect(td.srv.sessions.table[si] != null);
try std.testing.expect(td.srv.sessions.table[si].?.pty.checkExited() == null);
}
}
// PLURAL on purpose: the grace is a per-child number, and one session cannot
// tell "one grace for the table" from "one grace each". A supervisor's stop
// timeout kills a daemon that spends the second shape.
test "Server: a table of TERM-ignoring shells costs one grace, not one each" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "stubborn");
defer td.deinit();
// The stubborn shell is the harness's, and its doors do not return until
// that session's own traps are armed. The arming is what makes the child
// ignore HUP and TERM, and only a child that ignores them pays the grace
// at all, so a shell signalled before its `trap` line ran would let every
// assertion below hold with no stubborn shell in the table.
try td.startStubborn(alloc, .{});
// Eight beside the default: enough that serial grace (9 x 500ms) and
// shared grace (500ms) cannot be confused, cheap enough to spawn.
for (1..9) |i| {
var nb: [8]u8 = undefined;
const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i});
const c = try td.attachStubborn(alloc, nm, 80, 24);
defer c.close();
}
var kids: [max_sessions]std.posix.pid_t = undefined;
var n: usize = 0;
for (&td.srv.sessions.table) |*slot| {
if (slot.* == null) continue;
const s = &slot.*.?;
try std.testing.expect(s.pty.checkExited() == null);
kids[n] = s.pty.child;
n += 1;
}
try std.testing.expectEqual(@as(usize, 9), n);
var t = try std.time.Timer.start();
td.shutdown();
const elapsed_ms = t.read() / std.time.ns_per_ms;
if (elapsed_ms >= 1500) {
std.debug.print(
"teardown of {d} stubborn shells took {d}ms: the grace is being spent per child, not shared\n",
.{ n, elapsed_ms },
);
return error.TeardownGraceNotShared;
}
// Ask the OS, not the daemon: a pid that was reaped names no process at
// all, so `kill(pid, 0)` must fail. That catches a survivor, and it
// catches a zombie the daemon claimed to have waited for too — a zombie
// is still a process table entry and `kill(pid, 0)` on one SUCCEEDS,
// which is the same verdict the /proc read this replaces gave, since a
// zombie keeps its /proc directory. Spelled through `kill` rather than
// /proc because every OS has it.
for (kids[0..n]) |pid| {
try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0));
}
}
test "Server: an invalid name on the wire is refused, not created" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "badname", .{ .shell = "/bin/cat" });
defer td.deinit();
// A raw-wire client can spell any bytes it likes; a name no tool could
// ever address must die here, not become a session.
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "has space");
defer c.close();
const f = (try awaitFrame(alloc, &td.srv, c.handle, .exit_status, 400)) orelse
return error.NoRefusal;
defer f.deinit(alloc);
try std.testing.expect(f.payload.len == 1 and f.payload[0] == 1);
try std.testing.expect(td.srv.sessions.table[1] == null);
}
test "Server: safeName refuses to hand the console a peer's bytes" {
// status_req's payload is the whole frame, capped only by max_payload,
// and the decoders deliberately do not validate names. These strings
// are what a peer can put in one; none of them may reach stderr.
try std.testing.expectEqualStrings("<invalid>", SessionTable.safeName("\x1b]0;pwned\x07"));
try std.testing.expectEqualStrings("<invalid>", SessionTable.safeName("\x1b[2J"));
try std.testing.expectEqualStrings("<invalid>", SessionTable.safeName("has space"));
try std.testing.expectEqualStrings("<invalid>", SessionTable.safeName("a" ** (proto.session_name_max + 1)));
// A legal name still prints as itself — the point is to filter, not to
// stop naming the session an operator asked about.
try std.testing.expectEqualStrings("b", SessionTable.safeName("b"));
// ...and an empty tail is the default session, named, not "<invalid>".
try std.testing.expectEqualStrings(proto.default_session, SessionTable.safeName(""));
}
/// One script serves every session the daemon spawns (there is one
/// spawn_plan), so "session a dies while b lives" is spelled by what each
/// client TYPES, not by what each shell is.
fn writeMortalScript(tmp: *TmpDir) !void {
try tmp.dir.writeFile(.{
.sub_path = "mortal.sh",
.data =
\\#!/bin/sh
\\while read -r line; do
\\ case "$line" in
\\ die*) exit "${line#die }" ;;
\\ esac
\\ echo "echo:$line"
\\done
\\
,
.flags = .{ .mode = 0o755 },
});
}
test "Server: one session's shell exiting drops only its clients; the daemon carries on" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "onedies");
defer td.deinit();
try writeMortalScript(&td.tmp);
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/mortal.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer ca.close();
const fa = (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400)) orelse
return error.NoSnapshotA;
fa.deinit(alloc);
const si_a = td.srv.sessions.find("a") orelse return error.SessionAMissing;
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer cb.close();
// b's liveness established BEFORE a dies, so its survival below is a
// comparison and not a hope.
var rep_b = try Grid.init(alloc, 80, 24);
defer rep_b.deinit();
try proto.writeFrame(cb.handle, .input, "pre\n");
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, cb.handle, rep_b, "echo:pre", 400),
);
// Kill a's shell and pump until its client hears so. b is what makes
// this a comparison: a's death must reach a's client and nobody else's.
try proto.writeFrame(ca.handle, .input, "die 0\n");
var status: ?u8 = null;
if (try awaitFrame(alloc, &td.srv, ca.handle, .exit_status, 500)) |frame| {
defer frame.deinit(alloc);
try std.testing.expectEqual(@as(usize, 1), frame.payload.len);
status = frame.payload[0];
}
try std.testing.expectEqual(@as(?u8, 0), status);
// The session is gone — slot null, name free — and no client slot still
// points where it was.
try std.testing.expect(td.srv.sessions.table[si_a] == null);
try std.testing.expect(td.srv.sessions.find("a") == null);
for (td.srv.clients) |slot| {
const cs = slot orelse continue;
if (cs.session) |si| try std.testing.expect(si != si_a);
}
// exit_status was the last frame queued before the drop, so what a's
// connection reads next is EOF — the daemon-side close, not a reset.
const Stray = struct {
fn on(_: ?*anyopaque, _: proto.Frame) anyerror!void {
return error.FramesAfterExitStatus;
}
};
try std.testing.expect(try h.awaitClosed(alloc, ca.handle, 1000, .{ .on = Stray.on }));
// And b never noticed: still attached, still echoing.
try proto.writeFrame(cb.handle, .input, "post\n");
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, cb.handle, rep_b, "echo:post", 400),
);
}
/// `run` on a thread; the code it answers is a fact only `run` holds.
fn runThread(srv: *Server, out: *?u8) void {
out.* = srv.run() catch |err| {
std.debug.print("run() failed: {t}\n", .{err});
return;
};
}
/// Ask the daemon for its stats until `want` appears. A connection of its OWN
/// per call, which is the assertion's teeth: a reply that misses its 200 ms
/// stays buffered, so a shared socket lets a later question be answered by an
/// earlier one's frame — generated before the very event being waited on.
fn waitStats(alloc: std.mem.Allocator, sock_path: []const u8, want: []const u8) !void {
const obs = try dial.dial(sock_path);
defer obs.close();
var tries: usize = 0;
while (tries < 40) : (tries += 1) {
try proto.writeFrame(obs.handle, .stats_req, "");
const frame = (try awaitFrameOn(alloc, obs.handle, .stats_reply, 200)) orelse continue;
defer frame.deinit(alloc);
if (std.mem.indexOf(u8, frame.payload, want) != null) return;
}
std.debug.print("stats never said {s}\n", .{want});
return error.StatsNeverSaid;
}
/// The socket half of the claim: every wait here runs while `run` pumps on
/// its own thread, so a daemon that RETURNED from `run` answers none of it
/// and each one times out.
fn probeEmptiedDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !void {
// Attach the default session, then hang up its shell — the daemon's
// only one, so the table empties behind it. 7 rather than 0 so the
// caller's exit-code pin can tell the two contracts apart: under the
// retired one this shell's code WAS the daemon's.
const c1 = try dial.dialAttachNamed(sock_path, 80, 24, proto.default_session);
defer c1.close();
const snap = (try awaitFrameOn(alloc, c1.handle, .snapshot, 4000)) orelse
return error.NoFirstSnapshot;
snap.deinit(alloc);
// The liveness half, and not a nicety: the snapshot proves the DAEMON
// seated this client, never that the script behind the pty has reached
// its first `read`. A line written before it does sits in the tty's input
// queue, and a shell that touches the terminal on startup discards it —
// so `die` was echoed, never read, and the session outlived a test whose
// whole subject is the session ending. Seen on macOS, where /bin/sh is
// bash and the script's startup is longer; the same race is open on
// Linux and merely lost less often. One round trip through the script
// closes it.
var rep = try Grid.init(alloc, 80, 24);
defer rep.deinit();
try proto.writeFrame(c1.handle, .input, "alive\n");
if (!try h.awaitReplicaText(alloc, c1.handle, 8000, .{
.replica = rep,
.needle = "echo:alive",
})) return error.ShellNeverRead;
try proto.writeFrame(c1.handle, .input, "die 7\n");
try waitStats(alloc, sock_path, "sessions=0");
// Still serving with nothing to serve: a birth on the emptied daemon
// takes the default name back, which is the attach `mux --sock PATH`
// sends.
const c2 = try dial.dialAttachNamed(sock_path, 80, 24, proto.default_session);
defer c2.close();
const reborn = (try awaitFrameOn(alloc, c2.handle, .snapshot, 4000)) orelse
return error.EmptyDaemonRefusedABirth;
reborn.deinit(alloc);
try waitStats(alloc, sock_path, "sessions=1");
}
test "Server: a daemon outlives its last session and ends only on stop" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "livesempty");
defer td.deinit();
try writeMortalScript(&td.tmp);
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/mortal.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
// The flag is global by necessity (a signal handler shares it); reset
// so this test neither inherits a stale request nor leaves one behind.
shutdown_flag.store(false, .release);
defer shutdown_flag.store(false, .release);
try td.start(.{ .shell = script });
var code: ?u8 = null;
const th = try std.Thread.spawn(.{}, runThread, .{ &td.srv, &code });
// Captured, not propagated: nothing may return between the spawn and
// the join, because `deinit` is a demolition list and must never run
// under a live pump thread.
const probed = probeEmptiedDaemon(alloc, td.sock_path);
shutdown_flag.store(true, .release);
th.join();
try probed;
// 0 and not 7. The shell this daemon was born with exited 7, and under
// the retired contract that WAS the daemon's exit code — so this line
// fails on a reap that still answers, not merely on a shutdown that
// returns the wrong thing.
try std.testing.expectEqual(@as(?u8, 0), code);
}
test "Server: a dead name re-attaches as a fresh session with a new epoch" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "reborn");
defer td.deinit();
try writeMortalScript(&td.tmp);
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/mortal.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
// First instance of "a": hold on to what its attach answered — the
// epoch its snapshots were stamped with, and the newest seq counted
// under it. That pair is exactly what a surviving client would quote.
const c1 = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer c1.close();
const f1 = (try awaitFrame(alloc, &td.srv, c1.handle, .snapshot, 400)) orelse
return error.NoFirstSnapshot;
defer f1.deinit(alloc);
const p1 = try proto.readSnapshotPrefix(f1.payload);
try std.testing.expect(p1.epoch != 0);
// Kill it, and pump until the name is free.
try proto.writeFrame(c1.handle, .input, "die 0\n");
const Freed = struct {
srv: *Server,
name: []const u8,
fn yes(self: @This()) bool {
return self.srv.sessions.find(self.name) == null;
}
};
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, Freed{ .srv = &td.srv, .name = "a" }, Freed.yes));
// Re-attach the dead name quoting the DEAD instance's seq and epoch — the
// reconnect a client that missed the death sends. A fresh session cannot
// delta-serve seqs counted by a shell it never was.
const c2 = try dial.dial(td.sock_path);
defer c2.close();
var abuf: [proto.attach_max_len]u8 = undefined;
try proto.writeFrame(
c2.handle,
.attach,
proto.encodeAttachNamed(&abuf, 80, 24, p1.seq, p1.epoch, "a"),
);
// A delta here would mean the reborn session kept the old instance's
// seq space, which is the bug this whole test exists for.
const NoDelta = struct {
fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void {
if (frame.type == .delta) return error.DeltaAcrossInstances;
}
};
// A refused attach closes the connection and a daemon that never
// answered leaves it open; both reach here as "no snapshot arrived",
// which is the fact the assertion is about either way.
const f2 = (try h.awaitFrameSink(alloc, &td.srv, c2.handle, .snapshot, 400, .{ .on = NoDelta.on })) orelse
return error.NoRebornSnapshot;
defer f2.deinit(alloc);
const p2 = try proto.readSnapshotPrefix(f2.payload);
try std.testing.expect(p2.epoch != 0);
try std.testing.expect(p2.epoch != p1.epoch);
try std.testing.expect(td.srv.sessions.find("a") != null);
}
// ---------------------------------------------------------------------------
// Every out-of-band instrument learns a session name: dump, the observer's
// `status_req`, stats, and the attached-client tail-match rule.
// ---------------------------------------------------------------------------
test "Server: dump names a session; an unknown name answers in words" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "dumpnames", .{ .shell = "/bin/cat" });
defer td.deinit();
const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer ca.close();
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer cb.close();
try proto.writeFrame(ca.handle, .input, "MARKER-ALPHA\n");
try proto.writeFrame(cb.handle, .input, "MARKER-BETA\n");
var rep_a = try Grid.init(alloc, 80, 24);
defer rep_a.deinit();
var rep_b = try Grid.init(alloc, 80, 24);
defer rep_b.deinit();
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, ca.handle, rep_a, "MARKER-ALPHA", 400),
);
try std.testing.expect(
try pumpUntilReplicaSees(alloc, &td.srv, cb.handle, rep_b, "MARKER-BETA", 400),
);
// Asked from a's own connection, for b: dump answers the payload's
// tail, never the asker's own session.
try proto.writeFrame(ca.handle, .debug_dump, &[_]u8{ 1, 'b' });
const reply = (try awaitFrame(alloc, &td.srv, ca.handle, .dump_reply, 400)) orelse
return error.NoDumpReply;
defer reply.deinit(alloc);
try std.testing.expect(std.mem.indexOf(u8, reply.payload, "MARKER-BETA") != null);
try std.testing.expect(std.mem.indexOf(u8, reply.payload, "MARKER-ALPHA") == null);
// An unknown name is a dump_reply in words, not a fake connection loss
// — the only channel this verb has for "no" is the reply itself.
try proto.writeFrame(ca.handle, .debug_dump, &[_]u8{ 1, 'z' });
const bad = (try awaitFrame(alloc, &td.srv, ca.handle, .dump_reply, 400)) orelse
return error.NoDumpReplyForBadName;
defer bad.deinit(alloc);
try std.testing.expectEqualStrings("mux d: no such session: z\n", bad.payload);
}
test "Server: an observer's status_req names a session by tail" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "obsstatus");
defer td.deinit();
// The one-mark-on-command scripted shell, same shape as the OSC 133
// test: it answers to being told, not on its own, so a running push
// against b is unambiguously b's and never a's idle default.
try td.tmp.dir.writeFile(.{
.sub_path = "marks.sh",
.data =
\\#!/bin/sh
\\read -r start
\\printf '\033]133;C\007'
\\read -r stop
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/marks.sh", .{td.tmp.path()}, 0);
defer alloc.free(script);
try td.start(.{ .shell = script });
const a = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer a.close();
const b = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer b.close();
const fa = (try awaitFrame(alloc, &td.srv, a.handle, .snapshot, 400)) orelse
return error.NoSnapshotA;
fa.deinit(alloc);
const fb = (try awaitFrame(alloc, &td.srv, b.handle, .snapshot, 400)) orelse
return error.NoSnapshotB;
fb.deinit(alloc);
// Drive b into its C — a's shell is never told to run anything, so a
// stays at its idle default and the two sessions read differently.
try proto.writeFrame(b.handle, .input, "go\n");
const push = (try awaitFrame(alloc, &td.srv, b.handle, .cmd_state, 500)) orelse
return error.NoRunningPush;
push.deinit(alloc);
// A bare connection — an observer, never attached — asks about b by
// name alone.
const obs = try dial.dial(td.sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .status_req, "b");
const reply = (try awaitFrame(alloc, &td.srv, obs.handle, .status_reply, 400)) orelse
return error.NoStatusReply;
defer reply.deinit(alloc);
const st = try proto.decodeStatusReply(reply.payload);
try std.testing.expectEqual(proto.CmdPhase.running, st.cmd.phase);
try std.testing.expectEqual(proto.Mechanism.marks, st.cmd.mechanism);
// An unknown name still ends the connection, since `status_reply` has no room
// for words — but it says `exit_status` 1 FIRST. Silence made
// `mux a status --session nosuch` report a dead daemon.
try proto.writeFrame(obs.handle, .status_req, "z");
const refusal = (try awaitFrame(alloc, &td.srv, obs.handle, .exit_status, 400)) orelse
return error.NoRefusalForUnknownSession;
defer refusal.deinit(alloc);
try std.testing.expectEqualSlices(u8, &.{1}, refusal.payload);
try std.testing.expect(try h.pumpUntilClosed(alloc, &td.srv, obs.handle, 2400, .{}));
}
test "Server: stats names every live session" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "statsnames", .{ .shell = "/bin/cat" });
defer td.deinit();
// The default session (bare attach) plus one named session: two live
// sessions total, both of which must show up by name.
const ca = try dial.dialAttach(td.sock_path, 80, 24);
defer ca.close();
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer cb.close();
const fa = (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400)) orelse
return error.NoSnapshotA;
fa.deinit(alloc);
const fb = (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400)) orelse
return error.NoSnapshotB;
fb.deinit(alloc);
var buf: [Server.stats_text_len]u8 = undefined;
const text = try td.srv.statsText(&buf);
try std.testing.expect(std.mem.indexOf(u8, text, "sessions=2") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "session " ++ proto.default_session ++ " ") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "session b ") != null);
}
test "Server: sessions_req answers every live name, whoever asks" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "sessreq", .{ .shell = "/bin/cat" });
defer td.deinit();
const ca = try dial.dialAttach(td.sock_path, 80, 24);
defer ca.close();
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "work");
defer cb.close();
const fa = (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400)) orelse
return error.NoSnapshotA;
fa.deinit(alloc);
const fb = (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400)) orelse
return error.NoSnapshotB;
fb.deinit(alloc);
// Asked from the DEFAULT session's connection, and the answer still
// names both: the reply is about the daemon, not about the asker.
try proto.writeFrame(ca.handle, .sessions_req, "");
const reply = (try awaitFrame(alloc, &td.srv, ca.handle, .sessions_reply, 400)) orelse
return error.NoSessionsReply;
defer reply.deinit(alloc);
try std.testing.expectEqualStrings(proto.default_session ++ "\nwork", reply.payload);
}
test "Server: sessions_reply carries the daemon's own version behind the names" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "sessmeta", .{
.shell = "/bin/cat",
.version = "9.9.9-test",
});
defer td.deinit();
const ca = try dial.dialAttach(td.sock_path, 80, 24);
defer ca.close();
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "work");
defer cb.close();
const fa = (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400)) orelse
return error.NoSnapshotA;
fa.deinit(alloc);
const fb = (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400)) orelse
return error.NoSnapshotB;
fb.deinit(alloc);
try proto.writeFrame(ca.handle, .sessions_req, "");
const reply = (try awaitFrame(alloc, &td.srv, ca.handle, .sessions_reply, 400)) orelse
return error.NoSessionsReply;
defer reply.deinit(alloc);
// The names still read as exactly the names — the sibling test above
// pins a versionless daemon's payload byte-for-byte, so this one only
// has to show the line rides BEHIND them, invisibly to the iterator.
var it = proto.sessionsIter(reply.payload);
try std.testing.expectEqualStrings(proto.default_session, it.next() orelse return error.NameLost);
try std.testing.expectEqualStrings("work", it.next() orelse return error.NameLost);
try std.testing.expect(it.next() == null);
const meta = proto.parseSessionsMeta(reply.payload) orelse return error.MetaAbsent;
try std.testing.expectEqualStrings("9.9.9-test", meta.version);
// This test binary is still the file it was exec'd from, and the server
// must say so: stale=true here would mean the bit is invented rather
// than read off /proc. The true side needs a replaced binary under a
// running daemon, which is the e2e journey's to stage.
try std.testing.expect(!meta.stale);
}
/// "Every client of this session has gone" as a `pumpUntil` context. The
/// close of a socket is the peer's act; the daemon only learns about it on a
/// pump, so a test that asked for the count straight after `close()` would be
/// racing the reap.
const SessionEmptied = struct {
srv: *Server,
si: usize,
fn reached(s: SessionEmptied) bool {
return !s.srv.hasClientsIn(s.si);
}
};
test "Server: sessions_reply states how many clients hold each session" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "sesshold", .{
.shell = "/bin/cat",
.version = "0.0.1-test",
});
defer td.deinit();
// One held session and one nobody holds: a fixture where every session
// had the same number of clients could not tell a real count from a
// constant.
const ca = try dial.dialAttach(td.sock_path, 80, 24);
defer ca.close();
(try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400) orelse
return error.NoSnapshotA).deinit(alloc);
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "wk");
(try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400) orelse
return error.NoSnapshotB).deinit(alloc);
cb.close();
const wk = td.srv.sessions.find(proto.wireName("wk")) orelse return error.NoSession;
try std.testing.expect(try h.pumpUntil(&td.srv, 2000, SessionEmptied{
.srv = &td.srv,
.si = wk,
}, SessionEmptied.reached));
try proto.writeFrame(ca.handle, .sessions_req, "");
const reply = (try awaitFrame(alloc, &td.srv, ca.handle, .sessions_reply, 400)) orelse
return error.NoSessionsReply;
defer reply.deinit(alloc);
try std.testing.expectEqual(@as(?u8, 1), proto.parseSessionsHolds(reply.payload, proto.default_session));
try std.testing.expectEqual(@as(?u8, 0), proto.parseSessionsHolds(reply.payload, "wk"));
// The holds lines ride behind the names invisibly, exactly as the meta
// line does, and the meta line still parses out from beside them.
var names = proto.sessionsIter(reply.payload);
try std.testing.expectEqualStrings(proto.default_session, names.next() orelse return error.NameLost);
try std.testing.expectEqualStrings("wk", names.next() orelse return error.NameLost);
try std.testing.expect(names.next() == null);
const meta = proto.parseSessionsMeta(reply.payload) orelse return error.MetaAbsent;
try std.testing.expectEqualStrings("0.0.1-test", meta.version);
}
test "Server: an attached client's mismatched status tail is ignored" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "mismatch", .{ .shell = "/bin/cat" });
defer td.deinit();
const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer ca.close();
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer cb.close();
const fa = (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400)) orelse
return error.NoSnapshotA;
fa.deinit(alloc);
const fb = (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400)) orelse
return error.NoSnapshotB;
fb.deinit(alloc);
// A tail naming a DIFFERENT session than the one this connection
// attached to gets silence, never an answer about the tail instead:
// bounded negative probe, since the absence is what is under test.
try proto.writeFrame(ca.handle, .status_req, "b");
if (try awaitFrame(alloc, &td.srv, ca.handle, .status_reply, 30)) |leak| {
leak.deinit(alloc);
return error.MismatchedTailAnswered;
}
// The empty tail is the "no opinion" spelling and always matches: the
// reply arrives, and it is a's own state, not b's.
try proto.writeFrame(ca.handle, .status_req, "");
const ok = (try awaitFrame(alloc, &td.srv, ca.handle, .status_reply, 400)) orelse
return error.NoReplyForEmptyTail;
ok.deinit(alloc);
}
test "Server: a session-less slot's status_req resolves the tail like an observer's" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "quicstatus", .{ .shell = "/bin/cat" });
defer td.deinit();
// Stand up session "b" for real, over an ordinary attach.
const cb = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer cb.close();
const fb = (try awaitFrame(alloc, &td.srv, cb.handle, .snapshot, 400)) orelse
return error.NoSnapshotB;
fb.deinit(alloc);
// The shape a QUIC connection has between handshake and its first attach: a
// live client slot with NO session, reached through `pushInbound` exactly as
// a datagram would be. A `status_req` arm that `orelse return`s such a slot
// makes `mux a status --quic` time out. Slot 1, because slot 0 is claimed
// and stomping it would leak its queued snapshot.
const c = try connectedPair();
defer std.posix.close(c.peer);
td.srv.clients[1] = .{ .sink = .{ .socket = c.daemon } };
var frame: std.ArrayList(u8) = .empty;
defer frame.deinit(alloc);
try proto.appendFrame(&frame, alloc, .status_req, "b");
td.srv.pushInbound(1, frame.items);
// queueFrame flushes synchronously (see its doc comment), so the reply
// is already on the wire — no pump needed, same as the other
// pushInbound-driven tests above.
var got: ?proto.Frame = null;
{
var pfd = [_]std.posix.pollfd{
.{ .fd = c.peer, .events = std.posix.POLL.IN, .revents = 0 },
};
if ((try std.posix.poll(&pfd, 50)) != 0) got = try proto.readFrame(alloc, c.peer);
}
const reply = got orelse return error.NoStatusReplyForSessionlessSlot;
defer reply.deinit(alloc);
try std.testing.expectEqual(proto.MsgType.status_reply, reply.type);
_ = try proto.decodeStatusReply(reply.payload);
// A one-shot ask, not an attach: the slot still holds no session.
try std.testing.expect(td.srv.clients[1] != null);
try std.testing.expect(td.srv.clients[1].?.session == null);
// An unknown name refuses with `exit_status` 1 and then drops the CLIENT. The
// frame has to PRECEDE the close, or `mux a status --quic --session nosuch`
// reads silence as a dead daemon. The SOCKET sink only, which flushes as it
// queues; a quic sink reaches the peer through `reapClosing`'s last drain,
// which a unit test cannot stand up.
frame.clearRetainingCapacity();
try proto.appendFrame(&frame, alloc, .status_req, "zz");
td.srv.pushInbound(1, frame.items);
try std.testing.expect(td.srv.clients[1] == null);
const refusal = (try proto.readFrame(alloc, c.peer)) orelse
return error.NoRefusalForUnknownSession;
defer refusal.deinit(alloc);
try std.testing.expectEqual(proto.MsgType.exit_status, refusal.type);
try std.testing.expectEqualSlices(u8, &.{1}, refusal.payload);
var drain: [8]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(c.peer, &drain));
}
// ---------------------------------------------------------------------------
// Ending a session on request. Each test builds only the clients and sessions
// its own claim needs: the count in a refusal is per session, and a client is
// not its own "other".
// ---------------------------------------------------------------------------
fn shellPidOf(srv: *Server, name: []const u8) std.posix.pid_t {
return srv.ses(srv.sessions.find(proto.wireName(name)).?).pty.child;
}
fn alive(pid: std.posix.pid_t) bool {
std.posix.kill(pid, 0) catch return false;
return true;
}
/// Asked of the OS, not of the daemon: a session table that has forgotten a
/// shell says nothing about whether the process is gone.
const Dead = struct {
pid: std.posix.pid_t,
fn yes(self: Dead) bool {
return !alive(self.pid);
}
};
test "Server: end_req with another client attached is refused with the count; forced, both see the exit" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "endreq", .{ .shell = "/bin/sh" });
defer td.deinit();
const a1 = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer a1.close();
(try awaitFrame(alloc, &td.srv, a1.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
const a2 = try dial.dialAttachNamed(td.sock_path, 80, 24, "a");
defer a2.close();
(try awaitFrame(alloc, &td.srv, a2.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
const b1 = try dial.dialAttachNamed(td.sock_path, 80, 24, "b");
defer b1.close();
(try awaitFrame(alloc, &td.srv, b1.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
const pid_a = shellPidOf(&td.srv, "a");
// an observer asks: every attached client is "other"
const obs = try dial.dial(td.sock_path);
defer obs.close();
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, false, "a"));
const r1 = (try awaitFrame(alloc, &td.srv, obs.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r1.deinit(alloc);
const v1 = proto.parseEndReply(r1.payload) orelse return error.BadEndReply;
try std.testing.expect(!v1.accepted);
try std.testing.expectEqual(@as(u8, 2), v1.others);
try std.testing.expect(alive(pid_a));
// a1 asks over its own link: it is not its own "other"
try proto.writeFrame(a1.handle, .end_req, proto.encodeEndReq(&rq, false, "a"));
const r2 = (try awaitFrame(alloc, &td.srv, a1.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r2.deinit(alloc);
try std.testing.expectEqual(@as(u8, 1), (proto.parseEndReply(r2.payload) orelse return error.BadEndReply).others);
try std.testing.expect(alive(pid_a));
// forced: accepted, the shell dies, BOTH clients on "a" get exit_status, "b" is untouched
try proto.writeFrame(a1.handle, .end_req, proto.encodeEndReq(&rq, true, "a"));
const r3 = (try awaitFrame(alloc, &td.srv, a1.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r3.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r3.payload) orelse return error.BadEndReply).accepted);
const x2 = (try awaitFrame(alloc, &td.srv, a2.handle, .exit_status, 400)) orelse return error.NoExitOnSibling;
x2.deinit(alloc);
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, Dead{ .pid = pid_a }, Dead.yes));
try std.testing.expect(alive(shellPidOf(&td.srv, "b")));
}
test "Server: end_req alone on a session ends it at once, and an unknown name is refused" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "endsolo", .{ .shell = "/bin/sh" });
defer td.deinit();
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "solo");
defer c.close();
(try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
const pid = shellPidOf(&td.srv, "solo");
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "solo"));
const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, Dead{ .pid = pid }, Dead.yes));
const obs = try dial.dial(td.sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, true, "nosuch"));
const r2 = (try awaitFrame(alloc, &td.srv, obs.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r2.deinit(alloc);
const v = proto.parseEndReply(r2.payload) orelse return error.BadEndReply;
try std.testing.expect(!v.accepted);
try std.testing.expectEqualStrings("no such session", v.reason);
}
test "Server: competing explicit creators get one shell and never attach or resize an existing session" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "createonly", .{ .shell = "/bin/sh" });
defer td.deinit();
const a = try dial.dial(td.sock_path);
defer a.close();
const b = try dial.dial(td.sock_path);
defer b.close();
var req: [proto.create_req_max_len]u8 = undefined;
const bytes = proto.encodeCreateReq(&req, 80, 24, "fresh");
try proto.writeFrame(a.handle, .create_req, bytes);
try proto.writeFrame(b.handle, .create_req, bytes);
const ra = (try awaitFrame(alloc, &td.srv, a.handle, .create_reply, 400)) orelse return error.NoCreateReply;
defer ra.deinit(alloc);
const rb = (try awaitFrame(alloc, &td.srv, b.handle, .create_reply, 400)) orelse return error.NoCreateReply;
defer rb.deinit(alloc);
const sa = (try proto.parseCreateReply(ra.payload)).status;
const sb = (try proto.parseCreateReply(rb.payload)).status;
try std.testing.expect((sa == .created and sb == .exists) or (sa == .exists and sb == .created));
const si = td.srv.sessions.find("fresh") orelse return error.MissingSession;
const epoch = td.srv.ses(si).epoch;
for (td.srv.clients) |slot| if (slot) |live| try std.testing.expect(live.session != si);
// The common daemon-verb path also handles an attached client (including
// QUIC). A repeated create cannot alter the session it happens to hold.
const attached = try dial.dialAttachNamed(td.sock_path, 80, 24, "fresh");
defer attached.close();
(try awaitFrame(alloc, &td.srv, attached.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
try proto.writeFrame(attached.handle, .create_req, proto.encodeCreateReq(&req, 120, 40, "fresh"));
const exists = (try awaitFrame(alloc, &td.srv, attached.handle, .create_reply, 400)) orelse return error.NoCreateReply;
defer exists.deinit(alloc);
try std.testing.expectEqual(proto.CreateStatus.exists, (try proto.parseCreateReply(exists.payload)).status);
try std.testing.expectEqual(epoch, td.srv.ses(si).epoch);
try std.testing.expectEqual(@as(u16, 80), td.srv.colsNow(si));
try std.testing.expectEqual(@as(u16, 24), td.srv.rowsNow(si));
for ([_][]const u8{ &.{}, &.{ 80, 0, 24, 0 }, &.{ 80, 0, 24, 0, '#' }, &.{ 0, 0, 0, 0, 'z' } }) |bad| {
try proto.writeFrame(a.handle, .create_req, bad);
const refused = (try awaitFrame(alloc, &td.srv, a.handle, .create_reply, 400)) orelse return error.NoCreateReply;
defer refused.deinit(alloc);
try std.testing.expectEqual(proto.CreateStatus.refused, (try proto.parseCreateReply(refused.payload)).status);
}
try std.testing.expect(td.srv.sessions.find("z") == null);
}
test "Server: an observer's sessions_req is answered with every live name" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "obssess", .{ .shell = "/bin/sh" });
defer td.deinit();
const cx = try dial.dialAttachNamed(td.sock_path, 80, 24, "x");
defer cx.close();
(try awaitFrame(alloc, &td.srv, cx.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
const cy = try dial.dialAttachNamed(td.sock_path, 80, 24, "y");
defer cy.close();
(try awaitFrame(alloc, &td.srv, cy.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
const obs = try dial.dial(td.sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .sessions_req, "");
const r = (try awaitFrame(alloc, &td.srv, obs.handle, .sessions_reply, 200)) orelse return error.NoSessionsReply;
defer r.deinit(alloc);
// The whole payload, not two substrings: `"xy"`, a truncation after two
// names, and a name that aliased its neighbour's buffer all satisfy an
// indexOf. The last of those is one of the three escapes CLAUDE.md
// records passing a green gate.
try std.testing.expectEqualStrings("0\nx\ny", r.payload);
}
/// The grid of a NAMED session — `awaitGridText` only ever looks at slot 0.
fn awaitGridIn(
alloc: std.mem.Allocator,
srv: *Server,
name: []const u8,
needle: []const u8,
budget_ms: i64,
) !bool {
const deadline = std.time.milliTimestamp() + budget_ms;
while (std.time.milliTimestamp() < deadline) {
try srv.pumpOnce(5);
const si = srv.sessions.find(proto.wireName(name)) orelse return false;
const grid = try srv.ses(si).eng.dumpPlain(alloc);
defer alloc.free(grid);
if (std.mem.indexOf(u8, grid, needle) != null) return true;
}
return false;
}
test "Server: an accepted end kills a shell that ignores TERM and HUP" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "endkill", .{ .shell = "/bin/sh" });
defer td.deinit();
const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "stubborn");
defer c.close();
(try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
// The shell ignores both signals `requestExit` has, and never reads stdin
// again — so closing the master cannot end it either. TRAP"PED" is typed
// and TRAPPED is printed, so the needle can only be the shell's output
// and never the echo of the line that asked for it.
try proto.writeFrame(c.handle, .input, "trap '' TERM HUP; echo TRAP\"PED\"; while :; do sleep 1; done\n");
try std.testing.expect(try awaitGridIn(alloc, &td.srv, "stubborn", "TRAPPED", 5000));
const pid = shellPidOf(&td.srv, "stubborn");
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "stubborn"));
const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, Dead{ .pid = pid }, Dead.yes));
}
test "Server: a keystroke into an ending session does not cost that client its exit_status" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "endtype");
defer td.deinit();
try td.startStubborn(alloc, .{});
const c = try td.attachStubborn(alloc, "typing", 80, 24);
defer c.close();
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "typing"));
const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
// The master is closed but the shell is still alive, so this keystroke
// reaches a session with nowhere to put it. Dropping the client for it
// would trade the exit code for "connection to the daemon lost".
try proto.writeFrame(c.handle, .input, "x");
const x = (try awaitFrame(alloc, &td.srv, c.handle, .exit_status, 600)) orelse return error.NoExitStatus;
x.deinit(alloc);
}
test "Server: a repeated end_req does not push the SIGKILL deadline out" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "endagain");
defer td.deinit();
try td.startStubborn(alloc, .{});
const c = try td.attachStubborn(alloc, "nag", 80, 24);
defer c.close();
const pid = shellPidOf(&td.srv, "nag");
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "nag"));
const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
defer r.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
// The clock the assertions below read, started where the daemon starts
// its own: `endSession` arms the deadline when it accepts. MONOTONIC, and
// deliberately so — the deadline being graded is `monoMs() + grace`, so a
// calendar step landing between the two reads would fail a daemon that
// did exactly the right thing.
var since_end = try std.time.Timer.start();
// An observer nagging faster than the grace: if each accept restarted the
// clock, the shell would outlive every deadline it was ever given.
const obs = try dial.dial(td.sock_path);
defer obs.close();
// Its own loop rather than `h.pumpUntil`: the nag is WORK done every
// round, and a predicate that wrote to a socket to answer "is it dead"
// would be a worse thing to read than this.
var waited: u32 = 0;
while (alive(pid) and waited < 3000) : (waited += 80) {
proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, true, "nag")) catch {};
try td.srv.pumpOnce(20);
std.Thread.sleep(60 * std.time.ns_per_ms);
}
const outlived_ms = since_end.read() / std.time.ns_per_ms;
try std.testing.expect(!alive(pid));
// A dead shell is only half the claim, and it was the only half asserted:
// a shell that died to the FIRST SIGTERM is dead too, and a fixture that
// let that happen passed this test while the deadline it is named for was
// never reached. That is not hypothetical — it is what a wait on the wrong
// session did here, twice, and the difference was visible only as the
// suite running faster. So the duration is the pin now. A wall clock and
// not the loop counter above, which counts nominal turns rather than time.
//
// The LOWER bound is the property, and the only one of the two that is:
// living past the grace is what proves the shell ignored every TERM and
// left to the SIGKILL.
//
// The UPPER bound is a rail on the number, not a second pin, and it is
// worth saying which regression it does NOT catch. The one this test is
// named for is the `== null` guard dropped at the arm in `endSession`,
// and that guard's absence RESETS `end_by_ms` to now plus a grace on
// every accepted nag rather than adding one — so a deadline re-armed
// every 80 ms never fires at all, the shell outlives the loop's whole
// 3 s budget, and the `!alive` above is what fails. No multiplier here
// reaches that. What this bound does catch is a deadline that drifted a
// few turns of the loop, which is why it exists and why it is loose.
//
// 3x because the measurement it must clear is 661 ms on Linux and 668 to
// 682 ms on macOS under `make check` (2026-09-04) against a 500 ms grace,
// leaving about 2.2x. Not tighter: what varies is how long one turn of
// the loop takes to notice the kill, and a loaded box stretches both its
// sleep and its pump.
if (outlived_ms < Pty.term_grace_ms) {
std.debug.print(
"the shell died {d} ms after the accepted end, INSIDE the {d} ms grace: " ++
"it did not ignore the TERM, so the SIGKILL deadline was never reached\n",
.{ outlived_ms, Pty.term_grace_ms },
);
return error.ShellDiedInsideTheGrace;
}
if (outlived_ms >= 3 * Pty.term_grace_ms) {
std.debug.print(
"the shell lived {d} ms after the accepted end, past 3x the {d} ms grace: " ++
"the nagging looks to have pushed the deadline out\n",
.{ outlived_ms, Pty.term_grace_ms },
);
return error.GraceDeadlineMoved;
}
}
fn sockLost(s: *Server) bool {
return s.sock_watch.lost;
}
fn sockRefused(s: *Server) bool {
return s.sock_watch.refused != null;
}
test "Server: a socket path deleted under a running daemon is taken back within a tick, and never from a successor" {
const alloc = std.testing.allocator;
const sockpath = @import("sockpath");
var td = try h.TestDaemon.init(alloc, "lost", .{ .shell = "/bin/sh" });
defer td.deinit();
// Tick the watch on every pump: the second it waits in production is
// the wall's auto-start budget, not anything this test is grading.
td.srv.sock_watch_interval_ms = 0;
const path: []const u8 = td.sock_path;
const first_id = td.srv.bound.path_id;
// The 2026-09-04 shape: the FILE goes and the listener stays. Nothing
// can reach the daemon by name, and until the watch existed nothing
// ever would again.
try std.fs.cwd().deleteFile(path);
try std.testing.expect(!sockpath.answers(path));
// Within a tick the daemon has re-bound the path, and the file there is
// the one it now holds — a new inode, ours.
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, path, sockpath.answers));
try std.testing.expect(!std.meta.eql(first_id, td.srv.bound.path_id));
try std.testing.expect(td.srv.bound.path_id.stillAt(path));
try std.testing.expect(!td.srv.sock_watch.lost);
// And it SERVES there: this is not a file that reappeared.
{
const c = try dial.dial(path);
defer c.close();
try proto.writeFrame(c.handle, .sessions_req, "");
const reply = (try awaitFrame(alloc, &td.srv, c.handle, .sessions_reply, 400)) orelse
return error.NoReplyOnReboundPath;
reply.deinit(alloc);
}
// The successor case: the path goes again, and before the next tick a
// second listener has bound it — the second daemon a wall's auto-start
// made in the incident. "No socket stealing" holds for the re-bind as
// it does for a start: the successor's file is refused, not replaced.
const ours_before = td.srv.bound.path_id;
try std.fs.cwd().deleteFile(path);
const addr = try std.net.Address.initUnix(path);
var succ = try addr.listen(.{});
const succ_id = try sockpath.PathId.of(path);
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, &td.srv, sockLost));
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, &td.srv, sockRefused));
try std.testing.expectEqual(@as(?anyerror, error.DaemonAlreadyRunning), td.srv.sock_watch.refused);
try std.testing.expect(std.meta.eql(succ_id, try sockpath.PathId.of(path)));
try std.testing.expect(std.meta.eql(ours_before, td.srv.bound.path_id));
// The successor stops without unlinking (a SIGKILL's leftover). The
// next tick finds a dead socket file, which a claim may clear, and the
// path is ours again.
succ.deinit();
try std.testing.expect(try h.pumpUntil(&td.srv, 3000, path, sockpath.answers));
try std.testing.expect(td.srv.bound.path_id.stillAt(path));
try std.testing.expect(!std.meta.eql(ours_before, td.srv.bound.path_id));
try std.testing.expect(!td.srv.sock_watch.lost);
try std.testing.expectEqual(@as(?anyerror, null), td.srv.sock_watch.refused);
}