a73x

ac7f24d4

docs: step 3 plan for the Darwin arm, and the hardware findings amendment to the spec

a73x   2026-09-03 15:58

Commit message
docs: step 3 plan for the Darwin arm, and the hardware findings amendment to the spec

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

docs/superpowers/plans/2026-09-03-macos-port-step3-darwin-arm.md
Old New
@@ -0,0 +1,693 @@
1 # macOS Port, Step 3: the Darwin Arm — Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** `mux` builds, passes its unit suite and serves a real session on an Apple Silicon Mac, from the same tree that ships the Linux binary.
6
7 **Architecture:** Two new child files, `src/os/server_os_macos.zig` and `src/os/client_os_macos.zig`, fill the `.macos` arm of the two contract roots that step 2 left as a compile error. `sockpath.runtimeDir` gains its Darwin spelling. A shadow-SDK shim under `deps/` works around the one toolchain defect found on hardware (below), the Makefile learns it is on Darwin, the shell oracle grows a `Darwin` arm, and `test/mac.sh` drives the Mac over ssh the way `test/vm.sh` drives the VM.
8
9 **Tech Stack:** Zig 0.15.2 (vendored, `deps/zig/zig` on BOTH boxes), `@cImport` of `util.h` / `sys/un.h` / `sys/sysctl.h` for the libc calls `std.c` does not declare on Darwin, POSIX sh + lsof/ps/stat(1) BSD spellings in the harness.
10
11 **Spec:** `docs/superpowers/specs/2026-09-03-macos-port-design.md` — sections "Step 3: the macOS arm", "Testing", "Deferred", plus the amendment "Hardware findings, 2026-09-03" that this plan's rulings come from.
12
13 ## Global Constraints
14
15 - **The root is the contract, the arm is spellings only.** Every operation in `server_os_macos.zig` and `client_os_macos.zig` has the exact signature the root declares; no new `pub` name appears in an arm that the root does not call. Rules the root owns (server `peerCred` pid ≤ 0 → null, born-stale image, `openPtyPair` test-only) stay in the root.
16 - **Folder rule 7 stands:** `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, `so.peercred`, `so_peercred`, `iocsptlck`, `iocgptn`, `nosignal` appear under `src/os/` only. A Darwin arm that needs none of them still lives there.
17 - **Rule 6 (`posix.fork(`) gains a second excepted file** and nothing else: `src/os/server_os_macos.zig`. The `except` field becomes a list.
18 - **The Linux outcome does not change.** `make ci` green on Linux after every task; no Linux syscall, flag or message changes. A test that must differ by OS branches on `builtin.os.tag` inside the test, never by deleting the Linux assertion.
19 - **Ask the OS about the OS.** Every Darwin test asserts against a real child, pty, socket or file; a Darwin oracle helper is graded by `oracle_selftest` exactly as the Linux one is.
20 - **The hardware is `squirtle`** (Apple M1, macOS 26.6.2, Xcode 26.x, ssh key auth from this box; brew at `/opt/homebrew/bin`, NOT on the non-interactive ssh PATH). The repo is at `~/code/rad/mux` there, pushed from here with `git push squirtle:code/rad/mux <branch>` (the remote has `receive.denyCurrentBranch=updateInstead`, so push the branch and `git checkout` it there). It may be unreachable for stretches; a task whose verification needs it records `BLOCKED: squirtle unreachable` rather than skipping the check. Every ssh is `ssh -o BatchMode=yes squirtle '…'`, and every remote command that builds spells `export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH` first (Task 1 creates that dir).
21 - **Toolchain defect, stated once:** Xcode 26.4+ ships a macOS SDK whose `libSystem.B.tbd` lists `arm64e-macos` and no `arm64-macos`; zig 0.15.2's Mach-O linker matches only the latter and every libSystem symbol goes undefined, for the BUILD RUNNER too, so `zig build` cannot even start (Codeberg ziglang/zig#31658; upstream fix is in 0.16, not in any 0.15 release, and ghostty pins 0.15). Measured 2026-09-03 on squirtle: native `zig build-exe h.zig -lc` fails; `-target aarch64-macos` (zig's bundled stub) links and runs; brew zig 0.16 links. The workaround is a shadow SDK: a directory of symlinks to the real SDK with `usr/lib/libSystem{,.B}.tbd` replaced by zig's own `lib/libc/darwin/libSystem.tbd`, reached through an `xcrun` shim on PATH that answers `--show-sdk-path` with the shadow. Proven: with the shim, the full `zig build` on squirtle compiles ghostty and its three C++ deps and stops at exactly the three step-3 `@compileError`s.
22 - **Focused unit runs are `deps/zig/zig build test`** (whole suite); there is no `-Dtest-filter`, and a standalone `zig test FILE` does not link on either box.
23 - **Commit subjects are `type: what changed`**, no scope; the types are `feat fix refactor test docs build chore`.
24
25 ---
26
27 ## Rulings carried from the hardware probes (2026-09-03)
28
29 1. **Build host: native on the Mac, over ssh.** Cross-compiling from Linux stays blocked by ghostty's `apple_sdk.addPaths` resolving the host libc; a native build needs only the shadow-SDK shim. The shim retires itself: it passes `xcrun` through untouched when the real SDK's stub lists `arm64-macos`.
30 2. **The pty master answers on Darwin.** `tcgetattr`, `TIOCGPGRP`, `TIOCSWINSZ` and `TIOCGWINSZ` on the master all returned 0 against a forked child with the slave as its controlling tty. The Linux arm's shape is reused; nothing reopens the slave by name.
31 3. **Runtime dir: `$XDG_RUNTIME_DIR` when set, else `/tmp/mux-<uid>`, created `0700`.** The spec's three candidates all overflow the 104-byte `sun_path` at the longest name mux creates, which is `<dir>/mux-agent-<pid>-<12 hex>/agent-<32-char session>.sock` = dir + 68 + pid digits. Measured on squirtle: `$TMPDIR` is 48 bytes (121 total), `~/Library/Caches/mux` 34 (107), `~/.local/state/mux/run` 36 (109); `/tmp/mux-501` is 12 (85). `/tmp` is `/private/tmp`, sticky and world-writable, so the per-uid subdirectory carries the privacy the way tmux's `/tmp/tmux-UID` does, and the same owner-and-mode check guards it. Honouring `XDG_RUNTIME_DIR` first is what keeps every isolated rig (`make e2e`, `soak`, hand rigs) isolated on both OSes from one line.
32 4. **Upgrade carrier on Darwin: `mkstemp` in `/tmp`, unlinked before return, mode `0600`.** Private by file mode rather than by anonymity; the unlink window is the microsecond between the two calls and the file is empty then.
33 5. **Darwin `sendNoSig` sets `SO_NOSIGPIPE` on the fd per call** before a plain `send`. No adopt-socket hook in the roots: one `setsockopt` on a socket that already has the option is a no-op, and the two accept sites stay outside the platform rows.
34 6. **`forkDetached`'s test asserts a NEW session, not a leader pid.** macOS `ps` prints no numeric session id; `ps -o sess=` prints an opaque token that is equal within a session on both OSes. The Linux-only `sid == $$` claim stays under `if (builtin.os.tag == .linux)`.
35
36 ---
37
38 ## File Structure
39
40 | File | Responsibility |
41 |---|---|
42 | `deps/mac-sdk.sh` (new) | Builds `deps/mac-sdk/{sdk,bin/xcrun}` on a Darwin host; no-op when the real SDK is linkable; prints nothing on success. |
43 | `deps/quic/build-deps.sh` | Already patched in the tree (uncommitted): repo zig first, `-march` only on x86_64, Darwin native fences. Task 1 commits it. |
44 | `Makefile` | Darwin detection: `MUX_TARGET` default, PATH prefix for the shim, `mac-sdk` target, `mac` target for the journey. |
45 | `.gitignore` | `deps/mac-sdk` |
46 | `src/sockpath.zig` | Darwin `runtimeDir` arm + `ensureRuntimeDir`-style guard; the "unset" message names the right variable per OS. |
47 | `src/os/server_os_macos.zig` (new) | Darwin spellings of the 14 server operations. |
48 | `src/os/client_os_macos.zig` (new) | Darwin spellings of the 9 client operations. |
49 | `src/os/server_os.zig`, `src/os/client_os.zig` | `.macos` import arm; two test bodies branch on OS. |
50 | `build.zig` | rule 6 `except` list; nothing else. |
51 | `test/os_oracle.sh` | `case "$(uname)"` Darwin arm for every helper; `now_ms`. |
52 | `test/e2e_lib.sh`, six `e2e_*.sh`, `test/throughput.sh` | `date +%s%N` → `now_ms`. |
53 | `test/mac.sh` (new) | The `make mac` journey against squirtle. |
54 | `docs/decisions.md`, `README.md`, `CLAUDE.md` | The record. |
55
56 ---
57
58 ### Task 1: The shadow-SDK shim, the Makefile on Darwin, and the dep-script fix
59
60 **Files:**
61 - Create: `deps/mac-sdk.sh`
62 - Modify: `Makefile` (top, `install`, new targets), `.gitignore`
63 - Commit (already modified in tree): `deps/quic/build-deps.sh`
64
65 **Interfaces:**
66 - Produces: `deps/mac-sdk/bin/xcrun` on PATH makes `zig build` link on squirtle. Every later task's remote command exports that PATH.
67
68 - [ ] **Step 1: Write `deps/mac-sdk.sh`**
69
70 ```sh
71 #!/bin/sh
72 # deps/mac-sdk.sh — a shadow macOS SDK for zig 0.15.2, built on a Darwin host.
73 #
74 # Xcode 26.4 and later ship a libSystem.B.tbd whose `targets:` line lists
75 # arm64e-macos and no arm64-macos. zig 0.15.2's Mach-O linker matches the
76 # bare arm64 slice only, so against that SDK every libSystem symbol is
77 # undefined — for the build runner too, which is why `zig build` cannot
78 # even start (ziglang/zig#31658 on Codeberg; fixed in 0.16, which ghostty's
79 # pin cannot use). zig's own lib/libc/darwin/libSystem.tbd is what a
80 # `-target aarch64-macos` build links against, and it works. This script
81 # builds an SDK that is the real one in every path but usr/lib/libSystem*,
82 # where zig's stub stands in, and an `xcrun` shim that answers
83 # --show-sdk-path with it — zig finds the SDK by running `xcrun` off PATH
84 # (std/zig/system/darwin.zig), and so does ghostty's apple_sdk helper.
85 #
86 # Self-retiring: when the real SDK's stub lists arm64-macos again, no shadow
87 # is built and the shim, if a stale one is on PATH, passes straight through.
88 # Idempotent and cheap: the marker check is the first thing it does.
89 #
90 # ./deps/mac-sdk.sh builds deps/mac-sdk/{sdk,bin/xcrun}
91 set -eu
92 [ "$(uname)" = Darwin ] || exit 0
93 SELF="$(cd "$(dirname "$0")" && pwd)"
94 OUT="$SELF/mac-sdk"
95 ZIG_STUB="$SELF/zig/lib/libc/darwin/libSystem.tbd"
96 REAL="$(/usr/bin/xcrun --sdk macosx --show-sdk-path)"
97 if grep -m1 '^targets:' "$REAL/usr/lib/libSystem.B.tbd" | grep -q 'arm64-macos'; then
98 rm -rf "$OUT" # the SDK is linkable again; leave nothing to shadow it
99 exit 0
100 fi
101 [ -f "$ZIG_STUB" ] || { echo "deps/mac-sdk: no zig stub at $ZIG_STUB (is deps/zig in place?)" >&2; exit 1; }
102 # Marker: the shim exists AND points at this SDK. A new Xcode moves REAL.
103 if [ -x "$OUT/bin/xcrun" ] && [ "$(readlink "$OUT/sdk/usr/include")" = "$REAL/usr/include" ]; then
104 exit 0
105 fi
106 rm -rf "$OUT"
107 mkdir -p "$OUT/sdk/usr/lib" "$OUT/bin"
108 for e in "$REAL"/*; do b=$(basename "$e"); [ "$b" = usr ] || ln -s "$e" "$OUT/sdk/$b"; done
109 for e in "$REAL"/usr/*; do b=$(basename "$e"); [ "$b" = lib ] || ln -s "$e" "$OUT/sdk/usr/$b"; done
110 for e in "$REAL"/usr/lib/*; do
111 b=$(basename "$e")
112 case "$b" in libSystem.tbd | libSystem.B.tbd) ;; *) ln -s "$e" "$OUT/sdk/usr/lib/$b" ;; esac
113 done
114 cp "$ZIG_STUB" "$OUT/sdk/usr/lib/libSystem.tbd"
115 cp "$ZIG_STUB" "$OUT/sdk/usr/lib/libSystem.B.tbd"
116 cat > "$OUT/bin/xcrun" <<EOF
117 #!/bin/sh
118 # Shim from deps/mac-sdk.sh: only --show-sdk-path is answered here.
119 case "\$*" in
120 *--show-sdk-path*) [ -d "$OUT/sdk" ] && { echo "$OUT/sdk"; exit 0; } ;;
121 esac
122 exec /usr/bin/xcrun "\$@"
123 EOF
124 chmod +x "$OUT/bin/xcrun"
125 echo "deps/mac-sdk: shadow SDK over $REAL (zig 0.15.2 cannot link the Xcode 26.4+ stub)" >&2
126 ```
127
128 - [ ] **Step 2: Makefile — Darwin wiring**
129
130 At the top, after the `ZIG ?=` line:
131
132 ```make
133 # On a Mac the pinned zig cannot link against Xcode 26.4+'s SDK (see
134 # deps/mac-sdk.sh). The shim dir goes FIRST on PATH for every recipe here,
135 # and `mac-sdk` builds it; both are no-ops on Linux and on a Mac whose SDK
136 # is linkable. MUX_TARGET follows the host: a Mac installs a Mac binary.
137 UNAME_S := $(shell uname -s)
138 ifeq ($(UNAME_S),Darwin)
139 export PATH := $(CURDIR)/deps/mac-sdk/bin:/opt/homebrew/bin:$(PATH)
140 MUX_TARGET ?= aarch64-macos
141 endif
142 MUX_TARGET ?= x86_64-linux-musl
143 ```
144
145 Remove the existing `MUX_TARGET ?= x86_64-linux-musl` line further down (the `?=` above now owns it). Add:
146
147 ```make
148 mac-sdk:
149 sh ./deps/mac-sdk.sh
150
151 # The `make vm` of the macOS port: user journeys on the Mac, over ssh.
152 mac: build
153 sh ./test/mac.sh
154 ```
155
156 Make `build`, `check`, `test`, `install` depend on `mac-sdk`:
157
158 ```make
159 build: mac-sdk
160 $(ZIG) build
161 ```
162
163 (and the same `: mac-sdk` prerequisite on `check`, `test`, `install`, `throughput`). `.PHONY` gains `mac-sdk mac`.
164
165 - [ ] **Step 3: `.gitignore`** — add `deps/mac-sdk` under the `deps/zig` block with a one-line comment (`# Shadow SDK from deps/mac-sdk.sh, Darwin only.`).
166
167 - [ ] **Step 4: Verify on Linux**
168
169 Run: `sh -n deps/mac-sdk.sh && make build >/dev/null && echo ok` → `ok`; `sh deps/mac-sdk.sh; echo rc=$?` → `rc=0` with no output (the `uname` gate). `make check` green.
170
171 - [ ] **Step 5: Verify on squirtle** (skip with `BLOCKED` if unreachable)
172
173 ```sh
174 git push squirtle:code/rad/mux HEAD:refs/heads/macos-step3 && ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && git checkout -q macos-step3 && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make mac-sdk && ls -l deps/mac-sdk/bin/xcrun && xcrun --show-sdk-path && rm -rf .zig-cache && make build 2>&1 | grep -c "no server platform arm\|no client platform arm\|no default runtime directory"'
175 ```
176
177 Expected: the shim path printed as the SDK, and the final count is `3` — the three step-3 compile errors and NO `undefined symbol` line (`grep -c "undefined symbol"` → `0`).
178
179 - [ ] **Step 6: Commit**
180
181 ```sh
182 git add deps/mac-sdk.sh deps/quic/build-deps.sh Makefile .gitignore
183 git commit -m "build: a Mac builds natively through a shadow SDK, and the dep script follows the host"
184 ```
185
186 ---
187
188 ### Task 2: `sockpath.runtimeDir` on Darwin
189
190 **Files:**
191 - Modify: `src/sockpath.zig` (`runtimeDir`, the "unset" message near line 37, tests)
192
193 **Interfaces:**
194 - Produces: `runtimeDir()` returns `?[]const u8` on Darwin exactly as on Linux; new `pub fn runtimeDirFrom(env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8` (pure, tested on Linux) and `fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool` (creates `0700` if missing; then `lstat`: directory, not a symlink, owner `uid`, mode `0700`).
195
196 - [ ] **Step 1: Failing tests** (append to `src/sockpath.zig`)
197
198 ```zig
199 test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" {
200 var buf: [32]u8 = undefined;
201 try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom("/run/user/7", 501, &buf).?);
202 if (builtin.os.tag == .linux) {
203 try std.testing.expect(runtimeDirFrom(null, 501, &buf) == null);
204 } else {
205 try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(null, 501, &buf).?);
206 }
207 }
208
209 test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" {
210 var tmp = testtmp.init();
211 defer tmp.deinit();
212 var b: [std.fs.max_path_bytes]u8 = undefined;
213 const uid = std.c.geteuid();
214 const fresh = try std.fmt.bufPrint(&b, "{s}/rt", .{tmp.path()});
215 try std.testing.expect(ensureOwnedDir(fresh, uid));
216 const st = try std.posix.fstatat(std.posix.AT.FDCWD, fresh, 0);
217 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
218 try std.testing.expect(ensureOwnedDir(fresh, uid)); // second call is a check, not a mkdir
219 var b2: [std.fs.max_path_bytes]u8 = undefined;
220 const loose = try std.fmt.bufPrint(&b2, "{s}/loose", .{tmp.path()});
221 try std.posix.mkdir(loose, 0o750);
222 try std.testing.expect(!ensureOwnedDir(loose, uid));
223 var b3: [std.fs.max_path_bytes]u8 = undefined;
224 const link = try std.fmt.bufPrint(&b3, "{s}/link", .{tmp.path()});
225 try std.posix.symlink(fresh, link);
226 try std.testing.expect(!ensureOwnedDir(link, uid));
227 }
228 ```
229
230 `testtmp` is already a test import of `sockpath` (check `build.zig`'s row; add `.test_imports = &.{"testtmp"}` if it is not).
231
232 - [ ] **Step 2: Run** `deps/zig/zig build test 2>&1 | tail -5` → fails: `runtimeDirFrom` undefined.
233
234 - [ ] **Step 3: Implement**
235
236 ```zig
237 /// The directory the default daemon socket and every per-wall socket live
238 /// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
239 /// every isolated rig (make e2e, soak, a hand rig) keeps its sockets apart
240 /// from the user's. Linux has NO fallback: a guess cannot make two binaries
241 /// agree on one daemon, so the caller names it with --sock. Darwin falls
242 /// back to /tmp/mux-<uid>, created 0700 and checked on every ask — the
243 /// spec's three candidates ($TMPDIR, ~/Library/Caches, ~/.local/state) all
244 /// overflow sun_path at the longest name mux creates (dir + 68 + pid
245 /// digits against 103; measured 2026-09-03, docs/decisions.md), and /tmp
246 /// is sticky and world-writable, so the per-uid directory is what carries
247 /// the privacy, as tmux's /tmp/tmux-UID does.
248 pub fn runtimeDir() ?[]const u8 {
249 const env = std.posix.getenv("XDG_RUNTIME_DIR");
250 if (builtin.os.tag == .linux) return env;
251 const uid = std.c.geteuid();
252 const dir = runtimeDirFrom(env, uid, &darwin_dir_buf) orelse return null;
253 if (env == null and !ensureOwnedDir(dir, uid)) return null;
254 return dir;
255 }
256
257 var darwin_dir_buf: [32]u8 = undefined;
258
259 fn runtimeDirFrom(env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 {
260 if (env) |e| return e;
261 if (builtin.os.tag == .linux) return null;
262 return std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null;
263 }
264
265 /// True when PATH is a directory this uid owns with mode 0700 and no
266 /// symlink in the last step — the check tmux makes of /tmp/tmux-UID, for
267 /// the same reason: in a sticky world-writable /tmp, another uid can plant
268 /// a symlink or a loose directory at our name before we get there, and a
269 /// socket bound through either is theirs to connect to.
270 fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool {
271 std.posix.mkdir(path, 0o700) catch |e| if (e != error.PathAlreadyExists) return false;
272 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, std.posix.AT.SYMLINK_NOFOLLOW) catch return false;
273 return std.posix.S.ISDIR(st.mode) and st.uid == uid and (st.mode & 0o777) == 0o700;
274 }
275 ```
276
277 Update the "unset" message (line ~37) so on Darwin it reads `"{s}: no runtime directory: XDG_RUNTIME_DIR is unset and /tmp/mux-<uid> is not a 0700 directory owned by you (name a socket with --sock)\n"`; keep the Linux text byte-for-byte.
278
279 - [ ] **Step 4: Run** `deps/zig/zig build test 2>&1 | tail -3` → pass; `make check` green.
280
281 - [ ] **Step 5: Commit** — `git commit -m "feat: the Darwin runtime dir is XDG_RUNTIME_DIR, else /tmp/mux-UID checked like tmux does"`
282
283 ---
284
285 ### Task 3: `server_os_macos.zig`
286
287 **Files:**
288 - Create: `src/os/server_os_macos.zig`
289 - Modify: `src/os/server_os.zig` (import arm; the `forkDetached` test), `build.zig` (rule 6 `except` list)
290
291 **Interfaces:**
292 - Consumes: every `pub fn` the root calls through `impl`: `getpid peerCred sendNoSigNoWait sockType forkPty exitNow forkDetached closeFrom ptyMode ptyFgPgid setWinsize anonFd`. (`noteBootImage`/`selfImageStale` live in the root.)
293
294 - [ ] **Step 1: The arm**
295
296 ```zig
297 //! Darwin arm of `server_os`. Spellings only; the contract is in the root.
298 //! Three behaviours differ from Linux on purpose and are recorded in
299 //! docs/decisions.md (2026-09-03): `closeFrom` walks the fd table,
300 //! `anonFd` is an unlinked mkstemp file, and `sendNoSigNoWait` sets
301 //! SO_NOSIGPIPE on the fd per call because Darwin has no MSG_NOSIGNAL.
302 const std = @import("std");
303 const root = @import("server_os.zig");
304 const c = @cImport({
305 @cInclude("util.h"); // forkpty
306 @cInclude("sys/ioctl.h");
307 @cInclude("sys/socket.h");
308 @cInclude("sys/un.h"); // LOCAL_PEERPID
309 @cInclude("unistd.h"); // getpeereid, getdtablesize
310 @cInclude("stdlib.h"); // mkstemp
311 });
312
313 pub fn getpid() std.posix.pid_t {
314 return std.c.getpid();
315 }
316
317 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
318 var uid: c.uid_t = undefined;
319 var gid: c.gid_t = undefined;
320 if (c.getpeereid(fd, &uid, &gid) != 0) return null;
321 var pid: c.pid_t = 0;
322 var len: c.socklen_t = @sizeOf(c.pid_t);
323 // SOL_LOCAL is 0 on Darwin; LOCAL_PEERPID answers the peer's pid for a
324 // unix socket the way SO_PEERCRED's pid field does on Linux.
325 if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
326 return .{ .uid = uid, .pid = pid };
327 }
328
329 fn noSigPipe(fd: std.posix.socket_t) void {
330 const on: c_int = 1;
331 _ = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
332 }
333
334 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
335 noSigPipe(fd);
336 return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT);
337 }
338
339 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {
340 var t: c_int = undefined;
341 var len: c.socklen_t = @sizeOf(c_int);
342 if (c.getsockopt(fd, c.SOL_SOCKET, c.SO_TYPE, &t, &len) != 0) return error.NotASocket;
343 return @intCast(t);
344 }
345
346 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
347 var master: c_int = undefined;
348 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
349 const pid = c.forkpty(&master, null, null, &cws);
350 if (pid < 0) return error.ForkPtyFailed;
351 return .{ .pid = pid, .master = master };
352 }
353
354 pub fn exitNow(code: u8) noreturn {
355 std.c._exit(code);
356 }
357
358 pub fn forkDetached(
359 exe: [*:0]const u8,
360 argv: [*:null]const ?[*:0]const u8,
361 stdin_fd: std.posix.fd_t,
362 out_fd: std.posix.fd_t,
363 ) error{ForkFailed}!std.posix.pid_t {
364 const pid = std.posix.fork() catch return error.ForkFailed;
365 if (pid != 0) return pid;
366 _ = std.c.setsid();
367 std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127);
368 std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127);
369 std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127);
370 std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127);
371 unreachable;
372 }
373
374 pub fn closeFrom(first: std.posix.fd_t) void {
375 // No close_range on Darwin: one close per slot up to the table size,
376 // which is thousands of cheap EBADFs once per session start. Between
377 // fork and exec, so nothing else is opening fds underneath the walk.
378 var fd: std.posix.fd_t = first;
379 const top: std.posix.fd_t = c.getdtablesize();
380 while (fd < top) : (fd += 1) _ = std.c.close(fd);
381 }
382
383 pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!root.PtyMode {
384 // Measured 2026-09-03: Darwin's master answers tcgetattr for the slave's
385 // line discipline, so this is the Linux shape and not a reopen by name.
386 const t = try std.posix.tcgetattr(master);
387 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
388 }
389
390 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
391 var pgid: c.pid_t = 0;
392 if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
393 return @intCast(pgid);
394 }
395
396 pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void {
397 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
398 if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed;
399 }
400
401 pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t {
402 // No memfd on Darwin. A 0600 file this uid creates and unlinks before
403 // anyone could open it by name is private by mode where memfd is
404 // private by having no name; the window is the two calls below, on an
405 // empty file. /tmp rather than the runtime dir because this file
406 // imports nothing of ours and must not learn the socket directory.
407 var tmpl: [64]u8 = undefined;
408 const t = std.fmt.bufPrintZ(&tmpl, "/tmp/mux-{s}-XXXXXX", .{std.mem.span(name)}) catch return error.CarrierFailed;
409 const fd = c.mkstemp(t.ptr);
410 if (fd < 0) return error.CarrierFailed;
411 std.posix.unlink(std.mem.sliceTo(t, 0)) catch {
412 std.posix.close(fd);
413 return error.CarrierFailed;
414 };
415 // mkstemp opens O_CLOEXEC on modern Darwin; the candidate must inherit it.
416 const flags = std.c.fcntl(fd, std.posix.F.GETFD, @as(c_int, 0));
417 _ = std.c.fcntl(fd, std.posix.F.SETFD, flags & ~@as(c_int, std.posix.FD_CLOEXEC));
418 return fd;
419 }
420 ```
421
422 - [ ] **Step 2: Root import arm** in `src/os/server_os.zig`:
423
424 ```zig
425 pub const impl = switch (builtin.os.tag) {
426 .linux => @import("server_os_linux.zig"),
427 .macos => @import("server_os_macos.zig"),
428 else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)),
429 };
430 ```
431
432 - [ ] **Step 3: The `forkDetached` test** — replace its script and assertions:
433
434 ```zig
435 // `sess` is the one column both ps's print: the numeric sid on procps
436 // (== the leader's pid) and an opaque token on Darwin, equal within a
437 // session on both. A new session is the claim; the leader pid is a
438 // Linux-only strengthening.
439 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sess= -p $$ | tr -d ' '; ps -o sess= -p $PPID | tr -d ' '; echo $$" };
440
441 const sess = lines.next() orelse return error.NoOutput;
442 const parent_sess = lines.next() orelse return error.NoOutput;
443 const shpid = lines.next() orelse return error.NoOutput;
444 try std.testing.expect(!std.mem.eql(u8, sess, parent_sess));
445 if (builtin.os.tag == .linux) try std.testing.expectEqualStrings(shpid, sess);
446 try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10));
447 ```
448
449 Bump `buf` to `[128]u8`.
450
451 - [ ] **Step 4: Rule 6 in `build.zig`** — `except: ?[]const u8` → `except: []const []const u8 = &.{}`; the check at line ~457 becomes a loop (`for (ban.except) |ex| if (std.mem.eql(u8, path, ex)) { skip = true; }`); rule 6's entry: `.except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" }`. Update the rule's `why` with one sentence: "one fork per OS arm, and the arm's file is named here so a third file that forks is caught".
452
453 - [ ] **Step 5: Linux verify** — `deps/zig/zig build test 2>&1 | tail -3` pass; `make check` green (rule 6 now walks a list; `test/bans.sh` still plants its needle and reads the fatal).
454
455 - [ ] **Step 6: squirtle verify** — push, then:
456
457 ```sh
458 ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && git checkout -q macos-step3 && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make build 2>&1 | grep -c "no client platform arm"'
459 ```
460
461 Expected `1` or more and zero `no server platform arm` lines. (The client arm is Task 4; the build still cannot link.)
462
463 - [ ] **Step 7: Commit** — `git commit -m "feat: the daemon's Darwin arm"`
464
465 ---
466
467 ### Task 4: `client_os_macos.zig`
468
469 **Files:**
470 - Create: `src/os/client_os_macos.zig`
471 - Modify: `src/os/client_os.zig` (import arm)
472
473 - [ ] **Step 1: The arm**
474
475 ```zig
476 //! Darwin arm of `client_os`. Spellings only; the contract is in the root.
477 const std = @import("std");
478 const root = @import("client_os.zig");
479 const c = @cImport({
480 @cInclude("util.h"); // openpty (test-only through the root)
481 @cInclude("sys/ioctl.h");
482 @cInclude("sys/socket.h");
483 @cInclude("sys/un.h");
484 @cInclude("sys/sysctl.h"); // kinfo_proc for parentOf
485 @cInclude("unistd.h");
486 });
487
488 pub fn getpid() std.posix.pid_t {
489 return std.c.getpid();
490 }
491
492 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
493 var uid: c.uid_t = undefined;
494 var gid: c.gid_t = undefined;
495 if (c.getpeereid(fd, &uid, &gid) != 0) return null;
496 var pid: c.pid_t = 0;
497 var len: c.socklen_t = @sizeOf(c.pid_t);
498 if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
499 return .{ .uid = uid, .pid = pid };
500 }
501
502 pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
503 const on: c_int = 1;
504 _ = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
505 return std.posix.send(fd, bytes, 0);
506 }
507
508 /// sysctl KERN_PROC_PID: the kernel's own record of the process, the
509 /// Darwin answer to /proc/PID/stat. A pid the kernel no longer has is 0,
510 /// as on Linux.
511 pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
512 var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PID, pid };
513 var kp: c.struct_kinfo_proc = undefined;
514 var len: usize = @sizeOf(c.struct_kinfo_proc);
515 if (c.sysctl(&mib, mib.len, &kp, &len, null, 0) != 0 or len == 0) return 0;
516 return kp.kp_eproc.e_ppid;
517 }
518
519 pub fn geteuid() std.posix.uid_t {
520 return std.c.geteuid();
521 }
522
523 pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize {
524 var ws: std.posix.winsize = undefined;
525 if (c.ioctl(fd, c.TIOCGWINSZ, &ws) != 0) return null;
526 return ws;
527 }
528
529 pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void {
530 var w = ws;
531 if (c.ioctl(fd, c.TIOCSWINSZ, &w) != 0) return error.Unsupported;
532 }
533
534 pub fn openPtyPair() error{Unsupported}!root.PtyPair {
535 var master: c_int = undefined;
536 var slave: c_int = undefined;
537 if (c.openpty(&master, &slave, null, null, null) != 0) return error.Unsupported;
538 return .{ .master = master, .slave = slave };
539 }
540 ```
541
542 - [ ] **Step 2: Root import arm** — add `.macos => @import("client_os_macos.zig"),` beside the Linux line.
543
544 - [ ] **Step 3: Linux verify** — `deps/zig/zig build test 2>&1 | tail -3`; `make check`.
545
546 - [ ] **Step 4: squirtle verify — the first link**
547
548 ```sh
549 ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && git checkout -q macos-step3 && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make build 2>&1 | tail -20; ls -l zig-out/bin/mux && ./zig-out/bin/mux --version'
550 ```
551
552 Expected: a `mux` binary that prints its version. Whatever else fails here (a ghostty link line, a framework, a `std.c` name Darwin lacks) is THIS task's to fix, and each fix is one line in the report so Task 8 can record it.
553
554 - [ ] **Step 5: Commit** — `git commit -m "feat: the client's Darwin arm, and mux links on a Mac"`
555
556 ---
557
558 ### Task 5: `zig build test` green on the Mac
559
560 **Files:**
561 - Modify: whatever the run names. Expected: `src/os/spawn.zig` (nothing — its Linux branch is gated), `src/client/client.zig:2397` (`readlink /dev/fd/2` is not a symlink on Darwin: the test wants the fd's TARGET; use `ls -l /dev/fd/2 | sed 's/.*-> //'` on Linux vs … simplest portable form is `test -w /dev/fd/2 && echo open > FILE` if the assertion is only "stderr was the pipe"; read the test and keep its claim), `src/sockpath.zig:150` (already gated), any test spelling `/proc` inside a `test` block.
562
563 - [ ] **Step 1: Run on squirtle**
564
565 ```sh
566 ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && deps/zig/zig build test 2>&1 | grep -v "^\s*$" | tail -40'
567 ```
568
569 - [ ] **Step 2: Fix each failure at its cause**, one commit per distinct cause when they are unrelated, keeping every Linux assertion. A unit test that cannot hold on Darwin because the OS has no such fact (not because the spelling differs) is gated on `builtin.os.tag == .linux` with a one-line comment saying which fact Darwin lacks.
570
571 - [ ] **Step 3: Both suites green** — squirtle `zig build test` exit 0 (capture `$?` on the ssh, not the tail); Linux `make check` green.
572
573 - [ ] **Step 4: Commit(s)** — `test: the unit suite holds on Darwin` (or one per cause).
574
575 ---
576
577 ### Task 6: The oracle's Darwin arm and the harness's GNU spellings
578
579 **Files:**
580 - Modify: `test/os_oracle.sh`, `test/e2e_lib.sh` (`now_ms`, `_os` marker → the first real `case`), `test/e2e_01_boot.sh`, `e2e_04_handoff.sh`, `e2e_10_agent.sh`, `e2e_11_select.sh`, `e2e_14_upgrade.sh`, `test/throughput.sh` (`date +%s%N` → `now_ms`), `test/e2e_03_side.sh`, `e2e_14_upgrade.sh`, `e2e_16_push.sh` (`readlink -f` → `real_path`).
581
582 **Interfaces:**
583 - Produces: `now_ms` (epoch milliseconds), `real_path PATH` (canonical path), and a Darwin arm for every existing helper. Group files call only names in `os_oracle.sh`.
584
585 - [ ] **Step 1: The Darwin arm.** Restructure `os_oracle.sh` as ONE `case "$(uname)"` with the whole Linux block in the `*)` arm unchanged, and this `Darwin)` arm above it:
586
587 ```sh
588 Darwin)
589 # lsof is the fd oracle (no /proc): -Fn prints one `n<target>` line per
590 # fd, -Ft its type, -a ANDs the filters. ps's BSD columns answer the
591 # rest. Each helper asks the kernel (lsof and ps both read it through
592 # libproc), never a daemon.
593 pid_alive() { kill -0 "$1" 2>/dev/null; }
594 pid_exe() { lsof -p "$1" -a -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -1; }
595 pid_comm() { basename "$(ps -o comm= -p "$1" 2>/dev/null)"; }
596 pid_args() { ps -o args= -p "$1" 2>/dev/null; }
597 pid_children() { pgrep -P "$1" 2>/dev/null; }
598 pid_fd_count() { lsof -p "$1" -Ff 2>/dev/null | grep -c '^f[0-9]' || true; }
599 pid_fd_targets() { lsof -p "$1" -Fn 2>/dev/null | sed -n 's/^n//p'; }
600 pid_holds_fd_kind() {
601 case "$2" in
602 socket) lsof -p "$1" -a -U -Ff 2>/dev/null | grep -c '^f[0-9]' || true ;;
603 carrier) lsof -p "$1" -Fn 2>/dev/null | grep -c '^n/tmp/mux-.*XXXXXX\|^n/private/tmp/mux-' || true ;;
604 ptymaster) lsof -p "$1" -Fn 2>/dev/null | grep -c '^n/dev/ptmx' || true ;;
605 *) echo "pid_holds_fd_kind: no such fd kind '$2'" >&2; return 1 ;;
606 esac
607 }
608 pid_holds_unix_sock() { lsof -p "$1" -a -U -Fn 2>/dev/null | grep -qx "n$2"; }
609 pid_rss_kb() { ps -o rss= -p "$1" 2>/dev/null | tr -d ' ' || echo 0; }
610 udp_local_bound() { lsof -iUDP -P -n -Fn 2>/dev/null | grep -q "^n.*:$(printf '%d' "0x${1##*:}")$"; }
611 udp_table() { lsof -iUDP -P -n 2>/dev/null; }
612 file_mode() { stat -f %Lp "$1"; }
613 file_size() { stat -f %z "$1"; }
614 sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
615 real_path() { python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$1"; }
616 now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }
617 ;;
618 ```
619
620 and in the Linux arm add `real_path() { readlink -f "$1"; }` and `now_ms() { echo $(( $(date +%s%N) / 1000000 )); }`. The `udp_local_bound` Linux helper takes the hex form; the Darwin one is handed the same argument and converts. Read the two callers in `e2e_lib.sh` before settling the argument shape — the group file must not know which OS it is on.
621
622 The unlinked carrier: lsof prints an unlinked file's last path; verify the exact spelling in Task 6 Step 4 on the Mac and fix the pattern to what it prints.
623
624 - [ ] **Step 2: `now_ms` and `real_path` in the group files** — replace each `date +%s%N` arithmetic and each `readlink -f` with the helper. Keep the Linux output identical (ms, not ns — adjust the arithmetic at each site).
625
626 - [ ] **Step 3: Linux gate** — `make check` (bans.sh, `sh -n` on every script) and `E2E_ONLY=01_boot make e2e`, `E2E_ONLY=14_upgrade make e2e`, `E2E_ONLY=03_side make e2e` green; then the whole `make e2e` once.
627
628 - [ ] **Step 4: Darwin oracle self-test on squirtle**
629
630 ```sh
631 ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && export PATH=/opt/homebrew/bin:$PATH && brew list coreutils >/dev/null 2>&1 || brew install coreutils; sh -c ". test/os_oracle.sh; . test/e2e_lib.sh 2>/dev/null; OUT=/tmp/oracle TIME_SCALE=1 oracle_selftest && echo ORACLE_OK"'
632 ```
633
634 (`oracle_selftest` needs `defer_kill`, `wait_until`, `OUT`; if sourcing the lib whole is not possible without its argv, extract the self-test into `test/oracle_selftest.sh` that both `e2e_lib.sh` and this call source — a refactor, not a second copy.) Expected `ORACLE_OK`. `gtimeout` comes from coreutils; the existing `timeout` fallback covers it.
635
636 - [ ] **Step 5: Commit** — `test: the OS oracle answers on Darwin, and the harness spells time and paths through it`
637
638 ---
639
640 ### Task 7: `test/mac.sh` — the `make mac` journey
641
642 **Files:**
643 - Create: `test/mac.sh`
644 - Modify: `Makefile` (`mac` target from Task 1 already points here)
645
646 **Interfaces:**
647 - Consumes: `MUX_MAC` (default `squirtle`), the repo at `~/code/rad/mux` on the box, `deps/mac-sdk/bin` on PATH there.
648
649 - [ ] **Step 1: The script**, shaped like `test/vm.sh` (same header contract: real box, every claim asserted by a separate ssh through the Darwin oracle, `set -eu`, BatchMode). Legs, each a named step with its own failure line:
650
651 1. `push` — `git push "$MAC:code/rad/mux" HEAD:refs/heads/mac-journey` then remote `git checkout -q mac-journey`.
652 2. `build` — remote `make build` and `make check`, rc captured.
653 3. `unit` — remote `deps/zig/zig build test`, rc captured.
654 4. `daemon` — remote, under `XDG_RUNTIME_DIR=$(mktemp -d /tmp/muxj.XXXXXX)` exported for every later leg: `zig-out/bin/mux d start -d`; assert via `pid_holds_unix_sock DPID $XDG_RUNTIME_DIR/muxd.sock` from the oracle, and `pid_exe DPID` equals `real_path zig-out/bin/mux`.
655 5. `session` — `zig-out/bin/mux a run --sock … 'echo mac-$$'`-style (use whichever `mux a` verb the README documents for a one-shot command) and read the pid back off the grid; assert the pid is a child of the daemon via `pid_children`.
656 6. `attach` — `zig-out/bin/ptyclient` drives `mux --sock …` on a real pty, types `printf JOURNEY-%s\\n ok`, waits for `JOURNEY-ok` in the snapshot (the e2e lib has the wait; mirror its shape), detaches with the chord.
657 7. `stop` — `mux d stop`; assert `pid_alive DPID` false within 3 s, socket path gone.
658 8. Cleanup trap: `mux d stop` on the rig socket if still alive; `rm -rf` the rig dir.
659
660 - [ ] **Step 2: Run it** — `make mac` from this box; every leg prints its name and `ok`; exit 0.
661
662 - [ ] **Step 3: Commit** — `test: make mac drives the Mac over ssh the way make vm drives the VM`
663
664 ---
665
666 ### Task 8: `make e2e` on the Mac — the verdict, and the record
667
668 **Files:**
669 - Modify: `docs/decisions.md`, `README.md`, `CLAUDE.md`
670
671 - [ ] **Step 1: Run the e2e suite on squirtle** with the shim PATH and `brew`'s coreutils present:
672
673 ```sh
674 ssh -o BatchMode=yes squirtle 'cd ~/code/rad/mux && export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH && make e2e 2>&1 | tail -40; echo rc=$?'
675 ```
676
677 Then per group: `for g in $(ls test/e2e_[0-9]*_*.sh | sed 's,test/e2e_,,;s,\.sh,,'); do E2E_ONLY=$g make e2e >/tmp/e2e-$g.log 2>&1; echo "$g rc=$?"; done`. Fix a group only when the cause is a harness spelling (one more oracle helper, a BSD flag); a group that fails on a PRODUCT difference is recorded, not patched here.
678
679 - [ ] **Step 2: `docs/decisions.md`** — one dated entry (2026-09-03, "macOS port step 3: the Darwin arm"), plain sentences: the Xcode 26.4 stub finding with the measurements from the Global Constraints; the pty-master probe result; the sun_path measurements and the `/tmp/mux-UID` ruling; the four deliberate Darwin differences (`closeFrom`, `anonFd`, `sendNoSig` per call, the `sess` test); every fix Task 5 made; the per-group e2e verdict table; what is NOT done (x86_64-macos, universal binary, cross-compile).
680
681 - [ ] **Step 3: `README.md`** — a "macOS" section: aarch64 only, `make build` on the Mac needs Xcode CLT + cmake (brew), the shadow-SDK note in one paragraph, the runtime dir spelling, `make install` puts an aarch64 binary in `~/.local/bin`.
682
683 - [ ] **Step 4: `CLAUDE.md`** — the toolchain block gains the Mac line (`deps/zig` on the Mac is the aarch64 0.15.2 tarball; `make mac-sdk`; `MUX_TARGET` follows the host); the layout table's `src/os/` row names the two `_macos` children; rule 6's excepted files are two; the invariants gain one line under the sun_path bullet about `/tmp/mux-UID`. Re-measure the `wc -l` figures the reading section quotes.
684
685 - [ ] **Step 5: `make ci` on Linux**, then commit — `docs: the Darwin arm, its measurements and the Mac e2e verdict`
686
687 ---
688
689 ## Self-review
690
691 - **Spec coverage:** Step 3's two arm files (Tasks 3, 4), harness `case "$(uname)"` arms (Task 6), the dep-script arm (Task 1, already in tree), the two hardware probes (answered, recorded in rulings and Task 8), the three named Darwin differences (Task 3 doc header + Task 8), the `runtimeDir` spelling (Task 2), the Testing section's gate — `make ci` on the Mac is Task 8's run, the oracle's own test is Task 6 Step 4, the `make vm`-shaped journey is Task 7. The Deferred build-host decision is ruling 1.
692 - **Placeholders:** none; every step has its code or its exact command. Task 5 is a discovery task by design and says what "fix" means.
693 - **Type consistency:** `runtimeDirFrom(env, uid, buf)` (Task 2) is used only inside `sockpath.zig`; `now_ms`/`real_path` (Task 6) are the only new harness names and Task 7 uses `real_path`; the rule 6 `except` list type (Task 3) is the one `build.zig` change. Arm signatures were copied from the roots' `pub fn` lines on 2026-09-03.
docs/superpowers/specs/2026-09-03-macos-port-design.md
Old New
@@ -293,3 +293,44 @@ worth doing and is not this work.
293 293
294 **x86_64-macos and a universal binary** are one more target word each and 294 **x86_64-macos and a universal binary** are one more target word each and
295 are out of scope until someone has the hardware. 295 are out of scope until someone has the hardware.
296
297 ## Hardware findings, 2026-09-03 (amendment after step 2)
298
299 Measured on `squirtle` (Apple M1, macOS 26.6.2, Xcode 26.x) the day step 2
300 merged. Each of these settles a question the sections above left open, and
301 the step-3 plan (`docs/superpowers/plans/2026-09-03-macos-port-step3-darwin-arm.md`)
302 carries them as rulings.
303
304 - **Build host: native on the Mac, over ssh.** Not by choice among the
305 three options in "Deferred" but by finding: zig 0.15.2 cannot link a
306 NATIVE target against the SDK Xcode 26.4+ ships, because that SDK's
307 `libSystem.B.tbd` lists `arm64e-macos` and no `arm64-macos`, and the
308 0.15 Mach-O linker matches the bare slice only (ziglang/zig#31658 on
309 Codeberg; fixed upstream in 0.16, which ghostty's pin cannot take). The
310 build runner is a native link, so `zig build` cannot start. An explicit
311 `-target aarch64-macos` links against zig's bundled stub and runs, which
312 is the whole fix: a shadow SDK — the real one by symlink except
313 `usr/lib/libSystem{,.B}.tbd`, where zig's stub stands in — reached through
314 an `xcrun` shim on PATH. With it the full `zig build` compiled ghostty and
315 its three C++ dependencies natively and stopped at exactly the three
316 step-3 `@compileError`s. The QUIC stack built natively in about a minute
317 once the dep script's `native` word stopped assuming an x86 host.
318 - **Probe 1, the pty master:** `tcgetattr`, `TIOCGPGRP`, `TIOCSWINSZ` and
319 `TIOCGWINSZ` on the master all answered 0 against a forked child holding
320 the slave as its controlling tty. The Linux arm's shape is reused; nothing
321 reopens the slave by name, and `openPty` need not record it.
322 - **Probe 2, the socket directory:** the longest path mux creates is
323 `<dir>/mux-agent-<pid>-<12 hex>/agent-<session>.sock`, dir + 68 bytes +
324 pid digits at `session_name_max` 32, against 103 usable. `$TMPDIR` is 48
325 bytes on this box (121), `~/Library/Caches/mux` 34 (107),
326 `~/.local/state/mux/run` 36 (109): none of the three fits. `/tmp/mux-501`
327 is 12 (85). The Darwin spelling is therefore `$XDG_RUNTIME_DIR` when set
328 (every isolated rig sets it, on both OSes) and otherwise `/tmp/mux-<uid>`,
329 created `0700` and re-checked on every ask for owner, mode and no symlink
330 — tmux's `/tmp/tmux-UID` rule, for tmux's reason.
331 - **The upgrade carrier** on Darwin is a `mkstemp` file in `/tmp`, mode
332 `0600`, unlinked before `anonFd` returns; "private by mode" replaces
333 "private by having no name" for the width of two syscalls on an empty file.
334 - **Darwin `sendNoSig` sets `SO_NOSIGPIPE` per call.** No adopt-socket hook
335 is added to the roots: the option is idempotent and the accept sites stay
336 outside the platform rows.