a73x

b4020e15

feat: serve gives bind and the guarded unlink one owner

a73x   2026-08-31 21:58

Commit message
feat: serve gives bind and the guarded unlink one owner

Three binders answered the same trio independently — the daemon socket,
the per-session agent sockets, askpass's prompt socket — and only the
daemon's carried the stillAt-guarded unlink that sockpath's PathId record
paid for. `serve.bind` takes the policy (refuse a live owner, or clobber a
name that embeds our own identity) and `Bound.close` carries the guard, so
the other two gain it by conversion rather than by three edits.

cloexec defaults FALSE on purpose: `mux d upgrade` execs the candidate over
the running daemon, and the listener and every agent socket must survive
that exec.

Accept loops stay with their callers: the daemon's slot table, askpass's
credential check and the hub's thread-per-conn differ for reasons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWxBL1HBULH1ZwTNzzKTja

build.zig
Old New
@@ -149,6 +149,11 @@ const mod_table = [_]ModSpec{
149 // claim and the dev+ino record teardown compares against. A leaf — it 149 // claim and the dev+ino record teardown compares against. A leaf — it
150 // takes a path and nothing else, and knows no Server exists. 150 // takes a path and nothing else, and knows no Server exists.
151 .{ .name = "sockpath", .path = "src/sockpath.zig", .test_imports = &.{"testtmp"} }, 151 .{ .name = "sockpath", .path = "src/sockpath.zig", .test_imports = &.{"testtmp"} },
152 // The bind-and-unlink half of owning a socket path, above sockpath and
153 // below every binder: the daemon socket, the per-session agent sockets
154 // and askpass's prompt socket all take their listener from here, so the
155 // guarded unlink is written once instead of three times.
156 .{ .name = "serve", .path = "src/serve.zig", .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
152 // No imports that teach it anything, deliberately: the proxy is a byte 157 // No imports that teach it anything, deliberately: the proxy is a byte
153 // pump that knows nothing about the protocol it carries. `testtmp` is 158 // pump that knows nothing about the protocol it carries. `testtmp` is
154 // the one exception and does not weaken that — it hands its tests a 159 // the one exception and does not weaken that — it hands its tests a
@@ -723,8 +728,8 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
723 const test_order = [_][]const u8{ 728 const test_order = [_][]const u8{
724 "script", "cliflags", "testtmp", "spawn", "dial", "link", 729 "script", "cliflags", "testtmp", "spawn", "dial", "link",
725 "quic", "webhub", "agent", "term", "rawmode", "delaypipe", 730 "quic", "webhub", "agent", "term", "rawmode", "delaypipe",
726 "render", "wsclient", "ptyclient", "pty", "sockpath", "xdg", 731 "render", "wsclient", "ptyclient", "pty", "sockpath", "serve",
727 "proxy", "wall", "client", "daemon", "mux", 732 "xdg", "proxy", "wall", "client", "daemon", "mux",
728 }; 733 };
729 734
730 comptime { 735 comptime {
src/serve.zig
Old New
@@ -0,0 +1,197 @@
1 //! The server side of a unix socket path: the right to bind it and the duty
2 //! to unlink it, written once. Three binders used to answer this trio
3 //! independently — the daemon socket, the per-session agent sockets, the
4 //! askpass socket — and only the first carried the guarded unlink that
5 //! sockpath's incident record paid for. Accept loops are NOT here: the
6 //! daemon's slot-table accept, askpass's credential check and the hub's
7 //! thread-per-conn differ for reasons, and a shared loop would be a shape
8 //! they do not fit.
9 const std = @import("std");
10 const sockpath = @import("sockpath");
11
12 pub const Policy = enum {
13 /// Never steal a path that answers: sockpath.claim's refusal, the
14 /// daemon-socket rule.
15 refuse_live,
16 /// The name embeds our identity (a pid, a session name in our own
17 /// directory), so a leftover file there is ours — a previous us that
18 /// died without unlinking — and is deleted before the bind.
19 clobber_own,
20 };
21
22 pub const BindOpts = struct {
23 policy: Policy,
24 backlog: u31 = 128,
25 /// TRUE by default, which is what `std.net.Address.listen` did for every
26 /// binder before this module existed, and the flag is load-bearing for
27 /// the reason it always was: the daemon forks a shell per session, and a
28 /// listener leaked into one is a socket that shell could serve.
29 ///
30 /// It does NOT conflict with `mux d upgrade` execing the candidate over
31 /// the running daemon. That path clears the flag explicitly on the
32 /// listener, every pty master and every agent socket right before the
33 /// exec (`Server.execUpgrade`) and `sealAdoptedFds` puts it back on the
34 /// far side — so the fds that must cross say so at the exec, one by one,
35 /// rather than by standing permanently open to every forked shell.
36 cloexec: bool = true,
37 };
38
39 pub const Bound = struct {
40 fd: std.posix.fd_t,
41 path_id: sockpath.PathId,
42
43 /// Close, then unlink only if the path still names OUR socket: a newer
44 /// owner may have replaced the file, and deleting that one would steal
45 /// its clients. The stat comes after the close because a successor only
46 /// claims once nothing is listening — that narrows the race to the
47 /// stat→unlink gap, the floor Linux gives for deleting by name.
48 pub fn close(self: *Bound, path: []const u8) void {
49 if (self.fd == -1) return;
50 std.posix.close(self.fd);
51 self.fd = -1;
52 self.unlinkIfOurs(path);
53 }
54
55 /// The guard without the close, for the one caller that must not close:
56 /// askpass's `retire` runs on a process about to `std.posix.exit` with
57 /// detached pumps still live, one of which may be inside `declined` on
58 /// that Listener. The name has to leave the filesystem there; the
59 /// descriptor may not.
60 pub fn unlinkIfOurs(self: *const Bound, path: []const u8) void {
61 if (self.path_id.stillAt(path)) {
62 std.fs.cwd().deleteFile(path) catch {};
63 }
64 }
65 };
66
67 /// Bind, listen, and remember which inode is ours. Overlong paths are
68 /// initUnix's refusal (kernel truth, not a re-stated bound) — `mux d`'s
69 /// parse-time `sockpath.tooLong` with its own stderr wording remains the
70 /// one binder-side pre-check, and it lives with `mux d`.
71 pub fn bind(path: []const u8, opts: BindOpts) !Bound {
72 switch (opts.policy) {
73 .refuse_live => try sockpath.claim(path),
74 .clobber_own => std.fs.cwd().deleteFile(path) catch {},
75 }
76 const addr = try std.net.Address.initUnix(path);
77 const sock_flags: u32 = std.posix.SOCK.STREAM |
78 (if (opts.cloexec) @as(u32, std.posix.SOCK.CLOEXEC) else 0);
79 const fd = try std.posix.socket(std.posix.AF.UNIX, sock_flags, 0);
80 errdefer std.posix.close(fd);
81 try std.posix.bind(fd, &addr.any, addr.getOsSockLen());
82 try std.posix.listen(fd, opts.backlog);
83 return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) };
84 }
85
86 /// A listener that already exists — the upgrade manifest's adopted fd. The
87 /// PathId is re-stamped from the file as found, which is the manifest rule:
88 /// a watermark belongs to the space that minted it.
89 pub fn adopt(fd: std.posix.fd_t, path: []const u8) !Bound {
90 return .{ .fd = fd, .path_id = try sockpath.PathId.of(path) };
91 }
92
93 const TmpDir = @import("testtmp").TmpDir;
94 const testing = std.testing;
95
96 test "refuse_live refuses a path a live listener owns; clobber_own takes its own leftover" {
97 var tmp = try TmpDir.make();
98 defer tmp.cleanup();
99 var buf: [280]u8 = undefined;
100 const path = try std.fmt.bufPrint(&buf, "{s}/serve.sock", .{tmp.path()});
101
102 var first = try bind(path, .{ .policy = .refuse_live });
103 // sockpath.claim's own word for "something answers here": the refusal is
104 // the daemon-socket rule, so the error stays claim's rather than being
105 // re-spelled as a socket error this module invented.
106 try testing.expectError(error.DaemonAlreadyRunning, bind(path, .{ .policy = .refuse_live }));
107
108 // Kill the listener but leave the file: the dead-us case.
109 std.posix.close(first.fd);
110 first.fd = -1;
111 var second = try bind(path, .{ .policy = .clobber_own });
112 second.close(path);
113 }
114
115 test "close unlinks our socket but never a successor's" {
116 var tmp = try TmpDir.make();
117 defer tmp.cleanup();
118 var buf: [280]u8 = undefined;
119 const path = try std.fmt.bufPrint(&buf, "{s}/succ.sock", .{tmp.path()});
120
121 var old = try bind(path, .{ .policy = .clobber_own });
122 // A successor replaces the FILE while old's listener lives on — deleting
123 // a unix socket's path does not touch the listening fd, which is the
124 // incident shape sockpath.zig's PathId records: two owners, one name, and
125 // the displaced one's teardown must not delete by that name.
126 var succ = try bind(path, .{ .policy = .clobber_own });
127
128 old.close(path); // guard fires: the inode at path is succ's — no unlink
129 _ = try std.fs.cwd().statFile(path); // successor's file survived
130 succ.close(path); // ours: unlinked
131 try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));
132 }
133
134 test "unlinkIfOurs takes the name without the descriptor, and spares a successor's" {
135 var tmp = try TmpDir.make();
136 defer tmp.cleanup();
137 var buf: [280]u8 = undefined;
138 const path = try std.fmt.bufPrint(&buf, "{s}/retire.sock", .{tmp.path()});
139
140 // askpass's retire shape: the name goes, the fd stays open for the
141 // detached pumps that may still be reading this object.
142 var one = try bind(path, .{ .policy = .clobber_own });
143 one.unlinkIfOurs(path);
144 try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));
145 try testing.expect(one.fd != -1);
146 std.posix.close(one.fd);
147
148 var old = try bind(path, .{ .policy = .clobber_own });
149 var succ = try bind(path, .{ .policy = .clobber_own });
150 old.unlinkIfOurs(path);
151 _ = try std.fs.cwd().statFile(path);
152 std.posix.close(old.fd);
153 succ.close(path);
154 }
155
156 test "adopt re-stamps the id of the file as found, and close then unlinks it" {
157 var tmp = try TmpDir.make();
158 defer tmp.cleanup();
159 var buf: [280]u8 = undefined;
160 const path = try std.fmt.bufPrint(&buf, "{s}/adopt.sock", .{tmp.path()});
161
162 // The upgrade shape: a listener fd crosses the exec, and nothing
163 // re-claims or re-binds — claim's probe would find our own listener
164 // answering and refuse the daemon its own socket.
165 const first = try bind(path, .{ .policy = .refuse_live });
166 var after = try adopt(first.fd, path);
167 try testing.expectEqual(first.path_id, after.path_id);
168 after.close(path);
169 try testing.expectError(error.FileNotFound, std.fs.cwd().statFile(path));
170 }
171
172 test "cloexec is on by default and off only when asked" {
173 var tmp = try TmpDir.make();
174 defer tmp.cleanup();
175 var buf: [280]u8 = undefined;
176 const path = try std.fmt.bufPrint(&buf, "{s}/flag.sock", .{tmp.path()});
177
178 // Asked of the descriptor, not read off the call that made it: every
179 // binder here is in a process that forks shells, and the default going
180 // quietly false would hand each of them a listening socket. That is
181 // exactly how it broke once — `std.net.Address.listen` set the flag
182 // unconditionally, so a bind() written without it was a silent leak.
183 var on = try bind(path, .{ .policy = .clobber_own });
184 try testing.expect(try std.posix.fcntl(on.fd, std.posix.F.GETFD, 0) & std.posix.FD_CLOEXEC != 0);
185 on.close(path);
186
187 var off = try bind(path, .{ .policy = .clobber_own, .cloexec = false });
188 try testing.expect(try std.posix.fcntl(off.fd, std.posix.F.GETFD, 0) & std.posix.FD_CLOEXEC == 0);
189 off.close(path);
190 }
191
192 // Forces semantic analysis of every pub decl under `zig build test`, so an
193 // unreferenced decl must at least compile (the silent-module-loss hazard,
194 // decisions.md).
195 test {
196 std.testing.refAllDeclsRecursive(@This());
197 }