a73x

28c13610

fix: the Darwin send is non-blocking by the fd, not by the flag

a73x   2026-09-03 19:13

Commit message
fix: the Darwin send is non-blocking by the fd, not by the flag

MSG_DONTWAIT exists on Darwin and the name suggests it covers the whole
operation, but xnu only consults it when it takes the socket buffer lock.
The wait for buffer SPACE tests the socket's own SS_NBIO bit, which is
O_NONBLOCK on the file descriptor and nothing a send flag can reach. So
sendNoSigNoWait blocked there on a socket nobody had made non-blocking,
and the server attach suite hung in __sendto on the Mac with no output.

The arm now reads the fd's flags and adds O_NONBLOCK when it is missing,
which is a no-op for every fd the daemon sends on (each is non-blocking
from its accept) and the difference between a stall and an error for any
other. The root's doc says the change is sticky, and a new root test asks
the question the daemon's own fds cannot: a socketpair left blocking,
filled until the send reports WouldBlock.

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

src/os/server_os.zig
Old New
@@ -44,6 +44,12 @@ pub fn peerCred(fd: std.posix.socket_t) ?PeerCred {
44 /// operation and it BLOCKS: the two differ in that one respect, and a 44 /// operation and it BLOCKS: the two differ in that one respect, and a
45 /// shared name would let a caller that moved between them assume the 45 /// shared name would let a caller that moved between them assume the
46 /// other's behaviour. 46 /// other's behaviour.
47 /// An arm may make the fd itself non-blocking to keep that promise, and
48 /// that change is permanent for the fd — Darwin's does, because no send
49 /// flag can reach the wait it has to skip. Every fd the daemon sends on is
50 /// already non-blocking from its accept, so nothing of ours notices; a
51 /// caller that hands in a blocking fd and later expects blocking reads or
52 /// writes on it would.
47 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { 53 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
48 return impl.sendNoSigNoWait(fd, bytes); 54 return impl.sendNoSigNoWait(fd, bytes);
49 } 55 }
@@ -459,6 +465,41 @@ test "server_os.sendNoSigNoWait: a closed peer is an error, not a signal" {
459 try std.testing.expectEqual(@as(u32, std.posix.SOCK.STREAM), try sockType(sp[0])); 465 try std.testing.expectEqual(@as(u32, std.posix.SOCK.STREAM), try sockType(sp[0]));
460 } 466 }
461 467
468 test "server_os.sendNoSigNoWait: a full buffer is WouldBlock, not a stall" {
469 // The NoWait half of the name, asked of a socket nobody made
470 // non-blocking. Linux answers it from MSG_DONTWAIT alone. Darwin does
471 // not: xnu consults that flag when it takes the socket buffer lock, but
472 // the wait for buffer SPACE tests the socket's own SS_NBIO bit, which is
473 // O_NONBLOCK on the file descriptor and nothing a send flag can reach.
474 // An arm that only passes the flag therefore SLEEPS here, waiting for a
475 // peer that never reads, and this test hangs rather than failing — a
476 // `zig build test` that prints nothing for minutes is what that looks
477 // like, and it is the same stall a client that stopped reading would
478 // impose on the daemon's only pump.
479 //
480 // The daemon's own fds are all non-blocking from their accept
481 // (`Server.setNonblocking`), so this asks the operation the question the
482 // daemon cannot: the promise has to hold for the fd, not for the caller.
483 var sp: [2]std.posix.fd_t = undefined;
484 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
485 defer std.posix.close(sp[0]);
486 defer std.posix.close(sp[1]);
487 // Nobody ever reads sp[1]. A socket send buffer is a few hundred KB at
488 // most, so 32 MB of 64 KB writes is two orders of magnitude of slack and
489 // still finishes in well under a second; reaching the bound means the
490 // send is swallowing the fill instead of reporting it.
491 const chunk = [_]u8{'x'} ** (64 * 1024);
492 var sent: usize = 0;
493 while (sent < 32 << 20) {
494 const n = sendNoSigNoWait(sp[0], &chunk) catch |e| {
495 if (e != error.WouldBlock) return e;
496 return;
497 };
498 sent += n;
499 }
500 return error.SendNeverReportedAFullBuffer;
501 }
502
462 // Forces semantic analysis of every pub decl under `zig build test`, so an 503 // Forces semantic analysis of every pub decl under `zig build test`, so an
463 // unreferenced operation must at least compile for this OS. 504 // unreferenced operation must at least compile for this OS.
464 test { 505 test {
src/os/server_os_macos.zig
Old New
@@ -5,8 +5,9 @@
5 //! `closeFrom` walks the fd table because there is no close_range, 5 //! `closeFrom` walks the fd table because there is no close_range,
6 //! `anonFd` is an unlinked mkstemp file because there is no memfd, 6 //! `anonFd` is an unlinked mkstemp file because there is no memfd,
7 //! `sendNoSigNoWait` sets SO_NOSIGPIPE on the socket because there is no 7 //! `sendNoSigNoWait` sets SO_NOSIGPIPE on the socket because there is no
8 //! MSG_NOSIGNAL, and `peerCred` takes two calls because LOCAL_PEERCRED 8 //! MSG_NOSIGNAL and sets O_NONBLOCK on the fd because MSG_DONTWAIT does not
9 //! answers no pid. 9 //! reach xnu's wait for buffer space, and `peerCred` takes two calls because
10 //! LOCAL_PEERCRED answers no pid.
10 const std = @import("std"); 11 const std = @import("std");
11 const root = @import("server_os.zig"); 12 const root = @import("server_os.zig");
12 const c = @cImport({ 13 const c = @cImport({
@@ -62,6 +63,32 @@ pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.Send
62 const on: c_int = 1; 63 const on: c_int = 1;
63 const rc = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int)); 64 const rc = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
64 if (rc != 0 and std.posix.errno(rc) == .INVAL) return error.BrokenPipe; 65 if (rc != 0 and std.posix.errno(rc) == .INVAL) return error.BrokenPipe;
66
67 // The NoWait half is the fd's, not the send's. MSG_DONTWAIT exists on
68 // Darwin and the name suggests it covers this, but xnu only consults it
69 // when it takes the socket buffer lock. The wait for buffer SPACE, a
70 // little further into sosend, tests the socket's own SS_NBIO bit — which
71 // is O_NONBLOCK on the file descriptor and nothing the flags argument can
72 // reach. So a send with MSG_DONTWAIT on a blocking fd whose peer has
73 // stopped reading sleeps in the kernel until the peer drains, which is
74 // exactly the stall this operation promises the daemon it will never
75 // take. Linux honours the flag and needs none of this.
76 //
77 // Make it true rather than assume it: read the flags and add O_NONBLOCK
78 // when it is missing. It is a no-op for every fd the daemon owns
79 // (`Server.setNonblocking` sets it on each accepted client, and a `Sink`
80 // socket fd only ever comes from that accept), so the cost is one fcntl
81 // on a path that already makes two syscalls, and the fds that are not the
82 // daemon's — a socketpair a test or a future caller hands in — get the
83 // guarantee the name makes instead of a hang. The change is sticky, which
84 // is correct: an fd this operation may be called on must never block.
85 // An fd fcntl refuses is a bad fd or not a socket, and `send` names that
86 // better than a swallowed fcntl error would.
87 if (std.posix.fcntl(fd, std.posix.F.GETFL, 0)) |fl| {
88 const nb: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
89 if (fl & nb == 0) _ = std.posix.fcntl(fd, std.posix.F.SETFL, fl | nb) catch {};
90 } else |_| {}
91
65 return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT); 92 return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT);
66 } 93 }
67 94