a73x

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);
}