a73x

319c9d8e

fix: claim stats the path before it connects to it

a73x   2026-09-03 19:25

Commit message
fix: claim stats the path before it connects to it

The two kernels disagree about connecting to a path that is not a socket.
Linux answers ECONNREFUSED, the same errno a dead socket gives, which is
why the stat existed at all. Darwin answers ENOTSOCK, which std.posix.connect
treats as a programming error about the local fd and hits unreachable on —
so a daemon started with --sock notes.txt panicked instead of refusing, and
the Mac suite aborted the whole daemon binary with signal 6.

Asking the stat first means the connect is only ever made to something that
IS a socket. The refusal and the file both survive on either system.

The AddressInUse test is Linux-only now, and not over a spelling: Darwin's
bind follows a dangling symlink and creates the socket at the name it points
to, so there is no unbindable path there to grade.

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

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.