47cedd93
feat: the client's Darwin arm, and mux links on a Mac
a73x 2026-09-03 18:30
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` `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) | | 63 | | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `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 |
src/os/client_os.zig
| Old | New | ||
|---|---|---|---|
| @@ -8,6 +8,7 @@ const builtin = @import("builtin"); | |||
| 8 | 8 | ||
| 9 | pub const impl = switch (builtin.os.tag) { | 9 | pub const impl = switch (builtin.os.tag) { |
| 10 | .linux => @import("client_os_linux.zig"), | 10 | .linux => @import("client_os_linux.zig"), |
| 11 | .macos => @import("client_os_macos.zig"), | ||
| 11 | else => @compileError("mux has no client platform arm for " ++ @tagName(builtin.os.tag)), | 12 | else => @compileError("mux has no client platform arm for " ++ @tagName(builtin.os.tag)), |
| 12 | }; | 13 | }; |
| 13 | 14 | ||
| @@ -149,15 +150,33 @@ test "client_os.sendNoSig: a closed peer is an error, not a signal" { | |||
| 149 | .flags = 0, | 150 | .flags = 0, |
| 150 | }; | 151 | }; |
| 151 | std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); | 152 | std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); |
| 152 | var csp: [2]std.posix.fd_t = undefined; | 153 | // Two legs, because "the peer is gone" is two different states to |
| 153 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &csp) != 0) std.c._exit(2); | 154 | // the kernel and only the second is the one the probe meets. A |
| 154 | std.posix.close(csp[1]); | 155 | // socket that was NEVER written to and then lost its peer is the |
| 155 | _ = sendNoSig(csp[0], "x") catch |e| std.c._exit(if (e == error.BrokenPipe) 0 else 2); | 156 | // easy case; a socket that carried bytes and then lost its peer is |
| 157 | // the probe's own sequence, and on Darwin the two differ — the flag | ||
| 158 | // that suppresses the signal is a socket option there, and a socket | ||
| 159 | // the kernel has already shut down refuses to take one (see | ||
| 160 | // `client_os_macos.sendNoSig`). A test that asked only the first | ||
| 161 | // would pass on an arm that can never arm a live socket, and one | ||
| 162 | // that asked only the second would pass on an arm that only ever | ||
| 163 | // works after a successful send. | ||
| 164 | // | ||
| 156 | // A send that SUCCEEDED to a closed peer is as wrong as one that | 165 | // A send that SUCCEEDED to a closed peer is as wrong as one that |
| 157 | // signalled, and neither is 0. `_exit` rather than an exit that runs | 166 | // signalled, and neither is 0. `_exit` rather than an exit that runs |
| 158 | // atexit handlers: this child is a copy of a test runner mid-run and | 167 | // atexit handlers: this child is a copy of a test runner mid-run and |
| 159 | // must flush nothing of its parent's. | 168 | // must flush nothing of its parent's. |
| 160 | std.c._exit(2); | 169 | var gone: [2]std.posix.fd_t = undefined; |
| 170 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &gone) != 0) std.c._exit(2); | ||
| 171 | std.posix.close(gone[1]); | ||
| 172 | if (sendNoSig(gone[0], "x")) |_| std.c._exit(2) else |e| if (e != error.BrokenPipe) std.c._exit(2); | ||
| 173 | |||
| 174 | var live: [2]std.posix.fd_t = undefined; | ||
| 175 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &live) != 0) std.c._exit(2); | ||
| 176 | _ = sendNoSig(live[0], "x") catch std.c._exit(2); | ||
| 177 | std.posix.close(live[1]); | ||
| 178 | if (sendNoSig(live[0], "x")) |_| std.c._exit(2) else |e| if (e != error.BrokenPipe) std.c._exit(2); | ||
| 179 | std.c._exit(0); | ||
| 161 | } | 180 | } |
| 162 | const status = std.posix.waitpid(pid, 0).status; | 181 | const status = std.posix.waitpid(pid, 0).status; |
| 163 | try std.testing.expect(std.posix.W.IFEXITED(status)); | 182 | try std.testing.expect(std.posix.W.IFEXITED(status)); |
src/os/client_os_macos.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,108 @@ | |||
| 1 | //! Darwin arm of `client_os`. Spellings only; the contract is in the root. | ||
| 2 | //! Three operations use a different MECHANISM rather than a different | ||
| 3 | //! spelling, because the Linux one does not exist here, and each has its | ||
| 4 | //! twin in `server_os_macos` (docs/decisions.md, 2026-09-03, "the daemon's | ||
| 5 | //! Darwin arm"): `peerCred` takes two calls because LOCAL_PEERCRED answers | ||
| 6 | //! no pid, `sendNoSig` sets SO_NOSIGPIPE on the socket because there is no | ||
| 7 | //! MSG_NOSIGNAL, and `parentOf` asks sysctl because there is no /proc. | ||
| 8 | const std = @import("std"); | ||
| 9 | const root = @import("client_os.zig"); | ||
| 10 | const c = @cImport({ | ||
| 11 | @cInclude("util.h"); // openpty (test-only through the root) | ||
| 12 | @cInclude("sys/ioctl.h"); | ||
| 13 | @cInclude("sys/socket.h"); | ||
| 14 | @cInclude("sys/un.h"); // LOCAL_PEERPID | ||
| 15 | @cInclude("sys/sysctl.h"); // kinfo_proc for parentOf | ||
| 16 | @cInclude("unistd.h"); // getpeereid | ||
| 17 | }); | ||
| 18 | |||
| 19 | pub fn getpid() std.posix.pid_t { | ||
| 20 | return std.c.getpid(); | ||
| 21 | } | ||
| 22 | |||
| 23 | pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred { | ||
| 24 | // Two calls where Linux has one: Darwin's LOCAL_PEERCRED answers a | ||
| 25 | // `struct xucred` with no pid in it, so the uid comes from getpeereid | ||
| 26 | // and the pid from a socket option of its own. SOL_LOCAL is 0. | ||
| 27 | var uid: c.uid_t = undefined; | ||
| 28 | var gid: c.gid_t = undefined; | ||
| 29 | if (c.getpeereid(fd, &uid, &gid) != 0) return null; | ||
| 30 | var pid: c.pid_t = 0; | ||
| 31 | var len: c.socklen_t = @sizeOf(c.pid_t); | ||
| 32 | if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null; | ||
| 33 | // A pid of 0 — a peer the kernel will not name — passes through | ||
| 34 | // unjudged; the root's doc says why, and `askpass.dialOwner` is the one | ||
| 35 | // place the rule lives. | ||
| 36 | return .{ .uid = @intCast(uid), .pid = @intCast(pid) }; | ||
| 37 | } | ||
| 38 | |||
| 39 | pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { | ||
| 40 | // Darwin has no MSG_NOSIGNAL: the "do not raise SIGPIPE" bit is a | ||
| 41 | // property of the SOCKET, not of the send. Set per call because there | ||
| 42 | // is no one place every fd that reaches here is created, and the option | ||
| 43 | // is idempotent. | ||
| 44 | // | ||
| 45 | // A REFUSED set is the interesting case and must not fall through to | ||
| 46 | // send. Measured 2026-09-03 on macOS 26: Darwin's `sosetopt` rejects | ||
| 47 | // every socket option with EINVAL once a socket is shut down in both | ||
| 48 | // directions, which is exactly the state a hung-up peer leaves behind — | ||
| 49 | // so the one send that would raise the signal is also the one send the | ||
| 50 | // flag cannot be set for. EINVAL here is therefore not an argument | ||
| 51 | // complaint (level, name, value and length are all fixed above); it is | ||
| 52 | // the kernel saying the peer is gone, which is what `send` would have | ||
| 53 | // answered had it not signalled first. Every other setsockopt failure | ||
| 54 | // — a bad fd, not a socket — describes a socket that cannot raise | ||
| 55 | // SIGPIPE either, so those fall through and let `send` name them. | ||
| 56 | const on: c_int = 1; | ||
| 57 | const rc = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int)); | ||
| 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 — | ||
| 60 | // the agent probe writes five bytes and then polls for the answer. | ||
| 61 | return std.posix.send(fd, bytes, 0); | ||
| 62 | } | ||
| 63 | |||
| 64 | /// sysctl KERN_PROC_PID: the kernel's own record of the process, the Darwin | ||
| 65 | /// answer to /proc/PID/stat. A pid the kernel no longer has answers 0 with | ||
| 66 | /// a length of 0 rather than an error, so the length is read as well as the | ||
| 67 | /// return, and both mean the same 0 the Linux arm gives for a stat file | ||
| 68 | /// that will not open. | ||
| 69 | pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 70 | var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PID, pid }; | ||
| 71 | var kp: c.struct_kinfo_proc = undefined; | ||
| 72 | var len: usize = @sizeOf(c.struct_kinfo_proc); | ||
| 73 | if (c.sysctl(&mib, @intCast(mib.len), &kp, &len, null, 0) != 0 or len == 0) return 0; | ||
| 74 | return @intCast(kp.kp_eproc.e_ppid); | ||
| 75 | } | ||
| 76 | |||
| 77 | pub fn geteuid() std.posix.uid_t { | ||
| 78 | return std.c.geteuid(); | ||
| 79 | } | ||
| 80 | |||
| 81 | pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize { | ||
| 82 | // Through the C struct and copied field by field rather than casting a | ||
| 83 | // pointer: the two layouts agree on Darwin today, and a copy cannot | ||
| 84 | // stop agreeing silently. | ||
| 85 | var ws: c.struct_winsize = undefined; | ||
| 86 | if (c.ioctl(fd, c.TIOCGWINSZ, &ws) != 0) return null; | ||
| 87 | return .{ .row = ws.ws_row, .col = ws.ws_col, .xpixel = ws.ws_xpixel, .ypixel = ws.ws_ypixel }; | ||
| 88 | } | ||
| 89 | |||
| 90 | pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void { | ||
| 91 | var cws: c.struct_winsize = .{ | ||
| 92 | .ws_row = ws.row, | ||
| 93 | .ws_col = ws.col, | ||
| 94 | .ws_xpixel = ws.xpixel, | ||
| 95 | .ws_ypixel = ws.ypixel, | ||
| 96 | }; | ||
| 97 | if (c.ioctl(fd, c.TIOCSWINSZ, &cws) != 0) return error.Unsupported; | ||
| 98 | } | ||
| 99 | |||
| 100 | pub fn openPtyPair() error{Unsupported}!root.PtyPair { | ||
| 101 | // `openpty` and not the Linux arm's /dev/ptmx walk: Darwin's ptmx wants | ||
| 102 | // grantpt and unlockpt before the slave name is valid, and openpty is | ||
| 103 | // the libc call that does exactly that sequence. | ||
| 104 | var master: c_int = undefined; | ||
| 105 | var slave: c_int = undefined; | ||
| 106 | if (c.openpty(&master, &slave, null, null, null) != 0) return error.Unsupported; | ||
| 107 | return .{ .master = master, .slave = slave }; | ||
| 108 | } | ||