7b207001
docs: macOS port design — a platform layer, then a second arm
a73x 2026-09-03 07:03
Commit message
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. | ||