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