src/server/server_test_upgrade.zig
Ref: Size: 26.3 KiB History
const std = @import("std");
const proto = @import("term").protocol;
const upgrade = @import("upgrade.zig");
const TmpDir = @import("testtmp").TmpDir;
const server_os = @import("server_os");
const h = @import("server_test_harness.zig");
const dial = h.dial;
const srv_mod = @import("server.zig");
const SessionTable = @import("server_sessions.zig").SessionTable;
const Server = srv_mod.Server;
const awaitFrame = h.awaitFrame;
test "writeManifestTo: every session's own name crosses, not the last one's" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "names", .{ .shell = "/bin/sh" });
defer td.deinit();
// Three sessions, each with a name of its own. One session cannot catch
// a name that aliases its neighbour's buffer, which is how a by-value
// `Session` copy in the gather loop shipped every record pointing at
// the last name: right length, wrong bytes.
td.srv.sessions.table[1] = try SessionTable.create(alloc, td.srv.spawn_plan, "work", 80, 24, null);
td.srv.sessions.table[2] = try SessionTable.create(alloc, td.srv.spawn_plan, "logs", 80, 24, null);
const carrier = try server_os.anonFd("mux-names-test");
defer std.posix.close(carrier);
try td.srv.writeManifestTo(carrier, "0.0.1-99");
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
try std.testing.expectEqual(@as(usize, 3), parsed.sessions.len);
try std.testing.expectEqualStrings(proto.default_session, parsed.sessions[0].name);
try std.testing.expectEqualStrings("work", parsed.sessions[1].name);
try std.testing.expectEqualStrings("logs", parsed.sessions[2].name);
}
test "writeManifestTo: what crosses is what a session cannot rebuild" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "manif", .{
.shell = "/bin/sh",
.shell_integration = true,
.extra_env = &.{.{ .key = "PS1", .value = ">>" }},
});
defer td.deinit();
const s = &td.srv.sessions.table[0].?;
// Feed the engine a title so the manifest must carry it.
s.eng.feed("\x1b]0;t1\x07");
// Feed some content so dumpState is non-empty.
s.eng.feed("hello\r\n");
const carrier = try server_os.anonFd("mux-upgrade-test");
defer std.posix.close(carrier);
try td.srv.writeManifestTo(carrier, "0.0.1-99");
// Read back and parse.
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
// Daemon section: sock_path and writer identity cross.
try std.testing.expectEqualStrings(td.sock_path, parsed.daemon.sock_path);
try std.testing.expectEqualStrings("0.0.1-99", parsed.daemon.writer_version);
// The rollback target must be THIS process's binary — resolved inside
// writeManifestTo, never taken from a caller, because the first draft
// let call sites hand it the candidate's path and a failed adoption
// would then have exec'd the broken binary in a loop.
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
try std.testing.expectEqualStrings(try std.fs.selfExePath(&exe_buf), parsed.daemon.writer_path);
try std.testing.expectEqualStrings("/bin/sh", parsed.daemon.shell);
try std.testing.expect(parsed.daemon.shell_integration);
try std.testing.expectEqual(@as(usize, 1), parsed.daemon.extra_env.len);
try std.testing.expectEqualStrings("PS1", parsed.daemon.extra_env[0].key);
// Session section: name, cols/rows, title, pty fd.
try std.testing.expectEqual(@as(usize, 1), parsed.sessions.len);
try std.testing.expectEqualStrings(proto.default_session, parsed.sessions[0].name);
try std.testing.expectEqual(@as(u16, 80), parsed.sessions[0].cols);
try std.testing.expectEqual(@as(u16, 24), parsed.sessions[0].rows);
try std.testing.expectEqualStrings("t1", parsed.sessions[0].title.?);
try std.testing.expectEqual(s.pty.master, parsed.sessions[0].pty_fd);
}
test "initFromManifest: an adopted session answers a status_req without having been created" {
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}/adopt.sock", .{dir_path});
defer alloc.free(sock_path);
var srv = try Server.init(alloc, .{
.sock_path = sock_path,
.shell = "/bin/sh",
.cols = 100,
.rows = 30,
});
{
const s = &srv.sessions.table[0].?;
s.eng.feed("\x1b]0;adopted\x07");
// Set rather than earned: the mark machinery has its own tests, and
// the claim here is that the FACT crosses — a lost marks_seen
// demotes every later await from marks to pgid, silently.
s.cmd.marks_seen = true;
}
const old_epoch = srv.sessions.table[0].?.epoch;
const child = srv.sessions.table[0].?.pty.child;
const carrier = try server_os.anonFd("mux-adopt-test");
defer std.posix.close(carrier);
try srv.writeManifestTo(carrier, "0.0.1-99");
// Release the first Server's MEMORY by hand instead of calling deinit:
// deinit is the demolition list — it unlinks the socket, SIGKILLs the
// shell and deleteTrees the dirs — and every one of those is what the
// adopting Server is about to inherit. The descriptors and the child
// are left alone here and torn down once, by srv2.
{
const s = &srv.sessions.table[0].?;
s.freeOwned(alloc);
// The path string only. The listener behind it stays bound, because
// the fd is one of the things srv2 adopts.
if (s.agentPath()) |p| alloc.free(p);
}
if (srv.agents.dir) |d| alloc.free(d);
srv.shellint_arena.deinit();
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100");
defer srv2.deinit();
// The socket the OLD daemon bound still answers, over the inherited
// listener fd: no second bind, no claim, no unlink in between.
const obs = try dial.dial(sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .status_req, "");
const reply = (try awaitFrame(alloc, &srv2, obs.handle, .status_reply, 400)) orelse
return error.NoStatusReply;
defer reply.deinit(alloc);
const st = try proto.decodeStatusReply(reply.payload);
try std.testing.expectEqual(@as(u16, 100), st.cols);
try std.testing.expectEqual(@as(u16, 30), st.rows);
try std.testing.expectEqual(proto.Mechanism.marks, st.cmd.mechanism);
const s2 = &srv2.sessions.table[0].?;
try std.testing.expectEqualStrings(proto.default_session, s2.name());
// Same pid, so checkExited's waitpid still answers for this shell.
try std.testing.expectEqual(child, s2.pty.child);
// The title is the engine's own again, fed back as an OSC 0.
try std.testing.expectEqualStrings("adopted", s2.eng.title());
// A fresh epoch is what puts every returning client on the snapshot
// path: one quoting the old epoch must never be served deltas over a
// grid this process replayed.
try std.testing.expect(s2.epoch != old_epoch);
}
test "initFromManifest: a session whose agent socket file vanished loses forwarding, not the daemon" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const sock_path = try std.fmt.allocPrint(alloc, "{s}/goneagent.sock", .{tmp.path()});
defer alloc.free(sock_path);
var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
const agent_path = try alloc.dupeZ(
u8,
srv.sessions.table[0].?.agentPath() orelse return error.NoAgentSocket,
);
defer alloc.free(agent_path);
// The descriptor the manifest will carry, held here because after a
// failed adoption nothing on either Server names it any more.
const agent_fd = srv.sessions.table[0].?.agentFd();
const carrier = try server_os.anonFd("mux-goneagent-test");
defer std.posix.close(carrier);
try srv.writeManifestTo(carrier, "0.0.1-99");
// The seam: something outside mux cleaned the runtime directory between
// the manifest and the exec. Adoption re-stamps the socket's id from the
// FILE, so this is the one manifest field that can be missing from the
// filesystem when the new image reads it back.
try std.fs.cwd().deleteFile(agent_path);
// Memory only, as in the adopted-session tests above: deinit would
// demolish exactly what srv2 is about to inherit.
{
const s = &srv.sessions.table[0].?;
s.freeOwned(alloc);
// The path string only. The listener behind it stays bound, because
// the fd is one of the things srv2 adopts.
if (s.agentPath()) |p| alloc.free(p);
}
if (srv.agents.dir) |d| alloc.free(d);
srv.shellint_arena.deinit();
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
// The whole point: this returns a Server. Failing it would take every
// shell in the table down over one deleted file, and the rollback exec
// would hit the same missing file with the loop guard already set.
var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100");
defer srv2.deinit();
// The session is here and serving; only its forwarding is gone.
const s2 = &srv2.sessions.table[0].?;
try std.testing.expect(s2.agentPath() == null);
try std.testing.expectEqual(@as(std.posix.fd_t, -1), s2.agentFd());
const obs = try dial.dial(sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .status_req, "");
const reply = (try awaitFrame(alloc, &srv2, obs.handle, .status_reply, 400)) orelse
return error.NoStatusReply;
reply.deinit(alloc);
// The orphan. It is deliberately STILL OPEN at this point: until the
// last rollback point in main.zig is behind us, a rollback exec hands
// every manifest descriptor back to the old binary, this one included.
// std.posix.fcntl is unreachable on EBADF, so ask the syscall directly.
const still_open = std.posix.system.fcntl(agent_fd, std.posix.F.GETFD, @as(usize, 0));
try std.testing.expectEqual(std.posix.E.SUCCESS, std.posix.errno(still_open));
// Past that point main.zig calls this, and it must CLOSE the orphan
// rather than seal it: execUpgrade cleared CLOEXEC on the fd and no
// session names it now, so one left open is a listening socket
// inherited by every shell this daemon forks from here on.
srv2.sealAdoptedFds();
const closed = std.posix.system.fcntl(agent_fd, std.posix.F.GETFD, @as(usize, 0));
try std.testing.expectEqual(std.posix.E.BADF, std.posix.errno(closed));
}
test "initFromManifest: the return watermark is re-stamped, never carried across seq spaces" {
const alloc = std.testing.allocator;
var tmp = try TmpDir.make();
defer tmp.cleanup();
const sock_path = try std.fmt.allocPrint(alloc, "{s}/watermark.sock", .{tmp.path()});
defer alloc.free(sock_path);
var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
srv.sessions.table[0].?.last_return = .{
.phase = .returned,
.mechanism = .marks,
.exit_code = 7,
.start_row = 1,
.end_row = 2,
.seq = 999,
};
const carrier = try server_os.anonFd("mux-watermark-test");
defer std.posix.close(carrier);
try srv.writeManifestTo(carrier, "0.0.1-99");
// Memory only; the descriptors and the child are srv2's to tear down.
{
const s = &srv.sessions.table[0].?;
s.freeOwned(alloc);
// The path string only. The listener behind it stays bound, because
// the fd is one of the things srv2 adopts.
if (s.agentPath()) |p| alloc.free(p);
}
if (srv.agents.dir) |d| alloc.free(d);
srv.shellint_arena.deinit();
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100");
defer srv2.deinit();
const s2 = &srv2.sessions.table[0].?;
// The verdict crosses: it is what an await about a PAST command answers.
try std.testing.expectEqual(@as(?u8, 7), s2.last_return.?.exit_code);
// The watermark cannot. The delta tracker is rebuilt from zero, so a
// seq from the old space is a watermark from the FUTURE, and no return
// after the upgrade can ever exceed it: measured on a live daemon, the
// first `mux a run` after an upgrade timed out and the second did not.
try std.testing.expectEqual(s2.tracker.seq, s2.last_return.?.seq);
}
test "validateUpgrade: same version without the flag names both versions" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "val1", .{ .shell = "/bin/sh" });
defer td.deinit();
const reason = td.srv.validateUpgrade(.{
.allow_same_version = false,
.version = "0.0.1-13",
.path = "/bin/sh",
}, "0.0.1-13");
try std.testing.expect(reason != null);
try std.testing.expect(std.mem.indexOf(u8, reason.?, "0.0.1-13") != null);
if (reason) |r| alloc.free(r);
}
test "validateUpgrade: a relative path is refused" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "val2", .{ .shell = "/bin/sh" });
defer td.deinit();
const reason = td.srv.validateUpgrade(.{
.allow_same_version = true,
.version = "0.0.1-14",
.path = "relative/mux",
}, "0.0.1-13");
try std.testing.expect(reason != null);
if (reason) |r| alloc.free(r);
}
test "validateUpgrade: a non-executable path is refused" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.init(alloc, "val3", .{ .shell = "/bin/sh" });
defer td.deinit();
// /dev/null exists but is not executable.
const reason = td.srv.validateUpgrade(.{
.allow_same_version = true,
.version = "0.0.1-14",
.path = "/dev/null",
}, "0.0.1-13");
try std.testing.expect(reason != null);
if (reason) |r| alloc.free(r);
}
test "clearCloexec: a CLOEXEC carrier's flag flips" {
// A carrier is never CLOEXEC, so arm the flag with the production
// inverse of the call under test, then clear it and read F_GETFD.
const fd = try server_os.anonFd("mux-clearcloexec-test");
defer std.posix.close(fd);
try Server.setCloexec(fd);
// Confirm it starts with CLOEXEC.
const before = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
try std.testing.expect(before & std.posix.FD_CLOEXEC != 0);
// Clear it.
try Server.clearCloexec(fd);
// Confirm the flag is gone.
const after = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
try std.testing.expect(after & std.posix.FD_CLOEXEC == 0);
}
/// FD_CLOEXEC, read back from the kernel — a daemon reporting on its own
/// fd table cannot catch itself being wrong.
fn hasCloexec(fd: std.posix.fd_t) !bool {
const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
return flags & std.posix.FD_CLOEXEC != 0;
}
test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before the last rollback" {
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}/cloexec.sock", .{dir_path});
defer alloc.free(sock_path);
var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
const carrier = try server_os.anonFd("mux-cloexec-test");
defer std.posix.close(carrier);
try srv.writeManifestTo(carrier, "0.0.1-99");
// What execUpgrade does on the way out: the flag is cleared so the fds
// cross the exec. The adopting side must put it back.
try Server.clearCloexec(srv.bound.fd);
try Server.clearCloexec(srv.sessions.table[0].?.pty.master);
if (srv.sessions.table[0].?.agentFd() != -1)
try Server.clearCloexec(srv.sessions.table[0].?.agentFd());
// Memory only, as in the adopted-session test above: deinit would
// demolish exactly what srv2 is about to inherit.
{
const s = &srv.sessions.table[0].?;
s.freeOwned(alloc);
// The path string only. The listener behind it stays bound, because
// the fd is one of the things srv2 adopts.
if (s.agentPath()) |p| alloc.free(p);
}
if (srv.agents.dir) |d| alloc.free(d);
srv.shellint_arena.deinit();
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100");
defer srv2.deinit();
const s2 = &srv2.sessions.table[0].?;
// Adoption alone must NOT set it: `rollback` execs the old binary with
// these very descriptors, and a flag set here closes them at that exec.
// Flagged in initFromManifest, this test was green and the rolled-back
// daemon panicked adopting a manifest naming fds it no longer had.
try std.testing.expect(!try hasCloexec(srv2.bound.fd));
try std.testing.expect(!try hasCloexec(s2.pty.master));
if (s2.agentFd() != -1) try std.testing.expect(!try hasCloexec(s2.agentFd()));
srv2.sealAdoptedFds();
try std.testing.expect(try hasCloexec(srv2.bound.fd));
try std.testing.expect(try hasCloexec(s2.pty.master));
if (s2.agentFd() != -1) try std.testing.expect(try hasCloexec(s2.agentFd()));
}
test "Server: an upgrade asked for during a session's hangup is refused, not attempted" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "upending");
defer td.deinit();
// A real version, so every other check would PASS: without the ending
// session this upgrade is one the daemon would go through with.
try td.startStubborn(alloc, .{ .version = "0.0.1-1" });
// The shell outlives the hangup by the whole grace, so the exec's
// clearCloexec and the manifest's pty_fd would both see -1.
const ender = try dial.dial(td.sock_path);
defer ender.close();
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(ender.handle, .end_req, proto.encodeEndReq(&rq, true, ""));
const r = (try awaitFrame(alloc, &td.srv, ender.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);
// A candidate that passes every OTHER check, so the refusal can only be
// the ending session: a wrong version or an unrunnable path would refuse
// this upgrade whether or not anything was hanging up.
try td.tmp.dir.writeFile(.{
.sub_path = "fakemux.sh",
.data =
\\#!/bin/sh
\\case "$1" in
\\ --version) printf 'mux 9.9.9\n' ;;
\\esac
\\exit 0
\\
,
.flags = .{ .mode = 0o755 },
});
const cand = try std.fmt.allocPrint(alloc, "{s}/fakemux.sh", .{td.tmp.path()});
defer alloc.free(cand);
const obs = try dial.dial(td.sock_path);
defer obs.close();
var ureq: [512]u8 = undefined;
try proto.writeFrame(obs.handle, .upgrade_req, try proto.encodeUpgradeReq(&ureq, .{
.allow_same_version = false,
.version = "9.9.9",
.path = cand,
}));
const reply = (try awaitFrame(alloc, &td.srv, obs.handle, .upgrade_reply, 400)) orelse return error.NoUpgradeReply;
defer reply.deinit(alloc);
try std.testing.expect(reply.payload.len > 1);
try std.testing.expectEqual(@as(u8, 1), reply.payload[0]);
try std.testing.expect(std.mem.indexOf(u8, reply.payload[1..], "session ending") != null);
}
test "Server: an EMPTY daemon upgrades — the manifest names no session and the new image serves" {
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}/upempty.sock", .{dir_path});
defer alloc.free(sock_path);
// A shell that leaves the moment it is forked: the daemon's only
// session, so the table is empty a pump later. That state was
// unreachable while a daemon left with its last session; it is an
// ordinary one now, and the exec has to cross it.
try tmp.dir.writeFile(.{
.sub_path = "exit0.sh",
.data =
\\#!/bin/sh
\\exit 0
\\
,
.flags = .{ .mode = 0o755 },
});
const script = try std.fmt.allocPrintSentinel(alloc, "{s}/exit0.sh", .{dir_path}, 0);
defer alloc.free(script);
var srv = try Server.init(alloc, .{
.sock_path = sock_path,
.shell = script,
.version = "0.0.1-1",
});
const Emptied = struct {
srv: *Server,
fn yes(self: @This()) bool {
return self.srv.sessions.live() == 0;
}
};
try std.testing.expect(try h.pumpUntil(&srv, 3000, Emptied{ .srv = &srv }, Emptied.yes));
// A candidate that passes every check, run against a daemon holding
// nothing: emptiness must not be mistaken for the one state that DOES
// refuse an upgrade, a session mid-hangup.
try tmp.dir.writeFile(.{
.sub_path = "fakemux.sh",
.data =
\\#!/bin/sh
\\case "$1" in
\\ --version) printf 'mux 9.9.9\n' ;;
\\esac
\\exit 0
\\
,
.flags = .{ .mode = 0o755 },
});
const cand = try std.fmt.allocPrint(alloc, "{s}/fakemux.sh", .{dir_path});
defer alloc.free(cand);
if (srv.validateUpgrade(.{
.allow_same_version = false,
.version = "9.9.9",
.path = cand,
}, "0.0.1-1")) |reason| {
defer alloc.free(reason);
std.debug.print("an empty daemon was refused an upgrade: {s}\n", .{reason});
return error.EmptyDaemonRefusedUpgrade;
}
const carrier = try server_os.anonFd("mux-empty-upgrade");
defer std.posix.close(carrier);
try srv.writeManifestTo(carrier, "0.0.1-1");
// The first Server's MEMORY only, never deinit: deinit unlinks the
// socket and deleteTrees the dirs, and the adopting Server is about to
// inherit all of them. With no sessions there is nothing else to free.
if (srv.agents.dir) |d| alloc.free(d);
srv.shellint_arena.deinit();
var file = std.fs.File{ .handle = carrier };
try file.seekTo(0);
const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
defer alloc.free(buf);
var parsed = try upgrade.parseManifest(alloc, buf);
defer parsed.deinit();
try std.testing.expectEqual(@as(usize, 0), parsed.sessions.len);
var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-2");
defer srv2.deinit();
try std.testing.expectEqual(@as(usize, 0), srv2.sessions.live());
// Serving, on the listener fd the old image bound: an empty daemon that
// came through an exec is still the box a client can be born on.
const obs = try dial.dial(sock_path);
defer obs.close();
try proto.writeFrame(obs.handle, .stats_req, "");
const reply = (try awaitFrame(alloc, &srv2, obs.handle, .stats_reply, 400)) orelse
return error.NoStatsReply;
defer reply.deinit(alloc);
try std.testing.expect(std.mem.indexOf(u8, reply.payload, "sessions=0") != null);
}
test "Server: an accepted end cancels a pending upgrade — the exec never sees a hung-up master" {
const alloc = std.testing.allocator;
var td = try h.TestDaemon.open(alloc, "upcancel");
defer td.deinit();
try td.startStubborn(alloc, .{ .version = "0.0.1-1" });
// Plural: the end takes ONE session and the upgrade would have carried
// the others, so a guard that only looked at the ending slot's own
// clients would still pass here.
const ca = try td.attachStubborn(alloc, "a", 80, 24);
defer ca.close();
try td.tmp.dir.writeFile(.{
.sub_path = "fakemux.sh",
.data =
\\#!/bin/sh
\\case "$1" in
\\ --version) printf 'mux 9.9.9\n' ;;
\\esac
\\exit 0
\\
,
.flags = .{ .mode = 0o755 },
});
const cand = try std.fmt.allocPrint(alloc, "{s}/fakemux.sh", .{td.tmp.path()});
defer alloc.free(cand);
// Two observers, seated in slot order BEFORE either speaks: the hazard
// is one pump servicing an upgrade at a lower slot and an end at a
// higher one, after which `run` execs on a master that is already -1.
const up = try dial.dial(td.sock_path);
defer up.close();
try td.srv.pumpOnce(20);
const ender = try dial.dial(td.sock_path);
defer ender.close();
try td.srv.pumpOnce(20);
var ureq: [512]u8 = undefined;
try proto.writeFrame(up.handle, .upgrade_req, try proto.encodeUpgradeReq(&ureq, .{
.allow_same_version = false,
.version = "9.9.9",
.path = cand,
}));
const reply = (try awaitFrame(alloc, &td.srv, up.handle, .upgrade_reply, 800)) orelse
return error.NoUpgradeReply;
defer reply.deinit(alloc);
try std.testing.expectEqual(@as(u8, 0), reply.payload[0]); // accepted
try std.testing.expect(td.srv.pending_upgrade != null);
var rq: [proto.end_req_max_len]u8 = undefined;
try proto.writeFrame(ender.handle, .end_req, proto.encodeEndReq(&rq, true, "a"));
const r = (try awaitFrame(alloc, &td.srv, ender.handle, .end_reply, 400)) orelse
return error.NoEndReply;
defer r.deinit(alloc);
try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
// The claim. `clearCloexec(-1)` is `unreachable` in the pinned std, so
// an exec that still ran here would panic the daemon and take every
// session with it — a refusal is what the operator must get instead.
try std.testing.expect(td.srv.pending_upgrade == null);
}