a73x

64c0073b

feat: the daemon's Darwin arm

a73x   2026-09-03 18:13

Commit message
feat: the daemon's Darwin arm

`server_os`'s .macos arm, so the daemon side of the tree compiles on a Mac.
Three operations do not exist there and are spelled differently on purpose:
`closeFrom` walks the fd table to `getdtablesize` because Darwin has no
close_range, `anonFd` is an mkstemp file unlinked before anyone could open it
by name because there is no memfd, and `sendNoSigNoWait` sets SO_NOSIGPIPE on
the socket per call because there is no MSG_NOSIGNAL. `peerCred` needs two
calls where Linux needs one: LOCAL_PEERCRED answers no pid, so the uid comes
from getpeereid and the pid from LOCAL_PEERPID.

The `forkDetached` test now asks for `ps -o sess=`, the one column both ps's
print, and claims a NEW session rather than the leader pid, which only procps
answers numerically.

Rule 6's `except` becomes a list: the fork's spelling is per OS arm, so each
arm's file is named in build.zig and a third file that forks is still caught.

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

CLAUDE.md
Old New
@@ -60,7 +60,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
60 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 60 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
61 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 61 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
62 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 62 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
63 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` · `client_os`(`client_os.zig`) — `client_os_linux` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 63 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
64 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | 64 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them |
65 65
66 The grouping itself is a convention now, not a gate: the table wires the 66 The grouping itself is a convention now, not a gate: the table wires the
@@ -75,15 +75,15 @@ one: no file under `src/` at all may spell `"/bin/sh"` or `"-c"`, because
75 every program mux runs is exec'd as argv and no shell of ours parses a line 75 every program mux runs is exec'd as argv and no shell of ours parses a line
76 we built; its three markers are `flags.zig`, `main.zig` and 76 we built; its three markers are `flags.zig`, `main.zig` and
77 `server_test_session.zig`, and two of the three cover prose and a fixture 77 `server_test_session.zig`, and two of the three cover prose and a fixture
78 rather than a shell the product runs. Rule 6 is the one `posix.fork` site, 78 rather than a shell the product runs. Rule 6 is the `posix.fork` site, one
79 and it names its file in `except` rather than in the file. The three keep 79 per OS arm, and it names those files in `except` rather than in the files.
80 their numbers because the in-file `folder rule N exemption:` lines cite them 80 The three keep their numbers because the in-file `folder rule N exemption:` lines cite them
81 by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`, 81 by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`,
82 `close_range`, `exit_group`, the `SO_PEERCRED` socket option, the two 82 `close_range`, `exit_group`, the `SO_PEERCRED` socket option, the two
83 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`, 83 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`,
84 comments included — 84 comments included —
85 the roots `server_os.zig` and `client_os.zig` are the contract and their 85 the roots `server_os.zig` and `client_os.zig` are the contract and their
86 `_linux` children the spellings (spec 2026-09-03). Rule 4's three remaining 86 per-OS children the spellings (spec 2026-09-03). Rule 4's three remaining
87 debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each 87 debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each
88 of which produces VT bytes by contract. 88 of which produces VT bytes by contract.
89 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay 89 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay
@@ -305,10 +305,12 @@ own. Test fixtures in `test/`:
305 a daemon on someone else's box that nothing reports. 305 a daemon on someone else's box that nothing reports.
306 - **The daemon starts itself, and it execs THIS image.** `mux d start` IS 306 - **The daemon starts itself, and it execs THIS image.** `mux d start` IS
307 the daemon in the foreground; `-d` is the flag that forks one, and 307 the daemon in the foreground; `-d` is the flag that forks one, and
308 `server_os_linux.forkDetached` is the only `posix.fork` under `src/`, and 308 `forkDetached` is the only `posix.fork` under `src/` — one spelling per OS
309 `main.forkDaemon` is its one caller — build.zig's 309 arm, `server_os_linux.forkDetached` and `server_os_macos.forkDetached` —
310 folder rule 6, whose `except` names the one file. Every other starter 310 and `main.forkDaemon` is its one caller, through the `server_os` root.
311 spells the argv and execs: `endpoint --start` calls in-process, the 311 build.zig's folder rule 6 names those arm files in `except`, so a third
312 file that forks is still caught. Every other starter spells the argv and
313 execs: `endpoint --start` calls in-process, the
312 local client's entry runs `mux d start -d --sock PATH` with its own fd 2 314 local client's entry runs `mux d start -d --sock PATH` with its own fd 2
313 lent to it, so the daemon's refusals reach the user in the daemon's own 315 lent to it, so the daemon's refusals reach the user in the daemon's own
314 words. The fork execs `spawn.selfExe` — the running image, 316 words. The fork execs `spawn.selfExe` — the running image,
build.zig
Old New
@@ -379,11 +379,13 @@ const SourceBan = struct {
379 needles: []const []const u8, 379 needles: []const []const u8,
380 /// What is wrong with spelling it, in the fatal's own voice. 380 /// What is wrong with spelling it, in the fatal's own voice.
381 why: []const u8, 381 why: []const u8,
382 /// The one file a rule is ABOUT rather than against: rule 6 exists to 382 /// The files a rule is ABOUT rather than against: rule 6 exists to say
383 /// say WHERE the fork lives, so naming that file here is the rule's 383 /// WHERE the fork lives, so naming those files here is the rule's
384 /// content and not a hole in it. Unlike the in-file `exemption:` line, 384 /// content and not a hole in it. A list rather than one name, because
385 /// which any file may write for itself, this is a diff to build.zig. 385 /// the fork's spelling is per OS arm and each arm is a file. Unlike the
386 except: ?[]const u8 = null, 386 /// in-file `exemption:` line, which any file may write for itself, this
387 /// is a diff to build.zig.
388 except: []const []const u8 = &.{},
387 }; 389 };
388 390
389 const source_bans = [_]SourceBan{ 391 const source_bans = [_]SourceBan{
@@ -415,11 +417,13 @@ const source_bans = [_]SourceBan{
415 .rule = "6", 417 .rule = "6",
416 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, 418 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" },
417 .needles = &.{"posix.fork("}, 419 .needles = &.{"posix.fork("},
418 .except = "src/os/server_os_linux.zig", 420 .except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" },
419 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ 421 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++
420 "every other starter spells that argv and execs this image. A " ++ 422 "every other starter spells that argv and execs this image. A " ++
421 "client that forked a daemon would be choosing the daemon's " ++ 423 "client that forked a daemon would be choosing the daemon's " ++
422 "flags, its log and its refusals, none of which it can see", 424 "flags, its log and its refusals, none of which it can see. One " ++
425 "fork per OS arm, and the arm's file is named here so a third " ++
426 "file that forks is caught",
423 }, 427 },
424 .{ 428 .{
425 .rule = "7", 429 .rule = "7",
@@ -454,7 +458,11 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
454 var paths: std.ArrayList([]const u8) = .empty; 458 var paths: std.ArrayList([]const u8) = .empty;
455 zigFilesIn(b, sub, &paths); 459 zigFilesIn(b, sub, &paths);
456 for (paths.items) |path| { 460 for (paths.items) |path| {
457 if (ban.except) |ex| if (std.mem.eql(u8, path, ex)) continue; 461 var excepted = false;
462 for (ban.except) |ex| {
463 if (std.mem.eql(u8, path, ex)) excepted = true;
464 }
465 if (excepted) continue;
458 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| 466 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e|
459 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) }); 467 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) });
460 if (std.mem.indexOf(u8, src, exempt) != null) continue; 468 if (std.mem.indexOf(u8, src, exempt) != null) continue;
docs/decisions.md
Old New
@@ -8316,3 +8316,100 @@ refactor moved no byte on the wire. The gate itself needs its old side
8316 re-pinned to a version that still exists; that is not this branch's work. 8316 re-pinned to a version that still exists; that is not this branch's work.
8317 8317
8318 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`. 8318 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.
8319
8320 ## 2026-09-03 — the daemon's Darwin arm (macOS port, step 3)
8321
8322 `server_os_macos.zig` fills the server root's `.macos` arm. Eleven of the
8323 twelve operations are a spelling change and nothing else. Three are a
8324 different mechanism, because the Linux one does not exist on Darwin, and
8325 one of the three is a trap that a straight translation walks into.
8326
8327 **`sendNoSigNoWait` sets `SO_NOSIGPIPE`, and a REFUSED set is the answer.**
8328 Darwin has no `MSG_NOSIGNAL`: "do not raise SIGPIPE" is a property of the
8329 socket, not of the send, so the arm sets the option per call — there is no
8330 one place every fd this operation is handed gets created (an accepted
8331 client, a socketpair end, an fd adopted across an upgrade), and the option
8332 is idempotent. Measured on macOS 26 with a C program and again with a Zig
8333 one: Darwin's `sosetopt` rejects EVERY socket option with EINVAL once a
8334 socket is shut down in both directions, which is exactly the state a
8335 hung-up peer leaves behind. So the one send that would raise the signal is
8336 also the one send the flag cannot be set for, and the obvious arm — set,
8337 ignore the result, send — signals precisely when it is supposed not to.
8338 The first draft did exactly that and the root's own SIGPIPE test caught it
8339 on the box: the child died of a signal instead of exiting 0. EINVAL on this
8340 call is therefore read as the kernel saying the peer is gone (the level,
8341 name, value and length are all fixed in the source, so nothing else about
8342 the arguments can be invalid) and returned as `error.BrokenPipe`, which is
8343 what `send` would have answered had it not signalled first. Every other
8344 setsockopt failure describes a socket that cannot raise SIGPIPE either, so
8345 those fall through and let `send` name them.
8346
8347 That test now has two legs, because a socket that never carried a byte and
8348 one that lost its peer mid-stream are different states to the kernel and
8349 only the second is the pump's own sequence. One leg alone passes on an arm
8350 that can never arm a live socket, the other alone passes on an arm that
8351 only works after a successful send.
8352
8353 **`closeFrom` walks the fd table.** No `close_range` on Darwin, so it is
8354 one close per slot from the floor to `getdtablesize()`, which is the soft
8355 `RLIMIT_NOFILE` and therefore also the ceiling on any fd this process could
8356 be holding. A few hundred cheap EBADFs once per session start, between fork
8357 and exec, so nothing is opening fds underneath the walk.
8358
8359 **`anonFd` is an unlinked `mkstemp` file.** No `memfd_create`. A 0600 file
8360 this uid creates and unlinks before anyone could open it by name is private
8361 by mode where memfd is private by having no name; the window is those two
8362 calls, on an empty file. `/tmp` rather than the runtime directory because
8363 `src/os/` imports nothing of ours and must not learn the socket directory.
8364 `mkstemp` opens CLOEXEC on modern Darwin and the carrier must survive
8365 `mux d upgrade`'s exec, so the flag comes back off before the fd is
8366 returned. The root's existing pin — nlink 0, not CLOEXEC, readable and
8367 writable — passes on the box unchanged.
8368
8369 **`peerCred` needs two calls where Linux needs one.** Darwin's
8370 `LOCAL_PEERCRED` answers a `struct xucred` with no pid in it, so the uid
8371 comes from `getpeereid` and the pid from `LOCAL_PEERPID` at level
8372 `SOL_LOCAL` (0). Both answer for a socketpair, so the root's pin holds
8373 there too.
8374
8375 **The `forkDetached` pin asks `getsid(2)`, not `ps`.** macOS's `ps` has no
8376 `sid` column at all, and its `sess` column is the kernel address of the
8377 session, which reads 0 for anyone but root — so the natural port of the old
8378 Linux pin compares 0 against 0 and passes whatever the child did. `getsid`
8379 is POSIX and answers the number on both, and it makes the claim STRONGER
8380 than it was: the returned pid names a session LEADER (its sid is its pid)
8381 in a session that is not the caller's, where the old pin read the leader
8382 pid off procps and could say nothing on Darwin. `getsid` needs the child
8383 ALIVE and not merely unreaped: Darwin answers -1 for a zombie where Linux
8384 still names its session, which is how the second draft failed on the box.
8385 So the child's stdin is a pipe rather than /dev/null, pre-loaded with a
8386 word it echoes back before it blocks reading a second — which holds it
8387 still for the question and makes `forkDetached`'s `stdin_fd` argument
8388 load-bearing, where /dev/null pinned nothing about stdin at all. The ECHO
8389 is what pins it and not the block: a draft that asserted "still running"
8390 with WNOHANG passed green with stdin dup2'd from the wrong fd, because a
8391 child wired elsewhere also ends early and when it ends is a race the
8392 parent wins most of the time. Both claims were graded by mutation on
8393 Linux — deleting `setsid` fails the session claim, wiring stdin to the
8394 wrong fd fails the echo — and the whole file was then run on the Mac.
8395
8396 **`@cImport` of `<util.h>` works after all.** Step 2 recorded that Zig
8397 ships no `util.h` for Darwin and that the arm would need one
8398 `extern "c" fn forkpty` line. On a box with the Command Line Tools the
8399 macOS SDK supplies the header, and `@cImport` of `util.h`, `sys/ioctl.h`,
8400 `sys/socket.h`, `sys/un.h`, `unistd.h` and `stdlib.h` compiles, which is
8401 what the arm does. `forkpty` needs no `-lutil` there; it is in libSystem.
8402
8403 **Rule 6's `except` is a list now.** The one `posix.fork` site is one PER
8404 OS ARM, so build.zig names each arm's file and a third file that forks is
8405 still caught. `test/bans.sh` still plants its needle in `src/server` and
8406 reads rule 6's own fatal.
8407
8408 **How it was graded.** `src/os/server_os.zig` imports nothing of ours, so
8409 the whole arm was compiled AND RUN on the Mac ahead of the client arm, as
8410 `zig test src/os/server_os.zig -lc` — 11 tests, all passing, including the
8411 pty, peer-credential, fd-barrier, carrier and SIGPIPE pins. The tree itself
8412 still cannot link there until the client arm exists; `make build` on the box
8413 stops at the client root's `@compileError` and nothing else.
8414
8415 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.
src/os/server_os.zig
Old New
@@ -12,6 +12,7 @@ const builtin = @import("builtin");
12 12
13 pub const impl = switch (builtin.os.tag) { 13 pub const impl = switch (builtin.os.tag) {
14 .linux => @import("server_os_linux.zig"), 14 .linux => @import("server_os_linux.zig"),
15 .macos => @import("server_os_macos.zig"),
15 else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)), 16 else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)),
16 }; 17 };
17 18
@@ -272,28 +273,65 @@ test "server_os.setWinsize then ptyMode: the master answers about the line disci
272 } 273 }
273 274
274 test "server_os.forkDetached: the child is a session leader writing to the fd it was given" { 275 test "server_os.forkDetached: the child is a session leader writing to the fd it was given" {
275 // Asked of the OS: the child prints its own session id and pid; a 276 // Asked of the OS, and through `getsid(2)` rather than `ps`: the session
276 // detached daemon is its own session leader, so they are equal. 277 // id is what "detached" means, and only procps prints it — macOS's ps
277 const pipe = try std.posix.pipe(); 278 // has no `sid` column at all, and its `sess` column is the kernel
278 defer std.posix.close(pipe[0]); 279 // address of the session, which reads 0 for anyone but root (measured
279 const devnull = try std.fs.cwd().openFile("/dev/null", .{}); 280 // 2026-09-03), so a `ps` claim there compares 0 against 0 and passes
280 defer devnull.close(); 281 // whatever the child did. `getsid` is POSIX and answers the number on
281 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sid= -p $$ | tr -d ' '; echo $$" }; 282 // both. The child still prints its own pid, so the claim is the whole
282 const pid = try forkDetached("/bin/sh", &argv, devnull.handle, pipe[1]); 283 // one: the process this returned a pid for is a session LEADER (its sid
283 std.posix.close(pipe[1]); 284 // is its pid) in a session that is not the caller's.
284 var buf: [64]u8 = undefined; 285 //
286 // `getsid` needs the child ALIVE, not merely unreaped: Darwin answers -1
287 // for a zombie where Linux still names its session (measured 2026-09-03
288 // on macOS 26). So `stdin_fd` is a pipe rather than /dev/null, and the
289 // child echoes a word out of it and then blocks reading a second — which
290 // holds it still for the question AND makes the stdin argument
291 // load-bearing. The echo is what pins stdin, not the block: a child
292 // wired to the wrong fd also ends early, but WHEN it ends is a race this
293 // test would win most of the time, and /dev/null pinned nothing at all.
294 const libc = struct {
295 extern "c" fn getsid(pid: std.posix.pid_t) std.posix.pid_t;
296 };
297 // CLOEXEC on both pipes: `forkDetached` closes nothing before it execs,
298 // so a plain pipe would leave the CHILD holding the write end of its own
299 // stdin and its `read` would never see the EOF this test closes for.
300 // The two ends it is given survive anyway, because dup2 clears the flag
301 // on the descriptor it writes.
302 const to_child = try std.posix.pipe2(.{ .CLOEXEC = true });
303 const from_child = try std.posix.pipe2(.{ .CLOEXEC = true });
304 defer std.posix.close(from_child[0]);
305 // Written BEFORE the fork, so the word is waiting in the pipe and the
306 // first read cannot block on this test's own ordering.
307 try std.testing.expectEqual(@as(usize, 5), try std.posix.write(to_child[1], "ping\n"));
308 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "echo $$; read word; echo $word; read hold" };
309 const pid = try forkDetached("/bin/sh", &argv, to_child[0], from_child[1]);
310 std.posix.close(to_child[0]);
311 std.posix.close(from_child[1]);
312 // To the second newline and no further: the child is holding its stdout
313 // open on purpose, so a read to EOF here would wait for an exit this test
314 // has not asked for yet.
315 var buf: [128]u8 = undefined;
285 var n: usize = 0; 316 var n: usize = 0;
286 while (true) { 317 while (std.mem.count(u8, buf[0..n], "\n") < 2) {
287 const got = try std.posix.read(pipe[0], buf[n..]); 318 const got = try std.posix.read(from_child[0], buf[n..]);
288 if (got == 0) break; 319 if (got == 0) return error.NoOutput;
289 n += got; 320 n += got;
290 } 321 }
322 const child_sid = libc.getsid(pid);
323 const own_sid = libc.getsid(0);
324 // The child's second `read` returns only when this end goes.
325 std.posix.close(to_child[1]);
291 _ = std.posix.waitpid(pid, 0); 326 _ = std.posix.waitpid(pid, 0);
292 var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n'); 327 var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n');
293 const sid = lines.next() orelse return error.NoOutput;
294 const shpid = lines.next() orelse return error.NoOutput; 328 const shpid = lines.next() orelse return error.NoOutput;
295 try std.testing.expectEqualStrings(shpid, sid); 329 const echoed = lines.next() orelse return error.NoOutput;
296 try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10)); 330 try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10));
331 try std.testing.expectEqualStrings("ping", echoed);
332 try std.testing.expect(own_sid > 0);
333 try std.testing.expectEqual(pid, child_sid);
334 try std.testing.expect(child_sid != own_sid);
297 } 335 }
298 336
299 test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" { 337 test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" {
@@ -383,13 +421,31 @@ test "server_os.sendNoSigNoWait: a closed peer is an error, not a signal" {
383 .flags = 0, 421 .flags = 0,
384 }; 422 };
385 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); 423 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null);
386 var csp: [2]std.posix.fd_t = undefined; 424 // Two legs, because "the peer is gone" is two different states to
387 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &csp) != 0) exitNow(2); 425 // the kernel and only the second is the one the daemon meets. A
388 std.posix.close(csp[1]); 426 // socket that was NEVER written to and then lost its peer is the
389 _ = sendNoSigNoWait(csp[0], "x") catch |e| exitNow(if (e == error.BrokenPipe) 0 else 2); 427 // easy case; a socket that carried frames and then lost its peer
428 // mid-stream is the pump's own sequence, and on Darwin the two
429 // differ — the flag that suppresses the signal is a socket option
430 // there, and a socket the kernel has already shut down refuses to
431 // take one (see server_os_macos.sendNoSigNoWait). A test that asked
432 // only the first would pass on an arm that can never arm a live
433 // socket, and one that asked only the second would pass on an arm
434 // that only ever works after a successful send.
435 //
390 // A send that SUCCEEDED to a closed peer is as wrong as one that 436 // A send that SUCCEEDED to a closed peer is as wrong as one that
391 // signalled, and neither is 0. 437 // signalled, and neither is 0.
392 exitNow(2); 438 var gone: [2]std.posix.fd_t = undefined;
439 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &gone) != 0) exitNow(2);
440 std.posix.close(gone[1]);
441 if (sendNoSigNoWait(gone[0], "x")) |_| exitNow(2) else |e| if (e != error.BrokenPipe) exitNow(2);
442
443 var live: [2]std.posix.fd_t = undefined;
444 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &live) != 0) exitNow(2);
445 _ = sendNoSigNoWait(live[0], "x") catch exitNow(2);
446 std.posix.close(live[1]);
447 if (sendNoSigNoWait(live[0], "x")) |_| exitNow(2) else |e| if (e != error.BrokenPipe) exitNow(2);
448 exitNow(0);
393 } 449 }
394 defer std.posix.close(f.master); 450 defer std.posix.close(f.master);
395 const status = std.posix.waitpid(f.pid, 0).status; 451 const status = std.posix.waitpid(f.pid, 0).status;
src/os/server_os_macos.zig
Old New
@@ -0,0 +1,154 @@
1 //! Darwin arm of `server_os`. Spellings only; the contract is in the root.
2 //! Four operations use a different MECHANISM rather than a different
3 //! spelling, because the Linux one does not exist here, and each is
4 //! recorded in docs/decisions.md (2026-09-03, "the daemon's Darwin arm"):
5 //! `closeFrom` walks the fd table because there is no close_range,
6 //! `anonFd` is an unlinked mkstemp file because there is no memfd,
7 //! `sendNoSigNoWait` sets SO_NOSIGPIPE on the socket because there is no
8 //! MSG_NOSIGNAL, and `peerCred` takes two calls because LOCAL_PEERCRED
9 //! answers no pid.
10 const std = @import("std");
11 const root = @import("server_os.zig");
12 const c = @cImport({
13 @cInclude("util.h"); // forkpty
14 @cInclude("sys/ioctl.h");
15 @cInclude("sys/socket.h");
16 @cInclude("sys/un.h"); // LOCAL_PEERPID
17 @cInclude("unistd.h"); // getpeereid, getdtablesize
18 @cInclude("stdlib.h"); // mkstemp
19 });
20
21 pub fn getpid() std.posix.pid_t {
22 return std.c.getpid();
23 }
24
25 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
26 // Two calls where Linux has one: Darwin's LOCAL_PEERCRED answers a
27 // `struct xucred` with no pid in it, so the uid comes from getpeereid
28 // and the pid from a socket option of its own.
29 var uid: c.uid_t = undefined;
30 var gid: c.gid_t = undefined;
31 if (c.getpeereid(fd, &uid, &gid) != 0) return null;
32 var pid: c.pid_t = 0;
33 var len: c.socklen_t = @sizeOf(c.pid_t);
34 // SOL_LOCAL is 0 on Darwin; LOCAL_PEERPID answers the peer's pid for a
35 // unix socket the way SO_PEERCRED's pid field does on Linux. The root
36 // rejects a non-positive pid, so a kernel that will not name the peer
37 // reads as "will not say" there rather than as a pid of 0 here.
38 if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
39 return .{ .uid = @intCast(uid), .pid = @intCast(pid) };
40 }
41
42 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
43 // Darwin has no MSG_NOSIGNAL: the "do not raise SIGPIPE" bit is a
44 // property of the SOCKET, not of the send. Set per call rather than once
45 // at accept, because every fd that reaches this operation must have it
46 // and there is no one place every such fd is created — an accepted
47 // client, a socketpair end, an adopted fd across an upgrade. The option
48 // is idempotent and costs one setsockopt on a path that is already a
49 // syscall.
50 //
51 // A REFUSED set is the interesting case and must not fall through to
52 // send. Measured 2026-09-03 on macOS 26: Darwin's `sosetopt` rejects
53 // every socket option with EINVAL once a socket is shut down in both
54 // directions, which is exactly the state a hung-up peer leaves behind —
55 // so the one send that would raise the signal is also the one send the
56 // flag cannot be set for. EINVAL here is therefore not an argument
57 // complaint (the level, name, value and length are all fixed above);
58 // it is the kernel saying the peer is gone, which is what `send` would
59 // have answered had it not signalled first. Every other setsockopt
60 // failure — a bad fd, not a socket — describes a socket that cannot
61 // raise SIGPIPE either, so those fall through and let `send` name them.
62 const on: c_int = 1;
63 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 return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT);
66 }
67
68 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {
69 var t: c_int = undefined;
70 var len: c.socklen_t = @sizeOf(c_int);
71 if (c.getsockopt(fd, c.SOL_SOCKET, c.SO_TYPE, &t, &len) != 0) return error.NotASocket;
72 return @intCast(t);
73 }
74
75 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
76 var master: c_int = undefined;
77 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
78 const pid = c.forkpty(&master, null, null, &cws);
79 if (pid < 0) return error.ForkPtyFailed;
80 return .{ .pid = pid, .master = master };
81 }
82
83 pub fn exitNow(code: u8) noreturn {
84 // `_exit(2)` and not `exit(3)`: the root's doc says why — atexit and the
85 // stdio flush would write the parent's pending bytes a second time.
86 std.c._exit(code);
87 }
88
89 pub fn forkDetached(
90 exe: [*:0]const u8,
91 argv: [*:null]const ?[*:0]const u8,
92 stdin_fd: std.posix.fd_t,
93 out_fd: std.posix.fd_t,
94 ) error{ForkFailed}!std.posix.pid_t {
95 const pid = std.posix.fork() catch return error.ForkFailed;
96 if (pid != 0) return pid;
97 _ = std.c.setsid();
98 std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127);
99 std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127);
100 std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127);
101 std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127);
102 unreachable;
103 }
104
105 pub fn closeFrom(first: std.posix.fd_t) void {
106 // No close_range on Darwin: one close per slot up to the table size,
107 // which is a few hundred cheap EBADFs once per session start. Between
108 // fork and exec, so nothing else is opening fds underneath the walk.
109 // `getdtablesize` is the soft RLIMIT_NOFILE, which is also the ceiling
110 // on any fd this process could be holding, so the walk cannot miss one.
111 var fd: std.posix.fd_t = first;
112 const top: std.posix.fd_t = c.getdtablesize();
113 while (fd < top) : (fd += 1) _ = std.c.close(fd);
114 }
115
116 pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!root.PtyMode {
117 // Measured 2026-09-03: Darwin's master answers tcgetattr for the slave's
118 // line discipline, so this is the Linux shape and not a reopen by name.
119 const t = try std.posix.tcgetattr(master);
120 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
121 }
122
123 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
124 var pgid: c.pid_t = 0;
125 if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
126 return @intCast(pgid);
127 }
128
129 pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void {
130 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
131 if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed;
132 }
133
134 pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t {
135 // No memfd on Darwin. A 0600 file this uid creates and unlinks before
136 // anyone could open it by name is private by mode where memfd is
137 // private by having no name; the window is the two calls below, on an
138 // empty file. /tmp rather than the runtime dir because this file
139 // imports nothing of ours and must not learn the socket directory.
140 var tmpl: [128]u8 = undefined;
141 const t = std.fmt.bufPrintZ(&tmpl, "/tmp/mux-{s}-XXXXXX", .{std.mem.span(name)}) catch return error.CarrierFailed;
142 const fd = c.mkstemp(t.ptr);
143 if (fd < 0) return error.CarrierFailed;
144 errdefer std.posix.close(fd);
145 // mkstemp fills the XXXXXX in place, so the name to unlink is `t` as it
146 // reads now and not the template that was printed into it.
147 std.posix.unlink(t) catch return error.CarrierFailed;
148 // mkstemp opens O_CLOEXEC on modern Darwin; the candidate must inherit
149 // the carrier across `mux d upgrade`'s exec, so the flag comes back off.
150 const flags = std.posix.fcntl(fd, std.posix.F.GETFD, 0) catch return error.CarrierFailed;
151 _ = std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC)) catch
152 return error.CarrierFailed;
153 return fd;
154 }