a73x

47cedd93

feat: the client's Darwin arm, and mux links on a Mac

a73x   2026-09-03 18:30

Commit message
feat: the client's Darwin arm, and mux links on a Mac

The nine operations `client_os` names, spelled for Darwin. Three of them
use a different mechanism rather than a different call, each one the twin
of what `server_os_macos` already found: `peerCred` is getpeereid plus
LOCAL_PEERPID because LOCAL_PEERCRED answers no pid, `sendNoSig` sets
SO_NOSIGPIPE on the socket because there is no MSG_NOSIGNAL, and
`parentOf` reads sysctl KERN_PROC_PID because there is no /proc.

The root's SIGPIPE test grows the second leg `server_os` already has: a
socket that carried bytes and THEN lost its peer is the state the agent
probe actually meets, and on Darwin it is the state whose setsockopt is
refused, so an arm that only worked before the first send would have gone
unnoticed.

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` `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 }