a73x

src/serve.zig

Ref:   Size: 11.6 KiB   History

//! The server side of a unix socket path: the right to bind it and the duty
//! to unlink it, written once. Three binders used to answer this trio
//! independently — the daemon socket, the per-session agent sockets, the
//! askpass socket — and only the first carried the guarded unlink that
//! sockpath's incident record paid for. Accept loops are NOT here: the
//! daemon's slot-table accept, askpass's credential check and the hub's
//! thread-per-conn differ for reasons, and a shared loop would be a shape
//! they do not fit.
const std = @import("std");
const sockpath = @import("sockpath");

pub const Policy = enum {
    /// Never steal a path that answers: sockpath.claim's refusal, the
    /// daemon-socket rule.
    refuse_live,
    /// The name embeds our identity (a pid, a session name in our own
    /// directory), so a leftover file there is ours — a previous us that
    /// died without unlinking — and is deleted before the bind.
    clobber_own,
};

pub const BindOpts = struct {
    policy: Policy,
    backlog: u31 = 128,
    /// TRUE by default, which is what `std.net.Address.listen` did for every
    /// binder before this module existed, and the flag is load-bearing for
    /// the reason it always was: the daemon forks a shell per session, and a
    /// listener leaked into one is a socket that shell could serve.
    ///
    /// It does NOT conflict with `mux d upgrade` execing the candidate over
    /// the running daemon. That path clears the flag explicitly on the
    /// listener, every pty master and every agent socket right before the
    /// exec (`Server.execUpgrade`) and `sealAdoptedFds` puts it back on the
    /// far side — so the fds that must cross say so at the exec, one by one,
    /// rather than by standing permanently open to every forked shell.
    cloexec: bool = true,
};

/// Which way the unlink guard went. The daemon logs it, because "the
/// socket was already gone when the daemon stopped" is the whole trail a
/// deleted path leaves, and the guard used to decline in silence
/// (issue 04b3019d, 2026-09-04).
pub const Unlink = enum {
    /// The path named our socket and it is gone now.
    unlinked,
    /// The path names somebody else's socket — a successor's — and stays.
    spared_successor,
    /// Nothing at the path to unlink, or nothing this process may stat.
    already_gone,
    /// The path names our socket and the unlink was refused (a directory
    /// we may no longer write, a read-only mount): the file is still
    /// there, and the next start's claim will read it as a dead leftover.
    /// It used to be reported as `already_gone`, which is the opposite of
    /// what an operator needs to know.
    unlink_failed,
    /// `close` on a Bound whose descriptor was already closed: nothing done.
    closed_before,
};

pub const Bound = struct {
    fd: std.posix.fd_t,
    path_id: sockpath.PathId,

    /// Close, then unlink only if the path still names OUR socket: a newer
    /// owner may have replaced the file, and deleting that one would steal
    /// its clients. The stat comes after the close because a successor only
    /// claims once nothing is listening — that narrows the race to the
    /// stat→unlink gap, the floor Linux gives for deleting by name.
    pub fn close(self: *Bound, path: []const u8) Unlink {
        if (self.fd == -1) return .closed_before;
        std.posix.close(self.fd);
        self.fd = -1;
        return self.unlinkIfOurs(path);
    }

    /// The guard without the close, for the one caller that must not close:
    /// askpass's `retire` runs on a process about to `std.posix.exit` with
    /// detached pumps still live, one of which may be inside `declined` on
    /// that Listener. The name has to leave the filesystem there; the
    /// descriptor may not.
    pub fn unlinkIfOurs(self: *const Bound, path: []const u8) Unlink {
        const now = sockpath.PathId.of(path) catch return .already_gone;
        if (!std.meta.eql(now, self.path_id)) return .spared_successor;
        std.fs.cwd().deleteFile(path) catch |e| return switch (e) {
            error.FileNotFound => .already_gone,
            else => .unlink_failed,
        };
        return .unlinked;
    }
};

/// Bind, listen, and remember which inode is ours. Overlong paths are
/// initUnix's refusal (kernel truth, not a re-stated bound) — `mux d`'s
/// parse-time `sockpath.tooLong` with its own stderr wording remains the
/// one binder-side pre-check, and it lives with `mux d`.
pub fn bind(path: []const u8, opts: BindOpts) !Bound {
    switch (opts.policy) {
        // The branch claim took is the DAEMON's to log, at its own claim
        // one call earlier; this one is the same question asked again
        // after the fork, and its answer is not news.
        .refuse_live => _ = try sockpath.claim(path),
        .clobber_own => std.fs.cwd().deleteFile(path) catch {},
    }
    const addr = try std.net.Address.initUnix(path);
    const sock_flags: u32 = std.posix.SOCK.STREAM |
        (if (opts.cloexec) @as(u32, std.posix.SOCK.CLOEXEC) else 0);
    const fd = try std.posix.socket(std.posix.AF.UNIX, sock_flags, 0);
    errdefer std.posix.close(fd);
    try std.posix.bind(fd, &addr.any, addr.getOsSockLen());
    try std.posix.listen(fd, opts.backlog);
    return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) };
}

/// A listener that already exists — the upgrade manifest's adopted fd. The
/// PathId is re-stamped from the file as found, which is the manifest rule:
/// a watermark belongs to the space that minted it.
pub fn adopt(fd: std.posix.fd_t, path: []const u8) !Bound {
    return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) };
}

const TmpDir = @import("testtmp").TmpDir;
const testing = std.testing;

test "refuse_live refuses a path a live listener owns; clobber_own takes its own leftover" {
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [280]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/serve.sock", .{tmp.path()});

    var first = try bind(path, .{ .policy = .refuse_live });
    // sockpath.claim's own word for "something answers here": the refusal is
    // the daemon-socket rule, so the error stays claim's rather than being
    // re-spelled as a socket error this module invented.
    try testing.expectError(error.DaemonAlreadyRunning, bind(path, .{ .policy = .refuse_live }));

    // Kill the listener but leave the file: the dead-us case.
    std.posix.close(first.fd);
    first.fd = -1;
    var second = try bind(path, .{ .policy = .clobber_own });
    try testing.expectEqual(Unlink.unlinked, second.close(path));
    // A second close is a no-op that says so, not a second unlink of
    // whatever is at the path by then.
    try testing.expectEqual(Unlink.closed_before, second.close(path));
}

test "close unlinks our socket but never a successor's" {
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [280]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/succ.sock", .{tmp.path()});

    var old = try bind(path, .{ .policy = .clobber_own });
    // A successor replaces the FILE while old's listener lives on — deleting
    // a unix socket's path does not touch the listening fd, which is the
    // incident shape sockpath.zig's PathId records: two owners, one name, and
    // the displaced one's teardown must not delete by that name.
    var succ = try bind(path, .{ .policy = .clobber_own });

    // guard fires: the inode at path is succ's — no unlink
    try testing.expectEqual(Unlink.spared_successor, old.close(path));
    _ = try std.fs.cwd().statFile(path); // successor's file survived
    try testing.expectEqual(Unlink.unlinked, succ.close(path)); // ours
    try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));

    // And a socket whose path somebody deleted under it — the 2026-09-04
    // incident shape — reports that the name was already gone, which is
    // the one word the daemon's log needed and did not have.
    var lost = try bind(path, .{ .policy = .clobber_own });
    try std.fs.cwd().deleteFile(path);
    try testing.expectEqual(Unlink.already_gone, lost.close(path));

    // And an unlink the filesystem refuses is reported as that, never as
    // "already gone": the file is still there for the next start to find.
    // A directory without write permission is the cheapest refusal; root
    // is exempt from that bit, so the case is skipped for uid 0.
    if (std.posix.getuid() != 0) {
        var held = try bind(path, .{ .policy = .clobber_own });
        try std.posix.fchmodat(std.fs.cwd().fd, tmp.path(), 0o500, 0);
        defer std.posix.fchmodat(std.fs.cwd().fd, tmp.path(), 0o700, 0) catch {};
        try testing.expectEqual(Unlink.unlink_failed, held.close(path));
        _ = try std.fs.cwd().statFile(path);
    }
}

test "unlinkIfOurs takes the name without the descriptor, and spares a successor's" {
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [280]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/retire.sock", .{tmp.path()});

    // askpass's retire shape: the name goes, the fd stays open for the
    // detached pumps that may still be reading this object.
    var one = try bind(path, .{ .policy = .clobber_own });
    try testing.expectEqual(Unlink.unlinked, one.unlinkIfOurs(path));
    try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));
    try testing.expect(one.fd != -1);
    std.posix.close(one.fd);

    var old = try bind(path, .{ .policy = .clobber_own });
    var succ = try bind(path, .{ .policy = .clobber_own });
    try testing.expectEqual(Unlink.spared_successor, old.unlinkIfOurs(path));
    _ = try std.fs.cwd().statFile(path);
    std.posix.close(old.fd);
    try testing.expectEqual(Unlink.unlinked, succ.close(path));
}

test "adopt re-stamps the id of the file as found, and close then unlinks it" {
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [280]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/adopt.sock", .{tmp.path()});

    // The upgrade shape: a listener fd crosses the exec, and nothing
    // re-claims or re-binds — claim's probe would find our own listener
    // answering and refuse the daemon its own socket.
    const first = try bind(path, .{ .policy = .refuse_live });
    var after = try adopt(first.fd, path);
    try testing.expectEqual(first.path_id, after.path_id);
    try testing.expectEqual(Unlink.unlinked, after.close(path));
    try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));
}

test "cloexec is on by default and off only when asked" {
    var tmp = try TmpDir.make();
    defer tmp.cleanup();
    var buf: [280]u8 = undefined;
    const path = try std.fmt.bufPrint(&buf, "{s}/flag.sock", .{tmp.path()});

    // Asked of the descriptor, not read off the call that made it: every
    // binder here is in a process that forks shells, and the default going
    // quietly false would hand each of them a listening socket. That is
    // exactly how it broke once — `std.net.Address.listen` set the flag
    // unconditionally, so a bind() written without it was a silent leak.
    var on = try bind(path, .{ .policy = .clobber_own });
    try testing.expect(try std.posix.fcntl(on.fd, std.posix.F.GETFD, 0) & std.posix.FD_CLOEXEC != 0);
    _ = on.close(path);

    var off = try bind(path, .{ .policy = .clobber_own, .cloexec = false });
    try testing.expect(try std.posix.fcntl(off.fd, std.posix.F.GETFD, 0) & std.posix.FD_CLOEXEC == 0);
    _ = off.close(path);
}

// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md).
test {
    std.testing.refAllDeclsRecursive(@This());
}