b4020e15
feat: serve gives bind and the guarded unlink one owner
a73x 2026-08-31 21:58
Commit message
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 | } | ||