319c9d8e
fix: claim stats the path before it connects to it
a73x 2026-09-03 19:25
Commit message
src/server/server_test_session.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,5 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const builtin = @import("builtin"); | ||
| 2 | const Engine = @import("term").engine.Engine; | 3 | const Engine = @import("term").engine.Engine; |
| 3 | const proto = @import("term").protocol; | 4 | const proto = @import("term").protocol; |
| 4 | const quic = @import("quic"); | 5 | const quic = @import("quic"); |
| @@ -102,10 +103,16 @@ test "Server: a path that cannot be bound fails as AddressInUse" { | |||
| 102 | defer alloc.free(sock_path); | 103 | defer alloc.free(sock_path); |
| 103 | 104 | ||
| 104 | // A dangling symlink reaches `bind()` the way a lost start-up race does, but | 105 | // A dangling symlink reaches `bind()` the way a lost start-up race does, but |
| 105 | // deterministically: the connect gets ENOENT so the probe reads the path as | 106 | // deterministically: the stat gets ENOENT so the probe reads the path as |
| 106 | // free, while the bind gets EADDRINUSE off the symlink's own entry. | 107 | // free, while the bind gets EADDRINUSE off the symlink's own entry. |
| 107 | try tmp.dir.symLink("no-such-target", "dangling.sock", .{}); | 108 | try tmp.dir.symLink("no-such-target", "dangling.sock", .{}); |
| 108 | 109 | ||
| 110 | // Linux only, and not because of a spelling: Darwin's bind FOLLOWS a | ||
| 111 | // dangling symlink and creates the socket at the name it points to, so | ||
| 112 | // there is no unbindable path to grade there. Measured 2026-09-03 on | ||
| 113 | // macOS 26 with a plain C bind, which returned 0. | ||
| 114 | if (builtin.os.tag != .linux) return error.SkipZigTest; | ||
| 115 | |||
| 109 | try expectInitRefused(alloc, sock_path, error.AddressInUse); | 116 | try expectInitRefused(alloc, sock_path, error.AddressInUse); |
| 110 | 117 | ||
| 111 | // And the path is left alone: we could not identify it as a dead | 118 | // And the path is left alone: we could not identify it as a dead |
| @@ -130,10 +137,11 @@ test "Server: a non-socket at the path is refused, not deleted" { | |||
| 130 | const contents = "mux must not eat this"; | 137 | const contents = "mux must not eat this"; |
| 131 | try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = contents }); | 138 | try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = contents }); |
| 132 | 139 | ||
| 133 | // Connecting to a regular file fails with ECONNREFUSED — the very same | 140 | // Nothing but the stat separates this file from a dead daemon's socket: |
| 134 | // errno a dead socket gives — so identifying stale sockets by the | 141 | // Linux answers a connect to a regular file with ECONNREFUSED, the very |
| 135 | // connect result alone would delete this file. It is the stat that | 142 | // same errno a dead socket gives, so identifying stale sockets by the |
| 136 | // saves it, and this test is what pins that. | 143 | // connect result alone would delete this file. `claim` stats first and |
| 144 | // never connects to a non-socket at all, and this test is what pins that. | ||
| 137 | try expectInitRefused(alloc, file_path, error.SockPathNotASocket); | 145 | try expectInitRefused(alloc, file_path, error.SockPathNotASocket); |
| 138 | 146 | ||
| 139 | const after = try tmp.dir.readFileAlloc(alloc, "notes.txt", 1024); | 147 | const after = try tmp.dir.readFileAlloc(alloc, "notes.txt", 1024); |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -166,21 +166,38 @@ pub fn answers(path: []const u8) bool { | |||
| 166 | /// with its sessions intact, but only the newest is reachable and the rest are | 166 | /// with its sessions intact, but only the newest is reachable and the rest are |
| 167 | /// stranded holding shells nobody can get back to. | 167 | /// stranded holding shells nobody can get back to. |
| 168 | /// | 168 | /// |
| 169 | /// So: unlink only what answers ECONNREFUSED *and* is a socket. | 169 | /// So: unlink only what is a socket *and* answers ECONNREFUSED. |
| 170 | /// - something answers → a live daemon owns this path. Refuse. | ||
| 171 | /// - nothing there → bind, nothing to clean up. | 170 | /// - nothing there → bind, nothing to clean up. |
| 171 | /// - not a socket → refuse; `--sock notes.txt` must not eat the file. | ||
| 172 | /// - something answers → a live daemon owns this path. Refuse. | ||
| 172 | /// - a dead socket file → ours to clear. | 173 | /// - a dead socket file → ours to clear. |
| 173 | /// - anything else → propagate; a path we cannot positively call a | 174 | /// - anything else → propagate; a path we cannot positively call a |
| 174 | /// dead daemon's leftover is not ours to delete. | 175 | /// dead daemon's leftover is not ours to delete. |
| 176 | /// | ||
| 177 | /// The STAT comes first, and the order is load-bearing rather than a | ||
| 178 | /// preference: the two kernels disagree about what connecting to a path that | ||
| 179 | /// is not a socket means. Linux answers ECONNREFUSED, the same errno a dead | ||
| 180 | /// socket gives, so the stat was what separated them. Darwin answers ENOTSOCK, | ||
| 181 | /// which `std.posix.connect` treats as a programming error about the local fd | ||
| 182 | /// and hits `unreachable` on — a panic, in a daemon, over a file the user | ||
| 183 | /// named. Asking the stat first means the connect is only ever made to | ||
| 184 | /// something that IS a socket, and neither kernel has a surprise there. | ||
| 175 | pub fn claim(path: []const u8) !void { | 185 | pub fn claim(path: []const u8) !void { |
| 186 | const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) { | ||
| 187 | // Nothing at the path, or a symlink to nothing: free as far as this | ||
| 188 | // function can tell, and `bind` gets the last word on the entry. | ||
| 189 | error.FileNotFound => return, | ||
| 190 | else => |e| return e, | ||
| 191 | }; | ||
| 192 | if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket; | ||
| 193 | |||
| 176 | if (std.net.connectUnixSocket(path)) |probe| { | 194 | if (std.net.connectUnixSocket(path)) |probe| { |
| 177 | probe.close(); | 195 | probe.close(); |
| 178 | return error.DaemonAlreadyRunning; | 196 | return error.DaemonAlreadyRunning; |
| 179 | } else |err| switch (err) { | 197 | } else |err| switch (err) { |
| 180 | error.FileNotFound => return, // free path; bind straight away | 198 | error.FileNotFound => return, // vanished under us; path is free |
| 181 | // Nobody is listening, which is NOT yet proof of a stale socket: Linux | 199 | // A socket file nobody is listening on: a dead daemon's leftover, |
| 182 | // answers ECONNREFUSED for a regular file exactly as for a dead socket, | 200 | // which the stat above has already confirmed is a socket. |
| 183 | // so only the stat below separates a daemon from `--sock notes.txt`. | ||
| 184 | error.ConnectionRefused => {}, | 201 | error.ConnectionRefused => {}, |
| 185 | // Every other errno names a path this process cannot positively call a | 202 | // Every other errno names a path this process cannot positively call a |
| 186 | // dead daemon's leftover, so it propagates BY NAME and the daemon's log | 203 | // dead daemon's leftover, so it propagates BY NAME and the daemon's log |
| @@ -188,12 +205,6 @@ pub fn claim(path: []const u8) !void { | |||
| 188 | else => |e| return e, | 205 | else => |e| return e, |
| 189 | } | 206 | } |
| 190 | 207 | ||
| 191 | const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) { | ||
| 192 | error.FileNotFound => return, // vanished under us; path is free | ||
| 193 | else => |e| return e, | ||
| 194 | }; | ||
| 195 | if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket; | ||
| 196 | |||
| 197 | std.fs.cwd().deleteFile(path) catch |err| switch (err) { | 208 | std.fs.cwd().deleteFile(path) catch |err| switch (err) { |
| 198 | // Someone else cleared it first. The path is free either way, | 209 | // Someone else cleared it first. The path is free either way, |
| 199 | // which is the only thing this function was after. | 210 | // which is the only thing this function was after. |