6e576071
fix: a read answers no when it cannot reach, and the unlink keeps its errno
a73x 2026-08-30 09:59
Commit message
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(); |