a73x

c3a7ad58

fix: the Darwin arms answer ENOTCONN as a gone peer, not a panic

a73x   2026-09-04 10:16

Commit message
fix: the Darwin arms answer ENOTCONN as a gone peer, not a panic

std.posix.send maps ENOTCONN to unreachable, because for a local fd it can
only mean the caller passed something unconnected. Darwin also uses it for
the FAR end: a peer that has begun closing but not finished answers ENOTCONN
where a moment earlier the socket still takes SO_NOSIGPIPE and a moment later
it answers EPIPE. So the one thing sendNoSig exists to prevent — a gone peer
taking the process down — happened anyway, as an abort instead of a signal.

Caught by a Mac make check on 2026-09-04: "attempt to unwrap error:
SocketNotConnected" out of client_os_macos.sendNoSig, under
mux_main.agentReachable, the probe that asks whether an ssh agent is
listening. The same run's other pass was green, so it is a race and the gate
was flaky rather than red. server_os_macos.sendNoSigNoWait had the identical
hole, on the daemon's own pump, where the abort would take every session
with it.

Both arms go through sendto, which RETURNS the error that send unwraps, and
map it to the contract's BrokenPipe. No test: the window is between a peer's
close starting and finishing, and it does not reproduce on demand — measured
against a peer that had closed and settled, all three shapes (accepted then
closed, closed with no delay, never accepted) answer EPIPE on both systems.
The panic path is gone by construction rather than by assertion.

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

src/os/client_os_macos.zig
Old New
@@ -58,7 +58,38 @@ pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!
58 if (rc != 0 and std.posix.errno(rc) == .INVAL) return error.BrokenPipe; 58 if (rc != 0 and std.posix.errno(rc) == .INVAL) return error.BrokenPipe;
59 // Flags 0, not DONTWAIT: this side BLOCKS. The root's doc says why — 59 // Flags 0, not DONTWAIT: this side BLOCKS. The root's doc says why —
60 // the agent probe writes five bytes and then polls for the answer. 60 // the agent probe writes five bytes and then polls for the answer.
61 return std.posix.send(fd, bytes, 0); 61 return std.posix.sendto(fd, bytes, 0, null, 0) catch |err| switch (err) {
62 // Darwin's THIRD spelling of "the peer is gone", and the one that
63 // aborts the process instead of being an error. `std.posix.send` maps
64 // ENOTCONN to `unreachable`, because for a local fd it can only mean
65 // the caller passed something unconnected; Darwin also uses it for the
66 // FAR end, for a peer that has begun closing but not finished. A
67 // moment earlier the socket still takes SO_NOSIGPIPE, a moment later
68 // it answers EPIPE, and in between it answers this.
69 //
70 // Seen once in a Mac `make check` on 2026-09-04, as `attempt to unwrap
71 // error: SocketNotConnected` out of this call, in the probe that asks
72 // whether an ssh agent is listening. It is a race and does not
73 // reproduce on demand: measured against a peer that had closed and
74 // settled, all three shapes — accepted then closed, closed with no
75 // delay, never accepted — answer EPIPE on both systems. `sendto`
76 // RETURNS the error where `send` unwraps it, which is why this arm
77 // goes through it directly and answers the contract's BrokenPipe.
78 error.SocketNotConnected => return error.BrokenPipe,
79 // Only a `sendto` carrying an ADDRESS can raise these, and this one
80 // passes null. `std.posix.send` calls them unreachable for the same
81 // reason.
82 error.AddressFamilyNotSupported,
83 error.SymLinkLoop,
84 error.NameTooLong,
85 error.FileNotFound,
86 error.NotDir,
87 error.NetworkUnreachable,
88 error.AddressNotAvailable,
89 error.UnreachableAddress,
90 => unreachable,
91 else => |e| return e,
92 };
62 } 93 }
63 94
64 /// sysctl KERN_PROC_PID: the kernel's own record of the process, the Darwin 95 /// sysctl KERN_PROC_PID: the kernel's own record of the process, the Darwin
src/os/server_os_macos.zig
Old New
@@ -89,7 +89,41 @@ pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.Send
89 if (fl & nb == 0) _ = std.posix.fcntl(fd, std.posix.F.SETFL, fl | nb) catch {}; 89 if (fl & nb == 0) _ = std.posix.fcntl(fd, std.posix.F.SETFL, fl | nb) catch {};
90 } else |_| {} 90 } else |_| {}
91 91
92 return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT); 92 return std.posix.sendto(fd, bytes, std.posix.MSG.DONTWAIT, null, 0) catch |err| switch (err) {
93 // Darwin's THIRD spelling of "the peer is gone", and the one that
94 // aborts the process instead of being an error. `std.posix.send` maps
95 // ENOTCONN to `unreachable`, because for a local fd it can only mean
96 // the caller passed something unconnected; Darwin also uses it for the
97 // FAR end, for a peer that has begun closing but not finished. A
98 // moment earlier the socket still takes SO_NOSIGPIPE, a moment later
99 // it answers EPIPE, and in between it answers this.
100 //
101 // Found by READING this arm, not by a trace out of it: the abort was
102 // caught on 2026-09-04 in the CLIENT arm, out of the probe that asks
103 // whether an ssh agent is listening (`client_os_macos.sendNoSig` says
104 // so, and quotes it). This arm reaches `send` the same way, so it had
105 // the same hole — and on the daemon's only pump, where an abort takes
106 // every session with it rather than one probe. It is a race and does
107 // not reproduce on demand: measured against a peer that had closed
108 // and settled, all three shapes — accepted then closed, closed with
109 // no delay, never accepted — answer EPIPE on both systems. `sendto`
110 // RETURNS the error where `send` unwraps it, which is why this arm
111 // goes through it directly and answers the contract's BrokenPipe.
112 error.SocketNotConnected => return error.BrokenPipe,
113 // Only a `sendto` carrying an ADDRESS can raise these, and this one
114 // passes null. `std.posix.send` calls them unreachable for the same
115 // reason.
116 error.AddressFamilyNotSupported,
117 error.SymLinkLoop,
118 error.NameTooLong,
119 error.FileNotFound,
120 error.NotDir,
121 error.NetworkUnreachable,
122 error.AddressNotAvailable,
123 error.UnreachableAddress,
124 => unreachable,
125 else => |e| return e,
126 };
93 } 127 }
94 128
95 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 { 129 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {