28c13610
fix: the Darwin send is non-blocking by the fd, not by the flag
a73x 2026-09-03 19:13
Commit message
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 | ||