a73x

7b207001

docs: macOS port design — a platform layer, then a second arm

a73x   2026-09-03 07:03

Commit message
docs: macOS port design — a platform layer, then a second arm

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

docs/superpowers/specs/2026-09-03-macos-port-design.md
Old New
@@ -0,0 +1,291 @@
1 # macOS port: a platform layer, then a second arm
2
3 Date 2026-09-03. HEAD at survey time `e6fff02f`.
4
5 ## Goal
6
7 `mux` builds and runs on macOS (Apple Silicon, `aarch64-macos`) with full
8 parity: daemon, pty, client, wall, agent surface, web hub, QUIC, and
9 `mux d upgrade`. The work is three steps in order, and each step is a
10 deliverable on its own:
11
12 1. **Identify the seams.** Done; the inventory is below.
13 2. **Push them behind an interface on Linux.** A platform layer, `src/os/`,
14 with one backend per side. Linux behaviour does not change. `make ci`
15 stays green throughout, and a source ban keeps the seams from growing
16 back.
17 3. **Write the macOS arm.** Two files and a build branch, plus the harness
18 and dep-script arms. The build host for that step is deliberately
19 undecided (see "Deferred").
20
21 ## Findings the design rests on
22
23 Measured on this box, 2026-09-03, with the pinned zig 0.15.2:
24
25 - `zig cc -target aarch64-macos` compiles and links a libc program using
26 `posix_openpt`, `kqueue`, `libproc` and `dyld` without an SDK. Zig ships
27 the Darwin libc headers; `<util.h>` (BSD `openpty`/`forkpty`) is NOT
28 among them, so the pty opens through `posix_openpt` + `grantpt` +
29 `unlockpt` + `ptsname`, which glibc has too.
30 - Zig's self-hosted Mach-O linker works; build.zig's `use_lld = true` on
31 every executable is the one linker blocker ("using LLD to link macho
32 files is unsupported").
33 - The QUIC stack (ngtcp2 1.25.0 + wolfSSL 5.9.2) cross-builds for
34 `aarch64-macos` with `zig cc` as the C compiler, given two additions to
35 `build-deps.sh`: `-DWOLFSSL_SYS_CA_CERTS=no` (mux is PSK-only, and the
36 system-CA path wants Security.framework) and a
37 `CMAKE_FIND_ROOT_PATH` fence so ngtcp2 stops finding the host's
38 `libwolfssl.so`. All three archives came out.
39 - `std.posix.socket` and `std.posix.accept` already emulate `SOCK_CLOEXEC`,
40 `SOCK_NONBLOCK` and `accept4` on Darwin with a trailing `fcntl`, so the
41 socket sites compile and behave unchanged.
42 - The event loop is `poll(2)` everywhere, by two recorded decisions; threads
43 are `std.Thread` only. Nothing to port there.
44 - ghostty-vt links three C++ libraries (simdutf, highway, utfcpp) whose
45 build.zig files each call ghostty's `apple_sdk.addPaths` on a Darwin
46 target. That helper resolves the HOST libc, so from a Linux host it
47 injects `-isystem /usr/include` and the C++ compile fails on glibc
48 headers. It is upstream code; build.zig cannot reach it.
49
50 ## Seam inventory
51
52 Ninety-odd sites, grouped by the side that owns them. The full report with
53 every `file:line` is the survey in the session scratchpad; the spec keeps
54 the shape, and the plan will re-grep every line before touching it.
55
56 **Server side** (the daemon and everything it execs):
57
58 | Seam | Today | Portable form |
59 |---|---|---|
60 | pty open | `forkpty` via `<pty.h>` | `posix_openpt`+`grantpt`+`unlockpt`+`ptsname`, then `fork`, `setsid`, `TIOCSCTTY`; same on both OSes |
61 | child bail-out | `std.os.linux.exit_group` (5 sites, `pty.zig` and `main.zig`) | `std.c._exit` |
62 | fd barrier in the child | `close_range(3, max)` syscall | Linux keeps `close_range`; Darwin loops `close(3..getdtablesize())` |
63 | line discipline, foreground pgid | `tcgetattr`/`TIOCGPGRP` on the MASTER | Linux unchanged; Darwin is a hardware probe (below) |
64 | upgrade manifest carrier | `memfd_create` (2 sites + 8 tests) | Linux keeps memfd; Darwin is an unlinked file in the 0700 runtime dir |
65 | stale-image verdict | `readlink /proc/self/exe` ends ` (deleted)` | dev+inode of the image opened at boot vs the path now; one rule on both OSes |
66 | non-blocking send | `MSG_NOSIGNAL` | Linux unchanged; Darwin sets `SO_NOSIGPIPE` once at accept |
67 | adopted QUIC fd check | `std.os.linux.getsockopt SO_TYPE` | `std.c.getsockopt` |
68 | peer credentials on accept | `SO_PEERCRED` | Linux unchanged; Darwin `getpeereid` + `LOCAL_PEERPID` |
69 | daemon fork | `std.os.linux.setsid` in `forkDaemon` | `std.c.setsid`; the fork moves with it |
70
71 **Client side** (the wall, askpass, the hub):
72
73 | Seam | Today | Portable form |
74 |---|---|---|
75 | askpass caller check | `SO_PEERCRED` (uid and pid) | as above |
76 | parent of a pid | parse `/proc/PID/stat` | Linux unchanged; Darwin `proc_pidinfo(PROC_PIDTBSDINFO)` |
77 | terminal size | `std.os.linux.ioctl TIOCGWINSZ` | `std.c.ioctl` with the platform's constant |
78 | test pty pair | `/dev/ptmx` + `TIOCSPTLCK` + `TIOCGPTN` | the same `posix_openpt` recipe the server uses |
79
80 **Shared root** (`xdg`, `sockpath`, `spawn`): every site here is a Linux
81 spelling of a portable call and needs no backend once respelled.
82 `std.os.linux.{getpid,geteuid,socketpair}` become `std.c.*`;
83 `access("/proc/PID")` becomes `kill(pid, 0)`; `max_sun_path` is derived at
84 comptime from `std.c.sockaddr.un`. Two policy items remain and each is one
85 `switch (builtin.os.tag)` in its owning file: the default runtime
86 directory (`sockpath`) and `selfExe`'s deleted-inode fallback (`spawn`,
87 which moves into the platform folder).
88
89 **Harness** (`test/*.sh`, 13 files): `/proc` reads for a pid's exe, comm,
90 cmdline, fd table, RSS and the UDP port table; GNU `timeout`, `stat -c`,
91 `ps --ppid`, `sed -i`, `sha256sum`, `nproc`. One site has no macOS answer:
92 `e2e_09_hosts.sh` walks other processes' `environ`, which SIP forbids.
93
94 **Build**: `quicDeps` names two prefixes (`native`, `musl`);
95 `build-deps.sh` has a two-way `case`, `-march=x86_64_v3`, `sha256sum`,
96 `nproc`; `use_lld = true` on every exe; `make install` and `release`
97 hard-wire `x86_64-linux-musl`.
98
99 ## Design
100
101 ### The folder table gains a platform layer
102
103 A new folder, `src/os/`, is the bottom of the import graph: it imports
104 nothing of ours. It holds two rows, one per side, so neither side can
105 reach the other's syscalls and an app that links the engine and a client
106 never links `fork` or a pty:
107
108 | Row | Root | Children | Imported by |
109 |---|---|---|---|
110 | `server_os` | `src/os/server_os.zig` | `server_os_linux.zig`, later `server_os_macos.zig` | `pty`, `daemon`, `mux` |
111 | `client_os` | `src/os/client_os.zig` | `client_os_linux.zig`, later `client_os_macos.zig` | `client`, `wall` |
112
113 Each root is the interface: `pub const impl = switch (builtin.os.tag) {
114 .linux => @import("server_os_linux.zig"), .macos => ..., else =>
115 @compileError("mux has no platform arm for this OS") }` and one `pub`
116 declaration per operation that forwards to it. The root file is where the
117 contract is written, one doc comment per operation naming the failure the
118 operation prevents (the fd barrier's comment moves here, for instance).
119 The children spell syscalls and nothing else. A macOS build with a
120 missing operation is a compile error naming the operation, never a
121 runtime surprise.
122
123 `spawn` moves from `src/cli/` into `src/os/` as `spawn.zig`, keeping its
124 row name. It was placed under `cli` to dodge rule 4; asking the OS for a
125 terminal is platform code, and the folder now exists.
126
127 `forkDaemon` and the daemon's peer-credential read move out of
128 `src/cli/main.zig` into `server_os` (`forkDetached`, `peerCred`), and
129 folder rule 6's `except` follows the fork to
130 `src/os/server_os_linux.zig`. `main.zig` keeps the argv it spells and the
131 stderr it lends. This is the one change to the CLI folder, and it moves
132 the entry back toward parse-and-dispatch.
133
134 ### The interface, by operation
135
136 `server_os`:
137
138 - `exitNow(code) noreturn` — a child's bail-out with no atexit and no
139 stdio flush, so a forked daemon or session child never flushes the
140 parent's buffers twice.
141 - `closeFrom(first_fd)` — the fd barrier. Everything at or above `first_fd`
142 is closed in the child before exec, so the upgrade manifest's key bytes
143 and an adopted listener cannot ride into a shell.
144 - `openPty(winsize) !struct{master, slave_path}` and `becomeSession(slave)`
145 — the two halves of what `forkpty` did, so the fork itself stays in the
146 child code that resets signals and injects env.
147 - `ptyMode(master) !Mode` and `ptyFgPgid(master) !pid` — the line
148 discipline and foreground group. On Linux they read the master. The
149 Darwin arm is written after the probe below decides whether the master
150 answers or the slave must be reopened.
151 - `anonFd(name) !fd` — the upgrade manifest carrier: an fd no path names
152 after the call returns, private to this uid, that survives an exec of
153 the same process. Linux is `memfd_create`; Darwin is `mkstemp` under the
154 runtime dir followed by `unlink`.
155 - `selfImageStale() bool` — has the file at the running image's path been
156 replaced since boot. Implemented ONCE, in the root, as dev+inode of an
157 fd opened at boot against `stat` of `std.fs.selfExePath` now; the
158 `(deleted)` string goes away and the wire word stays.
159 - `sendNoSig(fd, bytes, flags)` — a non-blocking send that cannot raise
160 SIGPIPE. Linux passes `MSG_NOSIGNAL`; Darwin's `acceptClient` sets
161 `SO_NOSIGPIPE` and `sendNoSig` is a plain send.
162 - `peerCred(fd) !struct{uid, pid}` — who is on the other end of a unix
163 socket.
164 - `forkDetached(argv, stderr_fd) !pid` — the one `posix.fork` under `src/`.
165 - `sockType(fd) !u32` — for the adopted-QUIC-fd check.
166
167 `client_os`:
168
169 - `peerCred(fd)` — same shape; the askpass uid-and-pid check.
170 - `parentOf(pid) !pid` — the askpass process-tree walk.
171 - `winSize(fd) !Winsize` — the wall's terminal size.
172 - `openPtyPair(winsize)` — test-only; the same recipe as `server_os.openPty`
173 written twice on purpose, because the sides do not import each other.
174
175 Shared-root items that stay in their owners as a `switch (builtin.os.tag)`:
176
177 - `sockpath.runtimeDir()` — Linux is `XDG_RUNTIME_DIR` with no fallback,
178 exactly as today. The Darwin arm is a single deliberate spelling chosen
179 in step 3 under the 104-byte `sun_path` budget (see the probe). The
180 askpass listener gate in `wallview` reads this function instead of the
181 env var, so the two agree by construction.
182 - `spawn.selfExe()` — `std.fs.selfExePath` on both; the deleted-inode
183 fallback arm is Linux-only and says so.
184
185 ### Folder rule 7
186
187 A new `SourceBan`: the needles `std.os.linux`, `/proc`, `memfd`,
188 `close_range`, `exit_group`, `PEERCRED`, `TIOCSPTLCK`, `TIOCGPTN` may not
189 appear in a production line under `src/`, `src/engine/`, `src/client/`,
190 `src/tui/`, `src/server/` or `src/cli/`. `src/os/` is not in the list, so
191 the children may spell anything; the roots have no reason to. The one
192 foreseeable exemption is `spawn.zig`'s Linux fallback arm, which is in
193 `src/os/` and needs none. The rule is what turns "we pushed the seams
194 behind an interface" from a claim into a gate: a new Linux-ism outside
195 `src/os/` fails `make check`. The scan reads comment lines as well as
196 code, as rules 4 to 6 do, so step 2 also rewords the comments that cite
197 `/proc` or `memfd` outside `src/os/` to name the operation instead
198 (`server_os.anonFd`, `pid_alive`); a comment that names the mechanism is
199 a comment that goes stale when the mechanism differs per OS.
200
201 ### The harness oracle
202
203 `test/e2e_lib.sh` gains helpers that answer the "ask the OS about the OS"
204 questions by name, so the pins stop spelling `/proc`:
205
206 `pid_exe PID`, `pid_comm PID`, `pid_args PID`, `pid_alive PID`,
207 `pid_fds PID` (one line per open fd, with the target as the kernel names
208 it), `pid_rss_kb PID`, `udp_ports` (bound local UDP ports), `file_mode
209 PATH`, `file_size PATH`, `with_timeout SECS CMD...`.
210
211 Step 2 implements the Linux arm from `/proc` and `stat -c` and rewrites
212 every pin to call the helper; step 3 adds `case "$(uname)"` arms from
213 `lsof`, `ps -o`, `stat -f` and `gtimeout`. The `environ` walk in
214 `e2e_09_hosts.sh` is rewritten in step 2 against `pid_args`, since a stray
215 `mux d start` is visible in its argv and `environ` has no portable
216 reading. The fixture rule stays: a helper that holds a dimension constant
217 is blind to it, so each helper's own test asserts on a process with an
218 off-origin pid, an fd table larger than three, and a non-empty UDP table.
219
220 ### Build and deps
221
222 - `quicDeps` and `build-deps.sh` take a target word from the resolved
223 target: `native`, `musl`, `aarch64-macos`. The prefix is
224 `deps/quic/out/<word>`, so `make clean-deps` and wan.sh keep their
225 contract. The macOS arm of the script adds the two wolfSSL flags the
226 probe found and drops `-march=x86_64_v3` for a non-x86 target.
227 `sha256sum` and `nproc` become `shasum -a 256` and `sysctl -n hw.ncpu`
228 behind a `case "$(uname)"`.
229 - `use_lld` is set only when the target is not Darwin.
230 - `make install` and `make release` gain a `MUX_TARGET` variable defaulting
231 to `x86_64-linux-musl`, so a macOS build is `make install
232 MUX_TARGET=aarch64-macos` and the release tarball carries the target
233 triple as it does today.
234
235 ### Step 3: the macOS arm
236
237 Two children, `server_os_macos.zig` and `client_os_macos.zig`, and the
238 harness and script arms above. Two questions are answered on hardware
239 before either file is written, each as a throwaway probe whose output is
240 recorded in `docs/decisions.md`:
241
242 1. **Does the pty master answer `tcgetattr` and `TIOCGPGRP` on Darwin?**
243 If yes the Linux arm's shape is reused. If not, `ptyMode` and
244 `ptyFgPgid` reopen the slave path by name, which `openPty` records for
245 that purpose.
246 2. **Which default socket directory fits.** `$TMPDIR` is `/var/folders/…`,
247 about fifty bytes, against a 103-byte usable `sun_path` that the
248 per-session agent socket and `mux-ask-PID.sock` are dialled through
249 from inside a session. The probe measures the longest path mux creates
250 under each candidate (`$TMPDIR`, `~/Library/Caches/mux`, `~/.local/state
251 /mux/run`) and picks the one with room; the spelling then lands in
252 `sockpath.runtimeDir` and README.
253
254 Behaviours that differ on purpose on Darwin, each named in
255 `docs/decisions.md` when written: `closeFrom` is O(fd table) rather than
256 one syscall; `anonFd` is an unlinked file, private to the uid by the
257 directory mode rather than by anonymity; the harness cannot read another
258 process's environment.
259
260 ### Testing
261
262 - Step 2 is a refactor: `make ci` green after every task, and the
263 cross-version gate (`make xversion`) against the current tip proves the
264 wire and the upgrade manifest did not change shape.
265 - Each interface operation gets a unit test in its root file that runs on
266 whatever OS built it, against a real fd, a real child, a real pty: the
267 fd barrier is asserted by a child shell testing `/dev/fd/N`, which both
268 OSes have; `anonFd` by `fstat` reporting zero links; `selfImageStale` by
269 renaming a copy of the test binary over its own path in a temp dir.
270 - Rule 7 is asserted the way rules 4 to 6 are: a doc-gate test that plants
271 each needle in a scratch file under each banned folder and reads the
272 fatal.
273 - Step 3's gate is `make ci` run on the Mac plus the harness helpers'
274 own tests there, and a `make vm`-shaped journey script driven from
275 whichever host builds.
276
277 ## Deferred
278
279 **Where the macOS binary is built.** Cross-compiling from Linux is blocked
280 by ghostty's `apple_sdk` lookup (finding above). The options are a native
281 build on the Mac driven over ssh the way `make vm` drives the VM,
282 prebuilt macOS archives for the three C++ deps passed through ghostty's
283 `-fsys=` flags, or a patched ghostty pin. The decision is taken after step
284 2 lands, when the Linux side no longer moves under it, and it is not
285 needed to write either `_macos.zig` file.
286
287 **Splitting the shared root** into a paths layer and a connection layer is
288 worth doing and is not this work.
289
290 **x86_64-macos and a universal binary** are one more target word each and
291 are out of scope until someone has the hardware.