a73x

6e576071

fix: a read answers no when it cannot reach, and the unlink keeps its errno

a73x   2026-08-30 09:59

Commit message
fix: a read answers no when it cannot reach, and the unlink keeps its errno

`answers` served two callers who want opposite defaults when the connect
fails for a third reason. It answered YES — right for `claim`, which
treats a no as a licence to unlink, and wrong for both starters: a socket
this process may not reach (a stale 0600 one at a shared /tmp path, a
directory without search permission) read as `already running`, and
`mux d start -d` exited 0 having started nothing.

So the connect that needs the errno owns it. `claim` runs its own again
and switches on it; `answers` is the plain read every other caller wanted.

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

src/cli/main.zig
Old New
@@ -2279,6 +2279,36 @@ test "start -d: an answering socket is already_running, nothing spawned" {
2279 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out)); 2279 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
2280 } 2280 }
2281 2281
2282 test "start -d: a socket this process may not reach is not `already running`" {
2283 // chmod does not bite root; as root the connect succeeds and the
2284 // premise of the test is gone.
2285 if (std.os.linux.geteuid() == 0) return error.SkipZigTest;
2286
2287 const testtmp = @import("testtmp");
2288 var tmp = try testtmp.TmpDir.make();
2289 defer tmp.cleanup();
2290 var buf: [128]u8 = undefined;
2291 const sock = try std.fmt.bufPrint(&buf, "{s}/mode0.sock", .{tmp.path()});
2292
2293 const addr = try std.net.Address.initUnix(sock);
2294 var server = try addr.listen(.{});
2295 defer server.deinit();
2296 try std.posix.fchmodat(std.posix.AT.FDCWD, sock, 0, 0);
2297
2298 // Past the read and into the spawn — SpawnFailed is the missing
2299 // binary talking, which is proof the probe did not answer for it.
2300 // `already_running` here is `mux d start -d` exiting 0 with no daemon.
2301 try std.testing.expectError(error.SpawnFailed, forkDaemon(
2302 std.testing.allocator,
2303 "/no/such/mux",
2304 &.{},
2305 sock,
2306 silentProgress(),
2307 200,
2308 null,
2309 ));
2310 }
2311
2282 test "start -d: a missing binary is SpawnFailed before any fork" { 2312 test "start -d: a missing binary is SpawnFailed before any fork" {
2283 const testtmp = @import("testtmp"); 2313 const testtmp = @import("testtmp");
2284 var tmp = try testtmp.TmpDir.make(); 2314 var tmp = try testtmp.TmpDir.make();
src/sockpath.zig
Old New
@@ -89,22 +89,12 @@ pub const PathId = struct {
89 } 89 }
90 }; 90 };
91 91
92 /// Whether anything is LISTENING at `path` right now — the one connect 92 /// Whether anything LISTENS at `path` now: a read, so every connect
93 /// probe, for `claim` and for everyone who only wants the answer. 93 /// error is a no. The decision needing the errno is `claim`.
94 pub fn answers(path: []const u8) bool { 94 pub fn answers(path: []const u8) bool {
95 if (std.net.connectUnixSocket(path)) |s| { 95 const s = std.net.connectUnixSocket(path) catch return false;
96 s.close(); 96 s.close();
97 return true; 97 return true;
98 } else |err| return switch (err) {
99 // Three errors are a no: nothing at the path, nothing listening,
100 // and a path the kernel's `sun_path` cannot even hold — no daemon
101 // is reachable through any of them. Every OTHER error is a yes,
102 // because a connect that failed for some third reason may still be
103 // a live daemon's, and a false here is a licence to unlink it —
104 // the field incident `claim` below exists for.
105 error.FileNotFound, error.ConnectionRefused, error.NameTooLong => false,
106 else => true,
107 };
108 } 98 }
109 99
110 /// Make the socket path ours to bind, or refuse it. Field incident this 100 /// Make the socket path ours to bind, or refuse it. Field incident this
@@ -124,13 +114,25 @@ pub fn answers(path: []const u8) bool {
124 /// identify as a dead daemon's leftover is not 114 /// identify as a dead daemon's leftover is not
125 /// something we may delete. 115 /// something we may delete.
126 pub fn claim(path: []const u8) !void { 116 pub fn claim(path: []const u8) !void {
127 if (answers(path)) return error.DaemonAlreadyRunning; 117 if (std.net.connectUnixSocket(path)) |probe| {
118 probe.close();
119 return error.DaemonAlreadyRunning;
120 } else |err| switch (err) {
121 error.FileNotFound => return, // free path; bind straight away
122 // Nobody is listening — which is NOT yet proof of a stale socket:
123 // Linux answers ECONNREFUSED for a regular file at the path
124 // exactly as it does for a dead socket, so the connect alone
125 // cannot tell a dead daemon from `mux d start --sock notes.txt`.
126 // The stat below is what separates them.
127 error.ConnectionRefused => {},
128 // Every other errno — EACCES on the socket or on a directory
129 // above it, a name too long for `sun_path` — names a path this
130 // process cannot positively call a dead daemon's leftover, so it
131 // is propagated by name and the daemon's log says which one
132 // refused the bind.
133 else => |e| return e,
134 }
128 135
129 // Nothing answered — which is NOT yet proof of a stale socket: Linux
130 // answers ECONNREFUSED for a regular file at the path exactly as it
131 // does for a dead socket, so the connect alone cannot tell a dead
132 // daemon from `mux d start --sock notes.txt`. The stat is what
133 // separates them.
134 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) { 136 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) {
135 error.FileNotFound => return, // vanished under us; path is free 137 error.FileNotFound => return, // vanished under us; path is free
136 else => |e| return e, 138 else => |e| return e,
@@ -186,6 +188,33 @@ test "answers: a live listener, a stale socket file, and a path with nothing on
186 try std.testing.expect(!answers("/" ++ "x" ** 200)); 188 try std.testing.expect(!answers("/" ++ "x" ** 200));
187 } 189 }
188 190
191 test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" {
192 // chmod does not bite root, so the connect would succeed and the test
193 // would assert the opposite of what it is named for.
194 if (std.os.linux.geteuid() == 0) return error.SkipZigTest;
195
196 const testtmp = @import("testtmp");
197 var tmp = try testtmp.TmpDir.make();
198 defer tmp.cleanup();
199
200 var buf: [64]u8 = undefined;
201 const path = try std.fmt.bufPrint(&buf, "{s}/mode0.sock", .{tmp.path()});
202 const addr = try std.net.Address.initUnix(path);
203 var listener = try addr.listen(.{});
204 defer listener.deinit();
205 try std.posix.fchmodat(std.posix.AT.FDCWD, path, 0, 0);
206
207 // A live daemon this process may not reach is one it cannot report as
208 // up: `forkDaemon` reads a yes here as `already_running` and exits 0
209 // having started nothing.
210 try std.testing.expect(!answers(path));
211
212 // And the same path is NOT a licence to unlink: only ECONNREFUSED on
213 // a socket file is, and everything else keeps its errno so the bind
214 // refusal names it.
215 try std.testing.expectError(error.AccessDenied, claim(path));
216 }
217
189 test "PathId: names the file it was taken from, not the path, and not a successor" { 218 test "PathId: names the file it was taken from, not the path, and not a successor" {
190 const testtmp = @import("testtmp"); 219 const testtmp = @import("testtmp");
191 var tmp = try testtmp.TmpDir.make(); 220 var tmp = try testtmp.TmpDir.make();