a73x

e542a342

docs: the Darwin arm, its measurements and the Mac e2e verdict

a73x   2026-09-04 10:16

Commit message
docs: the Darwin arm, its measurements and the Mac e2e verdict

One new entry in decisions.md for step 3 as a whole: the Xcode 26.4 stub
that zig 0.15.2 cannot link and the shadow SDK that works around it, the
pty-master probe, the sun_path measurements behind /tmp/mux-<uid>, the four
operations that are a different mechanism on Darwin rather than a different
spelling, the ENOTCONN panic the cross-OS gate found and why it has no pin,
both of Task 5's fixes with their measurements, what make mac and make xos
prove and how long they take, the two pre-existing bugs make xos surfaced,
the per-group e2e verdict, and what is not done.

Four corrections to the existing daemon-arm entry, each a carry item: it
said three mechanisms differ and documented four; closeFrom's cost scales
with the soft fd limit; the mkstemp CLOEXEC claim was asserted and not
measured; and the deferred alternative for SO_NOSIGPIPE was unrecorded.

README no longer says Linux only. Its macOS section, which was all about
the gates, gains what a person needs to build and run there: aarch64 only,
Xcode CLT and cmake, the shadow SDK in a paragraph, the /tmp/mux-<uid>
socket directory, and the one e2e group that is red on macOS on purpose.

CLAUDE.md gains the Mac toolchain lines, the runtimeDir rule under the
sun_path invariant, and the working rule this exercise earned: a harness
assumption is Linux-only until it has run on both operating systems. Every
wc -l figure in the reading section was re-measured.

build.zig's proxy row said its imports were empty apart from testtmp; it
imports sockpath now.

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

CLAUDE.md
Old New
@@ -1,12 +1,17 @@
1 # mux 1 # mux
2 2
3 Terminal multiplexer: ghostty-vt engine runs authoritatively in `mux d`, replicated 3 Terminal multiplexer: ghostty-vt engine runs authoritatively in `mux d`, replicated
4 in the client. Attach = one snapshot, then row deltas. Zig, Linux only, prototype. 4 in the client. Attach = one snapshot, then row deltas. Zig; Linux and
5 Apple-silicon macOS; prototype.
5 6
6 ## Toolchain (pinned — system zig will NOT build this) 7 ## Toolchain (pinned — system zig will NOT build this)
7 8
8 ```sh 9 ```sh
9 ZIG=deps/zig/zig # vendored 0.15.2 (ghostty pin); copy the toolchain here, gitignored 10 ZIG=deps/zig/zig # vendored 0.15.2 (ghostty pin); copy the toolchain here, gitignored
11 # on a Mac that is the aarch64 0.15.2 tarball, in the same place
12 make mac-sdk # Darwin only, and `make build` runs it: a shadow SDK
13 # under deps/mac-sdk, because zig 0.15.2 cannot link
14 # the stub Xcode 26.4+ ships. No-op elsewhere.
10 make build test e2e # Makefile already points at it 15 make build test e2e # Makefile already points at it
11 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate 16 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate
12 make ci # check + e2e + agent + throughput — the delivery gate 17 make ci # check + e2e + agent + throughput — the delivery gate
@@ -14,6 +19,8 @@ make agent soak bench throughput
14 make vm # real user journeys against the mux-e2e VM (test/vm.sh) 19 make vm # real user journeys against the mux-e2e VM (test/vm.sh)
15 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh) 20 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh)
16 make install # the one user binary -> ~/.local/bin, ReleaseSafe 21 make install # the one user binary -> ~/.local/bin, ReleaseSafe
22 # MUX_TARGET follows the HOST: a Mac installs a Mac
23 # binary, because that is the only one it can link
17 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..) 24 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..)
18 ``` 25 ```
19 26
@@ -22,23 +29,23 @@ First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once).
22 29
23 ## Reading this repo without burning context 30 ## Reading this repo without burning context
24 31
25 Files are large and comment-dense (~36% of Zig bytes are whole `//` lines). Reading the repo 32 Files are large and comment-dense (~37% of Zig bytes are whole `//` lines). Reading the repo
26 costs ~800k tokens; every token stays in context and is re-billed each turn. 33 costs ~800k tokens; every token stays in context and is re-billed each turn.
27 34
28 - **Never `cat` these:** `src/server/server.zig` (3.2k lines, ~31k tok; its tests are 35 - **Never `cat` these:** `src/server/server.zig` (3.3k lines, ~31k tok; its tests are
29 siblings in `src/server/`, none over 1.7k; the agent relay and the 36 siblings in `src/server/`, none over 1.7k; the agent relay and the
30 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`), 37 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`),
31 `src/tui/interact.zig` (4.5k), 38 `src/tui/interact.zig` (4.8k),
32 `docs/decisions.md` (7.8k). `test/e2e.sh` is a 182-line runner now; the 39 `docs/decisions.md` (8.7k). `test/e2e.sh` is a 188-line runner now; the
33 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one). 40 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one).
34 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. 41 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window.
35 `src/client/client.zig` is 3.1k now — cheap enough to read, and so is 42 `src/client/client.zig` is 3.3k now — cheap enough to read, and so is
36 every piece of the wall: `wallview.zig` is 2.4k (tiles, focus, births, 43 every piece of the wall: `wallview.zig` is 2.6k (tiles, focus, births,
37 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's 44 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's
38 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.6k), 45 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.9k),
39 `wall_layout.zig` (0.4k) and its tests in `src/tui/wall_test_*.zig` 46 `wall_layout.zig` (0.5k) and its tests in `src/tui/wall_test_*.zig`
40 (none over 1.4k). 47 (none over 1.4k).
41 Every figure here is `wc -l` on 2026-09-02; re-measure before trusting one. 48 Every figure here is `wc -l` on 2026-09-04; re-measure before trusting one.
42 - Every module has a `//!` header stating its contract. `head -12` on it 49 - Every module has a `//!` header stating its contract. `head -12` on it
43 answers most "what is this" questions for ~200 tokens. 50 answers most "what is this" questions for ~200 tokens.
44 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full 51 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full
@@ -378,6 +385,17 @@ own. Test fixtures in `test/`:
378 is private and `mux d` is `tooLong`'s only caller: it refuses at parse, 385 is private and `mux d` is `tooLong`'s only caller: it refuses at parse,
379 before any fork, on the stderr the asking client lent it. Everyone else 386 before any fork, on the stderr the asking client lent it. Everyone else
380 dials and reads the kernel's `NameTooLong`, which truncates nothing. 387 dials and reads the kernel's `NameTooLong`, which truncates nothing.
388 `sockpath.runtimeDir` is the one place that names the DIRECTORY those
389 paths go in, so the daemon, the client and the askpass listener agree by
390 construction. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
391 every isolated rig keeps its sockets away from the user's. Linux has NO
392 fallback — a guess cannot make two binaries agree on one daemon, so the
393 caller names it with `--sock` — and Darwin falls back to `/tmp/mux-<uid>`,
394 created 0700 and re-checked on every ask for owner, exact mode and no
395 symlink in the last step, as tmux checks `/tmp/tmux-UID`. Not `$TMPDIR`
396 and not `~/Library/Caches`: the longest name mux creates is the directory
397 plus 68 bytes plus the pid's digits, and neither of those directories
398 leaves room for it in 103 (measured 2026-09-03, docs/decisions.md).
381 - No socket stealing: `mux d start` refuses a path another daemon owns. 399 - No socket stealing: `mux d start` refuses a path another daemon owns.
382 400
383 ## Working rules 401 ## Working rules
@@ -393,6 +411,21 @@ own. Test fixtures in `test/`:
393 names aliased their neighbour's because every test held ONE session; a tile 411 names aliased their neighbour's because every test held ONE session; a tile
394 painted over the rail because `col_off` was 0 in every test body; the 412 painted over the rail because `col_off` was 0 in every test body; the
395 page's own rules went unpinned because no check executed the page. 413 page's own rules went unpinned because no check executed the page.
414 - **A harness assumption is Linux-only until it has run on both OSes.** The
415 shell scripts are the part of this repo with no compiler behind them, and
416 the e2e suite's first run on macOS was sixteen groups and ten of these.
417 Ask questions through `test/os_oracle.sh` and add an arm rather than a
418 branch in a group file. Three shapes bit hardest and are worth knowing by
419 name. A backtick inside an UNQUOTED heredoc is a command substitution
420 wherever it stands, comments included, and macOS ships programs a Linux
421 box does not — a comment that quoted `expect` started `/usr/bin/expect`,
422 which read the rest of the heredoc as its own script and hung the group
423 for its whole budget. `$TMPDIR` on macOS is a per-user directory that
424 ends in `/` and is nearly fifty characters long, so a path spelled from
425 it fails a string compare against the same path normalised, and does not
426 fit in a label bar. And BSD's `ps` and `wc` answer in a different shape
427 from GNU's — a full path where Linux gives a basename, a padded count
428 where Linux gives a bare number.
396 - **Ask the OS about the OS, not the daemon.** A claim about a pid, an fd, a 429 - **Ask the OS about the OS, not the daemon.** A claim about a pid, an fd, a
397 socket or a process tree is asserted against `/proc`, `ps`, or the shell's 430 socket or a process tree is asserted against `/proc`, `ps`, or the shell's
398 own `$$`. A daemon reporting on itself cannot catch itself being wrong — 431 own `$$`. A daemon reporting on itself cannot catch itself being wrong —
@@ -448,7 +481,7 @@ own. Test fixtures in `test/`:
448 ## Where the answers live 481 ## Where the answers live
449 482
450 `docs/roadmap.md` (~660 ln) the ranked queue, but stale past 2026-08-16 — 483 `docs/roadmap.md` (~660 ln) the ranked queue, but stale past 2026-08-16 —
451 `git-collab issue list` is the live order · `docs/decisions.md` (7.8k ln, grep 484 `git-collab issue list` is the live order · `docs/decisions.md` (8.7k ln, grep
452 only) every decision + measurement · `README.md` user-facing usage. Design 485 only) every decision + measurement · `README.md` user-facing usage. Design
453 history (specs, plans, the original handoff) lives in git: `git log -- docs/superpowers`. 486 history (specs, plans, the original handoff) lives in git: `git log -- docs/superpowers`.
454 487
README.md
Old New
@@ -3,8 +3,8 @@
3 Terminal multiplexer where detach/reattach is **state sync, not replay**: 3 Terminal multiplexer where detach/reattach is **state sync, not replay**:
4 the terminal engine (ghostty-vt) runs authoritatively in a daemon (`mux d`) 4 the terminal engine (ghostty-vt) runs authoritatively in a daemon (`mux d`)
5 and replicated in the client (`mux`). Attach costs one snapshot; everything 5 and replicated in the client (`mux`). Attach costs one snapshot; everything
6 after is row deltas; a dropped connection is a non-event. Linux only, 6 after is row deltas; a dropped connection is a non-event. Linux and
7 prototype (see [Status](#status)). 7 Apple-silicon macOS, prototype (see [Status](#status)).
8 8
9 ## Build 9 ## Build
10 10
@@ -35,6 +35,10 @@ make release
35 ssh HOST 'mkdir -p ~/.local/bin && tar xzf - -C ~/.local/bin' < dist/mux-vVERSION-x86_64-linux-musl.tar.gz 35 ssh HOST 'mkdir -p ~/.local/bin && tar xzf - -C ~/.local/bin' < dist/mux-vVERSION-x86_64-linux-musl.tar.gz
36 ``` 36 ```
37 37
38 On an Apple-silicon Mac the same build and install commands work, with two
39 things to install first and one binary you cannot cross-compile — see
40 [macOS](#macos).
41
38 ## Quick start, local 42 ## Quick start, local
39 43
40 ```sh 44 ```sh
@@ -707,7 +711,48 @@ whatever you last copied.
707 711
708 ## macOS 712 ## macOS
709 713
710 mux builds and runs on an Apple-silicon Mac. The gate for it is `make mac`, 714 mux builds and runs on an Apple-silicon Mac — `aarch64` only. There is no
715 Intel build, no universal binary, and no way to cross-compile the Mac
716 binary from Linux, so a Mac binary is made on a Mac.
717
718 You need Xcode's Command Line Tools for the SDK and `cmake` for the QUIC
719 dependencies:
720
721 ```sh
722 xcode-select --install
723 brew install cmake
724 make build # first build fetches + compiles the QUIC deps, as on Linux
725 make install # an aarch64 binary in ~/.local/bin
726 ```
727
728 `make build` runs `deps/mac-sdk.sh` first, which may build a shadow SDK
729 under `deps/mac-sdk/`. Xcode 26.4 and later ship a `libSystem` stub that
730 lists only the `arm64e` slice, and the pinned Zig 0.15.2 matches the bare
731 `arm64` one, so against that SDK every system symbol comes out undefined
732 and the build cannot start. The shadow is the real SDK by symlink
733 everywhere except that one stub, where Zig's own copy stands in, plus an
734 `xcrun` on PATH that points at it. It costs a few seconds once, retires
735 itself when a future Xcode lists `arm64` again, and needs nothing of you.
736
737 One runtime difference worth knowing. The daemon's default socket lives in
738 `$XDG_RUNTIME_DIR` when that is set, and macOS does not set it, so mux
739 falls back to `/tmp/mux-<uid>` — created 0700 and re-checked for owner and
740 mode on every use, the way tmux uses `/tmp/tmux-UID`. The macOS `$TMPDIR`
741 is not used for this: the longest socket name mux creates would not fit in
742 the 103 bytes a unix socket path gets.
743
744 `make test` passes on a Mac. `make e2e` has two groups that do not, both
745 deliberately and both for the fallback above. A leg in the select group
746 requires `mux d stats` and a bare `mux` to REFUSE when
747 `$XDG_RUNTIME_DIR` is unset, and a leg in the askpass group requires a
748 wall in that state to start no prompt socket at all. On macOS there is
749 always a runtime directory, so mux answers instead of refusing and the
750 wall arms its socket, which is the fallback working. The suite is linear,
751 so a `make e2e` on a Mac stops at the first of the two. The other gates —
752 `make agent`, `make soak`, `make throughput`, `make xversion` — have not
753 been run on a Mac at all.
754
755 The gate for macOS is `make mac`,
711 which is the macOS half of what `make vm` is for Linux: it pushes this 756 which is the macOS half of what `make vm` is for Linux: it pushes this
712 commit to the Mac named by `MUX_MAC` (default `squirtle`), builds it there, 757 commit to the Mac named by `MUX_MAC` (default `squirtle`), builds it there,
713 and then runs the user journeys against a *pristine* macOS virtual machine 758 and then runs the user journeys against a *pristine* macOS virtual machine
@@ -755,7 +800,8 @@ issue list` (`docs/roadmap.md` is the older ranked queue).
755 800
756 ## Status 801 ## Status
757 802
758 Prototype, eighteen milestones in, current cut tagged `v0.0.1-15`. All 803 Prototype, eighteen milestones in, current cut tagged `v0.0.1-15`. Linux
804 and Apple-silicon macOS; every gate below was measured on Linux. All
759 founding kill criteria cleared, measured on real networks rather than 805 founding kill criteria cleared, measured on real networks rather than
760 loopback: reattach into a live full-screen `nvim` in ~5ms from one 806 loopback: reattach into a live full-screen `nvim` in ~5ms from one
761 snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the 807 snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the
build.zig
Old New
@@ -191,10 +191,11 @@ const mod_table = [_]ModSpec{
191 // and askpass's prompt socket all take their listener from here, so the 191 // and askpass's prompt socket all take their listener from here, so the
192 // guarded unlink is written once instead of three times. 192 // guarded unlink is written once instead of three times.
193 .{ .name = "serve", .path = "src/serve.zig", .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} }, 193 .{ .name = "serve", .path = "src/serve.zig", .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
194 // No imports that teach it anything, deliberately: the proxy is a byte 194 // Nothing that teaches it the protocol, deliberately: the proxy is a
195 // pump that knows nothing about the protocol it carries. `testtmp` is 195 // byte pump that knows nothing about what it carries. Its two imports
196 // the one exception and does not weaken that — it hands its tests a 196 // are both about the PATH and never the bytes — `sockpath` for the
197 // short directory to put a socket in and knows nothing about the bytes. 197 // socket name it dials, `testtmp` for a short directory its tests can
198 // put one in.
198 .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} }, 199 .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
199 // Reflection over a caller's options struct, so it imports nothing: the 200 // Reflection over a caller's options struct, so it imports nothing: the
200 // struct is the flag table and the parser learns it at comptime. 201 // struct is the flag table and the parser learns it at comptime.
docs/decisions.md
Old New
@@ -8319,10 +8319,13 @@ Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.
8319 8319
8320 ## 2026-09-03 — the daemon's Darwin arm (macOS port, step 3) 8320 ## 2026-09-03 — the daemon's Darwin arm (macOS port, step 3)
8321 8321
8322 `server_os_macos.zig` fills the server root's `.macos` arm. Eleven of the 8322 `server_os_macos.zig` fills the server root's `.macos` arm. Most of the
8323 twelve operations are a spelling change and nothing else. Three are a 8323 operations are a spelling change and nothing else. FOUR are a different
8324 different mechanism, because the Linux one does not exist on Darwin, and 8324 mechanism, because the Linux one does not exist on Darwin, and one of the
8325 one of the three is a trap that a straight translation walks into. 8325 four is a trap that a straight translation walks into. They are the four
8326 below: `sendNoSigNoWait`, `closeFrom`, `anonFd` and `peerCred`. The
8327 `forkDetached` pin at the end is a fifth difference of the same kind, in
8328 the test rather than in the arm.
8326 8329
8327 **`sendNoSigNoWait` sets `SO_NOSIGPIPE`, and a REFUSED set is the answer.** 8330 **`sendNoSigNoWait` sets `SO_NOSIGPIPE`, and a REFUSED set is the answer.**
8328 Darwin has no `MSG_NOSIGNAL`: "do not raise SIGPIPE" is a property of the 8331 Darwin has no `MSG_NOSIGNAL`: "do not raise SIGPIPE" is a property of the
@@ -8342,7 +8345,11 @@ name, value and length are all fixed in the source, so nothing else about
8342 the arguments can be invalid) and returned as `error.BrokenPipe`, which is 8345 the arguments can be invalid) and returned as `error.BrokenPipe`, which is
8343 what `send` would have answered had it not signalled first. Every other 8346 what `send` would have answered had it not signalled first. Every other
8344 setsockopt failure describes a socket that cannot raise SIGPIPE either, so 8347 setsockopt failure describes a socket that cannot raise SIGPIPE either, so
8345 those fall through and let `send` name them. 8348 those fall through and let `send` name them. The more robust design is to
8349 arm `SO_NOSIGPIPE` once at fd BIRTH, which would need a new root operation
8350 at every socket and accept site; it is deferred as a follow-on, and the
8351 cost of not doing it is one misread errno if Darwin ever grows another
8352 EINVAL path on this call.
8346 8353
8347 That test now has two legs, because a socket that never carried a byte and 8354 That test now has two legs, because a socket that never carried a byte and
8348 one that lost its peer mid-stream are different states to the kernel and 8355 one that lost its peer mid-stream are different states to the kernel and
@@ -8354,16 +8361,19 @@ only works after a successful send.
8354 one close per slot from the floor to `getdtablesize()`, which is the soft 8361 one close per slot from the floor to `getdtablesize()`, which is the soft
8355 `RLIMIT_NOFILE` and therefore also the ceiling on any fd this process could 8362 `RLIMIT_NOFILE` and therefore also the ceiling on any fd this process could
8356 be holding. A few hundred cheap EBADFs once per session start, between fork 8363 be holding. A few hundred cheap EBADFs once per session start, between fork
8357 and exec, so nothing is opening fds underneath the walk. 8364 and exec, so nothing is opening fds underneath the walk. The cost scales
8365 with that soft limit rather than with the fds actually held, so a box that
8366 raises `ulimit -n` to a large number pays for the raise here.
8358 8367
8359 **`anonFd` is an unlinked `mkstemp` file.** No `memfd_create`. A 0600 file 8368 **`anonFd` is an unlinked `mkstemp` file.** No `memfd_create`. A 0600 file
8360 this uid creates and unlinks before anyone could open it by name is private 8369 this uid creates and unlinks before anyone could open it by name is private
8361 by mode where memfd is private by having no name; the window is those two 8370 by mode where memfd is private by having no name; the window is those two
8362 calls, on an empty file. `/tmp` rather than the runtime directory because 8371 calls, on an empty file. `/tmp` rather than the runtime directory because
8363 `src/os/` imports nothing of ours and must not learn the socket directory. 8372 `src/os/` imports nothing of ours and must not learn the socket directory.
8364 `mkstemp` opens CLOEXEC on modern Darwin and the carrier must survive 8373 `mkstemp` is asserted to open CLOEXEC on modern Darwin and the carrier must
8365 `mux d upgrade`'s exec, so the flag comes back off before the fd is 8374 survive `mux d upgrade`'s exec, so the flag is cleared before the fd is
8366 returned. The root's existing pin — nlink 0, not CLOEXEC, readable and 8375 returned. That assertion was never measured and does not need to be: the
8376 clear is harmless on an fd that never had the flag. The root's existing pin — nlink 0, not CLOEXEC, readable and
8367 writable — passes on the box unchanged. 8377 writable — passes on the box unchanged.
8368 8378
8369 **`peerCred` needs two calls where Linux needs one.** Darwin's 8379 **`peerCred` needs two calls where Linux needs one.** Darwin's
@@ -8413,3 +8423,310 @@ still cannot link there until the client arm exists; `make build` on the box
8413 stops at the client root's `@compileError` and nothing else. 8423 stops at the client root's `@compileError` and nothing else.
8414 8424
8415 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`. 8425 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.
8426
8427 ## 2026-09-03 — macOS port step 3: the Darwin arm
8428
8429 The client arm, the toolchain, the two gates and the verdict. Written on
8430 2026-09-04, over work that ran 2026-09-03 and 2026-09-04. The daemon's own
8431 arm is the entry above; this one is everything around it and the grade.
8432
8433 **The build host cannot be Linux, and the Mac needs a shadow SDK.** Xcode
8434 26.4 and later ship a `libSystem.B.tbd` whose `targets:` line lists
8435 `arm64e-macos` and no `arm64-macos`. zig 0.15.2's Mach-O linker matches the
8436 bare `arm64` slice only, so against that SDK every libSystem symbol is
8437 undefined — for the build runner too, which is a native link, so `zig build`
8438 cannot even start (ziglang/zig#31658; fixed in 0.16, which ghostty's pin
8439 cannot use). `deps/mac-sdk.sh` builds a shadow SDK that is the real one by
8440 symlink in every path except `usr/lib/libSystem{,.B}.tbd`, where zig's own
8441 bundled stub stands in, plus an `xcrun` shim on PATH that answers
8442 `--show-sdk-path` with it — zig finds the SDK by running `xcrun`
8443 (`std/zig/system/darwin.zig`) and so does ghostty's `apple_sdk` helper. It
8444 is self-retiring: when the real stub lists `arm64-macos` again the script
8445 builds nothing and a stale shim passes straight through. With it the whole
8446 `zig build` compiles ghostty and its three C++ dependencies natively, and
8447 the QUIC stack builds in about a minute once the dep script's `native` word
8448 stopped assuming an x86 host. Cross-compiling the Mac binary from Linux is
8449 still not possible: the QUIC prefix cross-builds, but the link needs the
8450 Mac SDK, and there is no shadow of it on a Linux box.
8451
8452 **The pty master answers the Linux shape.** `tcgetattr`, `TIOCGPGRP`,
8453 `TIOCSWINSZ` and `TIOCGWINSZ` on the master all returned 0 against a forked
8454 child holding the slave as its controlling tty, so the Linux arm's shape is
8455 reused unchanged: nothing reopens the slave by name and `forkPty` records
8456 only the master and the pid.
8457
8458 **The default socket directory is `/tmp/mux-<uid>`, and the measurement is
8459 why.** The longest path mux creates is
8460 `<dir>/mux-agent-<pid>-<12 hex>/agent-<session>.sock`, which is the
8461 directory plus 68 bytes plus the pid's digits at `session_name_max` 32,
8462 against 103 usable bytes of `sun_path`. On the test Mac `$TMPDIR` is 48
8463 bytes (121 needed), `~/Library/Caches/mux` 34 (107) and
8464 `~/.local/state/mux/run` 36 (109): none of the three spec candidates fits.
8465 `/tmp/mux-501` is 12 (85). So `sockpath.runtimeDir` takes
8466 `$XDG_RUNTIME_DIR` when it is set — every isolated rig sets it, on both
8467 OSes — and otherwise `/tmp/mux-<uid>`, created 0700 and re-checked on every
8468 ask for owner, exact mode and no symlink in the last step. `/tmp` is sticky
8469 and world-writable, so the per-uid directory is what carries the privacy,
8470 as tmux's `/tmp/tmux-UID` does; the check here is the stricter of the two,
8471 because tmux asks only that no other-user bit is set and this asks for
8472 exactly 0700. Linux keeps NO fallback: a guess cannot make two binaries
8473 agree on one daemon, so the caller names it with `--sock`.
8474
8475 **Four operations are a different mechanism, not a different spelling.**
8476 `closeFrom` walks the fd table one close at a time from the floor to
8477 `getdtablesize()`, because Darwin has no `close_range`; that is a few
8478 hundred cheap EBADFs once per session start, between fork and exec, so
8479 nothing is opening fds underneath the walk. `anonFd` is an unlinked
8480 `mkstemp` file in `/tmp` at mode 0600, because there is no `memfd_create`:
8481 private by mode where memfd is private by having no name, for the width of
8482 two syscalls on an empty file. That is a real change of medium — the
8483 upgrade manifest's key bytes touch a disk that memfd never did — and it is
8484 accepted because the file is 0600, unlinked before anyone could open it by
8485 name, empty until written, and on a FileVault volume by default; `shm_open`
8486 was the alternative and Darwin's does not support read/write, so the
8487 manifest writer would have had to mmap. `sendNoSig` sets `SO_NOSIGPIPE` per
8488 call because Darwin has no `MSG_NOSIGNAL` and "do not raise SIGPIPE" is a
8489 property of the socket rather than the send, and a REFUSED set is the
8490 answer: Darwin's `sosetopt` rejects every option with EINVAL once a socket
8491 is shut down both ways, which is exactly the hung-up state, so the arm
8492 reads that EINVAL as the peer being gone and returns `error.BrokenPipe`.
8493 Fourth, the `forkDetached` test asks `getsid(2)` rather than `ps`: macOS's
8494 `ps` has no `sid` column and its `sess` column is a kernel address that
8495 reads 0 for anyone but root, so the natural port of the Linux pin compared
8496 0 against 0 and passed whatever the child did.
8497
8498 **A Darwin panic the gate found, and the shape of its fix.** Attaching a
8499 Mac client to a Linux daemon crashed with `attempt to unwrap error:
8500 SocketNotConnected` in `client_os_macos.sendNoSig`. Darwin answers ENOTCONN
8501 on a send to a peer that is mid-close, and `std.posix.send` lists
8502 `SocketNotConnected` among the errors it maps to `unreachable`. Both Darwin
8503 arms call `sendto` instead — which returns that error rather than panicking
8504 on it — and map it to `BrokenPipe`, which is what both callers already
8505 handle and what `send` would have answered had it not panicked first. There
8506 is no test. The window is a race, and a deterministic pin would need the
8507 Linux arms to map ENOTCONN too, which this branch does not change: "the
8508 Linux outcome does not change" was a constraint on the whole port. A
8509 root-owned error map, written once above both arms, is the shape if that is
8510 ever done; today the switch is duplicated verbatim in the two arms, because
8511 a shared file would need a third module row and would break the leaf
8512 property the platform layer's rows have.
8513
8514 **Two fixes in the daemon's own suite, made when it first ran on a Mac.**
8515 The hangup-upgrade refusal test raced the stubborn shell's `trap`: on macOS
8516 `/bin/sh` execs bash 3.2, which takes 12 to 21 ms to arm a trap where dash
8517 takes 2 or less, so a fixed sleep that held on Linux did not hold there. The
8518 FIXTURE owns readiness now — `TestDaemon.startStubborn` and
8519 `attachStubborn` block on a per-pid marker the shell writes once its trap is
8520 armed — and five call sites each became one line. Separately the
8521 repeated-end test grew a duration pin: it times the accepted end to the
8522 shell's death on `std.time.Timer` and requires at least `Pty.term_grace_ms`
8523 and less than three times it, measured at 661 ms on Linux and 668 to 682 ms
8524 on macOS against a 500 ms grace. Both were graded by mutation on Linux
8525 before the Mac run.
8526
8527 **The shell harness asks a bare Mac, so its Darwin arm is perl.**
8528 `test/os_oracle.sh` gained a `Darwin` case beside the existing one: one
8529 spelling per question a pin asks, so a group file names the question and
8530 never the OS. Three of those spellings are perl rather than a Homebrew
8531 binary, and the reason is what the `make mac` gate runs on — a PRISTINE
8532 macOS guest with no developer tooling, where nothing `g`-prefixed exists.
8533 `real_path` is `Cwd::realpath` with a hand-written fallback, because
8534 `Cwd::realpath` is not the same function on every perl: on 5.42 with Cwd
8535 3.94 it hands back the spelling of a directory that is not there, where
8536 `readlink -f` answers nothing with rc 1, so the arm resolves the parent
8537 itself and `oracle_selftest` pins both branches. `now_ms` is
8538 `Time::HiRes`, not `/bin/date`: macOS 26's date answers `%N` and older ones
8539 do not, so spelling the system date would make the suite's clock depend on
8540 how new the OS is, and the failure on an older one is a literal `N` inside
8541 an arithmetic expansion partway through a run. It costs 4.5 ms a call
8542 against gdate's 2.1 and python3's 21.6 (measured 2026-09-03), which is
8543 inside the noise of every bracket in the e2e, whose tightest budget is
8544 1500 ms, and NOT inside the throughput gate's 10 ms ceiling around a 6 ms
8545 leg — a `make throughput` on a Mac will need `SOLO_MAX_MS` raised.
8546
8547 `timeout` is the third. GNU's binary is used when it is on PATH,
8548 Homebrew's `gtimeout` next, and a perl `alarm` wrapper last, so a group
8549 file keeps spelling `timeout` through all three. The perl arm answers
8550 GNU's exit contract for the five cases this harness asks — 0, 124 for the
8551 budget, the child's own status, 127 for a missing command, 128+N for a
8552 signal — and `oracle_selftest` pins all five. It differs from GNU in three
8553 measured ways, none of which a caller here can reach: a command that
8554 exists but is not executable answers 127 where GNU answers 126; a signal
8555 sent to the WRAPPER is not relayed to the child; and the child is not put
8556 in a process group, so GNU's kill-the-group on expiry becomes
8557 kill-the-child and a grandchild outlives the budget.
8558
8559 **The two gates.** `make mac` (`test/mac.sh`) drives eight legs on a tart
8560 clone of the pristine `mux-mac-base` guest, boots it in under twenty
8561 seconds (17.6 s on the run the ledger records) and
8562 has run green twice unaided from a Linux box. `make xos` (`test/xos.sh`) is
8563 the cross-OS gate: ten legs proving a Mac client against a Linux daemon and
8564 a Linux client against a Mac daemon, over both the ssh handoff and QUIC,
8565 plus the cross-arch upgrade refusal in both directions. It has run green
8566 three times from Linux — 21.6 s, 20.8 s, and 1:12 with a Mac recompile in
8567 it. The Linux side is the VM `mux-lan`, an x86_64 Ubuntu box; macOS
8568 delivered inbound UDP on 4433 to a daemon started from a non-GUI ssh
8569 session, so QUIC INTO a Mac works and needed no firewall exception.
8570
8571 **Two bugs `make xos` surfaced that predate this branch.** First,
8572 `mux d endpoint` announces the key it resolved from the DEFAULT path and
8573 never the listener's `--key`, so a daemon started as
8574 `mux d start -d --quic ADDR --key ELSEWHERE` loses every ssh handoff. It is
8575 not Darwin's and it is not fixed here; the gate uses the default key on the
8576 Linux box and the bug is filed. Second, `parsePreflight` compared `uname -m`
8577 verbatim against the zig target tag, so a Mac reporting `arm64` refused to
8578 upgrade itself against an `aarch64` image. That one IS fixed on this branch,
8579 because a port whose Mac cannot upgrade itself is not a port; the map is
8580 exact and a unit test is the only pin, since one Mac cannot host a
8581 Mac-to-Mac run.
8582
8583 **A lesson about the fixture itself.** Two agents sharing the Mac's single
8584 checkout raced each other's `git checkout` in the middle of a gate run, and
8585 the run graded a tree that was not the one it was asked about. The gate
8586 re-reads the tip after every run now. One box with one working copy is a
8587 shared mutable resource and has to be treated as one.
8588
8589 **`make e2e` on the Mac: the verdict.** Sixteen groups, run one at a time
8590 with `E2E_ONLY` and once as the whole suite, on macOS 26.6.2 (Apple M1) with
8591 Homebrew's coreutils present.
8592
8593 | group | alone | seconds | in the whole suite |
8594 |---|---|---|---|
8595 | 01_boot | rc 0 | 34 | pass |
8596 | 02_predict | rc 0 | 53 | pass |
8597 | 03_side | cannot run alone | — | pass |
8598 | 04_handoff | rc 0 | 17 | pass |
8599 | 05_session | rc 0 | 8 | pass |
8600 | 06_web | rc 0 | 21 | pass |
8601 | 07_wallcli | rc 0 | 30 | pass |
8602 | 08_mouse | rc 0 | 22 | pass |
8603 | 09_hosts | rc 0 | 87 | pass |
8604 | 10_agent | rc 0 | 15 | pass |
8605 | 11_select | cannot run alone | — | RED, by design |
8606 | 12_panes | rc 0 | 59 | not reached |
8607 | 13_birth | rc 0 | 37 | not reached |
8608 | 14_upgrade | rc 0 | 10 | not reached |
8609 | 15_askpass | rc 2 | 31 | RED, by design |
8610 | 16_push | rc 0 | 4 | not reached |
8611
8612 The whole-suite column stops at `11_select` because the suite is linear
8613 and `set -e`: the groups after it are graded by their standalone runs
8614 only. The suite reached 78 of its 113 scenarios before that leg.
8615
8616 **Not one of the failures was a defect in mux.** Ten Linux-only
8617 assumptions in the HARNESS accounted for all of them, and every fix was
8618 made and re-run green on both OSes:
8619
8620 - `proxy_pid` matched a `comm` column. BSD ps prints comm as the
8621 executable's full path where Linux prints the basename, and truncates it
8622 to the column width in a multi-column format, so no proxy was ever found
8623 and every scenario that tears a transport failed. It reads `args` now.
8624 - The handoff shim compared a shebang script's process name to `ssh`.
8625 Linux names such a process after the SCRIPT and Darwin after the
8626 INTERPRETER, so `ssh` and `sh` are both correct and the word says nothing
8627 about the product. Only the parent word, `mux`, is pinned now.
8628 - Three counter checks compared `wc -l` to a string. BSD wc pads its count
8629 to a column width, so " 1" was not "1".
8630 - The scenario log stamped `date +%s.%N`, and BSD date has no `%N`. It
8631 takes the oracle's `now_ms`. This one had no witness: the log is opt-in.
8632 - Every temporary path came out with `//` in it, because macOS sets
8633 `$TMPDIR` to a per-user directory ending in a slash, and two groups
8634 compare such a path as a string. The slash is stripped once, before the
8635 first of the 191 call sites that spell it.
8636 - `pid_args` answered a different SHAPE on each arm: Linux reads a
8637 NUL-terminated `/proc` entry and left a trailing space that
8638 `ps -o args=` does not. The one caller comparing a whole argv with `=`
8639 had to spell a space it could only have learned from Linux.
8640 `oracle_selftest` pins the shape now, which is the right place for it: a
8641 helper the two OSes answer differently makes every pin that reads it
8642 agree with anything.
8643 - Seven assertions across two groups grep a socket path out of a tile's
8644 LABEL BAR. A bar is as wide as its tile and `labelText` keeps the state
8645 word and cuts the label's TAIL, which is the half carrying the session
8646 name — and the macOS temporary directory's name alone was 48 characters
8647 on the test Mac, so an 80-column bar had none of it left. Those daemons'
8648 sockets are spelled under `/tmp` now, which is where a Linux run with no
8649 `$TMPDIR` has always put them, and the leak sweep reads both directories
8650 when they differ so the paths that moved are still swept.
8651 - The pager leg spelled the row numbers `less` leaves on screen. Attaching
8652 makes less repaint, and where the repaint puts the top of an at-the-end
8653 view depends on how many of the 24 rows the build spends on its status
8654 line: less 704 leaves it at 178 and less 668 at 179, three runs each. So
8655 the leg was off by one row on the Mac while the wheel worked perfectly.
8656 The leg READS the anchor now. A short attach waits for the paint and
8657 detaches, which leaves the daemon's grid holding the repainted view; the
8658 dump after it is the top row the wheel is about to move, taken while
8659 nothing is attached and nothing can change it, and the assertion is row
8660 anchor-24 present and row anchor gone. That is exactly 24 rows on both
8661 builds and it can still fail on 23, which matters: a leg that accepted
8662 154 OR 155 — which is what was delivered first — could no longer fail on
8663 a wheel that moved 23 rows, and 23 is what one lost notch looks like.
8664 - One symptom on this leg looked like a lost input byte and is not mux's.
8665 An earlier attempt anchored the view by TYPING `178g` into the pager
8666 after the attach; on macOS the leading `1` never arrived and less went to
8667 line 78, three runs out of three, while Linux went to 178 three out of
8668 three. Two probes settled where it goes. A session running `cat`, which
8669 echoes exactly what reaches its pty, received a first send of `abcdef`
8670 intact on both OSes, three runs each — so mux delivers the byte. And a
8671 `\x0c` sent first, with `178g` as the SECOND send, reached less whole on
8672 the Mac, three out of three. So whatever discards it is on the far side
8673 of the pty and not an input byte mux drops — by elimination, since less's
8674 own source was not read; a `TCSAFLUSH` on re-entering raw mode is what
8675 behaves this way and is the guess. Nothing in the product changes and the
8676 leg no longer types into the pager at all.
8677 Recorded because the shape — one byte, first write, one OS — is exactly
8678 what a real input bug would look like, and the next person to see it
8679 should know these two probes exist.
8680 - The agent-directory reap was read straight off a bound socket. The reap
8681 is not a boot step — it happens inside the successor daemon's first
8682 `makeDir`, when its first session is born — so the check raced the thing
8683 it asserted, and lost on a Mac where Linux had always won.
8684 - A backtick in an UNQUOTED heredoc is a command substitution wherever it
8685 appears, comments included, and three ptyclient scripts quoted words
8686 that way in their prose. On Linux the words are "command not found" and
8687 the run carries on none the wiser. On macOS `/usr/bin/expect` exists, so
8688 the shell started it, it read the rest of the heredoc as its own script,
8689 and the group hung for its entire 600 s budget. This one cost four gate
8690 runs, not one. The first sweep found two sites and MISSED the third,
8691 because its detector required the heredoc's delimiter to end the line and
8692 that opener has a trailing space after it; the replacement comment
8693 written for the third site had a backtick in it too. The rule is written
8694 above each of the three heredocs, which is all that gates it. A lint
8695 would be better and it is small — match the opener anywhere on the line,
8696 skip a quoted delimiter, and refuse a backtick in the body.
8697
8698 **Two groups stay RED, and both are red BY DESIGN — the same design.**
8699 `sockpath.runtimeDir` has no fallback on Linux and falls back to
8700 `/tmp/mux-<uid>` on Darwin, so every leg that pins what mux does with
8701 `$XDG_RUNTIME_DIR` unset is pinning a state macOS never reaches.
8702
8703 `11_select` has a leg that unsets it and requires `mux d stats` and bare
8704 `mux` to REFUSE, naming the variable. On Darwin they resolve the fallback
8705 and report what is or is not listening on `/tmp/mux-501/muxd.sock`, which
8706 is the arm working. `15_askpass`'s last leg unsets it and requires a wall
8707 to start no prompt listener, so ssh keeps its own prompts. On Darwin the
8708 wall arms the socket — the dial log shows it being handed over — the popup
8709 opens, it eats the detach key and the leg times out.
8710
8711 Neither was patched, because a leg that asserts the Linux rule is not
8712 wrong; it needs a Darwin half that asserts the fallback, and writing those
8713 is not this branch's work. One consequence is worth stating plainly:
8714 `11_select` stops at that leg, so the scenarios AFTER it in that group have
8715 never run on macOS at all. Everything in every other group has, both alone
8716 and in sequence.
8717
8718 **Not done, and not claimed.** There is no x86_64-macos build: the arm is
8719 written for `aarch64-macos` and nothing has compiled or run on an Intel Mac.
8720 There is no universal binary. The Mac binary cannot be cross-compiled from
8721 Linux, for the SDK reason above, so `make install` on a Mac names the native
8722 target — the only one that builds there — and a Mac release is cut on a Mac.
8723
8724 And only `make test` and `make e2e` have run there. `make agent`, `make
8725 soak`, `make throughput` and `make xversion` have never been run on a Mac,
8726 so nothing is known about them beyond that they pass on Linux.
8727 `test/agent.sh` in particular still spells `${TMPDIR:-/tmp}` the way the
8728 e2e suite did before this branch and defines its own `now_ms` through
8729 `python3` instead of taking the oracle's, so the first Mac run of it should
8730 be expected to find the same class of thing the e2e suite just did.
8731
8732 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.