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/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. | ||