64c0073b
feat: the daemon's Darwin arm
a73x 2026-09-03 18:13
Commit message
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 | } | ||