a73x

5ba8bafa

Merge branch macos-port-step3: the Darwin arms, and mux runs on a Mac (macOS port, step 3)

a73x   2026-09-04 13:14

Commit message
Merge branch macos-port-step3: the Darwin arms, and mux runs on a Mac (macOS port, step 3)

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

.gitignore
Old New
@@ -18,3 +18,6 @@ dist/
18 deps/zig 18 deps/zig
19 deps/quic/out 19 deps/quic/out
20 deps/quic/work 20 deps/quic/work
21
22 # Shadow SDK from deps/mac-sdk.sh, Darwin only.
23 deps/mac-sdk
CLAUDE.md
Old New
@@ -1,18 +1,36 @@
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
13 make agent soak bench throughput 18 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)
20 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh).
21 # Both take their boxes BY NAME and have no default
22 # for one: MAC_BOX (macOS, no toolchain, SCRUBBED),
23 # MAC_BUILDER (a Mac with zig + deps/mac-sdk, builds
24 # only, never scrubbed, defaults to $MAC_BOX), and
25 # for xos also LINUX_BOX (x86_64 Linux VM, scrubbed).
26 make provision-mac # clone, boot and key a macOS guest on a tart host and
27 # print one line; `eval "$(test/provision-mac.sh)"`
28 # is how MAC_BOX gets set. --down takes it away.
15 make install # the one user binary -> ~/.local/bin, ReleaseSafe 29 make install # the one user binary -> ~/.local/bin, ReleaseSafe
30 # MUX_TARGET follows the HOST: a Mac installs a Mac
31 # binary, because that is the only one it can link
32 make release release-mac # one tarball per OS into dist/, cut on that OS;
33 # release-mac runs `make release` on $MAC_BUILDER
16 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..) 34 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..)
17 ``` 35 ```
18 36
@@ -21,23 +39,23 @@ First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once).
21 39
22 ## Reading this repo without burning context 40 ## Reading this repo without burning context
23 41
24 Files are large and comment-dense (~36% of Zig bytes are whole `//` lines). Reading the repo 42 Files are large and comment-dense (~37% of Zig bytes are whole `//` lines). Reading the repo
25 costs ~800k tokens; every token stays in context and is re-billed each turn. 43 costs ~800k tokens; every token stays in context and is re-billed each turn.
26 44
27 - **Never `cat` these:** `src/server/server.zig` (3.2k lines, ~31k tok; its tests are 45 - **Never `cat` these:** `src/server/server.zig` (3.3k lines, ~31k tok; its tests are
28 siblings in `src/server/`, none over 1.7k; the agent relay and the 46 siblings in `src/server/`, none over 1.8k; the agent relay and the
29 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`), 47 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`),
30 `src/tui/interact.zig` (4.5k), 48 `src/tui/interact.zig` (4.8k),
31 `docs/decisions.md` (7.8k). `test/e2e.sh` is a 182-line runner now; the 49 `docs/decisions.md` (8.8k). `test/e2e.sh` is a 188-line runner now; the
32 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one). 50 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one).
33 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. 51 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window.
34 `src/client/client.zig` is 3.1k now — cheap enough to read, and so is 52 `src/client/client.zig` is 3.3k now — cheap enough to read, and so is
35 every piece of the wall: `wallview.zig` is 2.4k (tiles, focus, births, 53 every piece of the wall: `wallview.zig` is 2.6k (tiles, focus, births,
36 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's 54 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's
37 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.6k), 55 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.9k),
38 `wall_layout.zig` (0.4k) and its tests in `src/tui/wall_test_*.zig` 56 `wall_layout.zig` (0.5k) and its tests in `src/tui/wall_test_*.zig`
39 (none over 1.4k). 57 (none over 1.4k).
40 Every figure here is `wc -l` on 2026-09-02; re-measure before trusting one. 58 Every figure here is `wc -l` on 2026-09-04; re-measure before trusting one.
41 - Every module has a `//!` header stating its contract. `head -12` on it 59 - Every module has a `//!` header stating its contract. `head -12` on it
42 answers most "what is this" questions for ~200 tokens. 60 answers most "what is this" questions for ~200 tokens.
43 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full 61 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full
@@ -60,7 +78,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
60 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 78 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
61 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 79 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
62 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 80 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
63 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` · `client_os`(`client_os.zig`) — `client_os_linux` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 81 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
64 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | 82 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them |
65 83
66 The grouping itself is a convention now, not a gate: the table wires the 84 The grouping itself is a convention now, not a gate: the table wires the
@@ -75,15 +93,15 @@ one: no file under `src/` at all may spell `"/bin/sh"` or `"-c"`, because
75 every program mux runs is exec'd as argv and no shell of ours parses a line 93 every program mux runs is exec'd as argv and no shell of ours parses a line
76 we built; its three markers are `flags.zig`, `main.zig` and 94 we built; its three markers are `flags.zig`, `main.zig` and
77 `server_test_session.zig`, and two of the three cover prose and a fixture 95 `server_test_session.zig`, and two of the three cover prose and a fixture
78 rather than a shell the product runs. Rule 6 is the one `posix.fork` site, 96 rather than a shell the product runs. Rule 6 is the `posix.fork` site, one
79 and it names its file in `except` rather than in the file. The three keep 97 per OS arm, and it names those files in `except` rather than in the files.
80 their numbers because the in-file `folder rule N exemption:` lines cite them 98 The three keep their numbers because the in-file `folder rule N exemption:` lines cite them
81 by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`, 99 by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`,
82 `close_range`, `exit_group`, the `SO_PEERCRED` socket option, the two 100 `close_range`, `exit_group`, the `SO_PEERCRED` socket option, the two
83 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`, 101 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`,
84 comments included — 102 comments included —
85 the roots `server_os.zig` and `client_os.zig` are the contract and their 103 the roots `server_os.zig` and `client_os.zig` are the contract and their
86 `_linux` children the spellings (spec 2026-09-03). Rule 4's three remaining 104 per-OS children the spellings (spec 2026-09-03). Rule 4's three remaining
87 debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each 105 debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each
88 of which produces VT bytes by contract. 106 of which produces VT bytes by contract.
89 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay 107 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay
@@ -305,22 +323,27 @@ own. Test fixtures in `test/`:
305 a daemon on someone else's box that nothing reports. 323 a daemon on someone else's box that nothing reports.
306 - **The daemon starts itself, and it execs THIS image.** `mux d start` IS 324 - **The daemon starts itself, and it execs THIS image.** `mux d start` IS
307 the daemon in the foreground; `-d` is the flag that forks one, and 325 the daemon in the foreground; `-d` is the flag that forks one, and
308 `server_os_linux.forkDetached` is the only `posix.fork` under `src/`, and 326 `forkDetached` is the only `posix.fork` under `src/` — one spelling per OS
309 `main.forkDaemon` is its one caller — build.zig's 327 arm, `server_os_linux.forkDetached` and `server_os_macos.forkDetached` —
310 folder rule 6, whose `except` names the one file. Every other starter 328 and `main.forkDaemon` is its one caller, through the `server_os` root.
311 spells the argv and execs: `endpoint --start` calls in-process, the 329 build.zig's folder rule 6 names those arm files in `except`, so a third
330 file that forks is still caught. Every other starter spells the argv and
331 execs: `endpoint --start` calls in-process, the
312 local client's entry runs `mux d start -d --sock PATH` with its own fd 2 332 local client's entry runs `mux d start -d --sock PATH` with its own fd 2
313 lent to it, so the daemon's refusals reach the user in the daemon's own 333 lent to it, so the daemon's refusals reach the user in the daemon's own
314 words. The fork execs `spawn.selfExe` — the running image, 334 words. The fork execs `spawn.selfExe` — the running image,
315 `/proc/self/exe` read THROUGH to the file it names — with argv 335 `/proc/self/exe` read THROUGH to the file it names on Linux and
336 `std.fs.selfExePath` on Darwin, which has no such link — with argv
316 `mux d start …`. No `execvp`, no PATH walk, so an auto-start can only run 337 `mux d start …`. No `execvp`, no PATH walk, so an auto-start can only run
317 the binary that is already running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon 338 the binary that is already running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon
318 had died graded an installed v0.0.1-10 with no agent code in it.) The 339 had died graded an installed v0.0.1-10 with no agent code in it.) The
319 resolution is not cosmetic: `comm` is the basename of the filename handed 340 resolution is not cosmetic: `comm` is the basename of the filename handed
320 to execve, so exec'ing the link itself names every daemon `exe` and hides 341 to execve, so exec'ing the link itself names every daemon `exe` and hides
321 it from `pgrep mux` and `killall mux`. Both pins are in `e2e_03_side` — 342 it from `pgrep mux` and `killall mux`. Both pins are in `e2e_03_side`, and
322 `readlink /proc/PID/exe` for the image, `/proc/PID/comm` for the name, 343 they ask through `test/os_oracle.sh` (`pid_exe` for the image, `pid_comm`
323 and `/proc/PID/cmdline` for the argv the attach may say and no more. 344 for the name, `pid_args` for the argv the attach may say and no more) —
345 which is `readlink /proc/PID/exe` and the two other `/proc` reads on
346 Linux, and `ps` plus `lsof` on Darwin, where no such directory exists.
324 The detached child's log is APPENDED: one xdg log serves every socket on 347 The detached child's log is APPENDED: one xdg log serves every socket on
325 the box, so truncating would zero a daemon still writing to it. 348 the box, so truncating would zero a daemon still writing to it.
326 Running the daemon IN the fork instead was tried and crashes every Debug 349 Running the daemon IN the fork instead was tried and crashes every Debug
@@ -364,7 +387,8 @@ own. Test fixtures in `test/`:
364 the version rule, manifest and serving check stay daemon-side and are 387 the version rule, manifest and serving check stay daemon-side and are
365 never reimplemented client-side. No daemon on the box is install-only 388 never reimplemented client-side. No daemon on the box is install-only
366 success; no mux on the box is a refusal, because a push replaces an 389 success; no mux on the box is a refusal, because a push replaces an
367 install rather than inventing one. The manifest memfd carries only 390 install rather than inventing one. The manifest carrier (a memfd on Linux,
391 an unlinked `mkstemp` file at 0600 on Darwin) carries only
368 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the 392 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the
369 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback, 393 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback,
370 clients, delta trackers, agent channels and per-connection QUIC state are 394 clients, delta trackers, agent channels and per-connection QUIC state are
@@ -375,6 +399,17 @@ own. Test fixtures in `test/`:
375 is private and `mux d` is `tooLong`'s only caller: it refuses at parse, 399 is private and `mux d` is `tooLong`'s only caller: it refuses at parse,
376 before any fork, on the stderr the asking client lent it. Everyone else 400 before any fork, on the stderr the asking client lent it. Everyone else
377 dials and reads the kernel's `NameTooLong`, which truncates nothing. 401 dials and reads the kernel's `NameTooLong`, which truncates nothing.
402 `sockpath.runtimeDir` is the one place that names the DIRECTORY those
403 paths go in, so the daemon, the client and the askpass listener agree by
404 construction. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
405 every isolated rig keeps its sockets away from the user's. Linux has NO
406 fallback — a guess cannot make two binaries agree on one daemon, so the
407 caller names it with `--sock` — and Darwin falls back to `/tmp/mux-<uid>`,
408 created 0700 and re-checked on every ask for owner, exact mode and no
409 symlink in the last step, as tmux checks `/tmp/tmux-UID`. Not `$TMPDIR`
410 and not `~/Library/Caches`: the longest name mux creates is the directory
411 plus 68 bytes plus the pid's digits, and neither of those directories
412 leaves room for it in 103 (measured 2026-09-03, docs/decisions.md).
378 - No socket stealing: `mux d start` refuses a path another daemon owns. 413 - No socket stealing: `mux d start` refuses a path another daemon owns.
379 414
380 ## Working rules 415 ## Working rules
@@ -390,6 +425,21 @@ own. Test fixtures in `test/`:
390 names aliased their neighbour's because every test held ONE session; a tile 425 names aliased their neighbour's because every test held ONE session; a tile
391 painted over the rail because `col_off` was 0 in every test body; the 426 painted over the rail because `col_off` was 0 in every test body; the
392 page's own rules went unpinned because no check executed the page. 427 page's own rules went unpinned because no check executed the page.
428 - **A harness assumption is Linux-only until it has run on both OSes.** The
429 shell scripts are the part of this repo with no compiler behind them, and
430 the e2e suite's first run on macOS was sixteen groups and ten of these.
431 Ask questions through `test/os_oracle.sh` and add an arm rather than a
432 branch in a group file. Three shapes bit hardest and are worth knowing by
433 name. A backtick inside an UNQUOTED heredoc is a command substitution
434 wherever it stands, comments included, and macOS ships programs a Linux
435 box does not — a comment that quoted `expect` started `/usr/bin/expect`,
436 which read the rest of the heredoc as its own script and hung the group
437 for its whole budget. `$TMPDIR` on macOS is a per-user directory that
438 ends in `/` and is nearly fifty characters long, so a path spelled from
439 it fails a string compare against the same path normalised, and does not
440 fit in a label bar. And BSD's `ps` and `wc` answer in a different shape
441 from GNU's — a full path where Linux gives a basename, a padded count
442 where Linux gives a bare number.
393 - **Ask the OS about the OS, not the daemon.** A claim about a pid, an fd, a 443 - **Ask the OS about the OS, not the daemon.** A claim about a pid, an fd, a
394 socket or a process tree is asserted against `/proc`, `ps`, or the shell's 444 socket or a process tree is asserted against `/proc`, `ps`, or the shell's
395 own `$$`. A daemon reporting on itself cannot catch itself being wrong — 445 own `$$`. A daemon reporting on itself cannot catch itself being wrong —
@@ -445,7 +495,7 @@ own. Test fixtures in `test/`:
445 ## Where the answers live 495 ## Where the answers live
446 496
447 `docs/roadmap.md` (~660 ln) the ranked queue, but stale past 2026-08-16 — 497 `docs/roadmap.md` (~660 ln) the ranked queue, but stale past 2026-08-16 —
448 `git-collab issue list` is the live order · `docs/decisions.md` (7.8k ln, grep 498 `git-collab issue list` is the live order · `docs/decisions.md` (8.8k ln, grep
449 only) every decision + measurement · `README.md` user-facing usage. Design 499 only) every decision + measurement · `README.md` user-facing usage. Design
450 history (specs, plans, the original handoff) lives in git: `git log -- docs/superpowers`. 500 history (specs, plans, the original handoff) lives in git: `git log -- docs/superpowers`.
451 501
Makefile
Old New
@@ -2,7 +2,36 @@
2 # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. 2 # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere.
3 ZIG ?= $(CURDIR)/deps/zig/zig 3 ZIG ?= $(CURDIR)/deps/zig/zig
4 4
5 .PHONY: build check ci test e2e soak bench agent throughput vm coverage deps clean clean-deps xversion xversion-build install release 5 # On a Mac the pinned zig cannot link against Xcode 26.4+'s SDK (see
6 # deps/mac-sdk.sh). The shim dir goes FIRST on PATH for every recipe here,
7 # and `mac-sdk` builds it; both are no-ops on Linux and on a Mac whose SDK
8 # is linkable. MUX_TARGET follows the host: a Mac installs a Mac binary.
9 #
10 # `native` and NOT `aarch64-macos`, which names the same machine and does
11 # not build. zig 0.15.2 looks for the macOS SDK — by running `xcrun
12 # --show-sdk-path`, which is exactly what the shim above answers — only for
13 # a NATIVE target; an explicitly named one is a cross-compile and gets no
14 # system include path at all, so `@cImport` of <util.h> (forkpty's header,
15 # which lives in the SDK) fails with "file not found" and `make install` on
16 # a Mac cannot produce a binary. Found by test/xos.sh, which is the first
17 # thing in the tree to run `make install` on Darwin.
18 #
19 # RELEASE_TARGET and SHA256 follow the host for the same reason; the comment
20 # above `release` below says why that is now right. RELEASE_BUILD_TARGET is
21 # the `-Dtarget=` word and RELEASE_TARGET the tarball's NAME, because on a
22 # Mac those two differ: the artifact is `aarch64-macos` and the only spelling
23 # that builds it is `native`, per the paragraph above.
24 UNAME_S := $(shell uname -s)
25 ifeq ($(UNAME_S),Darwin)
26 export PATH := $(CURDIR)/deps/mac-sdk/bin:/opt/homebrew/bin:$(PATH)
27 MUX_TARGET ?= native
28 RELEASE_TARGET ?= aarch64-macos
29 RELEASE_BUILD_TARGET ?= native
30 SHA256 ?= shasum -a 256
31 endif
32 MUX_TARGET ?= x86_64-linux-musl
33
34 .PHONY: build check ci test e2e soak bench agent throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac
6 35
7 # The QUIC stack (deps/quic) is built on demand by build.zig, so no target 36 # The QUIC stack (deps/quic) is built on demand by build.zig, so no target
8 # here needs to depend on this one. It exists to make the one-time cost 37 # here needs to depend on this one. It exists to make the one-time cost
@@ -13,7 +42,7 @@ deps:
13 ./deps/quic/build-deps.sh native 42 ./deps/quic/build-deps.sh native
14 ./deps/quic/build-deps.sh musl 43 ./deps/quic/build-deps.sh musl
15 44
16 build: 45 build: mac-sdk
17 $(ZIG) build 46 $(ZIG) build
18 47
19 # Only the ONE user-facing binary: everything else in zig-out/bin 48 # Only the ONE user-facing binary: everything else in zig-out/bin
@@ -43,8 +72,8 @@ INSTDIR ?= dist/install
43 # 72 #
44 # The target the installed and released binaries are built for. Static 73 # The target the installed and released binaries are built for. Static
45 # musl on Linux, for the reason above; another OS names its triple here. 74 # musl on Linux, for the reason above; another OS names its triple here.
46 MUX_TARGET ?= x86_64-linux-musl 75 # The `?=` at the top of this file is what sets it, so a Mac gets its own.
47 install: 76 install: mac-sdk
48 $(ZIG) build -Dtarget=$(MUX_TARGET) -Doptimize=ReleaseSafe -p $(INSTDIR) 77 $(ZIG) build -Dtarget=$(MUX_TARGET) -Doptimize=ReleaseSafe -p $(INSTDIR)
49 install -d $(BINDIR) 78 install -d $(BINDIR)
50 install -m755 $(INSTDIR)/bin/mux $(BINDIR)/ 79 install -m755 $(INSTDIR)/bin/mux $(BINDIR)/
@@ -66,12 +95,34 @@ install:
66 VERSION := $(shell sed -n 's/^[[:space:]]*const version = "\(.*\)";/\1/p' build.zig | head -1) 95 VERSION := $(shell sed -n 's/^[[:space:]]*const version = "\(.*\)";/\1/p' build.zig | head -1)
67 RELDIR ?= dist 96 RELDIR ?= dist
68 RELBIN = $(RELDIR)/v$(VERSION) 97 RELBIN = $(RELDIR)/v$(VERSION)
69 RELTAR = $(RELDIR)/mux-v$(VERSION)-$(MUX_TARGET).tar.gz 98 # The release DOES follow the host now, which reverses the rule this line
99 # used to carry: "what a release IS must not follow the host that cuts it".
100 # That rule was written when there was one release, a Linux one, and the
101 # danger it named was a Mac quietly writing a Mach-O binary into the Linux
102 # tarball. It cannot: the target is in the tarball's NAME, so a Mac cuts
103 # mux-vN-aarch64-macos.tar.gz and nothing downstream can mistake that file
104 # for mux-vN-x86_64-linux-musl.tar.gz. And now that mux runs on two
105 # operating systems, a release IS one tarball per OS, each cut on that OS —
106 # there is no cross-compiling the Mac one (README, "macOS"), so the choice
107 # is not between one host and another but between a Mac release existing and
108 # not existing. The old rule's second argument was that macOS ships no
109 # sha256sum; SHA256 at the top of this file answers that with `shasum -a
110 # 256` instead of making it a reason to refuse.
111 #
112 # RELEASE_BUILD_TARGET is the word handed to `-Dtarget=`, kept apart from
113 # the NAME because on Darwin the two differ: `native` is the only spelling
114 # that builds, and `aarch64-macos` is what the artifact must be called. On
115 # Linux they are the same string, so this recipe is byte-for-byte what it
116 # was.
117 RELEASE_TARGET ?= x86_64-linux-musl
118 RELEASE_BUILD_TARGET ?= $(RELEASE_TARGET)
119 SHA256 ?= sha256sum
120 RELTAR = $(RELDIR)/mux-v$(VERSION)-$(RELEASE_TARGET).tar.gz
70 # The version guard below asks the STRIPPED artifact what it is, so a 121 # The version guard below asks the STRIPPED artifact what it is, so a
71 # stale stage directory cannot ship under a bumped number. 122 # stale stage directory cannot ship under a bumped number.
72 release: 123 release:
73 @test -n "$(VERSION)" || { echo "release: no version found in build.zig"; exit 1; } 124 @test -n "$(VERSION)" || { echo "release: no version found in build.zig"; exit 1; }
74 $(ZIG) build -Dtarget=$(MUX_TARGET) -Doptimize=ReleaseSafe -p $(RELDIR)/stage 125 $(ZIG) build -Dtarget=$(RELEASE_BUILD_TARGET) -Doptimize=ReleaseSafe -p $(RELDIR)/stage
75 rm -rf $(RELBIN) $(RELTAR) 126 rm -rf $(RELBIN) $(RELTAR)
76 install -d $(RELBIN) 127 install -d $(RELBIN)
77 install -m755 $(RELDIR)/stage/bin/mux $(RELBIN)/ 128 install -m755 $(RELDIR)/stage/bin/mux $(RELBIN)/
@@ -81,21 +132,31 @@ release:
81 echo "release: mux reports $$got, expected $(VERSION)"; exit 1; } 132 echo "release: mux reports $$got, expected $(VERSION)"; exit 1; }
82 tar czf $(RELTAR) -C $(RELBIN) mux 133 tar czf $(RELTAR) -C $(RELBIN) mux
83 @echo 134 @echo
84 @sha256sum $(RELTAR) 135 @$(SHA256) $(RELTAR)
85 @echo "install: tar xzf $(RELTAR) -C ~/.local/bin" 136 @echo "install: tar xzf $(RELTAR) -C ~/.local/bin"
86 @echo "remote: ssh HOST 'mkdir -p ~/.local/bin && tar xzf - -C ~/.local/bin' < $(RELTAR)" 137 @echo "remote: ssh HOST 'mkdir -p ~/.local/bin && tar xzf - -C ~/.local/bin' < $(RELTAR)"
87 @echo "publish: git collab release publish v$(VERSION) $(RELTAR)" 138 @echo "publish: git collab release publish v$(VERSION) $(RELTAR)"
88 139
89 test: 140 # The Mac half of a release. `make release` above cuts whichever OS it is run
141 # on, and this is how the OTHER one gets cut from here: MAC_BUILDER names a
142 # Mac holding this repo — the same box and the same variable the macOS gates
143 # take — and tools/release-mac.sh pushes the tag, runs `make release` there
144 # and publishes the tarball from there. No target of this Makefile can
145 # produce a Mac binary on Linux; only a Mac can. The script states the rest
146 # of the contract, including why MAC_BUILDER has no default.
147 release-mac:
148 ./tools/release-mac.sh
149
150 test: mac-sdk
90 $(ZIG) build test 151 $(ZIG) build test
91 152
92 e2e: 153 e2e: mac-sdk
93 $(ZIG) build e2e 154 $(ZIG) build e2e
94 155
95 soak: 156 soak: mac-sdk
96 $(ZIG) build soak 157 $(ZIG) build soak
97 158
98 bench: 159 bench: mac-sdk
99 $(ZIG) build bench 160 $(ZIG) build bench
100 161
101 # ReleaseSafe into its own prefix, for the reason throughput.sh opens with: 162 # ReleaseSafe into its own prefix, for the reason throughput.sh opens with:
@@ -120,7 +181,7 @@ bench:
120 # Outside `ci` deliberately — it needs an optional system package and is 181 # Outside `ci` deliberately — it needs an optional system package and is
121 # still several times slower than the suite it wraps. 182 # still several times slower than the suite it wraps.
122 COVDIR ?= dist/coverage 183 COVDIR ?= dist/coverage
123 coverage: 184 coverage: mac-sdk
124 $(ZIG) build -Doptimize=ReleaseSafe -p $(COVDIR) 185 $(ZIG) build -Doptimize=ReleaseSafe -p $(COVDIR)
125 ./test/coverage.sh $(COVDIR)/bin/mux \ 186 ./test/coverage.sh $(COVDIR)/bin/mux \
126 $(COVDIR)/bin/rawmode $(COVDIR)/bin/delaypipe $(COVDIR)/bin/render \ 187 $(COVDIR)/bin/rawmode $(COVDIR)/bin/delaypipe $(COVDIR)/bin/render \
@@ -135,16 +196,60 @@ vm:
135 ./test/vm.sh $(VMDIR)/bin/mux $(VMDIR)/bin/ptyclient 196 ./test/vm.sh $(VMDIR)/bin/mux $(VMDIR)/bin/ptyclient
136 197
137 THRUDIR ?= dist/throughput 198 THRUDIR ?= dist/throughput
138 throughput: 199 throughput: mac-sdk
139 $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) 200 $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR)
140 ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient 201 ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient
141 202
203 mac-sdk:
204 sh ./deps/mac-sdk.sh
205
206 # The `make vm` of the macOS port: user journeys on a real macOS, over ssh.
207 #
208 # Both macOS gates take their boxes BY NAME in the environment, and neither
209 # has a default for one — a gate that guessed a box would eventually guess
210 # somebody's laptop (test/box_lib.sh):
211 #
212 # MAC_BOX the macOS box under test, with no toolchain on it. Both
213 # gates may SCRUB it, so make it a VM: provision-mac below.
214 # MAC_BUILDER a Mac holding this repo with zig and deps/mac-sdk. It
215 # builds and is never scrubbed. Defaults to $MAC_BOX.
216 # LINUX_BOX (xos only) an x86_64 Linux VM both this box and MAC_BOX can
217 # reach, also scrubbed.
218 #
219 # eval "$(test/provision-mac.sh)" # export MAC_BOX=admin@...
220 # MAC_BUILDER=squirtle make mac
221 mac: build
222 sh ./test/mac.sh
223
224 # The pristine macOS guest the two gates run against: clone the hand-made
225 # base image on a tart host, boot it bridged onto the LAN, key it to this
226 # box, and print the one `export MAC_BOX=...` line to eval. `--down` when
227 # the run is over. The base image is a human action; this is not.
228 provision-mac:
229 @sh ./test/provision-mac.sh
230
231 # The cross-OS gate (test/xos.sh): a macOS client on a Linux daemon and a
232 # Linux client on a macOS daemon, over ssh and over QUIC. A hand gate beside
233 # vm and mac, never inside ci — it needs three other machines (MAC_BOX,
234 # LINUX_BOX and MAC_BUILDER, documented above), and ci stays hermetic and
235 # offline.
236 #
237 # Two builds, because the two sides of the seam are two architectures. The
238 # musl one is what gets installed on the Linux box: static, for vm's reason
239 # — that box is a VM on a host whose CPU this one did not build for. The
240 # Debug zig-out build is the Linux CLIENT and the pty fixture, which is what
241 # `make build` already produced.
242 XOSDIR ?= dist/xosgate
243 xos: build
244 $(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p $(XOSDIR)
245 ./test/xos.sh $(XOSDIR)/bin/mux zig-out/bin/mux zig-out/bin/ptyclient
246
142 # `zig build check` grades the tree; bans.sh grades the grader. The folder 247 # `zig build check` grades the tree; bans.sh grades the grader. The folder
143 # rules are the one gate whose failure mode is silence — a needle list 248 # rules are the one gate whose failure mode is silence — a needle list
144 # edited down to nothing, or a folder dropped from a rule's list, leaves a 249 # edited down to nothing, or a folder dropped from a rule's list, leaves a
145 # green tree asserting rules that no longer bite — so one planted needle per 250 # green tree asserting rules that no longer bite — so one planted needle per
146 # rule runs here, after the gate it is checking. Two seconds. 251 # rule runs here, after the gate it is checking. Two seconds.
147 check: 252 check: mac-sdk
148 $(ZIG) build check 253 $(ZIG) build check
149 sh test/bans.sh $(ZIG) 254 sh test/bans.sh $(ZIG)
150 255
@@ -161,7 +266,7 @@ ci:
161 $(MAKE) agent 266 $(MAKE) agent
162 $(MAKE) throughput 267 $(MAKE) throughput
163 268
164 agent: 269 agent: mac-sdk
165 $(ZIG) build agent 270 $(ZIG) build agent
166 271
167 # Cross-version gate (test/xversion.sh): this tree's client against a 272 # Cross-version gate (test/xversion.sh): this tree's client against a
@@ -177,7 +282,7 @@ agent:
177 # purpose: a bare `-Dtarget=` build overwrites zig-out/bin with binaries 282 # purpose: a bare `-Dtarget=` build overwrites zig-out/bin with binaries
178 # for the wrong libc, and the next `make e2e` would silently run those. 283 # for the wrong libc, and the next `make e2e` would silently run those.
179 XVER_OLD_WORKTREE ?= .. 284 XVER_OLD_WORKTREE ?= ..
180 xversion-build: 285 xversion-build: mac-sdk
181 $(ZIG) build -Dtarget=x86_64-linux-musl -p $(CURDIR)/.xversion/new 286 $(ZIG) build -Dtarget=x86_64-linux-musl -p $(CURDIR)/.xversion/new
182 cd $(XVER_OLD_WORKTREE) && $(ZIG) build -Dtarget=x86_64-linux-musl -p $(CURDIR)/.xversion/old 287 cd $(XVER_OLD_WORKTREE) && $(ZIG) build -Dtarget=x86_64-linux-musl -p $(CURDIR)/.xversion/old
183 288
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
@@ -25,9 +25,9 @@ that runs on any x86_64 Linux:
25 ~/Downloads/zig-x86_64-linux-0.15.2/zig build -Dtarget=x86_64-linux-musl 25 ~/Downloads/zig-x86_64-linux-0.15.2/zig build -Dtarget=x86_64-linux-musl
26 ``` 26 ```
27 27
28 `make release` does the whole release build instead — static musl, 28 `make release` does the whole release build instead — static musl on
29 `ReleaseSafe`, stripped, checked against `build.zig`'s version, and tarred 29 Linux, `ReleaseSafe`, stripped, checked against `build.zig`'s version, and
30 into `dist/`. The tarball is what to hand another machine, because it 30 tarred into `dist/`. The tarball is what to hand another machine, because it
31 carries the exec bit that a bare downloaded binary does not: 31 carries the exec bit that a bare downloaded binary does not:
32 32
33 ```sh 33 ```sh
@@ -35,6 +35,23 @@ 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 A release is one tarball per OS, named for the target it holds:
39 `mux-vVERSION-x86_64-linux-musl.tar.gz` and
40 `mux-vVERSION-aarch64-macos.tar.gz`. Each is cut on that OS — `make release`
41 builds for the host it runs on — and both are published under
42 `https://git.a73x.sh/mux/releases/vVERSION/`, so installing a published
43 build is one line:
44
45 ```sh
46 mkdir -p ~/.local/bin
47 curl -fsSL https://git.a73x.sh/mux/releases/vVERSION/mux-vVERSION-x86_64-linux-musl.tar.gz | tar xzf - -C ~/.local/bin
48 curl -fsSL https://git.a73x.sh/mux/releases/vVERSION/mux-vVERSION-aarch64-macos.tar.gz | tar xzf - -C ~/.local/bin
49 ```
50
51 On an Apple-silicon Mac the same build and install commands work, with two
52 things to install first and one binary you cannot cross-compile — see
53 [macOS](#macos).
54
38 ## Quick start, local 55 ## Quick start, local
39 56
40 ```sh 57 ```sh
@@ -705,6 +722,103 @@ bell rings. The clipboard READ direction (`OSC 52` query) is refused
705 deliberately — answering it would let anything in any session read 722 deliberately — answering it would let anything in any session read
706 whatever you last copied. 723 whatever you last copied.
707 724
725 ## macOS
726
727 mux builds and runs on an Apple-silicon Mac — `aarch64` only. There is no
728 Intel build, no universal binary, and no way to cross-compile the Mac
729 binary from Linux, so a Mac binary is made on a Mac.
730
731 You need Xcode's Command Line Tools for the SDK and `cmake` for the QUIC
732 dependencies, and the pinned toolchain in the same place a Linux checkout
733 puts it: unpack the aarch64 Zig 0.15.2 tarball so that the binary is at
734 `deps/zig/zig`. The Makefile spells that path and nothing else, so a
735 system `zig` is neither used nor enough.
736
737 ```sh
738 xcode-select --install
739 brew install cmake
740 make build # first build fetches + compiles the QUIC deps, as on Linux
741 make install # an aarch64 binary in ~/.local/bin
742 ```
743
744 The Mac tarball of a release is cut by `make release-mac`, which runs
745 `make release` on the Mac named by `MAC_BUILDER` — the same box, and the
746 same variable, the macOS gates build on — and publishes it from there.
747
748 `make build` runs `deps/mac-sdk.sh` first, which may build a shadow SDK
749 under `deps/mac-sdk/`. Xcode 26.4 and later ship a `libSystem` stub that
750 lists only the `arm64e` slice, and the pinned Zig 0.15.2 matches the bare
751 `arm64` one, so against that SDK every system symbol comes out undefined
752 and the build cannot start. The shadow is the real SDK by symlink
753 everywhere except that one stub, where Zig's own copy stands in, plus an
754 `xcrun` on PATH that points at it. It costs a few seconds once, retires
755 itself when a future Xcode lists `arm64` again, and needs nothing of you.
756
757 One runtime difference worth knowing. The daemon's default socket lives in
758 `$XDG_RUNTIME_DIR` when that is set, and macOS does not set it, so mux
759 falls back to `/tmp/mux-<uid>` — created 0700 and re-checked for owner and
760 mode on every use, the way tmux uses `/tmp/tmux-UID`. The macOS `$TMPDIR`
761 is not used for this: the longest socket name mux creates would not fit in
762 the 103 bytes a unix socket path gets.
763
764 `make test` passes on a Mac. `make e2e` has two groups that do not, both
765 deliberately and both for the fallback above. A leg in the select group
766 requires `mux d stats` and a bare `mux` to REFUSE when
767 `$XDG_RUNTIME_DIR` is unset, and a leg in the askpass group requires a
768 wall in that state to start no prompt socket at all. On macOS there is
769 always a runtime directory, so mux answers instead of refusing and the
770 wall arms its socket, which is the fallback working. The suite is linear,
771 so a `make e2e` on a Mac stops at the first of the two. The other gates —
772 `make agent`, `make soak`, `make throughput`, `make xversion` — have not
773 been run on a Mac at all.
774
775 The gate for macOS is `make mac`, the macOS half of what `make vm` is for
776 Linux. Both macOS gates take the machines they use BY NAME, as ssh targets
777 in the environment, and neither has a default for one:
778
779 | Name | What it is |
780 |---|---|
781 | `MAC_BOX` | the macOS box under test, with no developer tooling on it. Scrubbed to a mux-less state at the start of a run and again at the end, so make it a VM. |
782 | `MAC_BUILDER` | a Mac holding this repo with zig and the shadow SDK. It builds, and nothing else: no mux is installed there and nothing is reset there. Defaults to `MAC_BOX`. |
783 | `LINUX_BOX` | `make xos` only: an x86_64 Linux VM that both this box and `MAC_BOX` can reach. Scrubbed the same way. |
784
785 `make mac` pushes this commit to the builder, builds and runs the unit suite
786 there, streams the binary onto `MAC_BOX`, and then runs the user journeys —
787 so what is graded is the product on a bare Mac rather than on the machine
788 that compiled it. A run looks like this:
789
790 ```sh
791 eval "$(test/provision-mac.sh)" # prints: export MAC_BOX=admin@192.168.0.x
792 MAC_BUILDER=squirtle make mac
793 test/provision-mac.sh --down # when you are done with the guest
794 ```
795
796 `test/provision-mac.sh` is where the virtual machine's life is spelled: it
797 clones a pristine base image on a Mac that has Cirrus Labs `tart`, boots the
798 clone bridged onto the LAN so this box can ssh to it directly, and installs
799 this box's public key on it. The base image itself is made once, by hand, on
800 that Mac:
801
802 ```sh
803 brew trust cirruslabs/cli && brew install cirruslabs/cli/tart
804 tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base
805 tart run --no-graphics mux-mac-base & # user admin, password admin
806 tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
807 tart stop mux-mac-base
808 ```
809
810 `PUBKEY` is that Mac's own `~/.ssh/id_ed25519.pub`. Nothing stops you
811 pointing `MAC_BOX` at some other spare Mac instead, as long as you mean the
812 word scrubbed.
813
814 `make mac` and `make vm` each run one operating system on both ends of every
815 connection. `make xos` is the gate that does not: it attaches a macOS client
816 to a Linux daemon and a Linux client to a macOS daemon, over the ssh handoff
817 and over QUIC, and checks that a cross-architecture `mux d upgrade` is
818 refused in both directions before any of the image is streamed. It uses all
819 three names above, and pairs the two boxes' ssh keys itself, because each
820 one dials the other from the inside.
821
708 ## How it works 822 ## How it works
709 823
710 Both ends run a real terminal engine. The daemon's is authoritative: shell 824 Both ends run a real terminal engine. The daemon's is authoritative: shell
@@ -721,7 +835,8 @@ issue list` (`docs/roadmap.md` is the older ranked queue).
721 835
722 ## Status 836 ## Status
723 837
724 Prototype, eighteen milestones in, current cut tagged `v0.0.1-15`. All 838 Prototype, eighteen milestones in, current cut tagged `v0.0.1-15`. Linux
839 and Apple-silicon macOS; every gate below was measured on Linux. All
725 founding kill criteria cleared, measured on real networks rather than 840 founding kill criteria cleared, measured on real networks rather than
726 loopback: reattach into a live full-screen `nvim` in ~5ms from one 841 loopback: reattach into a live full-screen `nvim` in ~5ms from one
727 snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the 842 snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the
build.zig
Old New
@@ -191,11 +191,12 @@ 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 .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .test_imports = &.{"testtmp"} }, 198 // put one in.
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.
201 .{ .name = "cliflags", .path = "src/cli/flags.zig" }, 202 .{ .name = "cliflags", .path = "src/cli/flags.zig" },
@@ -209,7 +210,7 @@ const mod_table = [_]ModSpec{
209 // is the attach encoders, `link` is the round trip's wait — an embedder 210 // is the attach encoders, `link` is the round trip's wait — an embedder
210 // reaches a daemon by linking those two and this, instead of the whole 211 // reaches a daemon by linking those two and this, instead of the whole
211 // client module. 212 // client module.
212 .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link" }, .quic_tests = true }, 213 .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link", "sockpath" }, .quic_tests = true },
213 // The live connection itself — fd, pipe or QUIC — and the one wait-for-a- 214 // The live connection itself — fd, pipe or QUIC — and the one wait-for-a-
214 // frame loop. `term` for frames, `quic` for the third arm; policy stays 215 // frame loop. `term` for frames, `quic` for the third arm; policy stays
215 // with the rows that import this one. 216 // with the rows that import this one.
@@ -379,11 +380,13 @@ const SourceBan = struct {
379 needles: []const []const u8, 380 needles: []const []const u8,
380 /// What is wrong with spelling it, in the fatal's own voice. 381 /// What is wrong with spelling it, in the fatal's own voice.
381 why: []const u8, 382 why: []const u8,
382 /// The one file a rule is ABOUT rather than against: rule 6 exists to 383 /// The files a rule is ABOUT rather than against: rule 6 exists to say
383 /// say WHERE the fork lives, so naming that file here is the rule's 384 /// WHERE the fork lives, so naming those files here is the rule's
384 /// content and not a hole in it. Unlike the in-file `exemption:` line, 385 /// content and not a hole in it. A list rather than one name, because
385 /// which any file may write for itself, this is a diff to build.zig. 386 /// the fork's spelling is per OS arm and each arm is a file. Unlike the
386 except: ?[]const u8 = null, 387 /// in-file `exemption:` line, which any file may write for itself, this
388 /// is a diff to build.zig.
389 except: []const []const u8 = &.{},
387 }; 390 };
388 391
389 const source_bans = [_]SourceBan{ 392 const source_bans = [_]SourceBan{
@@ -415,11 +418,13 @@ const source_bans = [_]SourceBan{
415 .rule = "6", 418 .rule = "6",
416 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, 419 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" },
417 .needles = &.{"posix.fork("}, 420 .needles = &.{"posix.fork("},
418 .except = "src/os/server_os_linux.zig", 421 .except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" },
419 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ 422 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++
420 "every other starter spells that argv and execs this image. A " ++ 423 "every other starter spells that argv and execs this image. A " ++
421 "client that forked a daemon would be choosing the daemon's " ++ 424 "client that forked a daemon would be choosing the daemon's " ++
422 "flags, its log and its refusals, none of which it can see", 425 "flags, its log and its refusals, none of which it can see. One " ++
426 "fork per OS arm, and the arm's file is named here so a third " ++
427 "file that forks is caught",
423 }, 428 },
424 .{ 429 .{
425 .rule = "7", 430 .rule = "7",
@@ -454,7 +459,11 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
454 var paths: std.ArrayList([]const u8) = .empty; 459 var paths: std.ArrayList([]const u8) = .empty;
455 zigFilesIn(b, sub, &paths); 460 zigFilesIn(b, sub, &paths);
456 for (paths.items) |path| { 461 for (paths.items) |path| {
457 if (ban.except) |ex| if (std.mem.eql(u8, path, ex)) continue; 462 var excepted = false;
463 for (ban.except) |ex| {
464 if (std.mem.eql(u8, path, ex)) excepted = true;
465 }
466 if (excepted) continue;
458 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| 467 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e|
459 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) }); 468 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) });
460 if (std.mem.indexOf(u8, src, exempt) != null) continue; 469 if (std.mem.indexOf(u8, src, exempt) != null) continue;
@@ -649,7 +658,7 @@ fn checkOneRootReaches(b: *std.Build, root: []const u8, subdir: []const u8, pref
649 fn shellGate(b: *std.Build, step: *std.Build.Step) void { 658 fn shellGate(b: *std.Build, step: *std.Build.Step) void {
650 var paths: [64][]const u8 = undefined; 659 var paths: [64][]const u8 = undefined;
651 var n: usize = 0; 660 var n: usize = 0;
652 for ([_][]const u8{ "test", "tools", "deps/quic" }) |sub| { 661 for ([_][]const u8{ "test", "tools", "deps", "deps/quic" }) |sub| {
653 var dir = b.build_root.handle.openDir(sub, .{ .iterate = true }) catch |err| 662 var dir = b.build_root.handle.openDir(sub, .{ .iterate = true }) catch |err|
654 fatal("shell gate: cannot open {s}/ ({s})", .{ sub, @errorName(err) }); 663 fatal("shell gate: cannot open {s}/ ({s})", .{ sub, @errorName(err) });
655 defer dir.close(); 664 defer dir.close();
deps/mac-sdk.sh
Old New
@@ -0,0 +1,65 @@
1 #!/bin/sh
2 # deps/mac-sdk.sh — a shadow macOS SDK for zig 0.15.2, built on a Darwin host.
3 #
4 # Xcode 26.4 and later ship a libSystem.B.tbd whose `targets:` line lists
5 # arm64e-macos and no arm64-macos. zig 0.15.2's Mach-O linker matches the
6 # bare arm64 slice only, so against that SDK every libSystem symbol is
7 # undefined — for the build runner too, which is why `zig build` cannot
8 # even start (ziglang/zig#31658 on Codeberg; fixed in 0.16, which ghostty's
9 # pin cannot use). zig's own lib/libc/darwin/libSystem.tbd is what a
10 # `-target aarch64-macos` build links against, and it works. This script
11 # builds an SDK that is the real one in every path but usr/lib/libSystem*,
12 # where zig's stub stands in, and an `xcrun` shim that answers
13 # --show-sdk-path with it — zig finds the SDK by running `xcrun` off PATH
14 # (std/zig/system/darwin.zig), and so does ghostty's apple_sdk helper.
15 #
16 # Self-retiring: when the real SDK's stub lists arm64-macos again, no shadow
17 # is built and the shim, if a stale one is on PATH, passes straight through.
18 # Idempotent: a run that finds its own shadow already standing over the
19 # current SDK exits without touching it.
20 #
21 # That marker check is deliberately LAST of the three, after the xcrun fork
22 # and the tbd grep, even though `mac-sdk` is a phony target every make on a
23 # Mac runs. The fork cannot move: the marker compares against the real SDK's
24 # path and only xcrun knows it. The grep cannot move either, because an
25 # Xcode update in place leaves MacOSX.sdk at the same path -- so a marker
26 # consulted first would match its own stale shadow and go on shadowing an
27 # SDK that had been fixed. One fork and one local grep is what the retire
28 # check costs, and it is the cheaper half of the two.
29 #
30 # ./deps/mac-sdk.sh builds deps/mac-sdk/{sdk,bin/xcrun}
31 set -eu
32 [ "$(uname)" = Darwin ] || exit 0
33 SELF="$(cd "$(dirname "$0")" && pwd)"
34 OUT="$SELF/mac-sdk"
35 ZIG_STUB="$SELF/zig/lib/libc/darwin/libSystem.tbd"
36 REAL="$(/usr/bin/xcrun --sdk macosx --show-sdk-path)"
37 if grep -m1 '^targets:' "$REAL/usr/lib/libSystem.B.tbd" | grep -q 'arm64-macos'; then
38 rm -rf "$OUT" # the SDK is linkable again; leave nothing to shadow it
39 exit 0
40 fi
41 [ -f "$ZIG_STUB" ] || { echo "deps/mac-sdk: no zig stub at $ZIG_STUB (is deps/zig in place?)" >&2; exit 1; }
42 # Marker: the shim exists AND points at this SDK. A new Xcode moves REAL.
43 if [ -x "$OUT/bin/xcrun" ] && [ "$(readlink "$OUT/sdk/usr/include")" = "$REAL/usr/include" ]; then
44 exit 0
45 fi
46 rm -rf "$OUT"
47 mkdir -p "$OUT/sdk/usr/lib" "$OUT/bin"
48 for e in "$REAL"/*; do b=$(basename "$e"); [ "$b" = usr ] || ln -s "$e" "$OUT/sdk/$b"; done
49 for e in "$REAL"/usr/*; do b=$(basename "$e"); [ "$b" = lib ] || ln -s "$e" "$OUT/sdk/usr/$b"; done
50 for e in "$REAL"/usr/lib/*; do
51 b=$(basename "$e")
52 case "$b" in libSystem.tbd | libSystem.B.tbd) ;; *) ln -s "$e" "$OUT/sdk/usr/lib/$b" ;; esac
53 done
54 cp "$ZIG_STUB" "$OUT/sdk/usr/lib/libSystem.tbd"
55 cp "$ZIG_STUB" "$OUT/sdk/usr/lib/libSystem.B.tbd"
56 cat > "$OUT/bin/xcrun" <<EOF
57 #!/bin/sh
58 # Shim from deps/mac-sdk.sh: only --show-sdk-path is answered here.
59 case "\$*" in
60 *--show-sdk-path*) [ -d "$OUT/sdk" ] && { echo "$OUT/sdk"; exit 0; } ;;
61 esac
62 exec /usr/bin/xcrun "\$@"
63 EOF
64 chmod +x "$OUT/bin/xcrun"
65 echo "deps/mac-sdk: shadow SDK over $REAL (zig 0.15.2 cannot link the Xcode 26.4+ stub)" >&2
deps/quic/build-deps.sh
Old New
@@ -29,7 +29,11 @@ case "$T" in
29 esac 29 esac
30 30
31 SELF="$(cd "$(dirname "$0")" && pwd)" 31 SELF="$(cd "$(dirname "$0")" && pwd)"
32 ZIG="${ZIG:-$HOME/Downloads/zig-x86_64-linux-0.15.2/zig}" 32 # The repo's pinned toolchain first: every box that builds mux has it at
33 # deps/zig, and a Mac has no other 0.15.2 (brew ships 0.16). The Downloads
34 # path is the original dev box's spelling, kept so an old rig still runs.
35 ZIG="${ZIG:-$SELF/../zig/zig}"
36 [ -x "$ZIG" ] || ZIG="$HOME/Downloads/zig-x86_64-linux-0.15.2/zig"
33 OUT="$SELF/out/$T" 37 OUT="$SELF/out/$T"
34 W="$SELF/work" 38 W="$SELF/work"
35 39
@@ -60,9 +64,15 @@ mkdir -p "$W/src" "$W/bin"
60 # v3 (AVX2) costs nothing measurable at terminal bandwidth, and the musl 64 # v3 (AVX2) costs nothing measurable at terminal bandwidth, and the musl
61 # release target below is baseline anyway, so nothing shipped changes. 65 # release target below is baseline anyway, so nothing shipped changes.
62 # Zig-style CPU name (underscores): zig cc rejects clang's x86-64-v3. 66 # Zig-style CPU name (underscores): zig cc rejects clang's x86-64-v3.
67 # The flag is x86's: on an arm64 host (an Apple Silicon Mac is the one
68 # that builds) zig cc rejects it, so a native build there is plain zig cc.
69 case "$(uname -m)" in
70 x86_64) NATIVE_FLAGS="-march=x86_64_v3" ;;
71 *) NATIVE_FLAGS="" ;;
72 esac
63 cat > "$W/bin/zigcc-native" <<EOF 73 cat > "$W/bin/zigcc-native" <<EOF
64 #!/bin/sh 74 #!/bin/sh
65 exec $ZIG cc -march=x86_64_v3 "\$@" 75 exec $ZIG cc $NATIVE_FLAGS "\$@"
66 EOF 76 EOF
67 cat > "$W/bin/zigcc-musl" <<EOF 77 cat > "$W/bin/zigcc-musl" <<EOF
68 #!/bin/sh 78 #!/bin/sh
@@ -112,6 +122,27 @@ case "$T" in
112 # "Findings the design rests on". 122 # "Findings the design rests on".
113 XTRA="-DCMAKE_SYSTEM_NAME=Darwin -DCMAKE_SYSTEM_PROCESSOR=arm64 -DCMAKE_FIND_ROOT_PATH=$OUT -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY" 123 XTRA="-DCMAKE_SYSTEM_NAME=Darwin -DCMAKE_SYSTEM_PROCESSOR=arm64 -DCMAKE_FIND_ROOT_PATH=$OUT -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY"
114 WOLF_XTRA="-DWOLFSSL_SYS_CA_CERTS=no" ;; 124 WOLF_XTRA="-DWOLFSSL_SYS_CA_CERTS=no" ;;
125 native)
126 # A native build ON a Mac wants the same two fences for the same two
127 # reasons, minus the cross-compile words. ngtcp2's cmake calls
128 # find_package(wolfssl), so a brew-installed wolfSSL is what it finds
129 # ahead of the one built here -- the same hazard the cross branch
130 # above measured, and the reason to name wolfSSL rather than ngtcp2
131 # when someone goes looking for the offending package. And the CA
132 # path still wants Security.framework linked into a PSK-only binary
133 # that never verifies a certificate.
134 #
135 # Measured on squirtle 2026-09-03: with exactly these flags the QUIC
136 # stack built natively in about a minute, so the root-path fence
137 # standing without CMAKE_SYSTEM_NAME (which the cross branch needs
138 # and a native build has no business setting) is a measurement and
139 # not a guess. The log is in
140 # docs/superpowers/specs/2026-09-03-macos-port-design.md, "Hardware
141 # findings, 2026-09-03".
142 if [ "$(uname)" = Darwin ]; then
143 XTRA="-DCMAKE_FIND_ROOT_PATH=$OUT -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY"
144 WOLF_XTRA="-DWOLFSSL_SYS_CA_CERTS=no"
145 fi ;;
115 esac 146 esac
116 147
117 # wolfSSL. Three flags are load-bearing, all found by link failure in the 148 # wolfSSL. Three flags are load-bearing, all found by link failure in the
docs/decisions.md
Old New
@@ -8316,3 +8316,584 @@ refactor moved no byte on the wire. The gate itself needs its old side
8316 re-pinned to a version that still exists; that is not this branch's work. 8316 re-pinned to a version that still exists; that is not this branch's work.
8317 8317
8318 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`. 8318 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.
8319
8320 ## 2026-09-03 — the daemon's Darwin arm (macOS port, step 3)
8321
8322 `server_os_macos.zig` fills the server root's `.macos` arm. Most of the
8323 operations are a spelling change and nothing else. FOUR are a different
8324 mechanism, because the Linux one does not exist on Darwin, and one of the
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.
8329
8330 **`sendNoSigNoWait` sets `SO_NOSIGPIPE`, and a REFUSED set is the answer.**
8331 Darwin has no `MSG_NOSIGNAL`: "do not raise SIGPIPE" is a property of the
8332 socket, not of the send, so the arm sets the option per call — there is no
8333 one place every fd this operation is handed gets created (an accepted
8334 client, a socketpair end, an fd adopted across an upgrade), and the option
8335 is idempotent. Measured on macOS 26 with a C program and again with a Zig
8336 one: Darwin's `sosetopt` rejects EVERY socket option with EINVAL once a
8337 socket is shut down in both directions, which is exactly the state a
8338 hung-up peer leaves behind. So the one send that would raise the signal is
8339 also the one send the flag cannot be set for, and the obvious arm — set,
8340 ignore the result, send — signals precisely when it is supposed not to.
8341 The first draft did exactly that and the root's own SIGPIPE test caught it
8342 on the box: the child died of a signal instead of exiting 0. EINVAL on this
8343 call is therefore read as the kernel saying the peer is gone (the level,
8344 name, value and length are all fixed in the source, so nothing else about
8345 the arguments can be invalid) and returned as `error.BrokenPipe`, which is
8346 what `send` would have answered had it not signalled first. Every other
8347 setsockopt failure describes a socket that cannot raise SIGPIPE either, so
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.
8353
8354 That test now has two legs, because a socket that never carried a byte and
8355 one that lost its peer mid-stream are different states to the kernel and
8356 only the second is the pump's own sequence. One leg alone passes on an arm
8357 that can never arm a live socket, the other alone passes on an arm that
8358 only works after a successful send.
8359
8360 **`closeFrom` walks the fd table.** No `close_range` on Darwin, so it is
8361 one close per slot from the floor to `getdtablesize()`, which is the soft
8362 `RLIMIT_NOFILE` and therefore also the ceiling on any fd this process could
8363 be holding. A few hundred cheap EBADFs once per session start, between fork
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.
8367
8368 **`anonFd` is an unlinked `mkstemp` file.** No `memfd_create`. A 0600 file
8369 this uid creates and unlinks before anyone could open it by name is private
8370 by mode where memfd is private by having no name; the window is those two
8371 calls, on an empty file. `/tmp` rather than the runtime directory because
8372 `src/os/` imports nothing of ours and must not learn the socket directory.
8373 `mkstemp` is asserted to open CLOEXEC on modern Darwin and the carrier must
8374 survive `mux d upgrade`'s exec, so the flag is cleared before the fd is
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
8377 writable — passes on the box unchanged.
8378
8379 **`peerCred` needs two calls where Linux needs one.** Darwin's
8380 `LOCAL_PEERCRED` answers a `struct xucred` with no pid in it, so the uid
8381 comes from `getpeereid` and the pid from `LOCAL_PEERPID` at level
8382 `SOL_LOCAL` (0). Both answer for a socketpair, so the root's pin holds
8383 there too.
8384
8385 **The `forkDetached` pin asks `getsid(2)`, not `ps`.** macOS's `ps` has no
8386 `sid` column at all, and its `sess` column is the kernel address of the
8387 session, which reads 0 for anyone but root — so the natural port of the old
8388 Linux pin compares 0 against 0 and passes whatever the child did. `getsid`
8389 is POSIX and answers the number on both, and it makes the claim STRONGER
8390 than it was: the returned pid names a session LEADER (its sid is its pid)
8391 in a session that is not the caller's, where the old pin read the leader
8392 pid off procps and could say nothing on Darwin. `getsid` needs the child
8393 ALIVE and not merely unreaped: Darwin answers -1 for a zombie where Linux
8394 still names its session, which is how the second draft failed on the box.
8395 So the child's stdin is a pipe rather than /dev/null, pre-loaded with a
8396 word it echoes back before it blocks reading a second — which holds it
8397 still for the question and makes `forkDetached`'s `stdin_fd` argument
8398 load-bearing, where /dev/null pinned nothing about stdin at all. The ECHO
8399 is what pins it and not the block: a draft that asserted "still running"
8400 with WNOHANG passed green with stdin dup2'd from the wrong fd, because a
8401 child wired elsewhere also ends early and when it ends is a race the
8402 parent wins most of the time. Both claims were graded by mutation on
8403 Linux — deleting `setsid` fails the session claim, wiring stdin to the
8404 wrong fd fails the echo — and the whole file was then run on the Mac.
8405
8406 **`@cImport` of `<util.h>` works after all.** Step 2 recorded that Zig
8407 ships no `util.h` for Darwin and that the arm would need one
8408 `extern "c" fn forkpty` line. On a box with the Command Line Tools the
8409 macOS SDK supplies the header, and `@cImport` of `util.h`, `sys/ioctl.h`,
8410 `sys/socket.h`, `sys/un.h`, `unistd.h` and `stdlib.h` compiles, which is
8411 what the arm does. `forkpty` needs no `-lutil` there; it is in libSystem.
8412
8413 **Rule 6's `except` is a list now.** The one `posix.fork` site is one PER
8414 OS ARM, so build.zig names each arm's file and a third file that forks is
8415 still caught. `test/bans.sh` still plants its needle in `src/server` and
8416 reads rule 6's own fatal.
8417
8418 **How it was graded.** `src/os/server_os.zig` imports nothing of ours, so
8419 the whole arm was compiled AND RUN on the Mac ahead of the client arm, as
8420 `zig test src/os/server_os.zig -lc` — 11 tests, all passing, including the
8421 pty, peer-credential, fd-barrier, carrier and SIGPIPE pins. The tree itself
8422 still cannot link there until the client arm exists; `make build` on the box
8423 stops at the client root's `@compileError` and nothing else.
8424
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**, and
8476 the `forkDetached` pin is a fifth difference of the same kind in the test
8477 rather than in the arm. The four are `closeFrom`, `anonFd`, `sendNoSig` and
8478 `peerCred`, which is the count and the list the step-3 entry above gives.
8479 `closeFrom` walks the fd table one close at a time from the floor to
8480 `getdtablesize()`, because Darwin has no `close_range`; that is a few
8481 hundred cheap EBADFs once per session start, between fork and exec, so
8482 nothing is opening fds underneath the walk. `anonFd` is an unlinked
8483 `mkstemp` file in `/tmp` at mode 0600, because there is no `memfd_create`:
8484 private by mode where memfd is private by having no name, for the width of
8485 two syscalls on an empty file. That is a real change of medium — the
8486 upgrade manifest's key bytes touch a disk that memfd never did — and it is
8487 accepted because the file is 0600, unlinked before anyone could open it by
8488 name, empty until written, and on a FileVault volume by default; `shm_open`
8489 was the alternative and Darwin's does not support read/write, so the
8490 manifest writer would have had to mmap. `sendNoSig` sets `SO_NOSIGPIPE` per
8491 call because Darwin has no `MSG_NOSIGNAL` and "do not raise SIGPIPE" is a
8492 property of the socket rather than the send, and a REFUSED set is the
8493 answer: Darwin's `sosetopt` rejects every option with EINVAL once a socket
8494 is shut down both ways, which is exactly the hung-up state, so the arm
8495 reads that EINVAL as the peer being gone and returns `error.BrokenPipe`.
8496 `peerCred` needs two calls: Darwin's `LOCAL_PEERCRED` answers a `struct
8497 xucred` with no pid in it, so the uid comes from `getpeereid` and the pid
8498 from `LOCAL_PEERPID`. And the fifth, in the test: the `forkDetached` pin
8499 asks `getsid(2)` rather than `ps`, because macOS's `ps` has no `sid` column
8500 and its `sess` column is a kernel address that reads 0 for anyone but root,
8501 so the natural port of the Linux pin compared 0 against 0 and passed
8502 whatever the child did.
8503
8504 **A Darwin panic the gate found, and the shape of its fix.** Attaching a
8505 Mac client to a Linux daemon crashed with `attempt to unwrap error:
8506 SocketNotConnected` in `client_os_macos.sendNoSig`. Darwin answers ENOTCONN
8507 on a send to a peer that is mid-close, and `std.posix.send` lists
8508 `SocketNotConnected` among the errors it maps to `unreachable`. Both Darwin
8509 arms call `sendto` instead — which returns that error rather than panicking
8510 on it — and map it to `BrokenPipe`, which is what both callers already
8511 handle and what `send` would have answered had it not panicked first. There
8512 is no test. The window is a race, and a deterministic pin would need the
8513 Linux arms to map ENOTCONN too, which this branch does not change: "the
8514 Linux outcome does not change" was a constraint on the whole port. A
8515 root-owned error map, written once above both arms, is the shape if that is
8516 ever done; today the switch is duplicated verbatim in the two arms, because
8517 a shared file would need a third module row and would break the leaf
8518 property the platform layer's rows have.
8519
8520 **Two fixes in the daemon's own suite, made when it first ran on a Mac.**
8521 The hangup-upgrade refusal test raced the stubborn shell's `trap`: on macOS
8522 `/bin/sh` execs bash 3.2, which takes 12 to 21 ms to arm a trap where dash
8523 takes 2 or less, so a fixed sleep that held on Linux did not hold there. The
8524 FIXTURE owns readiness now — `TestDaemon.startStubborn` and
8525 `attachStubborn` block on a per-pid marker the shell writes once its trap is
8526 armed — and five call sites each became one line. Separately the
8527 repeated-end test grew a duration pin: it times the accepted end to the
8528 shell's death on `std.time.Timer` and requires at least `Pty.term_grace_ms`
8529 and less than three times it, measured at 661 ms on Linux and 668 to 682 ms
8530 on macOS against a 500 ms grace. Both were graded by mutation on Linux
8531 before the Mac run.
8532
8533 **The shell harness asks a bare Mac, so its Darwin arm is perl.**
8534 `test/os_oracle.sh` gained a `Darwin` case beside the existing one: one
8535 spelling per question a pin asks, so a group file names the question and
8536 never the OS. Three of those spellings are perl rather than a Homebrew
8537 binary, and the reason is what the `make mac` gate runs on — a PRISTINE
8538 macOS guest with no developer tooling, where nothing `g`-prefixed exists.
8539 `real_path` is `Cwd::realpath` with a hand-written fallback, because
8540 `Cwd::realpath` is not the same function on every perl: on 5.42 with Cwd
8541 3.94 it hands back the spelling of a directory that is not there, where
8542 `readlink -f` answers nothing with rc 1, so the arm resolves the parent
8543 itself and `oracle_selftest` pins both branches. `now_ms` is
8544 `Time::HiRes`, not `/bin/date`: macOS 26's date answers `%N` and older ones
8545 do not, so spelling the system date would make the suite's clock depend on
8546 how new the OS is, and the failure on an older one is a literal `N` inside
8547 an arithmetic expansion partway through a run. It costs 4.5 ms a call
8548 against gdate's 2.1 and python3's 21.6 (measured 2026-09-03), which is
8549 inside the noise of every bracket in the e2e, whose tightest budget is
8550 1500 ms, and NOT inside the throughput gate's 10 ms ceiling around a 6 ms
8551 leg — a `make throughput` on a Mac will need `SOLO_MAX_MS` raised.
8552
8553 `timeout` is the third. GNU's binary is used when it is on PATH,
8554 Homebrew's `gtimeout` next, and a perl `alarm` wrapper last, so a group
8555 file keeps spelling `timeout` through all three. The perl arm answers
8556 GNU's exit contract for the five cases this harness asks — 0, 124 for the
8557 budget, the child's own status, 127 for a missing command, 128+N for a
8558 signal — and `oracle_selftest` pins all five. It differs from GNU in three
8559 measured ways, none of which a caller here can reach: a command that
8560 exists but is not executable answers 127 where GNU answers 126; a signal
8561 sent to the WRAPPER is not relayed to the child; and the child is not put
8562 in a process group, so GNU's kill-the-group on expiry becomes
8563 kill-the-child and a grandchild outlives the budget.
8564
8565 **The two gates.** `make mac` (`test/mac.sh`) drives eight legs on a tart
8566 clone of the pristine `mux-mac-base` guest, boots it in under twenty
8567 seconds (17.6 s on the run the ledger records) and
8568 has run green twice unaided from a Linux box. `make xos` (`test/xos.sh`) is
8569 the cross-OS gate: ten legs proving a Mac client against a Linux daemon and
8570 a Linux client against a Mac daemon, over both the ssh handoff and QUIC,
8571 plus the cross-arch upgrade refusal in both directions. It has run green
8572 three times from Linux — 21.6 s, 20.8 s, and 1:12 with a Mac recompile in
8573 it. The Linux side is the VM `mux-lan`, an x86_64 Ubuntu box; macOS
8574 delivered inbound UDP on 4433 to a daemon started from a non-GUI ssh
8575 session, so QUIC INTO a Mac works and needed no firewall exception.
8576
8577 **Two bugs `make xos` surfaced that predate this branch.** First,
8578 `mux d endpoint` announces the key it resolved from the DEFAULT path and
8579 never the listener's `--key`, so a daemon started as
8580 `mux d start -d --quic ADDR --key ELSEWHERE` loses every ssh handoff. It is
8581 not Darwin's and it is not fixed here; the gate uses the default key on the
8582 Linux box and the bug is filed. Second, `parsePreflight` compared `uname -m`
8583 verbatim against the zig target tag, so a Mac reporting `arm64` refused to
8584 upgrade itself against an `aarch64` image. That one IS fixed on this branch,
8585 because a port whose Mac cannot upgrade itself is not a port; the map is
8586 exact and a unit test is the only pin, since one Mac cannot host a
8587 Mac-to-Mac run.
8588
8589 **A lesson about the fixture itself.** Two agents sharing the Mac's single
8590 checkout raced each other's `git checkout` in the middle of a gate run, and
8591 the run graded a tree that was not the one it was asked about. The gate
8592 re-reads the tip after every run now. One box with one working copy is a
8593 shared mutable resource and has to be treated as one.
8594
8595 **`make e2e` on the Mac: the verdict.** Sixteen groups, run one at a time
8596 with `E2E_ONLY` and once as the whole suite, on macOS 26.6.2 (Apple M1) with
8597 Homebrew's coreutils present.
8598
8599 | group | alone | seconds | in the whole suite |
8600 |---|---|---|---|
8601 | 01_boot | rc 0 | 34 | pass |
8602 | 02_predict | rc 0 | 53 | pass |
8603 | 03_side | cannot run alone | — | pass |
8604 | 04_handoff | rc 0 | 17 | pass |
8605 | 05_session | rc 0 | 8 | pass |
8606 | 06_web | rc 0 | 21 | pass |
8607 | 07_wallcli | rc 0 | 30 | pass |
8608 | 08_mouse | rc 0 | 22 | pass |
8609 | 09_hosts | rc 0 | 87 | pass |
8610 | 10_agent | rc 0 | 15 | pass |
8611 | 11_select | cannot run alone | — | RED, by design |
8612 | 12_panes | rc 0 | 59 | not reached |
8613 | 13_birth | rc 0 | 37 | not reached |
8614 | 14_upgrade | rc 0 | 10 | not reached |
8615 | 15_askpass | rc 2 | 31 | RED, by design |
8616 | 16_push | rc 0 | 4 | not reached |
8617
8618 The whole-suite column stops at `11_select` because the suite is linear
8619 and `set -e`: the groups after it are graded by their standalone runs
8620 only. The suite reached 78 of its 113 scenarios before that leg.
8621
8622 **Not one of the failures was a defect in mux.** Ten Linux-only
8623 assumptions in the HARNESS accounted for all of them, and every fix was
8624 made and re-run green on both OSes:
8625
8626 - `proxy_pid` matched a `comm` column. BSD ps prints comm as the
8627 executable's full path where Linux prints the basename, and truncates it
8628 to the column width in a multi-column format, so no proxy was ever found
8629 and every scenario that tears a transport failed. It reads `args` now.
8630 - The handoff shim compared a shebang script's process name to `ssh`.
8631 Linux names such a process after the SCRIPT and Darwin after the
8632 INTERPRETER, so `ssh` and `sh` are both correct and the word says nothing
8633 about the product. Only the parent word, `mux`, is pinned now.
8634 - Three counter checks compared `wc -l` to a string. BSD wc pads its count
8635 to a column width, so " 1" was not "1".
8636 - The scenario log stamped `date +%s.%N`, and BSD date has no `%N`. It
8637 takes the oracle's `now_ms`. This one had no witness: the log is opt-in.
8638 - Every temporary path came out with `//` in it, because macOS sets
8639 `$TMPDIR` to a per-user directory ending in a slash, and two groups
8640 compare such a path as a string. The slash is stripped once, before the
8641 first of the 191 call sites that spell it.
8642 - `pid_args` answered a different SHAPE on each arm: Linux reads a
8643 NUL-terminated `/proc` entry and left a trailing space that
8644 `ps -o args=` does not. The one caller comparing a whole argv with `=`
8645 had to spell a space it could only have learned from Linux.
8646 `oracle_selftest` pins the shape now, which is the right place for it: a
8647 helper the two OSes answer differently makes every pin that reads it
8648 agree with anything.
8649 - Seven assertions across two groups grep a socket path out of a tile's
8650 LABEL BAR. A bar is as wide as its tile and `labelText` keeps the state
8651 word and cuts the label's TAIL, which is the half carrying the session
8652 name — and the macOS temporary directory's name alone was 48 characters
8653 on the test Mac, so an 80-column bar had none of it left. Those daemons'
8654 sockets are spelled under `/tmp` now, which is where a Linux run with no
8655 `$TMPDIR` has always put them, and the leak sweep reads both directories
8656 when they differ so the paths that moved are still swept.
8657 - The pager leg spelled the row numbers `less` leaves on screen. Attaching
8658 makes less repaint, and where the repaint puts the top of an at-the-end
8659 view depends on how many of the 24 rows the build spends on its status
8660 line: less 704 leaves it at 178 and less 668 at 179, three runs each. So
8661 the leg was off by one row on the Mac while the wheel worked perfectly.
8662 The leg READS the anchor now. A short attach waits for the paint and
8663 detaches, which leaves the daemon's grid holding the repainted view; the
8664 dump after it is the top row the wheel is about to move, taken while
8665 nothing is attached and nothing can change it, and the assertion is row
8666 anchor-24 present and row anchor gone. That is exactly 24 rows on both
8667 builds and it can still fail on 23, which matters: a leg that accepted
8668 154 OR 155 — which is what was delivered first — could no longer fail on
8669 a wheel that moved 23 rows, and 23 is what one lost notch looks like.
8670 - One symptom on this leg looked like a lost input byte and is not mux's.
8671 An earlier attempt anchored the view by TYPING `178g` into the pager
8672 after the attach; on macOS the leading `1` never arrived and less went to
8673 line 78, three runs out of three, while Linux went to 178 three out of
8674 three. Two probes settled where it goes. A session running `cat`, which
8675 echoes exactly what reaches its pty, received a first send of `abcdef`
8676 intact on both OSes, three runs each — so mux delivers the byte. And a
8677 `\x0c` sent first, with `178g` as the SECOND send, reached less whole on
8678 the Mac, three out of three. So whatever discards it is on the far side
8679 of the pty and not an input byte mux drops — by elimination, since less's
8680 own source was not read; a `TCSAFLUSH` on re-entering raw mode is what
8681 behaves this way and is the guess. It stays OPEN, which is the word the
8682 README and the ledger use for it: elimination says where the byte is not
8683 lost, and nobody has yet said where it is. Nothing in the product changes
8684 and the leg no longer types into the pager at all.
8685 Recorded because the shape — one byte, first write, one OS — is exactly
8686 what a real input bug would look like, and the next person to see it
8687 should know these two probes exist.
8688 - The agent-directory reap was read straight off a bound socket. The reap
8689 is not a boot step — it happens inside the successor daemon's first
8690 `makeDir`, when its first session is born — so the check raced the thing
8691 it asserted, and lost on a Mac where Linux had always won.
8692 - A backtick in an UNQUOTED heredoc is a command substitution wherever it
8693 appears, comments included, and three ptyclient scripts quoted words
8694 that way in their prose. On Linux the words are "command not found" and
8695 the run carries on none the wiser. On macOS `/usr/bin/expect` exists, so
8696 the shell started it, it read the rest of the heredoc as its own script,
8697 and the group hung for its entire 600 s budget. This one cost four gate
8698 runs, not one. The first sweep found two sites and MISSED the third,
8699 because its detector required the heredoc's delimiter to end the line and
8700 that opener has a trailing space after it; the replacement comment
8701 written for the third site had a backtick in it too. The rule is written
8702 above each of the three heredocs. A lint is better and it is small —
8703 match the opener anywhere on the line, skip a quoted delimiter, and refuse
8704 a backtick in the body — so `test/bans.sh` has one, wired into `make
8705 check`, with a planted positive in both opener spellings and a quoted
8706 delimiter as the negative. The comments stay; the lint is what gates it.
8707
8708 **Two groups stay RED, and both are red BY DESIGN — the same design.**
8709 `sockpath.runtimeDir` has no fallback on Linux and falls back to
8710 `/tmp/mux-<uid>` on Darwin, so every leg that pins what mux does with
8711 `$XDG_RUNTIME_DIR` unset is pinning a state macOS never reaches.
8712
8713 `11_select` has a leg that unsets it and requires `mux d stats` and bare
8714 `mux` to REFUSE, naming the variable. On Darwin they resolve the fallback
8715 and report what is or is not listening on `/tmp/mux-501/muxd.sock`, which
8716 is the arm working. `15_askpass`'s last leg unsets it and requires a wall
8717 to start no prompt listener, so ssh keeps its own prompts. On Darwin the
8718 wall arms the socket — the dial log shows it being handed over — the popup
8719 opens, it eats the detach key and the leg times out.
8720
8721 Neither was patched, because a leg that asserts the Linux rule is not
8722 wrong; it needs a Darwin half that asserts the fallback, and writing those
8723 is not this branch's work. One consequence is worth stating plainly:
8724 `11_select` stops at that leg, so the scenarios AFTER it in that group have
8725 never run on macOS at all. Everything in every other group has, both alone
8726 and in sequence.
8727
8728 **Not done, and not claimed.** There is no x86_64-macos build: the arm is
8729 written for `aarch64-macos` and nothing has compiled or run on an Intel Mac.
8730 There is no universal binary. The Mac binary cannot be cross-compiled from
8731 Linux, for the SDK reason above, so `make install` on a Mac names the native
8732 target — the only one that builds there — and a Mac release is cut on a Mac.
8733
8734 And only `make test` and `make e2e` have run there. `make agent`, `make
8735 soak`, `make throughput` and `make xversion` have never been run on a Mac,
8736 so nothing is known about them beyond that they pass on Linux.
8737 `test/agent.sh` in particular still spells `${TMPDIR:-/tmp}` the way the
8738 e2e suite did before this branch and defines its own `now_ms` through
8739 `python3` instead of taking the oracle's, so the first Mac run of it should
8740 be expected to find the same class of thing the e2e suite just did.
8741
8742 Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`.
8743
8744 ## 2026-09-04 — the box gates take their boxes by name
8745
8746 `make mac` and `make xos` were written against one developer's machines and
8747 said so in their own source: a Mac called `squirtle`, a Linux VM at
8748 `ubuntu@192.168.0.37`, and a `MUX_MAC_IP` literal for the QUIC legs. Nobody
8749 else could run either gate. Worse, the two disagreed about what a gate may
8750 do to a machine it finds: `xos` scrubbed the Linux box to bare metal and
8751 tiptoed around the Mac — refusing to run at all if a mux was already going
8752 there, and carrying a `MAC_CFG_MADE` flag so it could put back a
8753 `~/.config/mux` the product had created — while `mac` cloned a fresh tart
8754 guest per run and reached it only through the host's NAT, over an
8755 ssh-through-ssh string in every command.
8756
8757 Both gates now take three ssh targets, and neither has a default for one:
8758 `MAC_BOX` (macOS, no toolchain, the box under test), `LINUX_BOX` (x86_64
8759 Linux, `xos` only) and `MAC_BUILDER` (a Mac with zig and the shadow SDK,
8760 which BUILDS and is neither installed onto nor scrubbed; it defaults to
8761 `MAC_BOX`). A missing name is a preflight refusal with rc 2 that names the
8762 variable and the line to run, before anything is written anywhere. There is
8763 no default because a gate that guesses a box eventually guesses somebody's
8764 laptop — which is the same reason the "it is a person's real machine"
8765 branches are gone rather than kept: MAC_BOX and LINUX_BOX are VMs the gate
8766 MAY scrub, stated once, and `box_scrub` is the whole of what that means.
8767
8768 The shared pieces live in `test/box_lib.sh`, sourced by both gates after
8769 `test/os_oracle.sh`: `box_preflight` (one `ssh -n` per box, `arm64`
8770 normalised to `aarch64` exactly as `main.archMatches` does for `mux d
8771 upgrade`), `box_pair` (idempotent key install, then a real dial from A to B
8772 so the pairing is proved and B's host key recorded where the plain ssh mux
8773 spawns will read it), `box_scrub`, and `box_ssh`/`box_scp`/`box_stream`,
8774 which replaced the three copies of the script-on-stdin runner the two files
8775 had between them.
8776
8777 The scrub runs at the START of a run and again at the END, so a failed run
8778 leaves nothing behind and the next one starts from the same place whatever
8779 happened. It writes NO shell rc file, which reverses what the old reset did.
8780 The old one inserted a `~/.local/bin` PATH line into the Linux box's
8781 `.bashrc`, and the first draft of this work added the Darwin mirror of it, a
8782 `~/.zshenv` line, on the reasoning that the entry dial's `~/.local/bin`
8783 lookup is part of what these gates test. That reasoning was wrong. Every
8784 remote spelling the product sends already carries the prefix itself —
8785 `handoff.local_bin_append`, `PATH="$PATH:$HOME/.local/bin"`, on the entry
8786 dial's word, on the upgrade preflight and on the upgrade's push — so a line
8787 in a box's shell rc tests nothing mux needs, and it is the fixture
8788 configuring the machine. A bare box that finds mux only because the gate
8789 edited its shell rc is exactly the configured developer machine the pristine
8790 guest exists to rule out. The gate's own remote scripts spell
8791 `$HOME/.local/bin/mux` in full instead, checked leg by leg, and `xos`'s
8792 install leg asserts the product's actual question: a plain
8793 `ssh BOX 'PATH="$PATH:$HOME/.local/bin"; command -v mux'` — that prefix
8794 character for character — answers `~/.local/bin/mux` on both boxes.
8795
8796 The one arrangement the scrub still makes is `loginctl enable-linger` on the
8797 Linux arm, because without it logind takes `/run/user/<uid>` down with the
8798 ssh session that started the daemon and reaps the detached daemon with it.
8799 It exits with its own code so a box without passwordless sudo is told that,
8800 rather than being handed "the reset was refused".
8801
8802 The Mac guest's lifecycle moved out of `test/mac.sh` and into
8803 `test/provision-mac.sh`, run from the Linux box: it clones the hand-made
8804 `mux-mac-base` on a tart host, boots the clone with `--net-bridged`, polls
8805 `tart ip --resolver=arp` (6 s when measured), installs this box's public key
8806 through the host's own, verifies a DIRECT ssh from here, and prints one line
8807 — `export MAC_BOX=admin@IP` — with everything else on stderr, so
8808 `eval "$(test/provision-mac.sh)"` works. Bridged and not NAT is what removes
8809 the host hop: the guest took `192.168.0.170` on the LAN, this box reaches it
8810 directly, and the Linux VM can see its port 22, which is what lets the two
8811 boxes dial each other for the cross-OS legs.
8812
8813 One consequence has to be handled rather than configured away. The guest's
8814 host key is new on every clone, so the harness's own ssh to `MAC_BOX` keeps
8815 none (`StrictHostKeyChecking=no`, `UserKnownHostsFile=/dev/null`) — but
8816 mux's OWN entry dial spawns a plain `ssh HOST` that reads the real
8817 `~/.ssh/known_hosts` and cannot be handed those options. `xos`'s preflight
8818 therefore does `ssh-keygen -R IP` and then one accept-new dial, so leg 7
8819 finds the key where the product looks for it; `box_pair` does the same
8820 forget-and-relearn on the far box, because a re-provisioned guest at the
8821 same address would otherwise be refused by a stale entry for the rest of
8822 that VM's life.
8823
8824 The binary reaches `MAC_BOX` as a stream — `ssh BUILDER cat` into
8825 `ssh MAC_BOX cat` — because neither Mac needs a key of the other's for that
8826 and only the orchestrating box can reach both. `cat` carries no mode, so
8827 `box_stream` chmods 755 and both gates compare the builder's sha256 against
8828 the far side's: the assertion that the bytes arrived is the hash, not the
8829 copy tool.
8830
8831 Leg counts: `xos` is still ten legs. `mac` is seven, not eight — the
8832 clone-and-boot leg left with the VM lifecycle, and what replaced it is the
8833 preflight line, which is not an `ok`. The user's real Mac is no longer a
8834 gate target at all; it can still be `MAC_BUILDER`, which is the one role
8835 that touches nothing.
8836
8837 ## 2026-09-04 — a release is one tarball per OS, and a Mac cuts the Mac one
8838
8839 `RELEASE_TARGET` now follows the host, the way `MUX_TARGET` already did.
8840 That reverses the rule the Makefile carried until today, which was that what
8841 a release IS must not follow the host that cuts it, and the reversal is
8842 worth stating because the old rule was right when it was written.
8843
8844 It was written when there was one release, a Linux one, and it guarded
8845 against one accident: `MUX_TARGET` had just been hoisted to the top of the
8846 Makefile behind a `uname -s` switch, which put `make release` on a Mac one
8847 `?=` away from building a Mach-O binary and shipping it as the Linux
8848 tarball. What removes that danger is not the rule but the NAME. The target
8849 is in the filename, so a Mac cuts `mux-vN-aarch64-macos.tar.gz` and a Linux
8850 box cuts `mux-vN-x86_64-linux-musl.tar.gz`, and no step downstream — the
8851 publish, the release listing, the README's install line, `mux d upgrade`'s
8852 stream — can take one for the other. The old rule's second argument, that
8853 macOS ships no `sha256sum`, was never an argument about what a release is;
8854 it is a spelling, and `SHA256` is `shasum -a 256` on Darwin now.
8855
8856 The reason to reverse it is that mux runs on two operating systems, and
8857 there is no cross-compiling the Mac binary: zig 0.15.2 asks `xcrun` for the
8858 SDK only for a NATIVE target, so `-Dtarget=aarch64-macos` gets no system
8859 include path and `@cImport` of `<util.h>` fails. So the Mac release exists
8860 only if a Mac cuts it. `RELEASE_BUILD_TARGET` is the second variable that
8861 falls out of this: `native` is the only `-Dtarget=` word that builds on a
8862 Mac, and `aarch64-macos` is the only name the artifact may carry, so the
8863 name and the build word are separate variables that happen to be the same
8864 string on Linux. `make release` on Linux is byte-for-byte the recipe it was.
8865
8866 `make release-mac` (tools/release-mac.sh) is how the Mac tarball gets cut
8867 from a Linux box. It refuses unless the tag exists locally AND points at
8868 HEAD — a release is cut from a tag, never a branch tip — pushes exactly that
8869 one tag (`--no-follow-tags` and an explicit refspec: a developer with
8870 `push.followTags = true`, which is a common global setting, otherwise sends
8871 every reachable annotated tag, measured as five extra tags in the hand
8872 check), and then hands ONE remote shell script to `MAC_BUILDER`. That
8873 script fetches the tag as a tag, detaches onto it, runs `make release` and
8874 publishes with the builder's own `git-collab`. The publish is the builder's
8875 because the builder is the box holding the bytes; nothing here runs
8876 git-collab locally, and nothing here touches main.
8877
8878 `MAC_BUILDER` has no default, and the refusal says so. A release published
8879 to whatever machine happened to be in someone's ssh config is worse than a
8880 refusal, and the variable is the same one the macOS gates take, so the
8881 builder is a box that is already known to build this repo.
8882
8883 The builder needs three one-time arrangements beyond what the gates need,
8884 all of them on the builder itself: its checkout's `origin` must be the real
8885 remote (a gate-only checkout can have been cloned from anywhere), and it
8886 needs `git-collab init` and `git-collab init-key` so it has an identity to
8887 publish under. That key is then trusted from the developer's own box with
8888 `git-collab key add --global --label BUILDER`; without the trust step the
8889 builder's publish is refused for the identity, which reads nothing like a
8890 build failure.
8891
8892 One outcome to expect rather than debug: a publish of a version that
8893 already exists is REFUSED, and that is correct. The answer is to download
8894 the published tarball from `https://HOST/mux/releases/vN/FILE`, extract it
8895 and `cmp` the BINARY against the local one — the gzip wrapper never matches,
8896 because it carries an mtime — and never `--force` on the strength of a
8897 matching sha alone. That rule was established at v0.0.1-16 (2026-08-29),
8898 where a publish was refused against an identical binary built 76 seconds
8899 earlier and the download-and-cmp is what established it was identical.
docs/superpowers/plans/2026-09-03-macos-port-step3-darwin-arm.md
Old New
@@ -0,0 +1,880 @@
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, against a macOS GUEST on the Mac
641
642 **Ruling (user, 2026-09-03):** the gate drives a macOS virtual machine on
643 squirtle, not the host, so every run starts from a pristine image the way
644 `make vm` starts from a scorched-earth reset. The host builds (it holds the
645 toolchain and the shadow SDK); the guest only RUNS the built binaries, which
646 is the stronger claim — a clean macOS with no developer tooling.
647
648 **The VM is a human/host action, never harness code** (the `vm.sh` rule).
649 Tooling: Cirrus Labs `tart` (installed 2026-09-03 on squirtle, `brew trust
650 cirruslabs/cli && brew install cirruslabs/cli/tart`), base image
651 `ghcr.io/cirruslabs/macos-tahoe-base:latest` (macOS 26; pulled once, ~28 GB).
652 The pristine VM is made ONCE by hand and documented in `test/mac.sh`'s header:
653
654 ```sh
655 tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base
656 tart run --no-graphics mux-mac-base & # user admin, password admin
657 tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
658 tart stop mux-mac-base
659 ```
660
661 (`PUBKEY` is the key squirtle's own `~/.ssh/id_*.pub` holds, so the host can
662 ssh into the guest without a password; `tart exec` needs the guest agent the
663 cirruslabs images ship.) `mux-mac-base` is never run again by the harness:
664 each run clones it.
665
666 **Files:**
667 - Create: `test/mac.sh`
668 - Modify: `Makefile` (`mac` target from Task 1 already points here), `README.md` (one paragraph under the macOS section: the base-VM recipe above)
669
670 **Interfaces:**
671 - Consumes: `MUX_MAC` (default `squirtle`), the repo at `~/code/rad/mux` on the host, `deps/mac-sdk/bin` on the host PATH, `tart` on the host, the pristine `mux-mac-base` VM, `test/os_oracle.sh` (its Darwin arm runs INSIDE the guest).
672
673 - [ ] **Step 1: The script**, shaped like `test/vm.sh` (same header contract; `set -eu`; BatchMode; every claim asserted by a separate ssh reading the guest through the oracle). All ssh to the guest goes THROUGH the host: `ssh squirtle "ssh -o BatchMode=yes -o StrictHostKeyChecking=no admin@$GIP '…'"`, one helper `gssh` that quotes once. Legs, each a named step with its own failure line:
674
675 1. `push` — `git push "$MAC:code/rad/mux" HEAD:refs/heads/mac-journey`, remote `git checkout -q mac-journey && git reset -q --hard mac-journey`.
676 2. `build` — on the host: `make build` and `make check` (rc captured); `deps/zig/zig build test` (rc captured) — the unit suite needs the toolchain and so runs on the host.
677 3. `vm` — on the host: `tart stop mux-mac 2>/dev/null; tart delete mux-mac 2>/dev/null; tart clone mux-mac-base mux-mac; nohup tart run --no-graphics mux-mac >/tmp/mux-mac.log 2>&1 &`; poll `tart ip mux-mac` up to 120 s; poll `gssh true` up to 120 s. Refuse with a clear line if `mux-mac-base` does not exist (`tart list`), pointing at the header recipe.
678 4. `install` — `scp` (through the host) `zig-out/bin/mux`, `zig-out/bin/ptyclient` and `test/os_oracle.sh` into the guest's `~/mux-e2e/`; `gssh '~/mux-e2e/mux --version'` prints the version; assert `file_mode` of the binary is `755` via the oracle sourced in the guest.
679 5. `daemon` — in the guest, with `XDG_RUNTIME_DIR` UNSET (this is the one place the Darwin fallback `/tmp/mux-<uid>` is exercised end to end; the Task 2 ⚠️ item): `~/mux-e2e/mux d start -d`; then assert via the oracle IN the guest: `pid_holds_unix_sock DPID /tmp/mux-501/muxd.sock` (uid 501 is `admin`; read it with `id -u`), `pid_exe DPID` equals `real_path ~/mux-e2e/mux`, and `file_mode /tmp/mux-$(id -u)` is `700`.
680 6. `session` — `~/mux-e2e/mux a …` one-shot (the `mux a` verb README documents) runs `echo mac-$$`; read the pid off the reply; assert it is a child of the daemon via `pid_children`.
681 7. `attach` — `~/mux-e2e/ptyclient` drives `~/mux-e2e/mux` on a real pty in the guest, types `printf JOURNEY-%s\\n ok`, waits for `JOURNEY-ok` in the snapshot (mirror the e2e lib's wait shape), detaches with the chord.
682 8. `stop` — `mux d stop`; `pid_alive DPID` false within 3 s; the socket path gone.
683 9. Cleanup trap: `tart stop mux-mac` on the host, always; the clone is left for inspection and replaced by the next run's clone.
684
685 - [ ] **Step 2: Run it** — `make mac` from this box; every leg prints its name and `ok`; exit 0. Record the wall-clock of the `vm` leg (clone + boot) in the report.
686
687 - [ ] **Step 3: Commit** — `test: make mac drives a pristine macOS guest on the Mac the way make vm drives the VM`
688
689 ---
690
691 ### Task 8: `make e2e` on the Mac — the verdict, and the record
692
693 **Files:**
694 - Modify: `docs/decisions.md`, `README.md`, `CLAUDE.md`
695
696 - [ ] **Step 1: Run the e2e suite on squirtle** with the shim PATH and `brew`'s coreutils present:
697
698 ```sh
699 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=$?'
700 ```
701
702 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.
703
704 - [ ] **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).
705
706 - [ ] **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`.
707
708 - [ ] **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.
709
710 - [ ] **Step 5: `make ci` on Linux**, then commit — `docs: the Darwin arm, its measurements and the Mac e2e verdict`
711
712 ---
713
714 ### Task 9: `test/xos.sh` — cross-OS journeys: a Mac client on a Linux daemon, a Linux client on a Mac daemon
715
716 **Ruling (user, 2026-09-04):** "once macos builds we'll also need to test
717 macos client to linux daemon and vice versa." The macOS port is a second
718 ARM of one product, and the wire is the claim step 3 has not graded: every
719 pin so far runs one OS on both ends. This task attaches across the seam in
720 both directions, over both transports, and pins that a cross-arch
721 `mux d upgrade HOST` is refused before a byte moves.
722
723 **Execution order:** run this task BEFORE Task 8, so Task 8's
724 `docs/decisions.md` entry records the cross-OS verdict beside the Mac e2e
725 table. The number is 9 because it was added after the plan was cut.
726
727 **The fixture (a human/eitri action, never harness code, the `vm.sh` rule):**
728
729 - The MAC is the HOST `squirtle` (`$MUX_MAC`, arm64, macOS 26): it holds
730 the toolchain and builds; `make install` there puts an aarch64 `mux` in
731 `~/.local/bin`, which is where `client.handoff`'s appended PATH finds it
732 for a non-login ssh (`local_bin_append`). The macOS GUEST is not used:
733 it holds no key for any Linux box and a pristine guest must stay that way.
734 - The LINUX daemon box is `mux-lan` (`$MUX_LAN`, default
735 `ubuntu@192.168.0.37`): an eitri VM on host `onyx` with its `lan`
736 network, x86_64 Ubuntu, reachable by plain ssh from BOTH this box and
737 the Mac (squirtle's `~/.ssh/id_ed25519.pub` was authorized by cloud-init,
738 this box's key appended after; the `mux-vm-gate` cert is refused there).
739 Made 2026-09-04 with eitri `vm_create {name: mux-lan, host: onyx,
740 network: lan}`; the DHCP lease can move, `vm_info` re-reads it.
741 - This box is the Linux CLIENT and the orchestrator: `zig-out/bin/mux`
742 and `zig-out/bin/ptyclient` from this tree drive the Linux→Mac legs.
743 - Reachability measured 2026-09-04: squirtle cannot ssh INTO this box
744 (publickey denied) or the e2e VM (charizard's private network), which is
745 why a LAN box exists. The Mac's non-interactive ssh PATH is
746 `~/.cargo/bin:/usr/bin:/bin:/usr/sbin:/sbin` — no brew, no
747 `~/.local/bin` — so every harness ssh onto the Mac spells
748 `$HOME/.local/bin/mux` and the repo's shim PATH itself.
749
750 **Files:**
751 - Create: `test/xos.sh`
752 - Modify: `Makefile` (`xos` target, beside `vm` and `mac`, never inside `ci`), `README.md` (one paragraph under the macOS section naming the gate and its two boxes), `CLAUDE.md` (the toolchain block's `make vm` line gains `xos`)
753
754 **Interfaces:**
755 - Consumes: `MUX_MAC` (default `squirtle`), `MUX_LAN` (default `ubuntu@192.168.0.37`), the repo at `~/code/rad/mux` on the Mac, `deps/mac-sdk/bin` on the Mac PATH, `test/os_oracle.sh` (its Linux arm runs on `mux-lan`, its Darwin arm on the Mac), `test/vm.sh`'s reset block and `test/mac.sh`'s `hssh`/push legs as the shapes to copy, `mux a`'s `--quic HOST --key PATH` spelling, `main.zig`'s refusal line `mux d upgrade: HOST is ARCH and this image is ARCH; refusing to push a binary that cannot run there`.
756 - Produces: `make xos`, exit 0 with `xos OK (N legs ...)`; the verdict Task 8 records.
757
758 - [ ] **Step 1: The script**, shaped like `test/mac.sh` (`set -eu`; BatchMode
759 everywhere the harness itself sshes; `ok`/`fail` with a literal leg-count
760 pin; every claim about a box asserted by a SEPARATE ssh reading that box
761 through the oracle, never by trusting the connection under test). Two
762 helpers, `mssh SECONDS` (script on stdin, on the Mac, under `/bin/sh -s`,
763 every inner ssh takes `-n` — `mac.sh`'s rule) and `lssh SECONDS` (the
764 same onto `$MUX_LAN`). Legs, each named:
765
766 1. `push` — `mac.sh`'s push leg verbatim (`--force` to `mac-journey`,
767 detach first, `HOST_SHA` equals `HEAD_SHA`).
768 2. `build` — on the Mac: `make build` and `make install` under the shim
769 PATH (`export PATH=$HOME/code/rad/mux/deps/mac-sdk/bin:/opt/homebrew/bin:$PATH`);
770 `$HOME/.local/bin/mux --version` equals this tree's `zig-out/bin/mux --version`.
771 Here: `$(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p dist/xosgate`
772 (the Makefile does it, `vm`'s spelling — static musl because `mux-lan`
773 is a VM on a host whose CPU this box did not build for; the laptop was
774 bricked by a native push once, `docs/decisions.md` 2026-09-01), and
775 this box's own `zig build` for the Debug client and `ptyclient`.
776 3. `reset` — on `mux-lan`, `vm.sh`'s scorched-earth block verbatim
777 (kill every mux, remove `~/.local/bin/mux`, state, cache, config and
778 `$XDG_RUNTIME_DIR/mux*`, `enable-linger`, the bashrc PATH line); then
779 `scp` the musl `mux` to `~/.local/bin/mux` and `test/os_oracle.sh` to
780 `~/xos/`; `~/.local/bin/mux --version` equals the tree's. On the MAC
781 nothing is killed and nothing under `~` is removed — it is the user's
782 machine, not a throwaway: the leg REFUSES with a clear line if
783 `pgrep -x mux` finds a mux there already ("stop it or run on another
784 Mac"), and asserts `/tmp/mux-$(id -u)` is absent, so the daemon the
785 Linux→Mac legs meet is the one they start. Both boxes: an isolated
786 `XDG_STATE_HOME` for every mux the HARNESS runs (`~/xos/state` there,
787 `$SCRATCH/state` here), because every attaching mux writes its daemon
788 into the hosts file (CLAUDE.md) — the daemons themselves run under
789 each box's DEFAULT runtime dir, since that is where the entry dial's
790 `mux d endpoint --start` will look.
791 4. `keys` — one 32-byte QUIC key for the run: `mux d keygen` into
792 `$SCRATCH/key` here, then installed 0600 at `~/xos/key` on both remote
793 boxes. Never `~/.config/mux/key` on the Mac (the user's). Every daemon
794 below starts with `--key ~/xos/key`, every QUIC client dials with
795 `--key`/`MUX_KEY_FILE`.
796 5. `mac-to-linux` — on `mux-lan`:
797 `MUX_SHELL_INTEGRATION=1 ~/.local/bin/mux d start -d --quic 0.0.0.0 --key ~/xos/key`;
798 read `LPID` (`pgrep -x mux`). On the Mac, `zig-out/bin/ptyclient` (built by
799 leg 2) drives `$HOME/.local/bin/mux $MUX_LAN` on a real pty with
800 `XDG_STATE_HOME=$HOME/xos/state`: `expect \x1b[?1049h`, settle,
801 `send printf "XOS-M2L-%s-%s\\n" $(uname -m) $$\n`, `expect XOS-M2L-x86_64-`,
802 detach with `\x1cd`, `waitexit`. The marker carries the SESSION shell's
803 `uname -m`, so `x86_64` on a Mac's screen is the cross-OS fact itself.
804 Read the shell pid off the capture; assert on `mux-lan` via the oracle
805 that `pid_children LPID` lists it; assert the capture holds NO
806 `landed over ssh` line (the entry dial fetched the daemon's QUIC
807 coordinates over ssh and moved onto QUIC — inbound UDP to a LAN VM
808 works, `mux-test-boxes`' rule), and via the oracle
809 `udp_local_bound LPID 4433` on `mux-lan`. `mux d dump` on `mux-lan`
810 still holds the marker after the detach.
811 6. `mac-to-linux-quic` — on the Mac, no ssh in the path:
812 `MUX_KEY_FILE=$HOME/xos/key $HOME/.local/bin/mux a run --quic 192.168.0.37 --key $HOME/xos/key --timeout 20000 'echo m2lq-$$'`
813 (`mux a` joins the session leg 5 made; `--quic HOST --key PATH` is its
814 own spelling): reply carries `"mechanism":"marks"`, `"exit_code":0`,
815 and a pid that `pid_children LPID` on `mux-lan` lists. This is the
816 Darwin wolfSSL build's PSK handshake against the musl build's, in one
817 round trip, with no ssh to fall back to.
818 7. `linux-to-mac` — from HERE: `zig-out/bin/ptyclient` drives
819 `zig-out/bin/mux $MUX_MAC` with `XDG_STATE_HOME=$SCRATCH/state`; the
820 entry dial runs `ssh squirtle 'PATH=...; mux d endpoint --start'`, which
821 is the daemon's ONLY starter on the Mac in this gate (`asked` is the
822 entry dial's alone). Marker `XOS-L2M-%s-%s` with `$(uname -m)` → expect
823 `XOS-L2M-arm64-`. Then on the Mac via the oracle's Darwin arm (the repo
824 checkout at `mac-journey` has it): `MPID` is `pgrep -x mux`,
825 `pid_holds_unix_sock MPID /tmp/mux-$(id -u)/muxd.sock` (no
826 `XDG_RUNTIME_DIR` on an ssh login — the Task 2 fallback, exercised by a
827 REAL `mux HOST`), `pid_exe MPID` equals `$HOME/.local/bin/mux` resolved
828 with `cd -P`/`pwd -P` (mac.sh's spelling), `pid_children MPID` lists the
829 shell pid. The daemon has no `--quic` here because the entry dial
830 started it bare, so this attach is the ssh-handoff transport: assert
831 the capture DOES say the attach landed over ssh, or — if the daemon
832 announces a QUIC arm anyway — record which and assert that one; the
833 implementer reads the announce and pins what it says, with the reason in
834 the leg's comment.
835 8. `linux-to-mac-quic` — on the Mac, stop the bare daemon
836 (`$HOME/.local/bin/mux d stop`, `pid_alive MPID` false within 3 s) and
837 start `MUX_SHELL_INTEGRATION=1 $HOME/.local/bin/mux d start -d --quic 0.0.0.0 --key $HOME/xos/key`;
838 from HERE `MUX_KEY_FILE=$SCRATCH/key zig-out/bin/mux a run --quic 192.168.0.47 --key $SCRATCH/key --timeout 20000 'echo l2mq-$$'`
839 after a pty attach `zig-out/bin/mux quic://192.168.0.47` (with the key
840 in `MUX_KEY_FILE`) has made a session: the reply's pid is a child of
841 the new `MPID`. **This is the leg that asks whether macOS delivers
842 inbound UDP 4433 to a daemon a non-GUI ssh session started.** If the
843 application firewall eats it, the leg fails with the dial's own words;
844 record that in the report as a product/platform finding, do NOT loosen
845 the leg — a Mac daemon nobody can dial over QUIC is a fact the user
846 needs, not a harness problem.
847 9. `upgrade-refused` — both ways, with the daemons of legs 5 and 8 up.
848 From HERE: `zig-out/bin/mux d upgrade $MUX_MAC --allow-same-version`
849 exits nonzero and stderr matches
850 `is arm64 and this image is x86_64; refusing to push a binary that cannot run there`;
851 on the Mac, `pgrep -x mux` is still `MPID` and `pid_exe` unchanged.
852 From the MAC: `$HOME/.local/bin/mux d upgrade $MUX_LAN --allow-same-version`
853 exits nonzero, stderr `is x86_64 and this image is arm64; refusing …`;
854 on `mux-lan`, `LPID` and its `pid_exe` unchanged. The preflight's
855 `uname -m` is the gate and nothing is streamed: assert the installed
856 binary's sha on each box (`shasum -a 256` / `sha256sum`, through the
857 oracle if it has a helper, else the two spellings in a `case`) equals
858 what leg 3 / leg 2 installed.
859 10. `stop` — `mux d stop` on both boxes with the isolated key/state;
860 `pid_alive` false within 3 s each; the Mac's `/tmp/mux-$(id -u)`
861 socket gone; `mux-lan`'s `$XDG_RUNTIME_DIR/muxd.sock` gone.
862 11. Cleanup trap: stop any daemon the run started on either box, always;
863 remove `~/xos` on `mux-lan`; on the Mac remove `~/xos` only (never
864 `~/.local/bin/mux`, which `make install` owns and the user may want).
865
866 - [ ] **Step 2: Run it** — `make xos` from this box, twice; every leg
867 prints its name and `ok`; exit 0. Record per-leg wall-clock and which
868 transport each attach landed on in the report. A leg that fails on a
869 PRODUCT difference (leg 8's firewall question is the expected one) is
870 reported with the box's own words and left RED — the user decides.
871
872 - [ ] **Step 3: Commit** — `test: make xos attaches a Mac client to a Linux daemon and a Linux client to a Mac daemon, over ssh and QUIC`
873
874 ---
875
876 ## Self-review
877
878 - **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.
879 - **Placeholders:** none; every step has its code or its exact command. Task 5 is a discovery task by design and says what "fix" means.
880 - **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.
src/cli/main.zig
Old New
@@ -655,7 +655,7 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
655 /// only for socket unlink. Both an already-absent daemon and a completed stop 655 /// only for socket unlink. Both an already-absent daemon and a completed stop
656 /// return zero, making the command idempotent for scripts. 656 /// return zero, making the command idempotent for scripts.
657 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 657 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
658 const stream = std.net.connectUnixSocket(sock_path) catch { 658 const stream = dial.dial(sock_path) catch {
659 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path}); 659 std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path});
660 return 0; 660 return 0;
661 }; 661 };
@@ -929,11 +929,30 @@ fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool
929 } 929 }
930 930
931 /// Slices into `out` — the caller keeps the buffer alive as long as the result. 931 /// Slices into `out` — the caller keeps the buffer alive as long as the result.
932 /// Whether the box's `uname -m` word names the machine `want_arch` does.
933 ///
934 /// The two sides spell one machine differently and neither is wrong: `uname -m`
935 /// on macOS prints `arm64`, and `want_arch` is `@tagName(builtin.cpu.arch)`,
936 /// which zig spells `aarch64` and has no `arm64` tag at all. Compared as bytes,
937 /// an Apple-silicon image refuses to push to an Apple-silicon box — the one
938 /// pairing where the check is certainly WRONG, since it is the same hardware on
939 /// both ends. `x86_64` is already the same word on every OS this runs on.
940 ///
941 /// One direction only, and one pair only: `aarch64` is never what a kernel
942 /// prints here, so accepting it in the other direction would widen the map for
943 /// nothing, and every other mismatch is a real refusal.
944 fn archMatches(reported: []const u8, want_arch: []const u8) bool {
945 if (std.mem.eql(u8, reported, want_arch)) return true;
946 return std.mem.eql(u8, reported, "arm64") and std.mem.eql(u8, want_arch, "aarch64");
947 }
948
932 fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight { 949 fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight {
933 var lines = std.mem.splitScalar(u8, out, '\n'); 950 var lines = std.mem.splitScalar(u8, out, '\n');
934 const arch = lines.next() orelse ""; 951 const arch = lines.next() orelse "";
935 if (arch.len == 0) return .no_answer; 952 if (arch.len == 0) return .no_answer;
936 if (!std.mem.eql(u8, arch, want_arch)) return .{ .bad_arch = arch }; 953 // The REPORTED word is what comes back on a refusal, so the message names
954 // the box's own spelling and the image's own spelling side by side.
955 if (!archMatches(arch, want_arch)) return .{ .bad_arch = arch };
937 const path = lines.next() orelse ""; 956 const path = lines.next() orelse "";
938 if (path.len == 0) return .no_mux; 957 if (path.len == 0) return .no_mux;
939 const announce = lines.next() orelse ""; 958 const announce = lines.next() orelse "";
@@ -1515,6 +1534,30 @@ test "parsePreflight: the line count is the diagnosis" {
1515 try std.testing.expect(parsePreflight("\n", "x86_64") == .no_answer); 1534 try std.testing.expect(parsePreflight("\n", "x86_64") == .no_answer);
1516 } 1535 }
1517 1536
1537 test "parsePreflight: a Mac's arm64 is the aarch64 this image is built for" {
1538 // The pairing this exists for: an Apple-silicon image pushing to an
1539 // Apple-silicon box. `uname -m` says `arm64`, `@tagName(builtin.cpu.arch)`
1540 // says `aarch64`, and a byte compare refused the one push that is
1541 // certainly safe. There is one Mac in the fixture and `mux d upgrade`
1542 // needs two, so `make xos` cannot reach this and the unit test is the pin.
1543 const mac = parsePreflight("arm64\n/Users/u/.local/bin/mux\nquic 1.2.3.4:1 k\n", "aarch64");
1544 try std.testing.expectEqualStrings("/Users/u/.local/bin/mux", mac.ready.path);
1545 try std.testing.expect(mac.ready.daemon_up);
1546
1547 // Neither half of the map loosens a real mismatch, and both refusals still
1548 // carry the BOX's spelling — test/xos.sh's upgrade-refused leg reads both
1549 // of these sentences off the wire in the two directions it pushes.
1550 const mac_from_intel = parsePreflight("arm64\n/Users/u/.local/bin/mux\n", "x86_64");
1551 try std.testing.expectEqualStrings("arm64", mac_from_intel.bad_arch);
1552 const intel_from_mac = parsePreflight("x86_64\n/home/u/.local/bin/mux\n", "aarch64");
1553 try std.testing.expectEqualStrings("x86_64", intel_from_mac.bad_arch);
1554
1555 // The map is one direction: a box that reports the zig tag is not a box
1556 // this has ever met, and it is not what makes an x86_64 image acceptable.
1557 try std.testing.expect(!archMatches("aarch64", "x86_64"));
1558 try std.testing.expect(archMatches("aarch64", "aarch64"));
1559 }
1560
1518 test "parseArgs: upgrade takes one HOST word, and only upgrade does" { 1561 test "parseArgs: upgrade takes one HOST word, and only upgrade does" {
1519 const r = parse(&.{ "d", "upgrade", "box" }); 1562 const r = parse(&.{ "d", "upgrade", "box" });
1520 try std.testing.expect(r == .command); 1563 try std.testing.expect(r == .command);
@@ -2020,7 +2063,15 @@ test "waitPidGone: returns only once the OS has no such process" {
2020 // Use a grandchild because a direct child can remain as a zombie that signal 2063 // Use a grandchild because a direct child can remain as a zombie that signal
2021 // zero still finds. The shell exits after printing the sleeper pid, leaving 2064 // zero still finds. The shell exits after printing the sleeper pid, leaving
2022 // the reparented process to terminate independently. 2065 // the reparented process to terminate independently.
2023 var child = std.process.Child.init(&.{ "sh", "-c", "sleep 0.3 & echo $!" }, std.testing.allocator); 2066 //
2067 // The sleeper's stdio goes to /dev/null so it does not hold the pipe this
2068 // test reads the pid through. Inheriting it made the read below block for
2069 // the sleeper's whole lifetime, so the pid was already dying by the time
2070 // the aliveness check ran and the wait proved nothing — on macOS the check
2071 // lost that race outright and the test failed at its first line. A second
2072 // of life, spent while `waitPidGone` polls, is what makes the wait mean
2073 // something.
2074 var child = std.process.Child.init(&.{ "sh", "-c", "sleep 1 >/dev/null 2>&1 </dev/null & echo $!" }, std.testing.allocator);
2024 child.stdout_behavior = .Pipe; 2075 child.stdout_behavior = .Pipe;
2025 try child.spawn(); 2076 try child.spawn();
2026 var buf: [32]u8 = undefined; 2077 var buf: [32]u8 = undefined;
src/cli/muxa.zig
Old New
@@ -960,10 +960,13 @@ pub fn main(args: []const [:0]const u8) !u8 {
960 960
961 const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) { 961 const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) {
962 // Runtime path resolution failures use the same JSON shape as other 962 // Runtime path resolution failures use the same JSON shape as other
963 // agent-mode failures. 963 // agent-mode failures. The `error` is this mode's own word, but the
964 // DETAIL is `sockpath`'s one sentence: `mux a` had its own copy,
965 // and on a Mac whose /tmp/mux-<uid> is the thing at fault that copy
966 // sent the reader off to set a variable that was already correct.
964 error.NoRuntimeDir => return fail( 967 error.NoRuntimeDir => return fail(
965 "no default socket path", 968 "no default socket path",
966 "XDG_RUNTIME_DIR is unset; name the socket with --sock", 969 sockpath.no_runtime_dir_reason,
967 ), 970 ),
968 else => |e| return e, 971 else => |e| return e,
969 }; 972 };
src/client/askpass.zig
Old New
@@ -10,6 +10,7 @@
10 const std = @import("std"); 10 const std = @import("std");
11 // `serve_mod` and not `serve`: Listener has its own `serve` method, and 11 // `serve_mod` and not `serve`: Listener has its own `serve` method, and
12 // inside the struct the bare name is ambiguous. 12 // inside the struct the bare name is ambiguous.
13 const dial = @import("dial");
13 const serve_mod = @import("serve"); 14 const serve_mod = @import("serve");
14 const xdg = @import("xdg"); 15 const xdg = @import("xdg");
15 const client_os = @import("client_os"); 16 const client_os = @import("client_os");
@@ -384,7 +385,10 @@ pub const Listener = struct {
384 /// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written, 385 /// byte ssh tries to log in with. Every failure is exit 1 with NOTHING written,
385 /// which ssh reads as a refused prompt. 386 /// which ssh reads as a refused prompt.
386 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 { 387 pub fn helperMain(prompt: []const u8, sock: []const u8, kind: Kind, out_fd: std.posix.fd_t) u8 {
387 const stream = std.net.connectUnixSocket(sock) catch return 1; 388 // Through `dial`, so `MUX_ASKPASS_SOCK` naming something that is not a
389 // socket is this function's exit 1 on both systems rather than a panic
390 // inside ssh's password helper on one of them.
391 const stream = dial.dial(sock) catch return 1;
388 defer stream.close(); 392 defer stream.close();
389 var line: [prompt_max + 2]u8 = undefined; 393 var line: [prompt_max + 2]u8 = undefined;
390 line[0] = @intFromEnum(kind); 394 line[0] = @intFromEnum(kind);
src/client/client.zig
Old New
@@ -2378,9 +2378,15 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2378 // A hosts line naming a box that is down used to put ssh's `No route to 2378 // A hosts line naming a box that is down used to put ssh's `No route to
2379 // host` onto the wall's alternate screen every poll, because the child's 2379 // host` onto the wall's alternate screen every poll, because the child's
2380 // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The 2380 // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The
2381 // fake records where its stderr POINTED, off `/dev/fd/2`, so "piped" is 2381 // fake records what KIND of file its stderr is, off `/dev/fd/2`, so
2382 // exact — `/dev/fd` because every OS this builds for has it and the 2382 // "piped" is exact — `/dev/fd` because every OS this builds for has it
2383 // Linux-only spelling would have to be ported alongside the test. 2383 // and the Linux-only spelling would have to be ported alongside the test.
2384 // Asked with `test -p` rather than by reading a link target: on Linux
2385 // `/dev/fd/2` is a symlink to `pipe:[N]` and on Darwin it is an entry of
2386 // the fdesc filesystem that is not a symlink at all, so `readlink` there
2387 // answers nothing and the check passed on an empty file. `test -p` stats
2388 // the path on both and reports the underlying object's type, which is the
2389 // claim being made.
2384 // BOTH values of `asked`, because the rule is the spawn's. 2390 // BOTH values of `asked`, because the rule is the spawn's.
2385 const alloc = std.testing.allocator; 2391 const alloc = std.testing.allocator;
2386 var stdin = try FakeStdin.install(""); 2392 var stdin = try FakeStdin.install("");
@@ -2394,7 +2400,7 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2394 2400
2395 var script_buf: [1024]u8 = undefined; 2401 var script_buf: [1024]u8 = undefined;
2396 const script = try std.fmt.bufPrint(&script_buf, 2402 const script = try std.fmt.bufPrint(&script_buf,
2397 \\readlink /dev/fd/2 > {[d]s}/e 2403 \\if [ -p /dev/fd/2 ]; then echo pipe > {[d]s}/e; else echo "not a pipe" > {[d]s}/e; fi
2398 \\printf 'boom: no route\n' >&2 2404 \\printf 'boom: no route\n' >&2
2399 \\exit 1 2405 \\exit 1
2400 , .{ .d = tmp.path() }); 2406 , .{ .d = tmp.path() });
@@ -2416,7 +2422,7 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2416 2422
2417 var err_buf: [std.fs.max_path_bytes]u8 = undefined; 2423 var err_buf: [std.fs.max_path_bytes]u8 = undefined;
2418 const on_err = try shimSaid(tmp.path(), "e", &err_buf); 2424 const on_err = try shimSaid(tmp.path(), "e", &err_buf);
2419 try std.testing.expect(std.mem.startsWith(u8, on_err, "pipe:")); 2425 try std.testing.expectEqualStrings("pipe", on_err);
2420 // Kept in every case: the picker row is painted from a dial 2426 // Kept in every case: the picker row is painted from a dial
2421 // nobody narrated, which is the whole point of keeping it here 2427 // nobody narrated, which is the whole point of keeping it here
2422 // rather than letting the bytes fall out onto a screen. 2428 // rather than letting the bytes fall out onto a screen.
src/dial.zig
Old New
@@ -4,19 +4,23 @@
4 //! 4 //!
5 //! Connecting a client to a daemon is the operation this product exists to 5 //! Connecting a client to a daemon is the operation this product exists to
6 //! perform, so it is a callable primitive rather than four lines every caller 6 //! perform, so it is a callable primitive rather than four lines every caller
7 //! writes again. It imports `term` for the attach encoders and `link` for the 7 //! writes again. It imports `term` for the attach encoders, `link` for the
8 //! one round trip's wait, and nothing else: an embedder that wants to reach a 8 //! one round trip's wait and `sockpath` for the connect itself, and nothing
9 //! daemon links this, `term` and `link`, not the client module's transports, 9 //! else: an embedder that wants to reach a daemon links those three, not the
10 //! hosts file and pane tree. 10 //! client module's transports, hosts file and pane tree. `sockpath` is here
11 //! rather than a bare `std.net.connectUnixSocket` because the two kernels
12 //! disagree about a path that is not a socket and one of them turns it into a
13 //! panic; `sockpath.connectSocket` says which and why.
11 const std = @import("std"); 14 const std = @import("std");
12 const proto = @import("term").protocol; 15 const proto = @import("term").protocol;
13 const link_mod = @import("link"); 16 const link_mod = @import("link");
17 const sockpath = @import("sockpath");
14 18
15 /// The connection alone, with no frame sent. What an observer verb, a probe 19 /// The connection alone, with no frame sent. What an observer verb, a probe
16 /// or a client resuming from a watermark wants: the first bytes on the 20 /// or a client resuming from a watermark wants: the first bytes on the
17 /// socket are then the caller's to choose. 21 /// socket are then the caller's to choose.
18 pub fn dial(sock_path: []const u8) !std.net.Stream { 22 pub fn dial(sock_path: []const u8) !std.net.Stream {
19 return std.net.connectUnixSocket(sock_path); 23 return sockpath.connectSocket(sock_path);
20 } 24 }
21 25
22 /// Dial and attach to the daemon's default session at this size. The 26 /// Dial and attach to the daemon's default session at this size. The
src/os/client_os.zig
Old New
@@ -8,6 +8,7 @@ const builtin = @import("builtin");
8 8
9 pub const impl = switch (builtin.os.tag) { 9 pub const impl = switch (builtin.os.tag) {
10 .linux => @import("client_os_linux.zig"), 10 .linux => @import("client_os_linux.zig"),
11 .macos => @import("client_os_macos.zig"),
11 else => @compileError("mux has no client platform arm for " ++ @tagName(builtin.os.tag)), 12 else => @compileError("mux has no client platform arm for " ++ @tagName(builtin.os.tag)),
12 }; 13 };
13 14
@@ -149,15 +150,36 @@ test "client_os.sendNoSig: a closed peer is an error, not a signal" {
149 .flags = 0, 150 .flags = 0,
150 }; 151 };
151 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); 152 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null);
152 var csp: [2]std.posix.fd_t = undefined; 153 // Two legs, because "the peer is gone" is two different states to
153 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &csp) != 0) std.c._exit(2); 154 // the kernel, and the first is the one the probe meets:
154 std.posix.close(csp[1]); 155 // `mux_main.agentReachable` sends once on a socket it just connected
155 _ = sendNoSig(csp[0], "x") catch |e| std.c._exit(if (e == error.BrokenPipe) 0 else 2); 156 // and then closes it, so a daemon that hung up in between leaves a
157 // socket that was never written to. The second leg — a socket that
158 // carried bytes and then lost its peer — is asked because this
159 // operation is a contract and not one caller's helper, and on Darwin
160 // the two states differ where they do not on Linux: the flag
161 // that suppresses the signal is a socket option there, and a socket
162 // the kernel has already shut down refuses to take one (see
163 // `client_os_macos.sendNoSig`). A test that asked only the first
164 // would pass on an arm that can never arm a live socket, and one
165 // that asked only the second would pass on an arm that only ever
166 // works after a successful send.
167 //
156 // A send that SUCCEEDED to a closed peer is as wrong as one that 168 // A send that SUCCEEDED to a closed peer is as wrong as one that
157 // signalled, and neither is 0. `_exit` rather than an exit that runs 169 // signalled, and neither is 0. `_exit` rather than an exit that runs
158 // atexit handlers: this child is a copy of a test runner mid-run and 170 // atexit handlers: this child is a copy of a test runner mid-run and
159 // must flush nothing of its parent's. 171 // must flush nothing of its parent's.
160 std.c._exit(2); 172 var gone: [2]std.posix.fd_t = undefined;
173 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &gone) != 0) std.c._exit(2);
174 std.posix.close(gone[1]);
175 if (sendNoSig(gone[0], "x")) |_| std.c._exit(2) else |e| if (e != error.BrokenPipe) std.c._exit(2);
176
177 var live: [2]std.posix.fd_t = undefined;
178 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &live) != 0) std.c._exit(2);
179 _ = sendNoSig(live[0], "x") catch std.c._exit(2);
180 std.posix.close(live[1]);
181 if (sendNoSig(live[0], "x")) |_| std.c._exit(2) else |e| if (e != error.BrokenPipe) std.c._exit(2);
182 std.c._exit(0);
161 } 183 }
162 const status = std.posix.waitpid(pid, 0).status; 184 const status = std.posix.waitpid(pid, 0).status;
163 try std.testing.expect(std.posix.W.IFEXITED(status)); 185 try std.testing.expect(std.posix.W.IFEXITED(status));
src/os/client_os_macos.zig
Old New
@@ -0,0 +1,139 @@
1 //! Darwin arm of `client_os`. Spellings only; the contract is in the root.
2 //! Three operations use a different MECHANISM rather than a different
3 //! spelling, because the Linux one does not exist here, and each has its
4 //! twin in `server_os_macos` (docs/decisions.md, 2026-09-03, "the daemon's
5 //! Darwin arm"): `peerCred` takes two calls because LOCAL_PEERCRED answers
6 //! no pid, `sendNoSig` sets SO_NOSIGPIPE on the socket because there is no
7 //! MSG_NOSIGNAL, and `parentOf` asks sysctl because there is no /proc.
8 const std = @import("std");
9 const root = @import("client_os.zig");
10 const c = @cImport({
11 @cInclude("util.h"); // openpty (test-only through the root)
12 @cInclude("sys/ioctl.h");
13 @cInclude("sys/socket.h");
14 @cInclude("sys/un.h"); // LOCAL_PEERPID
15 @cInclude("sys/sysctl.h"); // kinfo_proc for parentOf
16 @cInclude("unistd.h"); // getpeereid
17 });
18
19 pub fn getpid() std.posix.pid_t {
20 return std.c.getpid();
21 }
22
23 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
24 // Two calls where Linux has one: Darwin's LOCAL_PEERCRED answers a
25 // `struct xucred` with no pid in it, so the uid comes from getpeereid
26 // and the pid from a socket option of its own. SOL_LOCAL is 0.
27 var uid: c.uid_t = undefined;
28 var gid: c.gid_t = undefined;
29 if (c.getpeereid(fd, &uid, &gid) != 0) return null;
30 var pid: c.pid_t = 0;
31 var len: c.socklen_t = @sizeOf(c.pid_t);
32 if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
33 // A pid of 0 — a peer the kernel will not name — passes through
34 // unjudged; the root's doc says why, and `askpass.dialOwner` is the one
35 // place the rule lives.
36 return .{ .uid = @intCast(uid), .pid = @intCast(pid) };
37 }
38
39 pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
40 // Darwin has no MSG_NOSIGNAL: the "do not raise SIGPIPE" bit is a
41 // property of the SOCKET, not of the send. Set per call because there
42 // is no one place every fd that reaches here is created, and the option
43 // is idempotent.
44 //
45 // A REFUSED set is the interesting case and must not fall through to
46 // send. Measured 2026-09-03 on macOS 26: Darwin's `sosetopt` rejects
47 // every socket option with EINVAL once a socket is shut down in both
48 // directions, which is exactly the state a hung-up peer leaves behind —
49 // so the one send that would raise the signal is also the one send the
50 // flag cannot be set for. EINVAL here is therefore not an argument
51 // complaint (level, name, value and length are all fixed above); it is
52 // the kernel saying the peer is gone, which is what `send` would have
53 // answered had it not signalled first. Every other setsockopt failure
54 // — a bad fd, not a socket — describes a socket that cannot raise
55 // SIGPIPE either, so those fall through and let `send` name them.
56 const on: c_int = 1;
57 const rc = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
58 if (rc != 0 and std.posix.errno(rc) == .INVAL) return error.BrokenPipe;
59 // Flags 0, not DONTWAIT: this side BLOCKS. The root's doc says why —
60 // the agent probe writes five bytes and then polls for the answer.
61 return std.posix.sendto(fd, bytes, 0, null, 0) catch |err| switch (err) {
62 // Darwin's THIRD spelling of "the peer is gone", and the one that
63 // aborts the process instead of being an error. `std.posix.send` maps
64 // ENOTCONN to `unreachable`, because for a local fd it can only mean
65 // the caller passed something unconnected; Darwin also uses it for the
66 // FAR end, for a peer that has begun closing but not finished. A
67 // moment earlier the socket still takes SO_NOSIGPIPE, a moment later
68 // it answers EPIPE, and in between it answers this.
69 //
70 // Seen once in a Mac `make check` on 2026-09-04, as `attempt to unwrap
71 // error: SocketNotConnected` out of this call, in the probe that asks
72 // whether an ssh agent is listening. It is a race and does not
73 // reproduce on demand: measured against a peer that had closed and
74 // settled, all three shapes — accepted then closed, closed with no
75 // delay, never accepted — answer EPIPE on both systems. `sendto`
76 // RETURNS the error where `send` unwraps it, which is why this arm
77 // goes through it directly and answers the contract's BrokenPipe.
78 error.SocketNotConnected => return error.BrokenPipe,
79 // Only a `sendto` carrying an ADDRESS can raise these, and this one
80 // passes null. `std.posix.send` calls them unreachable for the same
81 // reason.
82 error.AddressFamilyNotSupported,
83 error.SymLinkLoop,
84 error.NameTooLong,
85 error.FileNotFound,
86 error.NotDir,
87 error.NetworkUnreachable,
88 error.AddressNotAvailable,
89 error.UnreachableAddress,
90 => unreachable,
91 else => |e| return e,
92 };
93 }
94
95 /// sysctl KERN_PROC_PID: the kernel's own record of the process, the Darwin
96 /// answer to /proc/PID/stat. A pid the kernel no longer has answers 0 with
97 /// a length of 0 rather than an error, so the length is read as well as the
98 /// return, and both mean the same 0 the Linux arm gives for a stat file
99 /// that will not open.
100 pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t {
101 var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PID, pid };
102 var kp: c.struct_kinfo_proc = undefined;
103 var len: usize = @sizeOf(c.struct_kinfo_proc);
104 if (c.sysctl(&mib, @intCast(mib.len), &kp, &len, null, 0) != 0 or len == 0) return 0;
105 return @intCast(kp.kp_eproc.e_ppid);
106 }
107
108 pub fn geteuid() std.posix.uid_t {
109 return std.c.geteuid();
110 }
111
112 pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize {
113 // Through the C struct and copied field by field rather than casting a
114 // pointer: the two layouts agree on Darwin today, and a copy cannot
115 // stop agreeing silently.
116 var ws: c.struct_winsize = undefined;
117 if (c.ioctl(fd, c.TIOCGWINSZ, &ws) != 0) return null;
118 return .{ .row = ws.ws_row, .col = ws.ws_col, .xpixel = ws.ws_xpixel, .ypixel = ws.ws_ypixel };
119 }
120
121 pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void {
122 var cws: c.struct_winsize = .{
123 .ws_row = ws.row,
124 .ws_col = ws.col,
125 .ws_xpixel = ws.xpixel,
126 .ws_ypixel = ws.ypixel,
127 };
128 if (c.ioctl(fd, c.TIOCSWINSZ, &cws) != 0) return error.Unsupported;
129 }
130
131 pub fn openPtyPair() error{Unsupported}!root.PtyPair {
132 // `openpty` and not the Linux arm's /dev/ptmx walk: Darwin's ptmx wants
133 // grantpt and unlockpt before the slave name is valid, and openpty is
134 // the libc call that does exactly that sequence.
135 var master: c_int = undefined;
136 var slave: c_int = undefined;
137 if (c.openpty(&master, &slave, null, null, null) != 0) return error.Unsupported;
138 return .{ .master = master, .slave = slave };
139 }
src/os/server_os.zig
Old New
@@ -12,6 +12,7 @@ const builtin = @import("builtin");
12 12
13 pub const impl = switch (builtin.os.tag) { 13 pub const impl = switch (builtin.os.tag) {
14 .linux => @import("server_os_linux.zig"), 14 .linux => @import("server_os_linux.zig"),
15 .macos => @import("server_os_macos.zig"),
15 else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)), 16 else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)),
16 }; 17 };
17 18
@@ -43,6 +44,12 @@ pub fn peerCred(fd: std.posix.socket_t) ?PeerCred {
43 /// operation and it BLOCKS: the two differ in that one respect, and a 44 /// operation and it BLOCKS: the two differ in that one respect, and a
44 /// shared name would let a caller that moved between them assume the 45 /// shared name would let a caller that moved between them assume the
45 /// other's behaviour. 46 /// other's behaviour.
47 /// An arm may make the fd itself non-blocking to keep that promise, and
48 /// that change is permanent for the fd — Darwin's does, because no send
49 /// flag can reach the wait it has to skip. Every fd the daemon sends on is
50 /// already non-blocking from its accept, so nothing of ours notices; a
51 /// caller that hands in a blocking fd and later expects blocking reads or
52 /// writes on it would.
46 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { 53 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
47 return impl.sendNoSigNoWait(fd, bytes); 54 return impl.sendNoSigNoWait(fd, bytes);
48 } 55 }
@@ -272,28 +279,65 @@ test "server_os.setWinsize then ptyMode: the master answers about the line disci
272 } 279 }
273 280
274 test "server_os.forkDetached: the child is a session leader writing to the fd it was given" { 281 test "server_os.forkDetached: the child is a session leader writing to the fd it was given" {
275 // Asked of the OS: the child prints its own session id and pid; a 282 // Asked of the OS, and through `getsid(2)` rather than `ps`: the session
276 // detached daemon is its own session leader, so they are equal. 283 // id is what "detached" means, and only procps prints it — macOS's ps
277 const pipe = try std.posix.pipe(); 284 // has no `sid` column at all, and its `sess` column is the kernel
278 defer std.posix.close(pipe[0]); 285 // address of the session, which reads 0 for anyone but root (measured
279 const devnull = try std.fs.cwd().openFile("/dev/null", .{}); 286 // 2026-09-03), so a `ps` claim there compares 0 against 0 and passes
280 defer devnull.close(); 287 // whatever the child did. `getsid` is POSIX and answers the number on
281 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sid= -p $$ | tr -d ' '; echo $$" }; 288 // both. The child still prints its own pid, so the claim is the whole
282 const pid = try forkDetached("/bin/sh", &argv, devnull.handle, pipe[1]); 289 // one: the process this returned a pid for is a session LEADER (its sid
283 std.posix.close(pipe[1]); 290 // is its pid) in a session that is not the caller's.
284 var buf: [64]u8 = undefined; 291 //
292 // `getsid` needs the child ALIVE, not merely unreaped: Darwin answers -1
293 // for a zombie where Linux still names its session (measured 2026-09-03
294 // on macOS 26). So `stdin_fd` is a pipe rather than /dev/null, and the
295 // child echoes a word out of it and then blocks reading a second — which
296 // holds it still for the question AND makes the stdin argument
297 // load-bearing. The echo is what pins stdin, not the block: a child
298 // wired to the wrong fd also ends early, but WHEN it ends is a race this
299 // test would win most of the time, and /dev/null pinned nothing at all.
300 const libc = struct {
301 extern "c" fn getsid(pid: std.posix.pid_t) std.posix.pid_t;
302 };
303 // CLOEXEC on both pipes: `forkDetached` closes nothing before it execs,
304 // so a plain pipe would leave the CHILD holding the write end of its own
305 // stdin and its `read` would never see the EOF this test closes for.
306 // The two ends it is given survive anyway, because dup2 clears the flag
307 // on the descriptor it writes.
308 const to_child = try std.posix.pipe2(.{ .CLOEXEC = true });
309 const from_child = try std.posix.pipe2(.{ .CLOEXEC = true });
310 defer std.posix.close(from_child[0]);
311 // Written BEFORE the fork, so the word is waiting in the pipe and the
312 // first read cannot block on this test's own ordering.
313 try std.testing.expectEqual(@as(usize, 5), try std.posix.write(to_child[1], "ping\n"));
314 const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "echo $$; read word; echo $word; read hold" };
315 const pid = try forkDetached("/bin/sh", &argv, to_child[0], from_child[1]);
316 std.posix.close(to_child[0]);
317 std.posix.close(from_child[1]);
318 // To the second newline and no further: the child is holding its stdout
319 // open on purpose, so a read to EOF here would wait for an exit this test
320 // has not asked for yet.
321 var buf: [128]u8 = undefined;
285 var n: usize = 0; 322 var n: usize = 0;
286 while (true) { 323 while (std.mem.count(u8, buf[0..n], "\n") < 2) {
287 const got = try std.posix.read(pipe[0], buf[n..]); 324 const got = try std.posix.read(from_child[0], buf[n..]);
288 if (got == 0) break; 325 if (got == 0) return error.NoOutput;
289 n += got; 326 n += got;
290 } 327 }
328 const child_sid = libc.getsid(pid);
329 const own_sid = libc.getsid(0);
330 // The child's second `read` returns only when this end goes.
331 std.posix.close(to_child[1]);
291 _ = std.posix.waitpid(pid, 0); 332 _ = std.posix.waitpid(pid, 0);
292 var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n'); 333 var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n');
293 const sid = lines.next() orelse return error.NoOutput;
294 const shpid = lines.next() orelse return error.NoOutput; 334 const shpid = lines.next() orelse return error.NoOutput;
295 try std.testing.expectEqualStrings(shpid, sid); 335 const echoed = lines.next() orelse return error.NoOutput;
296 try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10)); 336 try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10));
337 try std.testing.expectEqualStrings("ping", echoed);
338 try std.testing.expect(own_sid > 0);
339 try std.testing.expectEqual(pid, child_sid);
340 try std.testing.expect(child_sid != own_sid);
297 } 341 }
298 342
299 test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" { 343 test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" {
@@ -383,13 +427,31 @@ test "server_os.sendNoSigNoWait: a closed peer is an error, not a signal" {
383 .flags = 0, 427 .flags = 0,
384 }; 428 };
385 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); 429 std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null);
386 var csp: [2]std.posix.fd_t = undefined; 430 // Two legs, because "the peer is gone" is two different states to
387 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &csp) != 0) exitNow(2); 431 // the kernel and only the second is the one the daemon meets. A
388 std.posix.close(csp[1]); 432 // socket that was NEVER written to and then lost its peer is the
389 _ = sendNoSigNoWait(csp[0], "x") catch |e| exitNow(if (e == error.BrokenPipe) 0 else 2); 433 // easy case; a socket that carried frames and then lost its peer
434 // mid-stream is the pump's own sequence, and on Darwin the two
435 // differ — the flag that suppresses the signal is a socket option
436 // there, and a socket the kernel has already shut down refuses to
437 // take one (see server_os_macos.sendNoSigNoWait). A test that asked
438 // only the first would pass on an arm that can never arm a live
439 // socket, and one that asked only the second would pass on an arm
440 // that only ever works after a successful send.
441 //
390 // A send that SUCCEEDED to a closed peer is as wrong as one that 442 // A send that SUCCEEDED to a closed peer is as wrong as one that
391 // signalled, and neither is 0. 443 // signalled, and neither is 0.
392 exitNow(2); 444 var gone: [2]std.posix.fd_t = undefined;
445 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &gone) != 0) exitNow(2);
446 std.posix.close(gone[1]);
447 if (sendNoSigNoWait(gone[0], "x")) |_| exitNow(2) else |e| if (e != error.BrokenPipe) exitNow(2);
448
449 var live: [2]std.posix.fd_t = undefined;
450 if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &live) != 0) exitNow(2);
451 _ = sendNoSigNoWait(live[0], "x") catch exitNow(2);
452 std.posix.close(live[1]);
453 if (sendNoSigNoWait(live[0], "x")) |_| exitNow(2) else |e| if (e != error.BrokenPipe) exitNow(2);
454 exitNow(0);
393 } 455 }
394 defer std.posix.close(f.master); 456 defer std.posix.close(f.master);
395 const status = std.posix.waitpid(f.pid, 0).status; 457 const status = std.posix.waitpid(f.pid, 0).status;
@@ -403,6 +465,78 @@ test "server_os.sendNoSigNoWait: a closed peer is an error, not a signal" {
403 try std.testing.expectEqual(@as(u32, std.posix.SOCK.STREAM), try sockType(sp[0])); 465 try std.testing.expectEqual(@as(u32, std.posix.SOCK.STREAM), try sockType(sp[0]));
404 } 466 }
405 467
468 test "server_os.sendNoSigNoWait: a full buffer is WouldBlock, not a stall" {
469 // The NoWait half of the name, asked of a socket nobody made
470 // non-blocking. Linux answers it from MSG_DONTWAIT alone. Darwin does
471 // not: xnu consults that flag when it takes the socket buffer lock, but
472 // the wait for buffer SPACE tests the socket's own SS_NBIO bit, which is
473 // O_NONBLOCK on the file descriptor and nothing a send flag can reach.
474 // An arm that only passes the flag therefore SLEEPS here, waiting for a
475 // peer that never reads — the same stall a client that stopped reading
476 // would impose on the daemon's only pump. The watchdog below is what
477 // turns that sleep into a sentence.
478 //
479 // The daemon's own fds are all non-blocking from their accept
480 // (`Server.setNonblocking`), so this asks the operation the question the
481 // daemon cannot: the promise has to hold for the fd, not for the caller.
482 var sp: [2]std.posix.fd_t = undefined;
483 try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
484 defer std.posix.close(sp[0]);
485 defer std.posix.close(sp[1]);
486 // A WATCHDOG, because the failure this test exists to catch is a thread
487 // that never runs again: an arm that waits for the peer sleeps inside
488 // `send`, so no deadline checked between iterations would ever be read.
489 // Another thread has to be the one holding the clock. It cannot unblock
490 // the send — draining the peer would make the test pass for the wrong
491 // reason — so it prints the diagnosis and ends the process, which the
492 // build runner reports as a failed test command. That is a sentence
493 // naming the cause instead of a runner that goes quiet for its whole
494 // timeout, which CLAUDE.md names as the worst failure mode here.
495 const Watchdog = struct {
496 done: std.atomic.Value(bool) = .init(false),
497 fn run(self: *@This()) void {
498 var waited_ms: usize = 0;
499 // Ten seconds against a loop of at most a few hundred syscalls:
500 // slack enough that a loaded machine cannot trip it, short enough
501 // to be an answer rather than a wait.
502 while (waited_ms < 10_000) : (waited_ms += 50) {
503 if (self.done.load(.acquire)) return;
504 std.Thread.sleep(50 * std.time.ns_per_ms);
505 }
506 std.debug.print(
507 \\
508 \\server_os.sendNoSigNoWait blocked on a full send buffer instead of
509 \\answering WouldBlock, so the NoWait half of its name is not true on
510 \\this OS. On Darwin that is MSG_DONTWAIT without O_NONBLOCK on the fd:
511 \\xnu tests the socket's own SS_NBIO bit when it waits for buffer space.
512 \\
513 , .{});
514 exitNow(1);
515 }
516 };
517 var watchdog: Watchdog = .{};
518 const watcher = try std.Thread.spawn(.{}, Watchdog.run, .{&watchdog});
519 defer {
520 watchdog.done.store(true, .release);
521 watcher.join();
522 }
523
524 // Nobody ever reads sp[1]. A socket send buffer is a few hundred KB at
525 // most, so 32 MB of 64 KB writes is two orders of magnitude of slack and
526 // still finishes in well under a second; reaching the bound means the
527 // send is swallowing the fill instead of reporting it.
528 const chunk = [_]u8{'x'} ** (64 * 1024);
529 var sent: usize = 0;
530 while (sent < 32 << 20) {
531 const n = sendNoSigNoWait(sp[0], &chunk) catch |e| {
532 if (e != error.WouldBlock) return e;
533 return;
534 };
535 sent += n;
536 }
537 return error.SendNeverReportedAFullBuffer;
538 }
539
406 // Forces semantic analysis of every pub decl under `zig build test`, so an 540 // Forces semantic analysis of every pub decl under `zig build test`, so an
407 // unreferenced operation must at least compile for this OS. 541 // unreferenced operation must at least compile for this OS.
408 test { 542 test {
src/os/server_os_macos.zig
Old New
@@ -0,0 +1,215 @@
1 //! Darwin arm of `server_os`. Spellings only; the contract is in the root.
2 //! Four operations use a different MECHANISM rather than a different
3 //! spelling, because the Linux one does not exist here, and each is
4 //! recorded in docs/decisions.md (2026-09-03, "the daemon's Darwin arm"):
5 //! `closeFrom` walks the fd table because there is no close_range,
6 //! `anonFd` is an unlinked mkstemp file because there is no memfd,
7 //! `sendNoSigNoWait` sets SO_NOSIGPIPE on the socket because there is no
8 //! MSG_NOSIGNAL and sets O_NONBLOCK on the fd because MSG_DONTWAIT does not
9 //! reach xnu's wait for buffer space, and `peerCred` takes two calls because
10 //! LOCAL_PEERCRED answers no pid.
11 const std = @import("std");
12 const root = @import("server_os.zig");
13 const c = @cImport({
14 @cInclude("util.h"); // forkpty
15 @cInclude("sys/ioctl.h");
16 @cInclude("sys/socket.h");
17 @cInclude("sys/un.h"); // LOCAL_PEERPID
18 @cInclude("unistd.h"); // getpeereid, getdtablesize
19 @cInclude("stdlib.h"); // mkstemp
20 });
21
22 pub fn getpid() std.posix.pid_t {
23 return std.c.getpid();
24 }
25
26 pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred {
27 // Two calls where Linux has one: Darwin's LOCAL_PEERCRED answers a
28 // `struct xucred` with no pid in it, so the uid comes from getpeereid
29 // and the pid from a socket option of its own.
30 var uid: c.uid_t = undefined;
31 var gid: c.gid_t = undefined;
32 if (c.getpeereid(fd, &uid, &gid) != 0) return null;
33 var pid: c.pid_t = 0;
34 var len: c.socklen_t = @sizeOf(c.pid_t);
35 // SOL_LOCAL is 0 on Darwin; LOCAL_PEERPID answers the peer's pid for a
36 // unix socket the way SO_PEERCRED's pid field does on Linux. The root
37 // rejects a non-positive pid, so a kernel that will not name the peer
38 // reads as "will not say" there rather than as a pid of 0 here.
39 if (c.getsockopt(fd, 0, c.LOCAL_PEERPID, &pid, &len) != 0) return null;
40 return .{ .uid = @intCast(uid), .pid = @intCast(pid) };
41 }
42
43 pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize {
44 // Darwin has no MSG_NOSIGNAL: the "do not raise SIGPIPE" bit is a
45 // property of the SOCKET, not of the send. Set per call rather than once
46 // at accept, because every fd that reaches this operation must have it
47 // and there is no one place every such fd is created — an accepted
48 // client, a socketpair end, an adopted fd across an upgrade. The option
49 // is idempotent and costs one setsockopt on a path that is already a
50 // syscall.
51 //
52 // A REFUSED set is the interesting case and must not fall through to
53 // send. Measured 2026-09-03 on macOS 26: Darwin's `sosetopt` rejects
54 // every socket option with EINVAL once a socket is shut down in both
55 // directions, which is exactly the state a hung-up peer leaves behind —
56 // so the one send that would raise the signal is also the one send the
57 // flag cannot be set for. EINVAL here is therefore not an argument
58 // complaint (the level, name, value and length are all fixed above);
59 // it is the kernel saying the peer is gone, which is what `send` would
60 // have answered had it not signalled first. Every other setsockopt
61 // failure — a bad fd, not a socket — describes a socket that cannot
62 // raise SIGPIPE either, so those fall through and let `send` name them.
63 const on: c_int = 1;
64 const rc = c.setsockopt(fd, c.SOL_SOCKET, c.SO_NOSIGPIPE, &on, @sizeOf(c_int));
65 if (rc != 0 and std.posix.errno(rc) == .INVAL) return error.BrokenPipe;
66
67 // The NoWait half is the fd's, not the send's. MSG_DONTWAIT exists on
68 // Darwin and the name suggests it covers this, but xnu only consults it
69 // when it takes the socket buffer lock. The wait for buffer SPACE, a
70 // little further into sosend, tests the socket's own SS_NBIO bit — which
71 // is O_NONBLOCK on the file descriptor and nothing the flags argument can
72 // reach. So a send with MSG_DONTWAIT on a blocking fd whose peer has
73 // stopped reading sleeps in the kernel until the peer drains, which is
74 // exactly the stall this operation promises the daemon it will never
75 // take. Linux honours the flag and needs none of this.
76 //
77 // Make it true rather than assume it: read the flags and add O_NONBLOCK
78 // when it is missing. It is a no-op for every fd the daemon owns
79 // (`Server.setNonblocking` sets it on each accepted client, and a `Sink`
80 // socket fd only ever comes from that accept), so the cost is one fcntl
81 // on a path that already makes two syscalls, and the fds that are not the
82 // daemon's — a socketpair a test or a future caller hands in — get the
83 // guarantee the name makes instead of a hang. The change is sticky, which
84 // is correct: an fd this operation may be called on must never block.
85 // An fd fcntl refuses is a bad fd or not a socket, and `send` names that
86 // better than a swallowed fcntl error would.
87 if (std.posix.fcntl(fd, std.posix.F.GETFL, 0)) |fl| {
88 const nb: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
89 if (fl & nb == 0) _ = std.posix.fcntl(fd, std.posix.F.SETFL, fl | nb) catch {};
90 } else |_| {}
91
92 return std.posix.sendto(fd, bytes, std.posix.MSG.DONTWAIT, null, 0) catch |err| switch (err) {
93 // Darwin's THIRD spelling of "the peer is gone", and the one that
94 // aborts the process instead of being an error. `std.posix.send` maps
95 // ENOTCONN to `unreachable`, because for a local fd it can only mean
96 // the caller passed something unconnected; Darwin also uses it for the
97 // FAR end, for a peer that has begun closing but not finished. A
98 // moment earlier the socket still takes SO_NOSIGPIPE, a moment later
99 // it answers EPIPE, and in between it answers this.
100 //
101 // Found by READING this arm, not by a trace out of it: the abort was
102 // caught on 2026-09-04 in the CLIENT arm, out of the probe that asks
103 // whether an ssh agent is listening (`client_os_macos.sendNoSig` says
104 // so, and quotes it). This arm reaches `send` the same way, so it had
105 // the same hole — and on the daemon's only pump, where an abort takes
106 // every session with it rather than one probe. It is a race and does
107 // not reproduce on demand: measured against a peer that had closed
108 // and settled, all three shapes — accepted then closed, closed with
109 // no delay, never accepted — answer EPIPE on both systems. `sendto`
110 // RETURNS the error where `send` unwraps it, which is why this arm
111 // goes through it directly and answers the contract's BrokenPipe.
112 error.SocketNotConnected => return error.BrokenPipe,
113 // Only a `sendto` carrying an ADDRESS can raise these, and this one
114 // passes null. `std.posix.send` calls them unreachable for the same
115 // reason.
116 error.AddressFamilyNotSupported,
117 error.SymLinkLoop,
118 error.NameTooLong,
119 error.FileNotFound,
120 error.NotDir,
121 error.NetworkUnreachable,
122 error.AddressNotAvailable,
123 error.UnreachableAddress,
124 => unreachable,
125 else => |e| return e,
126 };
127 }
128
129 pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 {
130 var t: c_int = undefined;
131 var len: c.socklen_t = @sizeOf(c_int);
132 if (c.getsockopt(fd, c.SOL_SOCKET, c.SO_TYPE, &t, &len) != 0) return error.NotASocket;
133 return @intCast(t);
134 }
135
136 pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty {
137 var master: c_int = undefined;
138 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
139 const pid = c.forkpty(&master, null, null, &cws);
140 if (pid < 0) return error.ForkPtyFailed;
141 return .{ .pid = pid, .master = master };
142 }
143
144 pub fn exitNow(code: u8) noreturn {
145 // `_exit(2)` and not `exit(3)`: the root's doc says why — atexit and the
146 // stdio flush would write the parent's pending bytes a second time.
147 std.c._exit(code);
148 }
149
150 pub fn forkDetached(
151 exe: [*:0]const u8,
152 argv: [*:null]const ?[*:0]const u8,
153 stdin_fd: std.posix.fd_t,
154 out_fd: std.posix.fd_t,
155 ) error{ForkFailed}!std.posix.pid_t {
156 const pid = std.posix.fork() catch return error.ForkFailed;
157 if (pid != 0) return pid;
158 _ = std.c.setsid();
159 std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127);
160 std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127);
161 std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127);
162 std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127);
163 unreachable;
164 }
165
166 pub fn closeFrom(first: std.posix.fd_t) void {
167 // No close_range on Darwin: one close per slot up to the table size,
168 // which is a few hundred cheap EBADFs once per session start. Between
169 // fork and exec, so nothing else is opening fds underneath the walk.
170 // `getdtablesize` is the soft RLIMIT_NOFILE, which is also the ceiling
171 // on any fd this process could be holding, so the walk cannot miss one.
172 var fd: std.posix.fd_t = first;
173 const top: std.posix.fd_t = c.getdtablesize();
174 while (fd < top) : (fd += 1) _ = std.c.close(fd);
175 }
176
177 pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!root.PtyMode {
178 // Measured 2026-09-03: Darwin's master answers tcgetattr for the slave's
179 // line discipline, so this is the Linux shape and not a reopen by name.
180 const t = try std.posix.tcgetattr(master);
181 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
182 }
183
184 pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t {
185 var pgid: c.pid_t = 0;
186 if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
187 return @intCast(pgid);
188 }
189
190 pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void {
191 var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 };
192 if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed;
193 }
194
195 pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t {
196 // No memfd on Darwin. A 0600 file this uid creates and unlinks before
197 // anyone could open it by name is private by mode where memfd is
198 // private by having no name; the window is the two calls below, on an
199 // empty file. /tmp rather than the runtime dir because this file
200 // imports nothing of ours and must not learn the socket directory.
201 var tmpl: [128]u8 = undefined;
202 const t = std.fmt.bufPrintZ(&tmpl, "/tmp/mux-{s}-XXXXXX", .{std.mem.span(name)}) catch return error.CarrierFailed;
203 const fd = c.mkstemp(t.ptr);
204 if (fd < 0) return error.CarrierFailed;
205 errdefer std.posix.close(fd);
206 // mkstemp fills the XXXXXX in place, so the name to unlink is `t` as it
207 // reads now and not the template that was printed into it.
208 std.posix.unlink(t) catch return error.CarrierFailed;
209 // mkstemp opens O_CLOEXEC on modern Darwin; the candidate must inherit
210 // the carrier across `mux d upgrade`'s exec, so the flag comes back off.
211 const flags = std.posix.fcntl(fd, std.posix.F.GETFD, 0) catch return error.CarrierFailed;
212 _ = std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC)) catch
213 return error.CarrierFailed;
214 return fd;
215 }
src/proxy.zig
Old New
@@ -2,7 +2,13 @@
2 //! socket. Deliberately frame-agnostic — the transport thesis is that if an 2 //! socket. Deliberately frame-agnostic — the transport thesis is that if an
3 //! opaque byte pipe carries the protocol, transport is a swap and not a 3 //! opaque byte pipe carries the protocol, transport is a swap and not a
4 //! redesign. Keep this file's import list empty of `protocol`. 4 //! redesign. Keep this file's import list empty of `protocol`.
5 //!
6 //! `sockpath` is the one module of ours here, and it carries no wire contract:
7 //! it owns the question "what is at this path", which the connect below has to
8 //! ask because the two kernels answer a non-socket differently and one of them
9 //! answers with a panic.
5 const std = @import("std"); 10 const std = @import("std");
11 const sockpath = @import("sockpath");
6 const TmpDir = @import("testtmp").TmpDir; 12 const TmpDir = @import("testtmp").TmpDir;
7 13
8 /// Make a hangup surface as EPIPE from write() instead of killing the 14 /// Make a hangup surface as EPIPE from write() instead of killing the
@@ -27,7 +33,7 @@ pub fn run(sock_path: []const u8) !u8 {
27 /// local daemon. Exits 0 when either side hangs up cleanly, 1 if the daemon 33 /// local daemon. Exits 0 when either side hangs up cleanly, 1 if the daemon
28 /// socket cannot be reached or a read or write fails. 34 /// socket cannot be reached or a read or write fails.
29 pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8) !u8 { 35 pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8) !u8 {
30 const stream = std.net.connectUnixSocket(sock_path) catch { 36 const stream = sockpath.connectSocket(sock_path) catch {
31 std.debug.print("mux d proxy: cannot connect to {s}\n", .{sock_path}); 37 std.debug.print("mux d proxy: cannot connect to {s}\n", .{sock_path});
32 return 1; 38 return 1;
33 }; 39 };
src/server/pty.zig
Old New
@@ -233,8 +233,25 @@ test "Pty: deinit is bounded even when the child ignores HUP and TERM" {
233 pty.deinit(); 233 pty.deinit();
234 const elapsed_ms = t.read() / std.time.ns_per_ms; 234 const elapsed_ms = t.read() / std.time.ns_per_ms;
235 235
236 // Returned at all is the headline. The bound is the assertion that can 236 // The LOWER bound is the property. A child that died to the TERM sent
237 // still fail fast if someone widens the grace period without meaning to. 237 // before its `trap` line ran is dead too, and every assertion here holds
238 // for it while the SIGKILL path this test is named for was never walked —
239 // a vacuous pass whose only symptom is the suite running faster. The
240 // 300 ms above is 15x the worst arming measured (21 ms, bash 3.2 on a
241 // loaded Mac), so losing that race means the box is slow, not that the
242 // product changed; this turns it into a sentence rather than a green run.
243 // `pty` cannot reach the harness's armed-marker door — that is the
244 // daemon's fixture and this file is under it — so the clock is the pin.
245 if (elapsed_ms < Pty.term_grace_ms) {
246 std.debug.print(
247 "the child died {d} ms into the {d} ms grace: it did not ignore the TERM, " ++
248 "so the SIGKILL that bounds deinit was never reached\n",
249 .{ elapsed_ms, Pty.term_grace_ms },
250 );
251 return error.ChildDiedInsideTheGrace;
252 }
253 // Returned at all is the headline. The upper bound is the assertion that
254 // can still fail fast if someone widens the grace period without meaning to.
238 try std.testing.expect(elapsed_ms < 3000); 255 try std.testing.expect(elapsed_ms < 3000);
239 // Reaped, not merely abandoned: a deinit that returned while leaving a 256 // Reaped, not merely abandoned: a deinit that returned while leaving a
240 // zombie would satisfy the clock and leak the process. 257 // zombie would satisfy the clock and leak the process.
@@ -274,6 +291,33 @@ test "Pty: spawn /bin/sh, echo round trip" {
274 try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null); 291 try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null);
275 } 292 }
276 293
294 /// Wait for the shell behind `pty` to exit, DRAINING the master while it
295 /// waits, and return its code (null if the budget ran out). Draining is not
296 /// tidiness: the daemon reads every session's master continuously, and on
297 /// Darwin a shell cannot finish exiting while the output it wrote sits
298 /// undrained in the tty — the last close of the slave waits for that queue
299 /// to empty, so a test that only slept would deadlock against a shell that
300 /// had already run `exit`. Measured 2026-09-03 on macOS 26 with a plain
301 /// forkpty and no mux in the picture: the same child exits in 600 ms when
302 /// the master is read and never at all when it is not. Linux lets the exit
303 /// through either way, which is why this went unnoticed until the Mac.
304 fn waitExitDraining(pty: *Pty, budget_ms: u64) ?u32 {
305 var buf: [4096]u8 = undefined;
306 var waited_ms: u64 = 0;
307 while (waited_ms < budget_ms) : (waited_ms += 50) {
308 if (pty.checkExited()) |code| return code;
309 var fds = [_]std.posix.pollfd{
310 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
311 };
312 // EOF and EIO both mean the pty is finished; keep waiting for the
313 // status either way, because the exit code is what is being asked
314 // for and `checkExited` above is what answers it.
315 const ready = std.posix.poll(&fds, 50) catch 0;
316 if (ready > 0) _ = std.posix.read(pty.master, &buf) catch {};
317 }
318 return pty.checkExited();
319 }
320
277 /// Returns everything read, so a caller asserting absence can show what 321 /// Returns everything read, so a caller asserting absence can show what
278 /// it got. 322 /// it got.
279 fn readUntil( 323 fn readUntil(
@@ -403,14 +447,7 @@ test "Pty: checkExited reports shell exit" {
403 try std.testing.expect(pty.checkExited() == null); 447 try std.testing.expect(pty.checkExited() == null);
404 _ = try std.posix.write(pty.master, "exit 7\n"); 448 _ = try std.posix.write(pty.master, "exit 7\n");
405 449
406 var waited_ms: u64 = 0; 450 try std.testing.expectEqual(@as(?u32, 7), waitExitDraining(&pty, 5000));
407 var code: ?u32 = null;
408 while (waited_ms < 5000) : (waited_ms += 50) {
409 code = pty.checkExited();
410 if (code != null) break;
411 std.Thread.sleep(50 * std.time.ns_per_ms);
412 }
413 try std.testing.expectEqual(@as(?u32, 7), code);
414 } 451 }
415 452
416 test "Pty: a later spawn does not inherit an earlier session's master" { 453 test "Pty: a later spawn does not inherit an earlier session's master" {
@@ -593,14 +630,7 @@ test "Pty.adopt: an adopted pair still reports the child's real exit code" {
593 630
594 _ = try std.posix.write(adopted.master, "exit 7\n"); 631 _ = try std.posix.write(adopted.master, "exit 7\n");
595 632
596 var waited_ms: u64 = 0; 633 try std.testing.expectEqual(@as(?u32, 7), waitExitDraining(&adopted, 5000));
597 var code: ?u32 = null;
598 while (waited_ms < 5000) : (waited_ms += 50) {
599 code = adopted.checkExited();
600 if (code != null) break;
601 std.Thread.sleep(50 * std.time.ns_per_ms);
602 }
603 try std.testing.expectEqual(@as(?u32, 7), code);
604 634
605 adopted.deinit(); 635 adopted.deinit();
606 } 636 }
src/server/quic_server.zig
Old New
@@ -1391,20 +1391,28 @@ test "Listener: a packet addressed to any advertised CID reaches its connection"
1391 var probe_len: std.posix.socklen_t = @sizeOf(@TypeOf(probe_addr)); 1391 var probe_len: std.posix.socklen_t = @sizeOf(@TypeOf(probe_addr));
1392 try std.posix.getsockname(probe, @ptrCast(&probe_addr), &probe_len); 1392 try std.posix.getsockname(probe, @ptrCast(&probe_addr), &probe_len);
1393 1393
1394 // The baseline: nothing has been routed, so nothing may arrive. It waits
1395 // the same budget the real absence check does, so the two are the same
1396 // question asked twice and a budget too short to matter would show up
1397 // here as well.
1394 var pkt: [1300]u8 = undefined; 1398 var pkt: [1300]u8 = undefined;
1395 try std.testing.expect(!probeGotAnything(probe)); 1399 try std.testing.expect(!probeGotAnything(probe, probe_absence_ms));
1396 1400
1397 // Addressed to a CID this connection advertised: delivered, not 1401 // Addressed to a CID this connection advertised: delivered, not
1398 // answered. 1402 // answered. This is the test's central negative and it has to be a WAIT,
1403 // not a glance — a read that returns before any datagram could have
1404 // crossed loopback is true of a listener that answered wrongly.
1399 buildInitial(&pkt, sec.data[0..sec.datalen]); 1405 buildInitial(&pkt, sec.data[0..sec.datalen]);
1400 setup.l.route(&pkt, &probe_addr, probe_len); 1406 setup.l.route(&pkt, &probe_addr, probe_len);
1401 try std.testing.expect(!probeGotAnything(probe)); 1407 try std.testing.expect(!probeGotAnything(probe, probe_absence_ms));
1402 1408
1403 // Addressed to nobody: `accept` answers with a Retry, which is what 1409 // Addressed to nobody: `accept` answers with a Retry, which is what
1404 // every migrated packet used to get. 1410 // every migrated packet used to get. The budget here is generous because
1411 // a MISSING reply is the failure this checks for; how long the reply
1412 // really takes is what `probe_absence_ms` is sized against.
1405 buildInitial(&pkt, &stranger_cid); 1413 buildInitial(&pkt, &stranger_cid);
1406 setup.l.route(&pkt, &probe_addr, probe_len); 1414 setup.l.route(&pkt, &probe_addr, probe_len);
1407 try std.testing.expect(probeGotAnything(probe)); 1415 try std.testing.expect(probeGotAnything(probe, 2000));
1408 } 1416 }
1409 1417
1410 /// A syntactically valid, cryptographically meaningless Initial packet 1418 /// A syntactically valid, cryptographically meaningless Initial packet
@@ -1432,12 +1440,32 @@ fn buildInitial(pkt: *[1300]u8, dcid: []const u8) void {
1432 std.mem.writeInt(u16, pkt[i..][0..2], rest | 0x4000, .big); 1440 std.mem.writeInt(u16, pkt[i..][0..2], rest | 0x4000, .big);
1433 } 1441 }
1434 1442
1435 fn probeGotAnything(fd: std.posix.fd_t) bool { 1443 /// True when a datagram arrives within `budget_ms`. Every caller waits,
1444 /// including the ones asserting ABSENCE: loopback UDP is not synchronous
1445 /// everywhere — Linux hands the datagram over inside the `sendto`, so an
1446 /// immediate recv finds it, and Darwin does not. A read with no budget
1447 /// therefore CANNOT SEE a Retry on a Mac, which would make "nothing came
1448 /// back" true of a listener that answered wrongly. The absence budget is
1449 /// what makes the negative a claim rather than a race the Mac always wins;
1450 /// `probe_absence_ms` says how it was chosen.
1451 fn probeGotAnything(fd: std.posix.fd_t, budget_ms: i32) bool {
1436 var buf: [2048]u8 = undefined; 1452 var buf: [2048]u8 = undefined;
1453 var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
1454 if (budget_ms > 0) _ = std.posix.poll(&fds, budget_ms) catch return false;
1437 const n = std.posix.recv(fd, &buf, 0) catch return false; 1455 const n = std.posix.recv(fd, &buf, 0) catch return false;
1438 return n > 0; 1456 return n > 0;
1439 } 1457 }
1440 1458
1459 /// How long "nothing came back" waits before it is believed. Sized against
1460 /// the presence check at the end of the same test, on the same socket:
1461 /// timed on both systems 2026-09-03, a Retry this listener DOES send is
1462 /// readable in under a millisecond once the reader yields to a poll. Half a
1463 /// second is three orders of magnitude of slack, so a wrongly-emitted one has
1464 /// had every chance to arrive. What it is NOT sized against is `sendto`
1465 /// itself: Linux delivers inside the call and Darwin does not, which is why
1466 /// a glance with no budget at all could never fail on a Mac.
1467 const probe_absence_ms: i32 = 500;
1468
1441 test "Listener: a reply queued just before a close still reaches the peer" { 1469 test "Listener: a reply queued just before a close still reaches the peer" {
1442 const alloc = std.testing.allocator; 1470 const alloc = std.testing.allocator;
1443 const key: quic.Key = .{ .bytes = [_]u8{0x5C} ** quic.key_len }; 1471 const key: quic.Key = .{ .bytes = [_]u8{0x5C} ** quic.key_len };
src/server/server_test_agent.zig
Old New
@@ -244,7 +244,17 @@ test "Server: a full channel table refuses the newest dial and says so once" {
244 // so a long-lived agent connection holding a slot is the field failure 244 // so a long-lived agent connection holding a slot is the field failure
245 // this counts — eight of them turn forwarding off for every session. 245 // this counts — eight of them turn forwarding off for every session.
246 var dials: [max_agent_chans + 1]std.net.Stream = undefined; 246 var dials: [max_agent_chans + 1]std.net.Stream = undefined;
247 for (&dials) |*d| d.* = try dial.dial(path); 247 // Pump between dials, because the listen backlog is `max_agent_chans` and
248 // this dials one more than that. A queue that nobody accepts from is full
249 // at the last dial, and the two kernels answer that differently: Linux
250 // stretches, Darwin refuses the connect outright with ECONNREFUSED, which
251 // failed the test before the daemon had a chance to say anything. Letting
252 // the daemon accept between dials is also what the field looks like — ssh
253 // opens its channels one at a time against a running daemon.
254 for (&dials) |*d| {
255 d.* = try dial.dial(path);
256 try td.srv.pumpOnce(5);
257 }
248 defer for (dials) |d| d.close(); 258 defer for (dials) |d| d.close();
249 259
250 const Refusals = struct { 260 const Refusals = struct {
src/server/server_test_attach.zig
Old New
@@ -539,8 +539,30 @@ test "Server: scrollback fetch is per-client and independent" {
539 const b = try dial.dialAttach(td.sock_path, 100, 30); 539 const b = try dial.dialAttach(td.sock_path, 100, 30);
540 defer b.close(); 540 defer b.close();
541 541
542 // Typed by B; both clients see the history it produces. 542 // B attached LAST, and this waits for the daemon to have processed each
543 try proto.writeFrame(b.handle, .input, "seq 1 100\n"); 543 // attach in the order they were dialled — a state frame only exists once
544 // `seatClient` has run for that fd. The daemon is on its own thread here,
545 // so without the waits A's attach could be seated after B's and the grid
546 // would be at A's 80x24, which is the opposite of what the selections
547 // below are about. The frames are ASKED FOR, not discarded: a null here
548 // is a seat that never happened, and swallowing it would leave the wait
549 // in place while it proved nothing.
550 _ = (try h.firstStateFrame(alloc, a.handle, 10_000)) orelse return error.NoStateFrameForA;
551 _ = (try h.firstStateFrame(alloc, b.handle, 10_000)) orelse return error.NoStateFrameForB;
552
553 // Typed by B; both clients see the history it produces. Every output row
554 // carries the SAME marker, and the selections below read a row well down
555 // the list, so no assertion here depends on where the shell's own echo of
556 // the command landed or on what it looked like. That was not a spare
557 // precaution: macOS's /bin/sh is bash, whose readline redraws the input
558 // line whenever SIGWINCH arrives — carriage return, erase to end of line,
559 // prompt again — and the resize from the second attach arrives while that
560 // line is being echoed. Measured on macOS 26, the request that reads
561 // ">>seq 1 100" on Linux read "seq 1 100" there, and moving the target one
562 // row down landed on the echo instead of past it. Which column a
563 // REDRAWN input line starts in is the shell's business; a row the shell
564 // printed is the same on both.
565 try proto.writeFrame(b.handle, .input, "seq 1 100 | sed 's/.*/xxSELECTEDxx/'\n");
544 566
545 for ([_]std.posix.fd_t{ a.handle, b.handle }) |fd| { 567 for ([_]std.posix.fd_t{ a.handle, b.handle }) |fd| {
546 try std.testing.expect(try awaitHistoryRows(alloc, fd, 50, 10_000)); 568 try std.testing.expect(try awaitHistoryRows(alloc, fd, 50, 10_000));
@@ -552,8 +574,8 @@ test "Server: scrollback fetch is per-client and independent" {
552 // claim A's size nor disclose the reply to B. 574 // claim A's size nor disclose the reply to B.
553 const exact = proto.encodeSelectionReq(.{ 575 const exact = proto.encodeSelectionReq(.{
554 .id = 77, 576 .id = 77,
555 .anchor = .{ .row = 0, .col = 2 }, 577 .anchor = .{ .row = 10, .col = 2 },
556 .active = .{ .row = 0, .col = 10 }, 578 .active = .{ .row = 10, .col = 9 },
557 }); 579 });
558 const invalid = proto.encodeSelectionReq(.{ 580 const invalid = proto.encodeSelectionReq(.{
559 .id = 78, 581 .id = 78,
@@ -562,13 +584,13 @@ test "Server: scrollback fetch is per-client and independent" {
562 }); 584 });
563 const one = proto.encodeSelectionReq(.{ 585 const one = proto.encodeSelectionReq(.{
564 .id = 79, 586 .id = 79,
565 .anchor = .{ .row = 1, .col = 0 }, 587 .anchor = .{ .row = 10, .col = 0 },
566 .active = .{ .row = 1, .col = 0 }, 588 .active = .{ .row = 10, .col = 0 },
567 }); 589 });
568 const sentinel = proto.encodeSelectionReq(.{ 590 const sentinel = proto.encodeSelectionReq(.{
569 .id = 80, 591 .id = 80,
570 .anchor = .{ .row = 0, .col = 2 }, 592 .anchor = .{ .row = 10, .col = 2 },
571 .active = .{ .row = 0, .col = 10 }, 593 .active = .{ .row = 10, .col = 9 },
572 }); 594 });
573 try proto.writeFrame(a.handle, .selection_req, &exact); 595 try proto.writeFrame(a.handle, .selection_req, &exact);
574 try proto.writeFrame(a.handle, .selection_req, &invalid); 596 try proto.writeFrame(a.handle, .selection_req, &invalid);
@@ -586,7 +608,7 @@ test "Server: scrollback fetch is per-client and independent" {
586 0 => { 608 0 => {
587 try std.testing.expectEqual(@as(u32, 77), reply.id); 609 try std.testing.expectEqual(@as(u32, 77), reply.id);
588 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status); 610 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status);
589 try std.testing.expectEqualStrings("seq 1 100", reply.text); 611 try std.testing.expectEqualStrings("SELECTED", reply.text);
590 }, 612 },
591 1 => { 613 1 => {
592 try std.testing.expectEqual(@as(u32, 78), reply.id); 614 try std.testing.expectEqual(@as(u32, 78), reply.id);
@@ -596,7 +618,7 @@ test "Server: scrollback fetch is per-client and independent" {
596 2 => { 618 2 => {
597 try std.testing.expectEqual(@as(u32, 79), reply.id); 619 try std.testing.expectEqual(@as(u32, 79), reply.id);
598 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status); 620 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status);
599 try std.testing.expectEqualStrings("1", reply.text); 621 try std.testing.expectEqualStrings("x", reply.text);
600 }, 622 },
601 3 => { 623 3 => {
602 // Reaching this request proves the malformed frame before 624 // Reaching this request proves the malformed frame before
@@ -605,7 +627,7 @@ test "Server: scrollback fetch is per-client and independent" {
605 // assertion. 627 // assertion.
606 try std.testing.expectEqual(@as(u32, 80), reply.id); 628 try std.testing.expectEqual(@as(u32, 80), reply.id);
607 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status); 629 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.status);
608 try std.testing.expectEqualStrings("seq 1 100", reply.text); 630 try std.testing.expectEqualStrings("SELECTED", reply.text);
609 }, 631 },
610 else => unreachable, 632 else => unreachable,
611 } 633 }
@@ -1386,12 +1408,21 @@ test "Server: an observer that never reads its replies is dropped, not allowed t
1386 defer peer.close(); 1408 defer peer.close();
1387 try td.srv.pumpOnce(20); 1409 try td.srv.pumpOnce(20);
1388 // Every reply is up to sessions_text_len bytes and the peer never reads 1410 // Every reply is up to sessions_text_len bytes and the peer never reads
1389 // one, so a small send buffer is what an unread socket looks like a few 1411 // one, so a small buffer is what an unread socket looks like a few frames
1390 // frames sooner. Set on the DAEMON's end: for AF_UNIX it is the sender's 1412 // sooner. Set on BOTH ends, because the two kernels put the bytes in
1391 // buffer that fills. 1413 // different places: for AF_UNIX Linux holds them against the SENDER's
1414 // send buffer, while Darwin hands them to the peer and charges the
1415 // RECEIVER's receive buffer, so capping only the daemon's end left the
1416 // Mac with room for the whole burst and no drop to grade.
1417 // 1 KB and not 4 KB: the replies to this burst are a few bytes each, so
1418 // the cap has to sit below their total on BOTH systems, and what a given
1419 // request buys differs — measured 2026-09-03, asking for 4096 leaves
1420 // 1792 bytes of room on Linux and the full 4096 on Darwin, which the
1421 // whole burst fitted inside. Asking for 1024 leaves about 1024 on each.
1392 const fd = td.srv.observers[0].?.fd; 1422 const fd = td.srv.observers[0].?.fd;
1393 const sndbuf: c_int = 4096; 1423 const small: c_int = 1024;
1394 try std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.SNDBUF, std.mem.asBytes(&sndbuf)); 1424 try std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.SNDBUF, std.mem.asBytes(&small));
1425 try std.posix.setsockopt(peer.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVBUF, std.mem.asBytes(&small));
1395 1426
1396 // One write, hundreds of frames: they fit in a single 64 KB read, which 1427 // One write, hundreds of frames: they fit in a single 64 KB read, which
1397 // is what makes the whole burst one uninterrupted drain. 1428 // is what makes the whole burst one uninterrupted drain.
@@ -1400,10 +1431,19 @@ test "Server: an observer that never reads its replies is dropped, not allowed t
1400 for (0..400) |_| try proto.appendFrame(&burst, alloc, .sessions_req, ""); 1431 for (0..400) |_| try proto.appendFrame(&burst, alloc, .sessions_req, "");
1401 try proto.writeAllFd(peer.handle, burst.items); 1432 try proto.writeAllFd(peer.handle, burst.items);
1402 1433
1434 // Pumps, plural, and bounded: one pump answers at most
1435 // `max_observer_frames_per_pump` of the burst, and how many of those
1436 // replies it takes to fill a socket is the kernel's business — Darwin's
1437 // smallest usable buffer holds more of them than Linux's. What is being
1438 // graded is that the daemon DROPS this peer rather than waiting on it,
1439 // and that no pump along the way blocked.
1403 const t0 = std.time.milliTimestamp(); 1440 const t0 = std.time.milliTimestamp();
1404 try td.srv.pumpOnce(20); 1441 for (0..64) |_| {
1442 try td.srv.pumpOnce(20);
1443 if (td.srv.observers[0] == null) break;
1444 }
1405 const spent = std.time.milliTimestamp() - t0; 1445 const spent = std.time.milliTimestamp() - t0;
1406 // The claim: the pump came back. A blocking write here never returns 1446 // The claim: every pump came back. A blocking write here never returns
1407 // until the peer reads, and no session on the box is served meanwhile. 1447 // until the peer reads, and no session on the box is served meanwhile.
1408 try std.testing.expect(spent < 5_000); 1448 try std.testing.expect(spent < 5_000);
1409 try std.testing.expect(td.srv.observers[0] == null); 1449 try std.testing.expect(td.srv.observers[0] == null);
@@ -1454,10 +1494,7 @@ test "Server: the two bounded deadlines read a monotonic clock, not the calendar
1454 1494
1455 var td = try h.TestDaemon.open(alloc, "mono"); 1495 var td = try h.TestDaemon.open(alloc, "mono");
1456 defer td.deinit(); 1496 defer td.deinit();
1457 const script = try h.writeStubbornShell(alloc, &td.tmp); 1497 try td.startStubborn(alloc, .{});
1458 defer alloc.free(script);
1459
1460 try td.start(.{ .shell = script });
1461 1498
1462 // CLOCK_MONOTONIC counts from boot and CLOCK_REALTIME from 1970, so any 1499 // CLOCK_MONOTONIC counts from boot and CLOCK_REALTIME from 1970, so any
1463 // machine that has been up less than a decade separates them by a 1500 // machine that has been up less than a decade separates them by a
@@ -1508,7 +1545,33 @@ test "Server: an observer that dribbles a huge frame is dropped at the cap, not
1508 const dribble = try alloc.alloc(u8, srv_mod.observer_inbound_max + 1); 1545 const dribble = try alloc.alloc(u8, srv_mod.observer_inbound_max + 1);
1509 defer alloc.free(dribble); 1546 defer alloc.free(dribble);
1510 @memset(dribble, 'x'); 1547 @memset(dribble, 'x');
1511 try proto.writeAllFd(peer.handle, dribble); 1548
1549 // Dribble it the way a real peer does — write, let the daemon pump,
1550 // write again — rather than in one blocking writeAllFd. Nobody reads
1551 // this socket except `pumpOnce`, so the whole dribble has to fit in the
1552 // send buffer for a single write to return, and it does not everywhere:
1553 // a unix stream socket holds a few hundred KB on Linux and about 8 KB on
1554 // Darwin, which is the cap itself. The one write wedged the Mac suite in
1555 // `write` with no output. Non-blocking so a full buffer is a short write
1556 // to hand back to the pump instead of a stall, and the loop stops the
1557 // moment the daemon drops the observer, which is the whole point.
1558 const fl = try std.posix.fcntl(peer.handle, std.posix.F.GETFL, 0);
1559 const nb: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
1560 _ = try std.posix.fcntl(peer.handle, std.posix.F.SETFL, fl | nb);
1561 var off: usize = 0;
1562 // Bounded: every iteration either moves bytes or gives the daemon a pump
1563 // to drain them, so a run that spends this many without the drop has
1564 // stopped making progress and should fail rather than hang.
1565 for (0..1000) |_| {
1566 if (off == dribble.len or td.srv.observers[0] == null) break;
1567 off += std.posix.write(peer.handle, dribble[off..]) catch |e| switch (e) {
1568 // The drop closes the socket under us; that IS the outcome.
1569 error.WouldBlock => @as(usize, 0),
1570 error.BrokenPipe, error.ConnectionResetByPeer => break,
1571 else => return e,
1572 };
1573 try td.srv.pumpOnce(20);
1574 }
1512 1575
1513 try td.srv.pumpOnce(20); 1576 try td.srv.pumpOnce(20);
1514 try std.testing.expect(td.srv.observers[0] == null); 1577 try std.testing.expect(td.srv.observers[0] == null);
src/server/server_test_harness.zig
Old New
@@ -1,6 +1,6 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const Engine = @import("term").engine.Engine; 2 const Engine = @import("term").engine.Engine;
3 const Pty = @import("pty").Pty; 3 pub const Pty = @import("pty").Pty;
4 const proto = @import("term").protocol; 4 const proto = @import("term").protocol;
5 const replica_mod = @import("term").replica; 5 const replica_mod = @import("term").replica;
6 const quic = @import("quic"); 6 const quic = @import("quic");
@@ -163,6 +163,53 @@ pub const TestDaemon = struct {
163 return self; 163 return self;
164 } 164 }
165 165
166 /// `Opts` minus `shell`, which the stubborn doors supply: naming a shell
167 /// there is the one thing a stubborn-shell test must not do. One field
168 /// today rather than a copy of the whole list — a test that needs a grid
169 /// size or an env pair on a stubborn shell adds it here, in the one place
170 /// both doors read.
171 pub const StubbornOpts = struct { version: []const u8 = "" };
172
173 /// The DEFAULT session's door. Writes the stubborn shell, starts the
174 /// daemon on it, and does not return until that shell's traps are armed.
175 ///
176 /// The wait belongs to the door and not to the tests, because five of them
177 /// needed it and the two that spelled it by hand both got it wrong: first
178 /// by not waiting at all, then by waiting on the wrong session. A test
179 /// that comes through here cannot make either mistake, because it never
180 /// names a session or a slot.
181 pub fn startStubborn(self: *TestDaemon, alloc: std.mem.Allocator, opts: StubbornOpts) !void {
182 const script = try writeStubbornShell(alloc, &self.tmp);
183 // Freed here on purpose: `start` dupes it into `self.shell`, which is
184 // what the Server keeps.
185 defer alloc.free(script);
186 try self.start(.{ .shell = script, .version = opts.version });
187 try awaitStubbornArmed(&self.srv, &self.tmp, "", stubborn_arm_ms);
188 }
189
190 /// A NAMED session's door: birth it by attaching, wait for the snapshot
191 /// that says the daemon seated the client, then wait for THAT session's
192 /// own shell to arm. A different shell with a different pid from the
193 /// default session's, which is exactly what a hand-written wait got
194 /// wrong.
195 ///
196 /// Pumps, through `awaitFrame`, so it is for the tests that drive
197 /// `pumpOnce` themselves — which is every caller of the stubborn shell.
198 pub fn attachStubborn(
199 self: *TestDaemon,
200 alloc: std.mem.Allocator,
201 name: []const u8,
202 cols: u16,
203 rows: u16,
204 ) !std.net.Stream {
205 const c = try dial.dialAttachNamed(self.sock_path, cols, rows, name);
206 errdefer c.close();
207 (try awaitFrame(alloc, &self.srv, c.handle, .snapshot, 400) orelse
208 return error.NoState).deinit(alloc);
209 try awaitStubbornArmed(&self.srv, &self.tmp, name, stubborn_arm_ms);
210 return c;
211 }
212
166 /// Run the daemon's pump on a thread, for the tests that talk to it over a 213 /// Run the daemon's pump on a thread, for the tests that talk to it over a
167 /// real socket instead of driving `pumpOnce` themselves. 214 /// real socket instead of driving `pumpOnce` themselves.
168 pub fn threaded(self: *TestDaemon) !void { 215 pub fn threaded(self: *TestDaemon) !void {
@@ -579,7 +626,7 @@ pub fn writeDyingGapShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
579 /// A shell that IGNORES both signals `Pty.requestExit` has and never reads 626 /// A shell that IGNORES both signals `Pty.requestExit` has and never reads
580 /// stdin again, so closing the master cannot end it either — SIGKILL is the 627 /// stdin again, so closing the master cannot end it either — SIGKILL is the
581 /// only thing left that can. What a bounded end has to survive. 628 /// only thing left that can. What a bounded end has to survive.
582 pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 { 629 fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
583 // Blocked in `open(2)` on a fifo nobody writes: no child to orphan, no spin. 630 // Blocked in `open(2)` on a fifo nobody writes: no child to orphan, no spin.
584 // A `sleep` loop leaves a `sleep` behind for up to its full second (measured: 631 // A `sleep` loop leaves a `sleep` behind for up to its full second (measured:
585 // 791 ms past the shell's SIGKILL), and looping on `read` off the closed 632 // 791 ms past the shell's SIGKILL), and looping on `read` off the closed
@@ -588,15 +635,24 @@ pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
588 // The loop stays as the fallback so a box that cannot mkfifo still gets a 635 // The loop stays as the fallback so a box that cannot mkfifo still gets a
589 // stubborn shell: a fixture that quietly exited here would let the 636 // stubborn shell: a fixture that quietly exited here would let the
590 // bounded-end gates pass for the wrong reason. 637 // bounded-end gates pass for the wrong reason.
638 //
639 // The file it touches on line three is how a caller knows the traps are
640 // ARMED, and `awaitStubbornArmed` is the wait. Nothing else announces it:
641 // this shell prints nothing by design, so a test that ended it the
642 // instant it was spawned was racing the shell's own startup, and the
643 // SIGTERM `requestExit` sends landed while TERM was still fatal. The
644 // name carries `$$` because a test may run several of these at once and
645 // a shared marker would be answered by whichever armed first.
591 const body = try std.fmt.allocPrint(alloc, 646 const body = try std.fmt.allocPrint(alloc,
592 \\#!/bin/sh 647 \\#!/bin/sh
593 \\trap '' TERM HUP 648 \\trap '' TERM HUP
594 \\if mkfifo "{s}/stubborn.fifo" 2>/dev/null; then 649 \\: > "{[d]s}/stubborn-$$.armed"
595 \\ read x < "{s}/stubborn.fifo" 650 \\if mkfifo "{[d]s}/stubborn.fifo" 2>/dev/null; then
651 \\ read x < "{[d]s}/stubborn.fifo"
596 \\fi 652 \\fi
597 \\while :; do sleep 1; done 653 \\while :; do sleep 1; done
598 \\ 654 \\
599 , .{ tmp.path(), tmp.path() }); 655 , .{ .d = tmp.path() });
600 defer alloc.free(body); 656 defer alloc.free(body);
601 try tmp.dir.writeFile(.{ 657 try tmp.dir.writeFile(.{
602 .sub_path = "stubborn.sh", 658 .sub_path = "stubborn.sh",
@@ -606,6 +662,55 @@ pub fn writeStubbornShell(alloc: std.mem.Allocator, tmp: *TmpDir) ![:0]u8 {
606 return std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{tmp.path()}, 0); 662 return std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{tmp.path()}, 0);
607 } 663 }
608 664
665 /// Wait until the stubborn shell behind the session `wire_name` spells has
666 /// installed the traps that make it stubborn, or fail saying it never did.
667 ///
668 /// Private, and reached only through `TestDaemon.startStubborn` and
669 /// `TestDaemon.attachStubborn`, which is the whole point: each door knows
670 /// which session it just created, so no test picks the name and none can pick
671 /// the wrong one. Both spellings of getting it wrong by hand have already
672 /// happened — no wait at all, then a wait on the default session while the
673 /// test ended a named one. The second was the more dangerous, because a "nag"
674 /// that dies to the first pre-trap SIGTERM satisfies `expect(!alive(pid))`
675 /// without the SIGKILL deadline that test is named for ever being reached.
676 /// `""` is the default session here, since `sessions.find` resolves it.
677 ///
678 /// The wait has to happen at all because `Pty.requestExit` closes the master
679 /// (SIGHUP) and
680 /// sends SIGTERM; the script ignores both ONCE its `trap` line has run, and
681 /// dies to either before that. So a shell spawned and ended within the same
682 /// millisecond was a coin toss, and the two systems do not toss the same
683 /// coin: Linux's /bin/sh is dash and arms in 2 ms or less, macOS's is bash
684 /// 3.2 and took 12 to 21 ms across five spawns (measured 2026-09-04 on macOS
685 /// 26 under `make check`, whose parallel doc and format steps are load that
686 /// `zig build test` does not have).
687 ///
688 /// What that bought was a silent wrong answer rather than a failure: the
689 /// upgrade test's session was reaped 20 ms after its accepted end, so
690 /// `validateUpgrade` found no session mid-hangup and ACCEPTED an upgrade the
691 /// daemon must refuse — the one thing that test exists to prevent, on the
692 /// only OS where the race was reliably lost.
693 ///
694 /// It polls a file rather than the pump, because the shell writes that file
695 /// on its own and a daemon on a thread would make pumping here a race.
696 /// Long enough that no loaded machine can trip it, short enough to be an
697 /// answer: the arming itself took 21 ms at worst across the measurements
698 /// above.
699 const stubborn_arm_ms: i64 = 5000;
700
701 fn awaitStubbornArmed(srv: *Server, tmp: *TmpDir, wire_name: []const u8, budget_ms: i64) !void {
702 const si = srv.sessions.find(wire_name) orelse return error.NoSuchSessionToArm;
703 const s = srv.sessions.table[si] orelse return error.NoSuchSessionToArm;
704 var name_buf: [64]u8 = undefined;
705 const name = try std.fmt.bufPrint(&name_buf, "stubborn-{d}.armed", .{s.pty.child});
706 const deadline = std.time.milliTimestamp() + budget_ms;
707 while (std.time.milliTimestamp() < deadline) {
708 if (tmp.dir.access(name, .{})) |_| return else |_| {}
709 std.Thread.sleep(2 * std.time.ns_per_ms);
710 }
711 return error.StubbornShellNeverArmed;
712 }
713
609 // --------------------------------------------------------------------------- 714 // ---------------------------------------------------------------------------
610 // The harness's own primitive. `pumpUntil` is what the sibling files assert 715 // The harness's own primitive. `pumpUntil` is what the sibling files assert
611 // their daemon-side conditions through, so its two outcomes are pinned here 716 // their daemon-side conditions through, so its two outcomes are pinned here
src/server/server_test_quic.zig
Old New
@@ -307,11 +307,22 @@ test "Server: one QUIC client leaving does not disturb the other" {
307 b.drain(); 307 b.drain();
308 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 308 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
309 defer replica.deinit(); 309 defer replica.deinit();
310 try quicPump(&td.srv, &both, 15000, &b, struct { 310 // The predicate has to be the ASSERTION, not a weaker relative of it:
311 fn f(t: *quic_server.TestPeer) bool { 311 // `echoed()` counts every byte B has ever taken, and B took a snapshot
312 return t.echoed() > 0 and findFrame(t.cl.in.items, .delta) != null; 312 // long before this marker, so "more than zero, and some delta has
313 // arrived" was already true the moment the pump started. It returned at
314 // once and the assertion below then graded bytes from earlier in the
315 // test. On Linux the delta happened to be late enough that the pump
316 // waited anyway; on the Mac it was not, and the test failed.
317 const Grew = struct {
318 peer: *quic_server.TestPeer,
319 was: usize,
320 fn f(self: *@This()) bool {
321 return self.peer.echoed() > self.was;
313 } 322 }
314 }.f); 323 };
324 var grew: Grew = .{ .peer = &b, .was = before };
325 try quicPump(&td.srv, &both, 15000, &grew, Grew.f);
315 try std.testing.expect(b.echoed() > before); 326 try std.testing.expect(b.echoed() > before);
316 } 327 }
317 328
src/server/server_test_session.zig
Old New
@@ -1,4 +1,5 @@
1 const std = @import("std"); 1 const std = @import("std");
2 const builtin = @import("builtin");
2 const Engine = @import("term").engine.Engine; 3 const Engine = @import("term").engine.Engine;
3 const proto = @import("term").protocol; 4 const proto = @import("term").protocol;
4 const quic = @import("quic"); 5 const quic = @import("quic");
@@ -7,6 +8,7 @@ const xdg = @import("xdg");
7 const TmpDir = @import("testtmp").TmpDir; 8 const TmpDir = @import("testtmp").TmpDir;
8 const h = @import("server_test_harness.zig"); 9 const h = @import("server_test_harness.zig");
9 const dial = h.dial; 10 const dial = h.dial;
11 const Pty = h.Pty;
10 const srv_mod = @import("server.zig"); 12 const srv_mod = @import("server.zig");
11 const SessionTable = @import("server_sessions.zig").SessionTable; 13 const SessionTable = @import("server_sessions.zig").SessionTable;
12 const Server = srv_mod.Server; 14 const Server = srv_mod.Server;
@@ -102,10 +104,16 @@ test "Server: a path that cannot be bound fails as AddressInUse" {
102 defer alloc.free(sock_path); 104 defer alloc.free(sock_path);
103 105
104 // A dangling symlink reaches `bind()` the way a lost start-up race does, but 106 // A dangling symlink reaches `bind()` the way a lost start-up race does, but
105 // deterministically: the connect gets ENOENT so the probe reads the path as 107 // deterministically: the stat gets ENOENT so the probe reads the path as
106 // free, while the bind gets EADDRINUSE off the symlink's own entry. 108 // free, while the bind gets EADDRINUSE off the symlink's own entry.
107 try tmp.dir.symLink("no-such-target", "dangling.sock", .{}); 109 try tmp.dir.symLink("no-such-target", "dangling.sock", .{});
108 110
111 // Linux only, and not because of a spelling: Darwin's bind FOLLOWS a
112 // dangling symlink and creates the socket at the name it points to, so
113 // there is no unbindable path to grade there. Measured 2026-09-03 on
114 // macOS 26 with a plain C bind, which returned 0.
115 if (builtin.os.tag != .linux) return error.SkipZigTest;
116
109 try expectInitRefused(alloc, sock_path, error.AddressInUse); 117 try expectInitRefused(alloc, sock_path, error.AddressInUse);
110 118
111 // And the path is left alone: we could not identify it as a dead 119 // And the path is left alone: we could not identify it as a dead
@@ -130,10 +138,11 @@ test "Server: a non-socket at the path is refused, not deleted" {
130 const contents = "mux must not eat this"; 138 const contents = "mux must not eat this";
131 try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = contents }); 139 try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = contents });
132 140
133 // Connecting to a regular file fails with ECONNREFUSED — the very same 141 // Nothing but the stat separates this file from a dead daemon's socket:
134 // errno a dead socket gives — so identifying stale sockets by the 142 // Linux answers a connect to a regular file with ECONNREFUSED, the very
135 // connect result alone would delete this file. It is the stat that 143 // same errno a dead socket gives, so identifying stale sockets by the
136 // saves it, and this test is what pins that. 144 // connect result alone would delete this file. `claim` stats first and
145 // never connects to a non-socket at all, and this test is what pins that.
137 try expectInitRefused(alloc, file_path, error.SockPathNotASocket); 146 try expectInitRefused(alloc, file_path, error.SockPathNotASocket);
138 147
139 const after = try tmp.dir.readFileAlloc(alloc, "notes.txt", 1024); 148 const after = try tmp.dir.readFileAlloc(alloc, "notes.txt", 1024);
@@ -729,35 +738,22 @@ test "Server: a table of TERM-ignoring shells costs one grace, not one each" {
729 var td = try h.TestDaemon.open(alloc, "stubborn"); 738 var td = try h.TestDaemon.open(alloc, "stubborn");
730 defer td.deinit(); 739 defer td.deinit();
731 740
732 // A real shell ignoring the two signals teardown relies on — HUP from 741 // The stubborn shell is the harness's, and its doors do not return until
733 // the master's close, then TERM — which is what an interactive shell 742 // that session's own traps are armed. The arming is what makes the child
734 // does with TERM anyway. Only these children pay the grace at all. 743 // ignore HUP and TERM, and only a child that ignores them pays the grace
735 const shim = try std.fmt.allocPrintSentinel(alloc, "{s}/stubborn.sh", .{td.tmp.path()}, 0); 744 // at all, so a shell signalled before its `trap` line ran would let every
736 defer alloc.free(shim); 745 // assertion below hold with no stubborn shell in the table.
737 { 746 try td.startStubborn(alloc, .{});
738 var f = try std.fs.cwd().createFile(shim, .{ .mode = 0o755 });
739 defer f.close();
740 try f.writeAll("#!/bin/sh\ntrap '' HUP TERM\nwhile :; do sleep 1; done\n");
741 }
742
743 try td.start(.{ .shell = shim });
744 747
745 // Eight beside the default: enough that serial grace (9 x 500ms) and 748 // Eight beside the default: enough that serial grace (9 x 500ms) and
746 // shared grace (500ms) cannot be confused, cheap enough to spawn. 749 // shared grace (500ms) cannot be confused, cheap enough to spawn.
747 for (1..9) |i| { 750 for (1..9) |i| {
748 var nb: [8]u8 = undefined; 751 var nb: [8]u8 = undefined;
749 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i}); 752 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i});
750 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, nm); 753 const c = try td.attachStubborn(alloc, nm, 80, 24);
751 defer c.close(); 754 defer c.close();
752 const f = (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400)) orelse
753 return error.NoSnapshotFillingTable;
754 f.deinit(alloc);
755 } 755 }
756 756
757 // Before signalling, not after: a TERM that lands before `trap` runs is
758 // honoured, and the test would pass without a stubborn child in it.
759 std.Thread.sleep(400 * std.time.ns_per_ms);
760
761 var kids: [max_sessions]std.posix.pid_t = undefined; 757 var kids: [max_sessions]std.posix.pid_t = undefined;
762 var n: usize = 0; 758 var n: usize = 0;
763 for (&td.srv.sessions.table) |*slot| { 759 for (&td.srv.sessions.table) |*slot| {
@@ -950,6 +946,24 @@ fn probeEmptiedDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !void {
950 const snap = (try awaitFrameOn(alloc, c1.handle, .snapshot, 4000)) orelse 946 const snap = (try awaitFrameOn(alloc, c1.handle, .snapshot, 4000)) orelse
951 return error.NoFirstSnapshot; 947 return error.NoFirstSnapshot;
952 snap.deinit(alloc); 948 snap.deinit(alloc);
949
950 // The liveness half, and not a nicety: the snapshot proves the DAEMON
951 // seated this client, never that the script behind the pty has reached
952 // its first `read`. A line written before it does sits in the tty's input
953 // queue, and a shell that touches the terminal on startup discards it —
954 // so `die` was echoed, never read, and the session outlived a test whose
955 // whole subject is the session ending. Seen on macOS, where /bin/sh is
956 // bash and the script's startup is longer; the same race is open on
957 // Linux and merely lost less often. One round trip through the script
958 // closes it.
959 var rep = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
960 defer rep.deinit();
961 try proto.writeFrame(c1.handle, .input, "alive\n");
962 if (!try h.awaitReplicaText(alloc, c1.handle, 8000, .{
963 .replica = rep,
964 .needle = "echo:alive",
965 })) return error.ShellNeverRead;
966
953 try proto.writeFrame(c1.handle, .input, "die 7\n"); 967 try proto.writeFrame(c1.handle, .input, "die 7\n");
954 968
955 try waitStats(alloc, sock_path, "sessions=0"); 969 try waitStats(alloc, sock_path, "sessions=0");
@@ -1604,14 +1618,10 @@ test "Server: a keystroke into an ending session does not cost that client its e
1604 1618
1605 var td = try h.TestDaemon.open(alloc, "endtype"); 1619 var td = try h.TestDaemon.open(alloc, "endtype");
1606 defer td.deinit(); 1620 defer td.deinit();
1607 const script = try h.writeStubbornShell(alloc, &td.tmp); 1621 try td.startStubborn(alloc, .{});
1608 defer alloc.free(script);
1609
1610 try td.start(.{ .shell = script });
1611 1622
1612 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "typing"); 1623 const c = try td.attachStubborn(alloc, "typing", 80, 24);
1613 defer c.close(); 1624 defer c.close();
1614 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
1615 1625
1616 var rq: [proto.end_req_max_len]u8 = undefined; 1626 var rq: [proto.end_req_max_len]u8 = undefined;
1617 try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "typing")); 1627 try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "typing"));
@@ -1632,14 +1642,10 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1632 1642
1633 var td = try h.TestDaemon.open(alloc, "endagain"); 1643 var td = try h.TestDaemon.open(alloc, "endagain");
1634 defer td.deinit(); 1644 defer td.deinit();
1635 const script = try h.writeStubbornShell(alloc, &td.tmp); 1645 try td.startStubborn(alloc, .{});
1636 defer alloc.free(script);
1637 1646
1638 try td.start(.{ .shell = script }); 1647 const c = try td.attachStubborn(alloc, "nag", 80, 24);
1639
1640 const c = try dial.dialAttachNamed(td.sock_path, 80, 24, "nag");
1641 defer c.close(); 1648 defer c.close();
1642 (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
1643 1649
1644 const pid = shellPidOf(&td.srv, "nag"); 1650 const pid = shellPidOf(&td.srv, "nag");
1645 var rq: [proto.end_req_max_len]u8 = undefined; 1651 var rq: [proto.end_req_max_len]u8 = undefined;
@@ -1647,6 +1653,12 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1647 const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply; 1653 const r = (try awaitFrame(alloc, &td.srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
1648 defer r.deinit(alloc); 1654 defer r.deinit(alloc);
1649 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted); 1655 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
1656 // The clock the assertions below read, started where the daemon starts
1657 // its own: `endSession` arms the deadline when it accepts. MONOTONIC, and
1658 // deliberately so — the deadline being graded is `monoMs() + grace`, so a
1659 // calendar step landing between the two reads would fail a daemon that
1660 // did exactly the right thing.
1661 var since_end = try std.time.Timer.start();
1650 1662
1651 // An observer nagging faster than the grace: if each accept restarted the 1663 // An observer nagging faster than the grace: if each accept restarted the
1652 // clock, the shell would outlive every deadline it was ever given. 1664 // clock, the shell would outlive every deadline it was ever given.
@@ -1661,5 +1673,50 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1661 try td.srv.pumpOnce(20); 1673 try td.srv.pumpOnce(20);
1662 std.Thread.sleep(60 * std.time.ns_per_ms); 1674 std.Thread.sleep(60 * std.time.ns_per_ms);
1663 } 1675 }
1676 const outlived_ms = since_end.read() / std.time.ns_per_ms;
1664 try std.testing.expect(!alive(pid)); 1677 try std.testing.expect(!alive(pid));
1678
1679 // A dead shell is only half the claim, and it was the only half asserted:
1680 // a shell that died to the FIRST SIGTERM is dead too, and a fixture that
1681 // let that happen passed this test while the deadline it is named for was
1682 // never reached. That is not hypothetical — it is what a wait on the wrong
1683 // session did here, twice, and the difference was visible only as the
1684 // suite running faster. So the duration is the pin now. A wall clock and
1685 // not the loop counter above, which counts nominal turns rather than time.
1686 //
1687 // The LOWER bound is the property, and the only one of the two that is:
1688 // living past the grace is what proves the shell ignored every TERM and
1689 // left to the SIGKILL.
1690 //
1691 // The UPPER bound is a rail on the number, not a second pin, and it is
1692 // worth saying which regression it does NOT catch. The one this test is
1693 // named for is the `== null` guard dropped at the arm in `endSession`,
1694 // and that guard's absence RESETS `end_by_ms` to now plus a grace on
1695 // every accepted nag rather than adding one — so a deadline re-armed
1696 // every 80 ms never fires at all, the shell outlives the loop's whole
1697 // 3 s budget, and the `!alive` above is what fails. No multiplier here
1698 // reaches that. What this bound does catch is a deadline that drifted a
1699 // few turns of the loop, which is why it exists and why it is loose.
1700 //
1701 // 3x because the measurement it must clear is 661 ms on Linux and 668 to
1702 // 682 ms on macOS under `make check` (2026-09-04) against a 500 ms grace,
1703 // leaving about 2.2x. Not tighter: what varies is how long one turn of
1704 // the loop takes to notice the kill, and a loaded box stretches both its
1705 // sleep and its pump.
1706 if (outlived_ms < Pty.term_grace_ms) {
1707 std.debug.print(
1708 "the shell died {d} ms after the accepted end, INSIDE the {d} ms grace: " ++
1709 "it did not ignore the TERM, so the SIGKILL deadline was never reached\n",
1710 .{ outlived_ms, Pty.term_grace_ms },
1711 );
1712 return error.ShellDiedInsideTheGrace;
1713 }
1714 if (outlived_ms >= 3 * Pty.term_grace_ms) {
1715 std.debug.print(
1716 "the shell lived {d} ms after the accepted end, past 3x the {d} ms grace: " ++
1717 "the nagging looks to have pushed the deadline out\n",
1718 .{ outlived_ms, Pty.term_grace_ms },
1719 );
1720 return error.GraceDeadlineMoved;
1721 }
1665 } 1722 }
src/server/server_test_upgrade.zig
Old New
@@ -444,12 +444,9 @@ test "Server: an upgrade asked for during a session's hangup is refused, not att
444 444
445 var td = try h.TestDaemon.open(alloc, "upending"); 445 var td = try h.TestDaemon.open(alloc, "upending");
446 defer td.deinit(); 446 defer td.deinit();
447 const script = try h.writeStubbornShell(alloc, &td.tmp);
448 defer alloc.free(script);
449
450 // A real version, so every other check would PASS: without the ending 447 // A real version, so every other check would PASS: without the ending
451 // session this upgrade is one the daemon would go through with. 448 // session this upgrade is one the daemon would go through with.
452 try td.start(.{ .shell = script, .version = "0.0.1-1" }); 449 try td.startStubborn(alloc, .{ .version = "0.0.1-1" });
453 450
454 // The shell outlives the hangup by the whole grace, so the exec's 451 // The shell outlives the hangup by the whole grace, so the exec's
455 // clearCloexec and the manifest's pty_fd would both see -1. 452 // clearCloexec and the manifest's pty_fd would both see -1.
@@ -598,17 +595,13 @@ test "Server: an accepted end cancels a pending upgrade — the exec never sees
598 595
599 var td = try h.TestDaemon.open(alloc, "upcancel"); 596 var td = try h.TestDaemon.open(alloc, "upcancel");
600 defer td.deinit(); 597 defer td.deinit();
601 const script = try h.writeStubbornShell(alloc, &td.tmp); 598 try td.startStubborn(alloc, .{ .version = "0.0.1-1" });
602 defer alloc.free(script);
603
604 try td.start(.{ .shell = script, .version = "0.0.1-1" });
605 599
606 // Plural: the end takes ONE session and the upgrade would have carried 600 // Plural: the end takes ONE session and the upgrade would have carried
607 // the others, so a guard that only looked at the ending slot's own 601 // the others, so a guard that only looked at the ending slot's own
608 // clients would still pass here. 602 // clients would still pass here.
609 const ca = try dial.dialAttachNamed(td.sock_path, 80, 24, "a"); 603 const ca = try td.attachStubborn(alloc, "a", 80, 24);
610 defer ca.close(); 604 defer ca.close();
611 (try awaitFrame(alloc, &td.srv, ca.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
612 605
613 try td.tmp.dir.writeFile(.{ 606 try td.tmp.dir.writeFile(.{
614 .sub_path = "fakemux.sh", 607 .sub_path = "fakemux.sh",
src/sockpath.zig
Old New
@@ -33,29 +33,108 @@ pub fn tooLong(prefix: []const u8, path: []const u8) bool {
33 pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const u8 { 33 pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const u8 {
34 return defaultSockPath(alloc) catch |err| switch (err) { 34 return defaultSockPath(alloc) catch |err| switch (err) {
35 error.NoRuntimeDir => { 35 error.NoRuntimeDir => {
36 std.debug.print( 36 std.debug.print("{s}: {s}\n", .{ prefix, no_runtime_dir_reason });
37 "{s}: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
38 .{prefix},
39 );
40 return null; 37 return null;
41 }, 38 },
42 else => |e| return e, 39 else => |e| return e,
43 }; 40 };
44 } 41 }
45 42
43 /// Why there is no default socket path, with no program prefix and no
44 /// trailing newline, so the one sentence serves both the humans'
45 /// `{prefix}: {reason}` line and `mux a`'s JSON `detail`. Two spellings
46 /// because the two OSes fail for different reasons: Linux has nothing to
47 /// fall back to, while Darwin has a fallback that can be refused, and a
48 /// Mac user who reads "XDG_RUNTIME_DIR is unset" would go and set a
49 /// variable that was never the problem. Public because `muxa.zig` is the
50 /// other caller and a second copy of this sentence is how the two drifted
51 /// apart in the first place.
52 pub const no_runtime_dir_reason = if (builtin.os.tag == .linux)
53 "XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)"
54 else
55 "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)";
56
46 /// The directory the default daemon socket and every per-wall socket live 57 /// The directory the default daemon socket and every per-wall socket live
47 /// in, or null. On Linux that is `$XDG_RUNTIME_DIR` and there is NO 58 /// in, or null. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
48 /// fallback: a guess cannot make two binaries agree on one daemon, so the 59 /// every isolated rig (make e2e, soak, a hand rig) keeps its sockets apart
49 /// caller names it with --sock. Another OS spells its own default here, 60 /// from the user's. Linux has NO fallback: a guess cannot make two binaries
50 /// once, so the daemon, the client and the askpass listener agree by 61 /// agree on one daemon, so the caller names it with --sock. Darwin falls
51 /// construction. 62 /// back to /tmp/mux-<uid>, created 0700 and checked on every ask — the
63 /// spec's three candidates ($TMPDIR, ~/Library/Caches, ~/.local/state) all
64 /// overflow sun_path at the longest name mux creates (dir + 68 + pid
65 /// digits against 103; measured 2026-09-03, docs/decisions.md), and /tmp
66 /// is sticky and world-writable, so the per-uid directory is what carries
67 /// the privacy, as tmux's /tmp/tmux-UID does.
52 pub fn runtimeDir() ?[]const u8 { 68 pub fn runtimeDir() ?[]const u8 {
53 return switch (builtin.os.tag) { 69 // The two OSes this builds for are named here rather than left as a
54 .linux => std.posix.getenv("XDG_RUNTIME_DIR"), 70 // linux-or-everything-else branch. `src/os/`'s roots already refuse a
55 else => @compileError("mux has no default runtime directory for " ++ @tagName(builtin.os.tag)), 71 // third tag at comptime, so a build for one cannot get this far today;
72 // this keeps the promise local, so that the day a third arm lands the
73 // compiler asks what its runtime directory is instead of silently
74 // handing it Darwin's /tmp fallback.
75 switch (builtin.os.tag) {
76 .linux, .macos => {},
77 else => @compileError("sockpath.runtimeDir: name this OS's runtime directory rule"),
78 }
79 const env = std.posix.getenv("XDG_RUNTIME_DIR");
80 if (builtin.os.tag == .linux) return env;
81 const uid = std.posix.geteuid();
82 const dir = runtimeDirFrom(builtin.os.tag, env, uid, &darwin_dir_buf) orelse return null;
83 // Only the FALLBACK is checked. `$XDG_RUNTIME_DIR` is the user's own
84 // statement of where their sockets go, and a rig that points it at a
85 // directory of another mode is not making a privacy mistake.
86 if (env == null and !ensureOwnedDir(dir, uid)) return null;
87 return dir;
88 }
89
90 /// Process-wide because the returned slice outlives the call and this
91 /// module allocates nothing. Every writer formats the same uid into it, so
92 /// two threads racing here write identical bytes.
93 var darwin_dir_buf: [32]u8 = undefined;
94
95 /// `runtimeDir` with its environment and its OS named rather than read.
96 /// Named environment because Zig tests cannot setenv; named OS because a
97 /// `builtin.os.tag` branch is comptime-eliminated, so the Darwin spelling
98 /// would go unasserted in a Linux gate and a typo in the format string
99 /// would ship green.
100 fn runtimeDirFrom(os: std.Target.Os.Tag, env: ?[]const u8, uid: std.posix.uid_t, buf: *[32]u8) ?[]const u8 {
101 if (env) |e| return e;
102 // `.macos` by name, not "not linux". The fallback is a Darwin rule with
103 // a Darwin reason (sun_path leaves no room for $TMPDIR or
104 // ~/Library/Caches), so a tag that is neither gets no directory rather
105 // than a borrowed one. `runtimeDir` refuses such a tag at comptime; this
106 // is the same answer for the callers that name the OS themselves.
107 return switch (os) {
108 .macos => std.fmt.bufPrint(buf, "/tmp/mux-{d}", .{uid}) catch null,
109 else => null,
56 }; 110 };
57 } 111 }
58 112
113 /// True when PATH is a directory this uid owns with mode 0700 and no
114 /// symlink in the last step. tmux checks /tmp/tmux-UID for the same
115 /// reason and this is the stricter rule — tmux asks only that no OTHER
116 /// bit is set, this asks for exactly 0700 — because in a sticky
117 /// world-writable /tmp another uid can plant a symlink or a loose
118 /// directory at our name before we get there, and a socket bound through
119 /// either is theirs to connect to.
120 fn ensureOwnedDir(path: []const u8, uid: std.posix.uid_t) bool {
121 if (std.posix.mkdir(path, 0o700)) |_| {
122 // mkdir's mode argument is masked by the umask, so a user carrying
123 // owner bits in theirs (0177, say) would get a 0600 directory that
124 // the exact-0700 check below then refuses on this run and every
125 // later one. chmod is not masked. Only the directory this call
126 // just created is set: one that was already there keeps whatever
127 // mode it has and is refused if that is wrong, because repairing
128 // it silently would hide another uid's plant rather than report it.
129 std.posix.fchmodat(std.posix.AT.FDCWD, path, 0o700, 0) catch return false;
130 } else |e| if (e != error.PathAlreadyExists) return false;
131 // NOFOLLOW: the stat has to describe the name we will bind under, not
132 // whatever it points at. A symlink to a directory this uid does own
133 // passes every other line here and still hands the socket away.
134 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, std.posix.AT.SYMLINK_NOFOLLOW) catch return false;
135 return std.posix.S.ISDIR(st.mode) and st.uid == uid and (st.mode & 0o777) == 0o700;
136 }
137
59 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 138 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
60 return sockPathFrom(alloc, runtimeDir()); 139 return sockPathFrom(alloc, runtimeDir());
61 } 140 }
@@ -91,10 +170,37 @@ pub const PathId = struct {
91 } 170 }
92 }; 171 };
93 172
173 /// Connect to a path that is supposed to BE a unix socket, with the one
174 /// disagreement between the two kernels settled here instead of at each
175 /// caller. Every dial in this product that names a path a USER typed comes
176 /// through this: `dial.dial`, `proxy.pump`, `answers` below.
177 ///
178 /// Linux answers a connect to anything at the path that is not a socket with
179 /// ECONNREFUSED — measured 2026-09-03 for a regular file, a directory, a
180 /// symlink to a file, a fifo and /dev/null, all five identical. Darwin answers
181 /// ENOTSOCK for the same five, and `std.posix.connect` maps that to
182 /// `unreachable`, because for a LOCAL fd it can only mean the caller passed
183 /// something that is not a socket. Our fd always is; on Darwin the errno is
184 /// about the far end. So `mux --sock notes.txt` aborted on a Mac where it
185 /// refuses on Linux, and so did `mux d proxy`, `mux d stop` and every dial
186 /// underneath the wall.
187 ///
188 /// The stat therefore comes first, and a non-socket is reported as
189 /// ConnectionRefused — Linux's own answer, so nothing on Linux moves and
190 /// Darwin says what Linux says. A path the stat cannot read at all falls
191 /// through to the connect, which names it (ENOENT for a missing path, and a
192 /// dangling symlink reads as missing on both).
193 pub fn connectSocket(path: []const u8) !std.net.Stream {
194 if (std.posix.fstatat(std.posix.AT.FDCWD, path, 0)) |st| {
195 if (!std.posix.S.ISSOCK(st.mode)) return error.ConnectionRefused;
196 } else |_| {}
197 return std.net.connectUnixSocket(path);
198 }
199
94 /// Whether anything LISTENS at `path` now: a read, so every connect 200 /// Whether anything LISTENS at `path` now: a read, so every connect
95 /// error is a no. The decision needing the errno is `claim`. 201 /// error is a no. The decision needing the errno is `claim`.
96 pub fn answers(path: []const u8) bool { 202 pub fn answers(path: []const u8) bool {
97 const s = std.net.connectUnixSocket(path) catch return false; 203 const s = connectSocket(path) catch return false;
98 s.close(); 204 s.close();
99 return true; 205 return true;
100 } 206 }
@@ -104,21 +210,38 @@ pub fn answers(path: []const u8) bool {
104 /// with its sessions intact, but only the newest is reachable and the rest are 210 /// with its sessions intact, but only the newest is reachable and the rest are
105 /// stranded holding shells nobody can get back to. 211 /// stranded holding shells nobody can get back to.
106 /// 212 ///
107 /// So: unlink only what answers ECONNREFUSED *and* is a socket. 213 /// So: unlink only what is a socket *and* answers ECONNREFUSED.
108 /// - something answers → a live daemon owns this path. Refuse.
109 /// - nothing there → bind, nothing to clean up. 214 /// - nothing there → bind, nothing to clean up.
215 /// - not a socket → refuse; `--sock notes.txt` must not eat the file.
216 /// - something answers → a live daemon owns this path. Refuse.
110 /// - a dead socket file → ours to clear. 217 /// - a dead socket file → ours to clear.
111 /// - anything else → propagate; a path we cannot positively call a 218 /// - anything else → propagate; a path we cannot positively call a
112 /// dead daemon's leftover is not ours to delete. 219 /// dead daemon's leftover is not ours to delete.
220 ///
221 /// The STAT comes first, and the order is load-bearing rather than a
222 /// preference: the two kernels disagree about what connecting to a path that
223 /// is not a socket means. Linux answers ECONNREFUSED, the same errno a dead
224 /// socket gives, so the stat was what separated them. Darwin answers ENOTSOCK,
225 /// which `std.posix.connect` treats as a programming error about the local fd
226 /// and hits `unreachable` on — a panic, in a daemon, over a file the user
227 /// named. Asking the stat first means the connect is only ever made to
228 /// something that IS a socket, and neither kernel has a surprise there.
113 pub fn claim(path: []const u8) !void { 229 pub fn claim(path: []const u8) !void {
230 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) {
231 // Nothing at the path, or a symlink to nothing: free as far as this
232 // function can tell, and `bind` gets the last word on the entry.
233 error.FileNotFound => return,
234 else => |e| return e,
235 };
236 if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket;
237
114 if (std.net.connectUnixSocket(path)) |probe| { 238 if (std.net.connectUnixSocket(path)) |probe| {
115 probe.close(); 239 probe.close();
116 return error.DaemonAlreadyRunning; 240 return error.DaemonAlreadyRunning;
117 } else |err| switch (err) { 241 } else |err| switch (err) {
118 error.FileNotFound => return, // free path; bind straight away 242 error.FileNotFound => return, // vanished under us; path is free
119 // Nobody is listening, which is NOT yet proof of a stale socket: Linux 243 // A socket file nobody is listening on: a dead daemon's leftover,
120 // answers ECONNREFUSED for a regular file exactly as for a dead socket, 244 // which the stat above has already confirmed is a socket.
121 // so only the stat below separates a daemon from `--sock notes.txt`.
122 error.ConnectionRefused => {}, 245 error.ConnectionRefused => {},
123 // Every other errno names a path this process cannot positively call a 246 // Every other errno names a path this process cannot positively call a
124 // dead daemon's leftover, so it propagates BY NAME and the daemon's log 247 // dead daemon's leftover, so it propagates BY NAME and the daemon's log
@@ -126,12 +249,6 @@ pub fn claim(path: []const u8) !void {
126 else => |e| return e, 249 else => |e| return e,
127 } 250 }
128 251
129 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) {
130 error.FileNotFound => return, // vanished under us; path is free
131 else => |e| return e,
132 };
133 if (!std.posix.S.ISSOCK(st.mode)) return error.SockPathNotASocket;
134
135 std.fs.cwd().deleteFile(path) catch |err| switch (err) { 252 std.fs.cwd().deleteFile(path) catch |err| switch (err) {
136 // Someone else cleared it first. The path is free either way, 253 // Someone else cleared it first. The path is free either way,
137 // which is the only thing this function was after. 254 // which is the only thing this function was after.
@@ -191,6 +308,41 @@ test "answers: a live listener, a stale socket file, and a path with nothing on
191 try std.testing.expect(!answers("/" ++ "x" ** 200)); 308 try std.testing.expect(!answers("/" ++ "x" ** 200));
192 } 309 }
193 310
311 test "connectSocket: a path that is not a socket is refused, never a panic" {
312 // The regression a Mac found. On Linux this passes with or without the
313 // stat, because the kernel answers ECONNREFUSED for a regular file just
314 // as it does for a dead socket. On Darwin the same connect answers
315 // ENOTSOCK, which `std.posix.connect` maps to `unreachable` — so without
316 // the guard this test does not fail, it ABORTS the test binary, and every
317 // caller that names a path a user typed aborts with it.
318 const testtmp = @import("testtmp");
319 var tmp = try testtmp.TmpDir.make();
320 defer tmp.cleanup();
321 try tmp.dir.writeFile(.{ .sub_path = "notes.txt", .data = "mux must not connect to this" });
322
323 var buf: [64]u8 = undefined;
324 const file = try std.fmt.bufPrint(&buf, "{s}/notes.txt", .{tmp.path()});
325
326 // The errno is Linux's own for this path, so the wording every caller
327 // already prints for "nothing is listening" is what a Mac user sees too.
328 try std.testing.expectError(error.ConnectionRefused, connectSocket(file));
329 try std.testing.expect(!answers(file));
330
331 // A DIRECTORY is the same story and the same errno on both, and it is
332 // what `--sock` pointed at a state directory looks like.
333 try std.testing.expect(!answers(tmp.path()));
334
335 // And the file is still there: this is a read, and nothing in the
336 // refusal path may touch what it refused.
337 var back: [64]u8 = undefined;
338 const f = try std.fs.cwd().openFile(file, .{});
339 defer f.close();
340 try std.testing.expectEqualStrings(
341 "mux must not connect to this",
342 back[0..try f.readAll(&back)],
343 );
344 }
345
194 test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" { 346 test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" {
195 // chmod does not bite root, so the connect would succeed and the test 347 // chmod does not bite root, so the connect would succeed and the test
196 // would assert the opposite of what it is named for. 348 // would assert the opposite of what it is named for.
@@ -251,6 +403,65 @@ test "PathId: names the file it was taken from, not the path, and not a successo
251 try std.testing.expect(!id.stillAt(sock_path)); 403 try std.testing.expect(!id.stillAt(sock_path));
252 } 404 }
253 405
406 test "runtimeDirFrom: the env var wins on every OS, and Darwin falls back to /tmp/mux-UID" {
407 var buf: [32]u8 = undefined;
408
409 // The env var wins on BOTH, so no OS invents its own place for the
410 // sockets a rig has already told it about: make e2e, soak and every
411 // hand rig point this at a directory of their own and expect both
412 // sides to agree.
413 try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom(.linux, "/run/user/7", 501, &buf).?);
414 try std.testing.expectEqualStrings("/run/user/7", runtimeDirFrom(.macos, "/run/user/7", 501, &buf).?);
415
416 // Linux does not guess. Two binaries that each guessed would disagree,
417 // and the client would start a second daemon beside the one already up.
418 try std.testing.expect(runtimeDirFrom(.linux, null, 501, &buf) == null);
419
420 // And the Darwin spelling is asserted HERE, in the Linux gate, because
421 // the OS is an argument rather than a comptime branch: a typo in the
422 // format string is caught by CI rather than by the first Mac to run it.
423 try std.testing.expectEqualStrings("/tmp/mux-501", runtimeDirFrom(.macos, null, 501, &buf).?);
424
425 // A third OS gets NOTHING, rather than Darwin's directory because it is
426 // not Linux. `runtimeDir` refuses such a tag at comptime, so this is the
427 // answer for the callers that pass a tag rather than read `builtin`.
428 try std.testing.expect(runtimeDirFrom(.freebsd, null, 501, &buf) == null);
429 }
430
431 test "ensureOwnedDir: creates 0700, accepts its own creation, refuses a symlink and a group-readable dir" {
432 const testtmp = @import("testtmp");
433 var tmp = try testtmp.TmpDir.make();
434 defer tmp.cleanup();
435
436 var b: [std.fs.max_path_bytes]u8 = undefined;
437 const uid = std.posix.geteuid();
438 const fresh = try std.fmt.bufPrint(&b, "{s}/rt", .{tmp.path()});
439 try std.testing.expect(ensureOwnedDir(fresh, uid));
440 const st = try std.posix.fstatat(std.posix.AT.FDCWD, fresh, 0);
441 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
442
443 // The second ask is the one every later `mux` makes: a check, not a
444 // mkdir, and it must not read its own directory as somebody else's.
445 try std.testing.expect(ensureOwnedDir(fresh, uid));
446
447 // A directory another uid could read the socket names out of is
448 // refused rather than reused. chmod after the mkdir, so the answer
449 // does not depend on the umask the suite happens to run under.
450 var b2: [std.fs.max_path_bytes]u8 = undefined;
451 const loose = try std.fmt.bufPrint(&b2, "{s}/loose", .{tmp.path()});
452 try std.posix.mkdir(loose, 0o750);
453 try std.posix.fchmodat(std.posix.AT.FDCWD, loose, 0o750, 0);
454 try std.testing.expect(!ensureOwnedDir(loose, uid));
455
456 // And the planted symlink, which is the whole reason for NOFOLLOW: it
457 // points at a directory this very test just proved good, so every
458 // check but the link check says yes.
459 var b3: [std.fs.max_path_bytes]u8 = undefined;
460 const link = try std.fmt.bufPrint(&b3, "{s}/link", .{tmp.path()});
461 try std.posix.symlink(fresh, link);
462 try std.testing.expect(!ensureOwnedDir(link, uid));
463 }
464
254 // Forces semantic analysis of every pub decl under `zig build test`, so an 465 // Forces semantic analysis of every pub decl under `zig build test`, so an
255 // unreferenced decl must at least compile (the silent-module-loss hazard, 466 // unreferenced decl must at least compile (the silent-module-loss hazard,
256 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 467 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
src/testtmp.zig
Old New
@@ -56,10 +56,15 @@ pub const TmpDir = struct {
56 } 56 }
57 }; 57 };
58 58
59 /// A pid nothing holds: `/bin/true`, spawned and waited for. What the 59 /// A pid nothing holds: `true`, spawned and waited for. What the reapers ask
60 /// reapers ask the OS about, made real rather than guessed from pid_max. 60 /// the OS about, made real rather than guessed from pid_max.
61 ///
62 /// Found on PATH rather than spelled absolutely, because the two systems put
63 /// it in different places: `/bin/true` on Linux, `/usr/bin/true` on macOS.
64 /// The absolute Linux spelling failed to spawn on the Mac, and every reaper
65 /// test that asks for a dead pid failed with it.
61 pub fn deadPid() !std.posix.pid_t { 66 pub fn deadPid() !std.posix.pid_t {
62 var child = std.process.Child.init(&.{"/bin/true"}, std.testing.allocator); 67 var child = std.process.Child.init(&.{"true"}, std.testing.allocator);
63 try child.spawn(); 68 try child.spawn();
64 // Read before the wait: `wait` sets `id` to undefined once the child 69 // Read before the wait: `wait` sets `id` to undefined once the child
65 // is reaped, and an undefined pid parsed out of a name is no pid. 70 // is reaped, and an undefined pid parsed out of a name is no pid.
src/tui/wallview.zig
Old New
@@ -1866,10 +1866,15 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1866 1866
1867 // ssh's prompts get somewhere to go, on a TERMINAL only: without one 1867 // ssh's prompts get somewhere to go, on a TERMINAL only: without one
1868 // there is no popup, so ssh keeps its prompts and fails them visibly. 1868 // there is no popup, so ssh keeps its prompts and fails them visibly.
1869 // The runtime directory and no fallback: a shared /tmp socket would be 1869 // `sockpath.runtimeDir` is the one place that names the directory, so
1870 // a password prompt any local user could answer. `sockpath.runtimeDir` 1870 // the popup lands beside the daemon socket on whatever OS this is —
1871 // is the one place that names it, so the popup lands beside the daemon 1871 // and what keeps this from being a password prompt any other local
1872 // socket on whatever OS this is. 1872 // user could answer is that directory's privacy, which each OS buys
1873 // differently. On Linux it is $XDG_RUNTIME_DIR with no fallback at
1874 // all, so there is no /tmp path to share. On Darwin the fallback is
1875 // /tmp/mux-<uid>, inside a world-writable directory, and what carries
1876 // the guarantee there is `sockpath.runtimeDir` refusing that name
1877 // unless it is a directory this uid owns at mode 0700.
1873 var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; 1878 var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1874 if (is_tty) { 1879 if (is_tty) {
1875 if (sockpath.runtimeDir()) |rt| { 1880 if (sockpath.runtimeDir()) |rt| {
test/bans.sh
Old New
@@ -14,6 +14,10 @@
14 # 14 #
15 # The scratch file is removed by an EXIT trap, so a failing assertion never 15 # The scratch file is removed by an EXIT trap, so a failing assertion never
16 # leaves a planted needle behind to refuse every later build in the tree. 16 # leaves a planted needle behind to refuse every later build in the tree.
17 #
18 # The last check in this file is not build.zig's at all: it reads the SHELL
19 # scripts for one shape no compiler sees, for the same reason as the rest.
20 # A rule nothing asserts is a rule that has already stopped biting.
17 set -u 21 set -u
18 22
19 ZIG="${1:-deps/zig/zig}" 23 ZIG="${1:-deps/zig/zig}"
@@ -111,5 +115,123 @@ must_skip src/server 'test "bans probe" {
111 _ = probe; 115 _ = probe;
112 }' 116 }'
113 117
118 # ---- the shell rule: no backtick inside an UNQUOTED heredoc -------------
119 #
120 # In an unquoted heredoc the shell runs a backquoted word as a command
121 # substitution wherever it stands, comments included. Most ptyclient scripts
122 # in this suite are QUOTED heredocs and safe; the ones that interpolate a
123 # fingerprint or a port have to leave the delimiter bare, and those are the
124 # ones this rule is for. This repo quotes a key or a verb in backticks
125 # everywhere else,
126 # so writing one into such a comment is the natural thing to do and it is a
127 # trap. There IS an expect program on macOS: the shell starts it, it reads
128 # the rest of the heredoc as its own script, and the group hangs until its
129 # budget runs out with nothing on stdout. That cost four gate runs on the
130 # macOS port before anyone spelled the rule (2026-09-04), and one of the
131 # four was the comment written to explain it.
132 #
133 # The same four directories shellGate compiles, so a script added tomorrow
134 # is covered without anybody remembering to add it. A heredoc whose
135 # delimiter is QUOTED is left alone, which is what makes this a rule about
136 # the opener rather than a ban on a byte.
137 heredoc_backticks() {
138 awk '
139 # The delimiter this line opens a heredoc with, or "" for a line that
140 # opens none. A three-angle form is a here-string and has no body. A
141 # QUOTED delimiter performs no substitution in its body, so there is
142 # nothing there to catch; the quotes are spelled \047 and \042 so this
143 # whole program fits inside one shell-quoted string.
144 function delim_of(line, p, rest, c) {
145 if (index(line, "<<<") > 0) return ""
146 p = index(line, "<<")
147 if (p == 0) return ""
148 rest = substr(line, p + 2)
149 sub(/^-/, "", rest)
150 sub(/^[ \t]+/, "", rest)
151 c = substr(rest, 1, 1)
152 if (c == "\047" || c == "\042") return ""
153 if (match(rest, /^[A-Za-z_][A-Za-z0-9_]*/)) return substr(rest, RSTART, RLENGTH)
154 return ""
155 }
156 FNR == 1 { inbody = 0 }
157 # Matched ANYWHERE on the line and not just at its end: the site this
158 # rule was written for spells its opener with a trailing space after
159 # the delimiter, and a first sweep that anchored on end-of-line walked
160 # straight past it.
161 !inbody {
162 d = delim_of($0)
163 if (d != "") { inbody = 1; delim = d; opened = FNR }
164 next
165 }
166 {
167 line = $0
168 gsub(/^[ \t]+|[ \t]+$/, "", line)
169 if (line == delim) { inbody = 0; next }
170 if (index($0, "\140") > 0)
171 printf "%s:%d (heredoc opened at %d): %s\n", FILENAME, FNR, opened, $0
172 }
173 ' "$@"
174 }
175
176 # The positive: the check has to SEE the shape, in both spellings of the
177 # opener, or it is a line in a file that never fires. Planted rather than
178 # quoted out of the tree, because the tree is the negative below and a
179 # check that only ever reads a clean tree proves nothing.
180 SHPROBE="$ROOT/test/zz_bans_heredoc_$$.sh"
181 # printf and not a heredoc, because this script would otherwise have to
182 # contain the very shape it refuses — and the trailing space after the
183 # first opener is half of what is being pinned.
184 #
185 # The FIXTURE's opener is BUILT rather than written. The sweep's opener
186 # detector spares a quoted delimiter, and it sees a quote right after the
187 # two angle brackets in the awk program at the top of `heredoc_backticks`,
188 # so those literals do not fool it — but a bare opener written out here
189 # would make it read this script's own fixture as a heredoc and report the
190 # three backticks planted below. The only ways out of that are an exception
191 # for this file or a rule that stops covering it. Neither is worth a pair of
192 # characters.
193 _a='<'
194 _hd="$_a$_a"
195 printf '%s\n' \
196 "cat ${_hd}EOF " \
197 '# prose quoting a `verb` the way this repo quotes one' \
198 'EOF' \
199 "cat ${_hd}-INNER" \
200 '# and a `second` under the dash form of the opener' \
201 'INNER' \
202 "cat ${_hd}'QUOTED'" \
203 '# a `third` here is inert, because the delimiter is quoted' \
204 'QUOTED' > "$SHPROBE"
205 _hits=$(heredoc_backticks "$SHPROBE" | wc -l | tr -d ' ')
206 rm -f "$SHPROBE"
207 if [ "$_hits" -eq 2 ]; then
208 echo "bans ok: the heredoc rule catches both openers and spares a quoted one"
209 else
210 echo "bans FAIL: the heredoc rule found $_hits of the 2 planted backticks."
211 echo " An unquoted opener must be caught, including the spelling"
212 echo " with a trailing space after the delimiter, and a quoted"
213 echo " one must be spared."
214 FAILED=1
215 fi
216
217 # The negative: the tree as it stands.
218 _sh=""
219 for _d in test tools deps deps/quic; do
220 for _f in "$ROOT/$_d"/*.sh; do
221 [ -f "$_f" ] && _sh="$_sh $_f"
222 done
223 done
224 # shellcheck disable=SC2086 # the list is words, and split is the point
225 _left=$(heredoc_backticks $_sh)
226 if [ -z "$_left" ]; then
227 echo "bans ok: no backtick inside an unquoted heredoc"
228 else
229 echo "bans FAIL: a backtick inside an unquoted heredoc:"
230 printf '%s\n' "$_left" | sed 's/^/ /'
231 echo " The shell runs it as a command substitution. Say the word"
232 echo " in plain prose instead; there is no way to quote it here."
233 FAILED=1
234 fi
235
114 [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; } 236 [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; }
115 echo "bans: every folder rule bit, and the test skip held" 237 echo "bans: every folder rule bit, the test skip held, and no heredoc runs its own comments"
test/box_lib.sh
Old New
@@ -0,0 +1,272 @@
1 # shellcheck shell=sh
2 # test/box_lib.sh — the boxes the macOS gates take by name, sourced never run.
3 #
4 # `make mac` and `make xos` used to spell one developer's machines in their
5 # own source: a Mac called squirtle, and a Linux VM at a literal LAN
6 # address. Nobody else could run either gate, and neither gate could say
7 # what it was allowed to do to the machines it found — one scrubbed a box to
8 # bare metal, the other tiptoed around a laptop it refused to touch. Both
9 # gates take their boxes as ssh targets in the environment now, and the
10 # arrangements they share live here rather than in three copies.
11 #
12 # LINUX_BOX an x86_64 Linux VM this box can ssh to with key auth. The
13 # gate installs a static-musl mux at ~/.local/bin/mux there.
14 # MAC_BOX an Apple-silicon macOS box this box can ssh to with key
15 # auth, with NO toolchain on it — normally a bridged tart
16 # guest from test/provision-mac.sh. It only RUNS what the
17 # builder built, which is the stronger claim: a bare Mac and
18 # not the machine that compiled the binary.
19 # MAC_BUILDER a Mac holding a checkout with zig and deps/mac-sdk in it.
20 # It BUILDS and does nothing else, so no gate installs a mux
21 # there and none scrubs it. It defaults to $MAC_BOX, which is
22 # right only when the one Mac is both.
23 #
24 # LINUX_BOX and MAC_BOX are VMs the gate MAY scrub, and box_scrub is the
25 # whole of what that means. Neither name has a default: a gate that guessed
26 # a box would eventually guess somebody's laptop.
27 #
28 # Sourced after test/os_oracle.sh, whose now_ms and file/pid helpers these
29 # functions leave alone.
30
31 # The word a refusal opens with, so a failure line reads as its own gate's.
32 BOX_GATE=${BOX_GATE:-box}
33
34 # The refusals in here are the shared ones, and they end the run: a box that
35 # will not take a key or will not reset is not a box the gate can grade.
36 box_die() {
37 echo "$BOX_GATE FAIL: $*"
38 exit 1
39 }
40
41 # BatchMode so a broken auth fails in seconds rather than prompting a
42 # harness nobody sits in front of.
43 BOX_SSH_BASE="-o BatchMode=yes -o ConnectTimeout=10"
44
45 # The one target whose host key is a fresh clone's, and so is a different key
46 # every run: a MAC_BOX that test/provision-mac.sh just made. A gate's own ssh
47 # to that box therefore keeps no host key at all — recording it would fill
48 # known_hosts with keys no later run ever sees again, and checking it would
49 # refuse every run after the first. LogLevel=ERROR because a throwaway
50 # known-hosts file makes ssh announce the new key on stderr every single
51 # time, and that line would be most of what a green run prints.
52 #
53 # mux's OWN ssh is a different matter: the entry dial spawns a plain
54 # `ssh HOST` that reads the real ~/.ssh/known_hosts and cannot be handed
55 # these options, which is why box_preflight forgets and re-learns that box's
56 # key there. A gate that dials MAC_BOX with mux sets this and does that.
57 BOX_EPHEMERAL=${BOX_EPHEMERAL:-}
58
59 # The ssh options for one target: loose for the ephemeral box above,
60 # accept-new for every other, because the first run against a real box
61 # should record its host key where plain ssh will find it.
62 box_opts_for() {
63 if [ -n "$BOX_EPHEMERAL" ] && [ "$1" = "$BOX_EPHEMERAL" ]; then
64 printf '%s' "$BOX_SSH_BASE -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
65 else
66 printf '%s' "$BOX_SSH_BASE -o StrictHostKeyChecking=accept-new"
67 fi
68 }
69
70 # box_ssh SECONDS TARGET — run the script on stdin on TARGET, under /bin/sh,
71 # with $PRELUDE in front of it.
72 #
73 # /bin/sh and not the box's login shell (zsh on a Mac): `$VAR` holding
74 # several words does not word-split in zsh, so an options string pasted into
75 # a command line there arrives as ONE argument and ssh answers "keyword
76 # batchmode extra arguments at end of line".
77 #
78 # The rule for a script handed to this: anything INSIDE it that reads stdin
79 # gets it closed — `-n` for an ssh, `</dev/null` for the rest. The remote sh
80 # is reading its own script off stdin, and a command that drains stdin
81 # swallows the lines after it, silently and with a 0 exit, so they simply
82 # never run. `mux d endpoint` is one of those: it announces on stdout and
83 # then PROXIES, and it ate the two assertions after it the first time the
84 # cross-OS gate ran its entry-dial leg.
85 #
86 # The quoting rule is that there is NO quoting: the script rides this
87 # shell's stdin all the way to the remote `sh -s` and no shell in between
88 # parses it as a command line. The one thing a caller owes it is a heredoc;
89 # called with no redirect it would read the enclosing script's stdin.
90 box_ssh() {
91 _bx_t=$1
92 _bx_target=$2
93 _bx_opts=$(box_opts_for "$_bx_target")
94 # shellcheck disable=SC2086 # the options are words on purpose
95 { printf '%s\n' "${PRELUDE:-}"; cat; } |
96 timeout "$_bx_t" ssh $_bx_opts "$_bx_target" /bin/sh -s
97 }
98
99 # box_scp TARGET LOCAL REMOTE — one local file onto TARGET, at a path
100 # relative to that box's $HOME unless it starts with a slash.
101 box_scp() {
102 _bc_target=$1
103 _bc_opts=$(box_opts_for "$_bc_target")
104 # shellcheck disable=SC2086 # the options are words on purpose
105 scp -q $_bc_opts "$2" "$_bc_target:$3"
106 }
107
108 # box_stream FROM SRCPATH TO DESTPATH — one file from one box to another,
109 # through this one, mode 755 at the far end.
110 #
111 # A `cat` and not an scp, because the two boxes need no key of each other's
112 # for this: the builder holds a binary and the box under test must run it,
113 # and the only machine that can reach both is the one driving the gate.
114 # `cat` carries no mode, so the chmod is explicit — and a caller who cares
115 # that the bytes arrived intact compares the two hashes rather than trusting
116 # this.
117 box_stream() {
118 _bm_from=$1
119 _bm_src=$2
120 _bm_to=$3
121 _bm_dest=$4
122 _bm_fopts=$(box_opts_for "$_bm_from")
123 _bm_topts=$(box_opts_for "$_bm_to")
124 # shellcheck disable=SC2086 # both option strings are words on purpose
125 ssh -n $_bm_fopts "$_bm_from" "cat $_bm_src" |
126 ssh $_bm_topts "$_bm_to" "cat > $_bm_dest.part && chmod 755 $_bm_dest.part && mv $_bm_dest.part $_bm_dest"
127 }
128
129 # box_preflight NAME TARGET WANT_ARCH [HINT] — the box answers, and it is the
130 # machine this gate is for. Runs for every box BEFORE the gate writes
131 # anything anywhere, so a run that cannot finish has not started either.
132 #
133 # `-n` is load-bearing. `timeout` runs its child in a background process
134 # group and an ssh with no -n reads its stdin to forward it; run from a
135 # terminal that is a background read of the tty, so the kernel stops ssh
136 # with SIGTTIN and the SIGTERM at 20 s cannot land on a stopped process. The
137 # gate sat for 17 minutes on `uname -m` before this was fixed (2026-09-04).
138 #
139 # `uname -m` on macOS prints `arm64` and zig's word for that machine is
140 # `aarch64`, the same one-way normalisation src/cli/main.zig's archMatches
141 # does for `mux d upgrade`'s preflight. Callers therefore spell WANT_ARCH
142 # the zig way for both machines.
143 box_preflight() {
144 _pf_name=$1
145 _pf_target=$2
146 _pf_want=$3
147 _pf_hint=${4:-}
148 if [ -z "$_pf_target" ]; then
149 echo "$BOX_GATE FAIL: $_pf_name is unset, and this gate has no default for it."
150 echo " A box named by hand is a box somebody chose; a guessed one is"
151 echo " eventually somebody's laptop."
152 [ -n "$_pf_hint" ] && printf ' %s\n' "$_pf_hint"
153 exit 2
154 fi
155 _pf_opts=$(box_opts_for "$_pf_target")
156 # shellcheck disable=SC2086 # the options are words on purpose
157 _pf_arch=$(timeout 20 ssh -n $_pf_opts "$_pf_target" 'uname -m' 2>"${BOX_ERRFILE:-/tmp/box_preflight.err}") || {
158 echo "$BOX_GATE FAIL: cannot ssh to $_pf_name ($_pf_target). It said:"
159 sed 's/^/ /' "${BOX_ERRFILE:-/tmp/box_preflight.err}" 2>/dev/null || true
160 [ -n "$_pf_hint" ] && printf ' %s\n' "$_pf_hint"
161 exit 2
162 }
163 [ "$_pf_arch" = "arm64" ] && _pf_arch=aarch64
164 [ "$_pf_arch" = "$_pf_want" ] || {
165 echo "$BOX_GATE FAIL: $_pf_name ($_pf_target) is $_pf_arch; this gate needs $_pf_want there."
166 exit 2
167 }
168 echo "preflight $_pf_name $_pf_target $_pf_arch"
169 }
170
171 # box_pair A B — A can ssh to B unattended, and can still after a re-run.
172 #
173 # Both directions of the cross-OS gate dial the other box FROM inside a box:
174 # a macOS client's entry dial spawns ssh at the Linux daemon, and a `mux d
175 # upgrade` in the other direction spawns ssh back. Neither box has ever seen
176 # the other on a first run, and a tart guest has never seen anything at all.
177 #
178 # Three steps, each idempotent: A gets a key if it has none, B accepts that
179 # key, and then A actually dials B — which both proves the pairing and
180 # records B's host key on A, where the plain ssh mux spawns will read it.
181 # Writing another machine's authorized_keys is in scope precisely because
182 # both boxes are VMs the gate may scrub.
183 box_pair() {
184 _pr_a=$1
185 _pr_b=$2
186 _pr_pub=$(box_ssh 60 "$_pr_a" <<'A' | grep '^ssh-' | head -1
187 set -eu
188 mkdir -p "$HOME/.ssh"
189 chmod 700 "$HOME/.ssh"
190 [ -f "$HOME/.ssh/id_ed25519" ] ||
191 ssh-keygen -q -t ed25519 -N "" -f "$HOME/.ssh/id_ed25519" </dev/null
192 cat "$HOME/.ssh/id_ed25519.pub"
193 A
194 )
195 [ -n "$_pr_pub" ] ||
196 box_die "pair: $_pr_a would not hand over an ssh public key"
197 box_ssh 60 "$_pr_b" >/dev/null <<B || box_die "pair: $_pr_b would not take $_pr_a's key"
198 set -eu
199 mkdir -p "\$HOME/.ssh"
200 chmod 700 "\$HOME/.ssh"
201 touch "\$HOME/.ssh/authorized_keys"
202 chmod 600 "\$HOME/.ssh/authorized_keys"
203 grep -qxF '$_pr_pub' "\$HOME/.ssh/authorized_keys" ||
204 printf '%s\n' '$_pr_pub' >> "\$HOME/.ssh/authorized_keys"
205 B
206 box_ssh 60 "$_pr_a" >/dev/null <<B || box_die "pair: $_pr_a still cannot ssh to $_pr_b with its own key"
207 ssh -n -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new $_pr_b true
208 B
209 }
210
211 # box_scrub TARGET — the mux-less box a new user has, on a box the gate is
212 # allowed to scrub. Run at the START of a run and again at the END, so a
213 # failed run leaves nothing behind and the run after it starts from the same
214 # place whatever happened.
215 #
216 # It writes NO shell rc file, which is a deliberate reversal of what the
217 # earlier reset did. Every remote spelling the PRODUCT sends already carries
218 # `PATH="$PATH:$HOME/.local/bin"` in front of it — that is
219 # `handoff.local_bin_append`, on the entry dial's word, on the upgrade
220 # preflight and on the upgrade's push — so a `~/.bashrc` or `~/.zshenv` line
221 # put there by the harness tests nothing the product needs. Worse, it is the
222 # fixture configuring the machine: a bare box that finds mux only because
223 # the gate edited its shell rc is exactly the configured developer machine a
224 # pristine guest exists to rule out. The gate's OWN remote scripts therefore
225 # spell `$HOME/.local/bin/mux` in full, or carry a PATH in the prelude.
226 #
227 # The one arrangement that is (re)made is linger, and only on Linux: without
228 # it logind tears down /run/user/<uid> — the daemon's socket directory —
229 # when the ssh session that started the daemon ends, and reaps the detached
230 # daemon with it. It is separated from the rest by its own exit code, so a
231 # box without passwordless sudo is told what is missing rather than being
232 # handed "the reset was refused".
233 #
234 # Both gates' scratch directory names are removed here, not just the calling
235 # gate's: a box may have run either, and neither should outlive a run.
236 box_scrub() {
237 _sc_rc=0
238 box_ssh 120 "$1" >/dev/null <<'S' || _sc_rc=$?
239 # Best-effort down to the mkdir: a box with no mux on it answers "no such
240 # process" to half of these, and that is the state this is aiming for.
241 set -u
242 for _m in "$HOME/.local/bin/mux" "$HOME/xos/mux" "$HOME/mux-e2e/mux"; do
243 [ -x "$_m" ] && "$_m" d stop >/dev/null 2>&1
244 done
245 sleep 0.3
246 pkill -x mux >/dev/null 2>&1
247 sleep 0.3
248 pkill -9 -x mux >/dev/null 2>&1
249 rm -rf "$HOME/.local/bin/mux" "$HOME/.local/state/mux" "$HOME/.cache/mux" \
250 "$HOME/.config/mux" "$HOME/xos" "$HOME/mux-e2e"
251 [ -n "${XDG_RUNTIME_DIR:-}" ] && rm -rf "$XDG_RUNTIME_DIR"/mux*
252 case "$(uname)" in
253 Darwin) rm -rf "/tmp/mux-$(id -u)" ;;
254 *) rm -rf "/run/user/$(id -u)"/mux* ;;
255 esac
256 # The install legs copy into this, so its absence is the run's problem.
257 mkdir -p "$HOME/.local/bin" || exit 1
258 # Its own code, because "no passwordless sudo on this box" is a different
259 # thing for a reader to fix than "the reset was refused".
260 case "$(uname)" in
261 Linux) sudo -n loginctl enable-linger "$(id -un)" >/dev/null 2>&1 || exit 3 ;;
262 esac
263 exit 0
264 S
265 case "$_sc_rc" in
266 0) ;;
267 3) box_die "scrub: $1 refused \`sudo -n loginctl enable-linger\`. Without linger,
268 logind takes /run/user/<uid> down with the ssh session that started
269 the daemon. Give that user passwordless sudo for loginctl." ;;
270 *) box_die "scrub: $1 refused the reset (rc $_sc_rc)" ;;
271 esac
272 }
test/e2e_01_boot.sh
Old New
@@ -417,6 +417,21 @@ AGENTLEFT=$(find "$(dirname "$SOCK3")" -maxdepth 1 -name "mux-agent-$D3DEAD-*" |
417 echo "e2e FAIL: a SIGKILLed daemon left $AGENTLEFT agent directories for pid $D3DEAD (want 1)"; exit 1; } 417 echo "e2e FAIL: a SIGKILLed daemon left $AGENTLEFT agent directories for pid $D3DEAD (want 1)"; exit 1; }
418 start_daemon "$SOCK3" "$OUT.d3b.d" "daemon did not rebind the stale socket" --shell /bin/sh 418 start_daemon "$SOCK3" "$OUT.d3b.d" "daemon did not rebind the stale socket" --shell /bin/sh
419 D3PID=$DPID 419 D3PID=$DPID
420 # The reap is not a boot step: it happens inside the successor's own
421 # `makeDir`, which runs when its FIRST session is born — here, when the
422 # stranded client above redials. A bound socket says nothing about that,
423 # so wait for the successor's own directory to appear and read the
424 # predecessor's only then. Asserting straight off start_daemon read a
425 # directory that was removed microseconds later, which is a race Linux
426 # won every time and a Mac lost (2026-09-04).
427 _i=0
428 until [ -n "$(find "$(dirname "$SOCK3")" -maxdepth 1 -name "mux-agent-$D3PID-*")" ]; do
429 _i=$((_i + 1)); [ "$_i" -lt $(( 100 * TIME_SCALE )) ] || {
430 echo "e2e FAIL: the restarted daemon never made an agent directory of its own,"
431 echo " so nothing here has reaped yet and the check below would lie:"
432 find "$(dirname "$SOCK3")" -maxdepth 1 -name 'mux-agent-*'; exit 1; }
433 sleep 0.1
434 done
420 AGENTLEFT=$(find "$(dirname "$SOCK3")" -maxdepth 1 -name "mux-agent-$D3DEAD-*" | wc -l) 435 AGENTLEFT=$(find "$(dirname "$SOCK3")" -maxdepth 1 -name "mux-agent-$D3DEAD-*" | wc -l)
421 [ "$AGENTLEFT" -eq 0 ] || { 436 [ "$AGENTLEFT" -eq 0 ] || {
422 echo "e2e FAIL: the restarted daemon left its SIGKILLed predecessor's agent directory:" 437 echo "e2e FAIL: the restarted daemon left its SIGKILLed predecessor's agent directory:"
@@ -677,16 +692,16 @@ mkfifo "$QFIFO"
677 ( sleep 0.3; printf '\034'; sleep 20 ) > "$QFIFO" & 692 ( sleep 0.3; printf '\034'; sleep 20 ) > "$QFIFO" &
678 QWPID=$! 693 QWPID=$!
679 defer_kill "$QWPID" 694 defer_kill "$QWPID"
680 QT0=$(date +%s%N) 695 QT0=$(now_ms)
681 set +e 696 set +e
682 timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \ 697 timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \
683 < "$QFIFO" > "$OUT.qa" 2>&1 698 < "$QFIFO" > "$OUT.qa" 2>&1
684 RC=$? 699 RC=$?
685 set -e 700 set -e
686 QT1=$(date +%s%N) 701 QT1=$(now_ms)
687 softkill "$QWPID" || true 702 softkill "$QWPID" || true
688 rm -f "$QFIFO" 703 rm -f "$QFIFO"
689 QMS=$(( (QT1 - QT0) / 1000000 )) 704 QMS=$(( QT1 - QT0 ))
690 [ "$RC" -eq 0 ] || { 705 [ "$RC" -eq 0 ] || {
691 echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)" 706 echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)"
692 cat "$OUT.qa"; exit 1; 707 cat "$OUT.qa"; exit 1;
@@ -908,7 +923,8 @@ SCOMM=$(pid_comm "$SPID")
908 echo "e2e FAIL: the daemon mux d start -d brought up has comm '$SCOMM', want 'mux'" 923 echo "e2e FAIL: the daemon mux d start -d brought up has comm '$SCOMM', want 'mux'"
909 exit 1; } 924 exit 1; }
910 # Non-tty stderr: exactly two lines, no dots. 925 # Non-tty stderr: exactly two lines, no dots.
911 [ "$(wc -l < "$OUT.start")" = "2" ] || { 926 # -eq and not =, here and below: BSD wc pads its count with spaces.
927 [ "$(wc -l < "$OUT.start")" -eq 2 ] || {
912 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; } 928 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; }
913 929
914 # The daemon it started serves a session (marker in, marker in dump). 930 # The daemon it started serves a session (marker in, marker in dump).
@@ -1111,7 +1127,7 @@ grep -q "daemon did not answer" "$OUT.dead" || {
1111 cat "$OUT.dead"; exit 1; } 1127 cat "$OUT.dead"; exit 1; }
1112 # Non-tty: exactly two lines, same as the success path. The newline that 1128 # Non-tty: exactly two lines, same as the success path. The newline that
1113 # terminates the dot line is tty-only, so nothing blank creeps in here. 1129 # terminates the dot line is tty-only, so nothing blank creeps in here.
1114 [ "$(wc -l < "$OUT.dead")" = "2" ] || { 1130 [ "$(wc -l < "$OUT.dead")" -eq 2 ] || {
1115 echo "e2e FAIL: non-tty failure not exactly two lines:"; cat "$OUT.dead"; exit 1; } 1131 echo "e2e FAIL: non-tty failure not exactly two lines:"; cat "$OUT.dead"; exit 1; }
1116 rm -rf "$DEADCFG" 1132 rm -rf "$DEADCFG"
1117 ok "a start whose daemon dies young says so, with the log path" 1133 ok "a start whose daemon dies young says so, with the log path"
test/e2e_03_side.sh
Old New
@@ -197,7 +197,7 @@ defer_kill "$PAPID"
197 # a logical path reached through a symlinked directory would fail a correct 197 # a logical path reached through a symlinked directory would fail a correct
198 # spawn unless this side resolves too. 198 # spawn unless this side resolves too.
199 PAEXE=$(pid_exe "$PAPID") 199 PAEXE=$(pid_exe "$PAPID")
200 PAWANT=$(readlink -f "$MUX_ELF") 200 PAWANT=$(real_path "$MUX_ELF")
201 [ "$PAEXE" = "$PAWANT" ] || { 201 [ "$PAEXE" = "$PAWANT" ] || {
202 echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT" 202 echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT"
203 echo " (an auto-start that resolves a NAME grades whatever is installed)" 203 echo " (an auto-start that resolves a NAME grades whatever is installed)"
@@ -221,9 +221,9 @@ PACOMM=$(pid_comm "$PAPID")
221 # rule the fork obeys — the child is the foreground daemon, and a `-d` 221 # rule the fork obeys — the child is the foreground daemon, and a `-d`
222 # still in its line would fork again. 222 # still in its line would fork again.
223 PACMD=$(pid_args "$PAPID") 223 PACMD=$(pid_args "$PAPID")
224 [ "$PACMD" = "mux d start --sock $SOCK15 " ] || { 224 [ "$PACMD" = "mux d start --sock $SOCK15" ] || {
225 echo "e2e FAIL: the auto-started daemon's argv is [$PACMD]," 225 echo "e2e FAIL: the auto-started daemon's argv is [$PACMD],"
226 echo " want [mux d start --sock $SOCK15 ]" 226 echo " want [mux d start --sock $SOCK15]"
227 exit 1; } 227 exit 1; }
228 assert_converged_pty "$OUT.pa" "$SOCK15" "local mux auto-start" 80 24 228 assert_converged_pty "$OUT.pa" "$SOCK15" "local mux auto-start" 80 24
229 # Same teardown, same reasons — and the stderr is captured rather than 229 # Same teardown, same reasons — and the stderr is captured rather than
test/e2e_04_handoff.sh
Old New
@@ -89,10 +89,12 @@ ssh_shim_head "$SSHIM_DIR/ssh"
89 cat >> "$SSHIM_DIR/ssh" <<'SHIM' 89 cat >> "$SSHIM_DIR/ssh" <<'SHIM'
90 echo $$ >> "${SSHIM_PIDLOG:?}" 90 echo $$ >> "${SSHIM_PIDLOG:?}"
91 # ps rather than the lib's pid_comm: this shim is written to disk and run 91 # ps rather than the lib's pid_comm: this shim is written to disk and run
92 # as its own `sh` by the client under test, so nothing the suite sourced 92 # as its own `sh` by the client under test, so nothing the suite sourced is
93 # is in scope here. `ps -o comm=` is the same answer the oracle gives and 93 # in scope here. Basenames taken by hand, for pid_comm's own reason: BSD ps
94 # is spelled the same way on every OS the suite could run on. 94 # prints comm as the executable's full path where Linux prints the
95 printf '%s %s\n' "$(ps -o comm= -p $$)" "$(ps -o comm= -p $PPID)" >> "${SSHIM_COMMLOG:?}" 95 # basename.
96 _sc=$(ps -o comm= -p $$); _sp=$(ps -o comm= -p $PPID)
97 printf '%s %s\n' "${_sc##*/}" "${_sp##*/}" >> "${SSHIM_COMMLOG:?}"
96 shift 98 shift
97 printf '%s\n' "$*" >> "${SSHIM_ARGLOG:?}" 99 printf '%s\n' "$*" >> "${SSHIM_ARGLOG:?}"
98 exec /bin/sh -c "$*" 100 exec /bin/sh -c "$*"
@@ -152,13 +154,20 @@ HASKS=$(grep -c -- 'mux d endpoint --start$' "$SSHIM_ARGLOG" || true)
152 [ "$HASKS" -eq 1 ] || { 154 [ "$HASKS" -eq 1 ] || {
153 echo "e2e FAIL: cold handoff ran the asking word $HASKS times, want exactly 1" 155 echo "e2e FAIL: cold handoff ran the asking word $HASKS times, want exactly 1"
154 cat "$SSHIM_ARGLOG"; exit 1; } 156 cat "$SSHIM_ARGLOG"; exit 1; }
155 # Asked of /proc, not of the client: that child was `ssh` itself, sitting 157 # Asked of the OS, not of the client: that child sat directly under `mux`,
156 # directly under `mux`. What this does NOT catch is a 158 # with nothing interposed. Only the PARENT word is pinned. The child's own
157 # `/bin/sh -c` line put back — bash and dash both exec a single simple 159 # comm is not comparable across the two OSes — Linux names a shebang
158 # command in place, so the shell is gone by the time /proc is read. That 160 # script's process after the SCRIPT (`ssh`) and Darwin after the
159 # regression is build.zig folder rule 5's, measured there; this leg pins the 161 # INTERPRETER (`sh`), so both words are correct answers and neither says
160 # shape the rule cannot see, that the argv reaches a real ssh unmangled. 162 # anything about the product. It is still recorded, because a failure here
161 HBADCOMM=$(grep -cv '^ssh mux$' "$SSHIM_COMMLOG" || true) 163 # is read by eye.
164 #
165 # What this does NOT catch is a `/bin/sh -c` line put back — bash and dash
166 # both exec a single simple command in place, so the shell is gone by the
167 # time the process table is read. That regression is build.zig folder rule
168 # 5's, measured there; this leg pins the shape the rule cannot see, that
169 # the argv reaches a real ssh unmangled.
170 HBADCOMM=$(awk '$2 != "mux" {n++} END {print n+0}' "$SSHIM_COMMLOG")
162 [ "$HBADCOMM" -eq 0 ] || { 171 [ "$HBADCOMM" -eq 0 ] || {
163 echo "e2e FAIL: the handoff spawned something other than ssh straight off mux:" 172 echo "e2e FAIL: the handoff spawned something other than ssh straight off mux:"
164 cat "$SSHIM_COMMLOG"; exit 1; } 173 cat "$SSHIM_COMMLOG"; exit 1; }
@@ -281,15 +290,15 @@ HHEXKEY=$(sed -n 's/^endpoint [0-9][0-9]* \([0-9a-f]*\)$/\1/p' "$HCACHE")
281 printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE" 290 printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE"
282 chmod 600 "$HCACHE" 291 chmod 600 "$HCACHE"
283 HSHIMS_C=$(wc -l < "$SSHIM_PIDLOG") 292 HSHIMS_C=$(wc -l < "$SSHIM_PIDLOG")
284 HT4=$(date +%s%N) 293 HT4=$(now_ms)
285 pipe_mux "$OUT.h3" "$OUT.h3.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 \ 294 pipe_mux "$OUT.h3" "$OUT.h3.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 \
286 "$MUX" "$HHOST" 295 "$MUX" "$HHOST"
287 pipe_send 'printf "heal-%%s\\n" ok\n' 296 pipe_send 'printf "heal-%%s\\n" ok\n'
288 wait_for "$OUT.h3" "heal-ok" 30 || { 297 wait_for "$OUT.h3" "heal-ok" 30 || {
289 echo "e2e FAIL: stale-cache handoff never served a session" 298 echo "e2e FAIL: stale-cache handoff never served a session"
290 cat "$OUT.h3" "$OUT.h3.err" 2>/dev/null; exit 1; } 299 cat "$OUT.h3" "$OUT.h3.err" 2>/dev/null; exit 1; }
291 HT5=$(date +%s%N) 300 HT5=$(now_ms)
292 HMS_C=$(( (HT5 - HT4) / 1000000 )) 301 HMS_C=$(( HT5 - HT4 ))
293 pipe_detach "stale-cache handoff client" 302 pipe_detach "stale-cache handoff client"
294 # The absent-grep, and the control that proves it can fire is scenario (d) 303 # The absent-grep, and the control that proves it can fire is scenario (d)
295 # below: the same pattern, asserted PRESENT, against a build where the 304 # below: the same pattern, asserted PRESENT, against a build where the
@@ -400,7 +409,7 @@ softkill "$HPOLLPID"
400 ok "a QUIC poller holds one client slot, not one per second" 409 ok "a QUIC poller holds one client slot, not one per second"
401 410
402 HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") 411 HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG")
403 HT0=$(date +%s%N) 412 HT0=$(now_ms)
404 pipe_mux "$OUT.h4" "$OUT.h4.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ 413 pipe_mux "$OUT.h4" "$OUT.h4.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \
405 "$MUX" "127.0.0.1" 414 "$MUX" "127.0.0.1"
406 pipe_send 'printf "fallback-%%s\\n" ok\n' 415 pipe_send 'printf "fallback-%%s\\n" ok\n'
@@ -413,8 +422,8 @@ pipe_send 'printf "fallback-%%s\\n" ok\n'
413 wait_for "$OUT.h4.err" "unreachable, attaching over ssh" 15 || { 422 wait_for "$OUT.h4.err" "unreachable, attaching over ssh" 15 || {
414 echo "e2e FAIL: key mismatch printed no fallback line in 15s; its stderr was:" 423 echo "e2e FAIL: key mismatch printed no fallback line in 15s; its stderr was:"
415 cat "$OUT.h4.err" 2>/dev/null; exit 1; } 424 cat "$OUT.h4.err" 2>/dev/null; exit 1; }
416 HT1=$(date +%s%N) 425 HT1=$(now_ms)
417 HMS=$(( (HT1 - HT0) / 1000000 )) 426 HMS=$(( HT1 - HT0 ))
418 # The exact line, with the port the daemon really holds: a fallback that 427 # The exact line, with the port the daemon really holds: a fallback that
419 # named the wrong coordinates would be a different bug wearing this one's 428 # named the wrong coordinates would be a different bug wearing this one's
420 # message. 429 # message.
@@ -492,7 +501,7 @@ HWMODE=$(file_mode "$HCACHE_D")
492 echo " anything looser, so this leg would attach COLD" 501 echo " anything looser, so this leg would attach COLD"
493 exit 1; } 502 exit 1; }
494 HSHIMS_W=$(wc -l < "$SSHIM_PIDLOG") 503 HSHIMS_W=$(wc -l < "$SSHIM_PIDLOG")
495 HT4=$(date +%s%N) 504 HT4=$(now_ms)
496 pipe_mux "$OUT.h6" "$OUT.h6.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ 505 pipe_mux "$OUT.h6" "$OUT.h6.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \
497 "$MUX" "127.0.0.1" 506 "$MUX" "127.0.0.1"
498 pipe_send 'printf "warmmiss-%%s\\n" ok\n' 507 pipe_send 'printf "warmmiss-%%s\\n" ok\n'
@@ -500,8 +509,8 @@ pipe_send 'printf "warmmiss-%%s\\n" ok\n'
500 wait_for "$OUT.h6.err" "unreachable, attaching over ssh" 15 || { 509 wait_for "$OUT.h6.err" "unreachable, attaching over ssh" 15 || {
501 echo "e2e FAIL: the warm miss printed no fallback line in 15s; its stderr was:" 510 echo "e2e FAIL: the warm miss printed no fallback line in 15s; its stderr was:"
502 cat "$OUT.h6.err" 2>/dev/null; exit 1; } 511 cat "$OUT.h6.err" 2>/dev/null; exit 1; }
503 HT5=$(date +%s%N) 512 HT5=$(now_ms)
504 HMS_W=$(( (HT5 - HT4) / 1000000 )) 513 HMS_W=$(( HT5 - HT4 ))
505 # ONCE, and naming the port the daemon really holds. Twice would be two 514 # ONCE, and naming the port the daemon really holds. Twice would be two
506 # dials wearing one message. 515 # dials wearing one message.
507 HFB_W=$(grep -c "^mux: quic://127.0.0.1:$HQPORT unreachable, attaching over ssh$" "$OUT.h6.err" || true) 516 HFB_W=$(grep -c "^mux: quic://127.0.0.1:$HQPORT unreachable, attaching over ssh$" "$OUT.h6.err" || true)
@@ -556,7 +565,7 @@ ok "a warm miss on silent coordinates costs ONE deadline (${HMS_W}ms), not two"
556 # never reach the announce at all. 565 # never reach the announce at all.
557 : > "$HCFGBAD" 566 : > "$HCFGBAD"
558 HSHIMS_E=$(wc -l < "$SSHIM_PIDLOG") 567 HSHIMS_E=$(wc -l < "$SSHIM_PIDLOG")
559 HT2=$(date +%s%N) 568 HT2=$(now_ms)
560 pipe_mux "$OUT.h5" "$OUT.h5.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" XDG_CONFIG_HOME="$HCFGBAD" \ 569 pipe_mux "$OUT.h5" "$OUT.h5.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" XDG_CONFIG_HOME="$HCFGBAD" \
561 PATH="$HPATH" timeout 40 \ 570 PATH="$HPATH" timeout 40 \
562 "$MUX" "mux-e2e-none@127.0.0.1" 571 "$MUX" "mux-e2e-none@127.0.0.1"
@@ -564,8 +573,8 @@ pipe_send 'printf "none-%%s\\n" ok\n'
564 wait_for "$OUT.h5" "none-ok" 20 || { 573 wait_for "$OUT.h5" "none-ok" 20 || {
565 echo "e2e FAIL: announce-none handoff never served a session" 574 echo "e2e FAIL: announce-none handoff never served a session"
566 cat "$OUT.h5" "$OUT.h5.err" 2>/dev/null; exit 1; } 575 cat "$OUT.h5" "$OUT.h5.err" 2>/dev/null; exit 1; }
567 HT3=$(date +%s%N) 576 HT3=$(now_ms)
568 HMS_E=$(( (HT3 - HT2) / 1000000 )) 577 HMS_E=$(( HT3 - HT2 ))
569 pipe_detach "announce-none client" 578 pipe_detach "announce-none client"
570 # The remote said why, in one line, on the stderr mux relays from ssh. 579 # The remote said why, in one line, on the stderr mux relays from ssh.
571 grep -q '^mux d endpoint: .*staying on ssh' "$OUT.h5.err" || { 580 grep -q '^mux d endpoint: .*staying on ssh' "$OUT.h5.err" || {
test/e2e_08_mouse.sh
Old New
@@ -200,9 +200,28 @@ ok "an application that asked for the mouse gets the wheel, and the client does
200 # scenario before the fix: four notches, grid unmoved. 200 # scenario before the fix: four notches, grid unmoved.
201 # 201 #
202 # `less +G` on 200 lines: the view starts at the END, so a wheel-UP is the 202 # `less +G` on 200 lines: the view starts at the END, so a wheel-UP is the
203 # direction with somewhere to go. Eight notches is 24 rows, one screenful, 203 # direction with somewhere to go. Eight notches at less's default of three
204 # which moves the top from 178 to 154 — and 178 leaves the screen entirely, 204 # rows a notch is 24 rows, one screenful, so the top row moves back by 24
205 # so the assertion has both a needle and its negative. 205 # and the row that was at the top leaves the screen entirely — a needle and
206 # its negative.
207 #
208 # The top row is READ off the grid rather than written down here, and the
209 # read is taken between two attaches so that nothing is racing it. Where a
210 # pager parks the end of a file is the pager's business and the builds
211 # disagree: attaching makes less repaint, and how many of the 24 rows the
212 # build spends on its own status line decides where the repainted view
213 # starts. less 704 leaves the top at 178 and less 668, the one macOS 26
214 # ships, at 179 — measured on this scenario, three runs each. So a leg that
215 # spelled 154 failed on the Mac by one row while the wheel worked
216 # perfectly, and one that accepted 154 OR 155 stopped being able to fail on
217 # a wheel that moved 23 rows, which is what a lost keystroke looks like.
218 #
219 # The short attach below is what makes 24 an exact number on both. It
220 # attaches, waits for the paint, and detaches, which leaves the daemon's
221 # grid holding the REPAINTED view; the dump after it is therefore the same
222 # top row the wheel is about to move, read while no client is attached and
223 # nothing can change it. A second attach at the same size repaints to the
224 # same place, measured three times on each OS.
206 # 225 #
207 # LESS is cleared in the script rather than trusted: an operator with 226 # LESS is cleared in the script rather than trusted: an operator with
208 # `LESS=--mouse` exported would have a pager that DOES ask for the mouse, 227 # `LESS=--mouse` exported would have a pager that DOES ask for the mouse,
@@ -218,19 +237,48 @@ EOF
218 chmod +x "$LESSSH" 237 chmod +x "$LESSSH"
219 start_daemon "$SOCK35" "$OUT.pgr.d" "pager daemon never bound" --shell "$LESSSH" 238 start_daemon "$SOCK35" "$OUT.pgr.d" "pager daemon never bound" --shell "$LESSSH"
220 D32PID=$DPID 239 D32PID=$DPID
221 # Not wait_grid: the needle is anchored (`-qx`, a row that is EXACTLY the 240 # Not wait_grid: the needle is anchored (`-qx`, a row that is EXACTLY one
222 # file's last line, so a half-painted `2` does not pass) and the dump is 241 # of the file's lines, so a half-painted `19` does not pass) and the dump
223 # trimmed to the last rows, because a pager's whole grid is 24 rows of 242 # is trimmed to the last rows, because a pager's whole grid is 24 rows of
224 # numbers that say nothing about why it stopped short. 243 # numbers that say nothing about why it stopped short. 190 rather than the
244 # file's last line: it is well inside the page either build draws, so the
245 # needle does not depend on the height question the comment above settles.
225 i=0 246 i=0
226 until "$MUX" d dump --sock "$SOCK35" | grep -qx "200"; do 247 until "$MUX" d dump --sock "$SOCK35" | grep -qx "190"; do
227 i=$((i+1)); [ "$i" -lt $(( 100 * TIME_SCALE )) ] || { 248 i=$((i+1)); [ "$i" -lt $(( 100 * TIME_SCALE )) ] || {
228 echo "e2e FAIL: pager never reached the end of the file:" 249 echo "e2e FAIL: pager never painted the page it was sent to:"
229 "$MUX" d dump --sock "$SOCK35" | tail -3; exit 1; } 250 "$MUX" d dump --sock "$SOCK35" | tail -3; exit 1; }
230 sleep 0.1 251 sleep 0.1
231 done 252 done
253 # The anchoring attach. Same size as the one that follows it, because the
254 # thing being fixed is where a repaint at THIS size lands.
232 set +e 255 set +e
233 hostroom pager 256 hostroom pager
257 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 \
258 --out "$OUT.pgranchor" --err "$OUT.pgranchor.err" \
259 -- "$MUX" --sock "$SOCK35" > "$OUT.pgranchor.log" 2>&1 <<'EOF'
260 expect 199 15000
261 settle 800 15000
262 send \x1cd
263 waitexit 10000
264 EOF
265 RC=$?
266 set -e
267 rc0 "pager: the anchoring attach exited $RC:" "$OUT.pgranchor.log"
268 PGRTOP=$("$MUX" d dump --sock "$SOCK35" | sed -n '1p')
269 # Refused rather than trusted: an anchor that is not a line number, or one
270 # with no room for a screenful above it, would make the arithmetic below
271 # assert nothing. Both would mean the pager is not where this leg thinks.
272 case "$PGRTOP" in
273 ''|*[!0-9]*)
274 echo "e2e FAIL: pager: the top row after the anchoring attach is not a line number: '$PGRTOP'"
275 "$MUX" d dump --sock "$SOCK35" | head -3; exit 1 ;;
276 esac
277 [ "$PGRTOP" -gt 24 ] || {
278 echo "e2e FAIL: pager: top row $PGRTOP leaves no room for a screenful of wheel"
279 "$MUX" d dump --sock "$SOCK35" | head -3; exit 1; }
280
281 set +e
234 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pgr" --err "$OUT.pgr.err" \ 282 XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pgr" --err "$OUT.pgr.err" \
235 -- "$MUX" --sock "$SOCK35" > "$OUT.pgr.log" 2>&1 <<'EOF' 283 -- "$MUX" --sock "$SOCK35" > "$OUT.pgr.log" 2>&1 <<'EOF'
236 expect 199 15000 284 expect 199 15000
@@ -250,11 +298,12 @@ rc0 "pager: ptyclient leg exited $RC:" "$OUT.pgr.log"
250 "$MUX" d dump --sock "$SOCK35" > "$OUT.pgrcap" 2>&1 298 "$MUX" d dump --sock "$SOCK35" > "$OUT.pgrcap" 2>&1
251 # A whole line, because the pager's status line carries the file's PATH — 299 # A whole line, because the pager's status line carries the file's PATH —
252 # which holds this run's pid and could spell any short number. 300 # which holds this run's pid and could spell any short number.
253 grep -qx "154" "$OUT.pgrcap" || { 301 grep -qx "$(( PGRTOP - 24 ))" "$OUT.pgrcap" || {
254 echo "e2e FAIL: pager: the wheel moved nothing on the alternate screen:" 302 echo "e2e FAIL: pager: the wheel did not move the alternate screen back 24 rows"
303 echo " (the top was $PGRTOP, so row $(( PGRTOP - 24 )) should be on screen):"
255 cat "$OUT.pgrcap"; exit 1; } 304 cat "$OUT.pgrcap"; exit 1; }
256 grep -qx "178" "$OUT.pgrcap" && { 305 grep -qx "$PGRTOP" "$OUT.pgrcap" && {
257 echo "e2e FAIL: pager: the old top row is still on screen, so the view did not move a screenful:" 306 echo "e2e FAIL: pager: the old top row $PGRTOP is still on screen, so the view did not move a screenful:"
258 cat "$OUT.pgrcap"; exit 1; } 307 cat "$OUT.pgrcap"; exit 1; }
259 # The pager never asked for the mouse, so it must never have SEEN a mouse 308 # The pager never asked for the mouse, so it must never have SEEN a mouse
260 # report: what reached it was arrow keys, which leave no text behind. 309 # report: what reached it was arrow keys, which leave no text behind.
@@ -647,9 +696,17 @@ ok "an application in a tile off the origin reads the press on its own row"
647 # the way a real terminal does. With the re-arm gone, the click back is 696 # the way a real terminal does. With the re-arm gone, the click back is
648 # bytes a terminal would never send — the verb exits 3 saying so, instead 697 # bytes a terminal would never send — the verb exits 3 saying so, instead
649 # of this leg green-lighting a wall no mouse can steer. 698 # of this leg green-lighting a wall no mouse can steer.
650 SOCKMC1="${TMPDIR:-/tmp}/muxd-e2e-deadclick1-$$.sock" 699 # /tmp and not $TMPDIR, which is the only place in this suite that says so.
700 # The assertion below greps a socket path out of a tile's LABEL BAR, and a
701 # bar on a two-pane 100-column wall is 50 columns: `labelText` keeps the
702 # state word and cuts the label's tail, which is the half that carries the
703 # `deadclick2` this leg looks for. macOS puts $TMPDIR in a per-user
704 # directory whose name alone was 48 characters on the box this was
705 # measured on, so the bar had nothing of the name left in it. The clicks below are at fixed columns, so widening
706 # the terminal is not free here the way it is in the hosts group.
707 SOCKMC1="/tmp/muxd-e2e-deadclick1-$$.sock"
651 defer_sock "$SOCKMC1" 708 defer_sock "$SOCKMC1"
652 SOCKMC2="${TMPDIR:-/tmp}/muxd-e2e-deadclick2-$$.sock" 709 SOCKMC2="/tmp/muxd-e2e-deadclick2-$$.sock"
653 defer_sock "$SOCKMC2" 710 defer_sock "$SOCKMC2"
654 MCSTATE="${TMPDIR:-/tmp}/mux-e2e-deadclick-state-$$" 711 MCSTATE="${TMPDIR:-/tmp}/mux-e2e-deadclick-state-$$"
655 defer_rm "$MCSTATE" 712 defer_rm "$MCSTATE"
test/e2e_09_hosts.sh
Old New
@@ -14,9 +14,18 @@
14 # `mux` in this suite now records the DAEMON it attached to, so a hosts 14 # `mux` in this suite now records the DAEMON it attached to, so a hosts
15 # file read out of the shared $XDG_STATE_HOME would be every other group's 15 # file read out of the shared $XDG_STATE_HOME would be every other group's
16 # attaches as much as this one's. 16 # attaches as much as this one's.
17 SOCKH1="${TMPDIR:-/tmp}/muxd-e2e-hosts1-$$.sock" 17 # /tmp and not $TMPDIR, which this group and the dead-click leg in
18 # e2e_08_mouse.sh are the only places to say. Six assertions below grep one
19 # of these paths out of a tile's LABEL BAR, and a bar is as wide as the
20 # terminal: `labelText` keeps the state word and cuts the label's TAIL,
21 # which is the half carrying the `#SESSION` and the daemon's name. macOS
22 # puts $TMPDIR in a per-user directory whose name alone was 48 characters
23 # on the box this was measured on, so an 80-column bar had none of that
24 # left in it. /tmp is short on both
25 # OSes, and on Linux it is where a $TMPDIR-less run already puts them.
26 SOCKH1="/tmp/muxd-e2e-hosts1-$$.sock"
18 defer_sock "$SOCKH1" 27 defer_sock "$SOCKH1"
19 SOCKH2="${TMPDIR:-/tmp}/muxd-e2e-hosts2-$$.sock" 28 SOCKH2="/tmp/muxd-e2e-hosts2-$$.sock"
20 defer_sock "$SOCKH2" 29 defer_sock "$SOCKH2"
21 HSTATE="${TMPDIR:-/tmp}/mux-e2e-hosts-state-$$" 30 HSTATE="${TMPDIR:-/tmp}/mux-e2e-hosts-state-$$"
22 defer_rm "$HSTATE" 31 defer_rm "$HSTATE"
@@ -186,7 +195,9 @@ defer_kill "$HEPID"
186 grep -qxF -- "--sock $HESOCK" "$HESTATE/mux/hosts" || { 195 grep -qxF -- "--sock $HESOCK" "$HESTATE/mux/hosts" || {
187 echo "e2e FAIL: hosts: the empty file did not gain the local daemon's line:" 196 echo "e2e FAIL: hosts: the empty file did not gain the local daemon's line:"
188 cat "$HESTATE/mux/hosts"; exit 1; } 197 cat "$HESTATE/mux/hosts"; exit 1; }
189 [ "$(wc -l < "$HESTATE/mux/hosts")" = "1" ] || { 198 # -eq and not =: BSD wc pads its count to a column width, so the string
199 # comparison read " 1" and failed on a file that was exactly right.
200 [ "$(wc -l < "$HESTATE/mux/hosts")" -eq 1 ] || {
190 echo "e2e FAIL: hosts: the first mux wrote more than the daemon it attached to:" 201 echo "e2e FAIL: hosts: the first mux wrote more than the daemon it attached to:"
191 cat "$HESTATE/mux/hosts"; exit 1; } 202 cat "$HESTATE/mux/hosts"; exit 1; }
192 # ...and the session behind that line is a real shell, asked of the OS: the 203 # ...and the session behind that line is a real shell, asked of the OS: the
test/e2e_10_agent.sh
Old New
@@ -87,6 +87,15 @@ esac
87 # Unquoted heredoc, like the OSC 52 leg's and for the same reason: $FP48 has 87 # Unquoted heredoc, like the OSC 52 leg's and for the same reason: $FP48 has
88 # to interpolate. Nothing else in the script needs expanding — `\n` and 88 # to interpolate. Nothing else in the script needs expanding — `\n` and
89 # `\x1b` are not escapes the shell touches. 89 # `\x1b` are not escapes the shell touches.
90 #
91 # No BACKTICKS below this line, comments included, and that is a rule and
92 # not a preference: an unquoted heredoc runs `word` as a command
93 # substitution wherever it appears, and the comments here used to quote
94 # `x`, `d`, `expect` and `settle` the way the rest of this repo quotes a
95 # key or a verb. On Linux all four are "command not found" and the run
96 # carries on none the wiser. On macOS /usr/bin/expect exists, so the shell
97 # started it, it read the rest of the heredoc as its own script, and the
98 # group hung until its budget ran out (2026-09-04).
90 set +e 99 set +e
91 hostroom agt48 100 hostroom agt48
92 XDG_STATE_HOME="$HOSTROOM" SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \ 101 XDG_STATE_HOME="$HOSTROOM" SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 100 --rows 30 \
@@ -98,14 +107,15 @@ send ssh-add -l\n
98 expect $FP48 15000 107 expect $FP48 15000
99 settle 400 15000 108 settle 400 15000
100 send exit\n 109 send exit\n
101 # On a TERMINAL the last tile's exit leaves an EMPTY WALL — `x` ends a 110 # On a TERMINAL the last tile's exit leaves an EMPTY WALL — the x key ends
102 # session, never mux — and the picker opens over it. Esc closes the popup 111 # a session, never mux — and the picker opens over it. Esc closes the popup
103 # and `d` is what leaves. A piped mux still exits with the shell's code. 112 # and the d key is what leaves. A piped mux still exits with the shell's
113 # code.
104 # 114 #
105 # `expect`, not `settle`: the popup is the CLAIM, so waiting for its legend 115 # An expect verb here rather than a settle: the popup is the CLAIM, so
106 # is what makes this leg fail if the exit ever ends mux again instead. The 116 # waiting for its legend is what makes this leg fail if the exit ever ends
107 # empty-wall scenario asserts a picker on a wall that never had a tile; 117 # mux again instead. The empty-wall scenario asserts a picker on a wall
108 # only this one sees the TRANSITION. 118 # that never had a tile; only this one sees the TRANSITION.
109 expect c new session 15000 119 expect c new session 15000
110 send \x1b 120 send \x1b
111 settle 300 15000 121 settle 300 15000
@@ -207,7 +217,7 @@ ok "agent forwarding: -A with no agent is a usage error, not a silent no-op"
207 assert_stopped "$SOCK48" "$D42PID" "agent forwarding: the positive leg's daemon" "$OUT.agtstop0" 217 assert_stopped "$SOCK48" "$D42PID" "agent forwarding: the positive leg's daemon" "$OUT.agtstop0"
208 start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh 218 start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh
209 D42PID=$DPID 219 D42PID=$DPID
210 AR0=$(date +%s%N) 220 AR0=$(now_ms)
211 set +e 221 set +e
212 hostroom agtn 222 hostroom agtn
213 # The neighbour, written down: the wall is the layout, so the daemon's own 223 # The neighbour, written down: the wall is the layout, so the daemon's own
@@ -234,8 +244,8 @@ waitexit 10000
234 EOF 244 EOF
235 RC=$? 245 RC=$?
236 set -e 246 set -e
237 AR1=$(date +%s%N) 247 AR1=$(now_ms)
238 AMS=$(( (AR1 - AR0) / 1000000 )) 248 AMS=$(( AR1 - AR0 ))
239 rc0 "agent-refusal: the leg exited $RC:" "$OUT.agtn.log" 249 rc0 "agent-refusal: the leg exited $RC:" "$OUT.agtn.log"
240 # Quoted heredoc, unlike the leg above: `$?` has to reach the SESSION's 250 # Quoted heredoc, unlike the leg above: `$?` has to reach the SESSION's
241 # shell as two characters, not be spent by this one. 251 # shell as two characters, not be spent by this one.
@@ -438,13 +448,20 @@ expect $FPA 20000
438 send echo flip-now\n 448 send echo flip-now\n
439 expect $FPB 25000 449 expect $FPB 25000
440 send exit\n 450 send exit\n
441 # Empty wall, then the picker: see the first agent leg — and `expect` on 451 # Empty wall, then the picker: see the first agent leg, and an expect verb
442 # the popup's own legend, never a settle. The picker opens on the host 452 # on the popup's own legend, never a settle. The picker opens on the host
443 # POLL (up to a second after the last session dies) while a settle fires a 453 # POLL (up to a second after the last session dies) while a settle fires a
444 # fixed time after the last output byte; a picker that opened between the 454 # fixed time after the last output byte; a picker that opened between the
445 # settle and the `\x1b` would eat the `d` that follows (every byte is the 455 # settle and the Esc would eat the d that follows (every byte is the
446 # popup's while it is up) and this leg would fail at waitexit on a change 456 # popup's while it is up) and this leg would fail at waitexit on a change
447 # nobody made. 457 # nobody made.
458 #
459 # No backticks below the heredoc opener, comments included, for the reason
460 # written above the first agent leg's script: in an unquoted heredoc the
461 # shell runs a backquoted word as a command substitution wherever it
462 # stands, and this comment used to quote the expect verb that way. There is
463 # an expect program on macOS, so the shell started it, it read the rest of
464 # the heredoc as its own script, and the gate hung for its whole budget.
448 expect c new session 20000 465 expect c new session 20000
449 send \x1b 466 send \x1b
450 settle 300 20000 467 settle 300 20000
@@ -467,10 +484,10 @@ send echo b-ready\n
467 expect flip-now 30000 484 expect flip-now 30000
468 send ssh-add -l\n 485 send ssh-add -l\n
469 expect $FPB 20000 486 expect $FPB 20000
470 # A's `exit` ends the session both clients hold, so B's tile goes too and 487 # A's exit command ends the session both clients hold, so B's tile goes too and
471 # B is left on the same empty wall with the same picker over it. The 488 # B is left on the same empty wall with the same picker over it. The
472 # legend and not a settle, doubly so here: B is a passive observer, so its 489 # legend and not a settle, doubly so here: B is a passive observer, so its
473 # screen is already quiet when A types `exit` and a duration could expire 490 # screen is already quiet when A types exit and a duration could expire
474 # before the transition this waits for has happened at all. 491 # before the transition this waits for has happened at all.
475 expect c new session 20000 492 expect c new session 20000
476 send \x1b 493 send \x1b
test/e2e_11_select.sh
Old New
@@ -195,7 +195,8 @@ rc0 "drag copy: ptyclient leg exited $RC (no OSC 52 with the dragged text?):" "$
195 # nothing to copy, and a client that copied on every button release would 195 # nothing to copy, and a client that copied on every button release would
196 # have written two. 196 # have written two.
197 SELCOPIES=$(grep -ao "$(printf '\033]52;')" "$OUT.selcap" | wc -l) 197 SELCOPIES=$(grep -ao "$(printf '\033]52;')" "$OUT.selcap" | wc -l)
198 [ "$SELCOPIES" = "1" ] || { 198 # -eq and not =: BSD wc pads its count to a column width.
199 [ "$SELCOPIES" -eq 1 ] || {
199 echo "e2e FAIL: drag copy: $SELCOPIES OSC 52 writes on the client's tty, expected 1"; exit 1; } 200 echo "e2e FAIL: drag copy: $SELCOPIES OSC 52 writes on the client's tty, expected 1"; exit 1; }
200 # ---- a drag over WIDE cells leaves the client's screen converged ------- 201 # ---- a drag over WIDE cells leaves the client's screen converged -------
201 # 202 #
@@ -374,14 +375,24 @@ defer_kill "$AGENT48PID"
374 cat "$OUT.agtmute.env"; exit 1; } 375 cat "$OUT.agtmute.env"; exit 1; }
375 kill -STOP "$AGENT48PID" 376 kill -STOP "$AGENT48PID"
376 set +e 377 set +e
377 SSH_AUTH_SOCK="$AGENT48" XDG_STATE_HOME="$AGSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \ 378 # Assembled rather than written as one heredoc, because ONE of its lines
378 --out "$OUT.agtmute" --err "$OUT.agtmute.err" \ 379 # needs a value from THIS shell: the session times its own ssh-add, and the
379 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agtmute.log" 2>&1 <<'EOF' 380 # clock that does it is `date +%s%N` on one OS and a perl one-liner on the
381 # other, so the text comes from now_ms_snippet. Both halves stay QUOTED
382 # heredocs — the lines around the seam spell `$?`, `$R` and a backticked
383 # `d` that all have to reach the SESSION as characters rather than be spent
384 # here — and the seam itself is a printf, which expands exactly the one
385 # thing it is given and nothing else on the line.
386 {
387 cat <<'EOF'
380 expect \x1b[?1049h 15000 388 expect \x1b[?1049h 15000
381 settle 400 15000 389 settle 400 15000
382 send timeout 8 ssh-add -l; echo mu""te1=$?\n 390 send timeout 8 ssh-add -l; echo mu""te1=$?\n
383 expect mute1= 20000 391 expect mute1= 20000
384 send S=$(date +%s%N); ssh-add -l; R=$?; echo mu""te2=$R ms=$(( ($(date +%s%N)-S)/1000000 ))\n 392 EOF
393 printf 'send %s; S=$(now_ms); ssh-add -l; R=$?; echo mu""te2=$R ms=$(( $(now_ms) - S ))\\n\n' \
394 "$(now_ms_snippet)"
395 cat <<'EOF'
385 expect mute2= 15000 396 expect mute2= 15000
386 settle 400 15000 397 settle 400 15000
387 send exit\n 398 send exit\n
@@ -396,6 +407,10 @@ settle 300 15000
396 send \x1cd 407 send \x1cd
397 waitexit 10000 408 waitexit 10000
398 EOF 409 EOF
410 } > "$OUT.agtmute.script"
411 SSH_AUTH_SOCK="$AGENT48" XDG_STATE_HOME="$AGSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
412 --out "$OUT.agtmute" --err "$OUT.agtmute.err" \
413 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agtmute.log" 2>&1 < "$OUT.agtmute.script"
399 RC=$? 414 RC=$?
400 set -e 415 set -e
401 # The agent is resumed before it is killed: a stopped process ignores 416 # The agent is resumed before it is killed: a stopped process ignores
test/e2e_14_upgrade.sh
Old New
@@ -38,7 +38,7 @@ defer_rm "$UPHOME"
38 # which answers with the resolved image — a $TMPDIR reached through a 38 # which answers with the resolved image — a $TMPDIR reached through a
39 # symlink would never equal a logical path and the wait would time out on a 39 # symlink would never equal a logical path and the wait would time out on a
40 # copy that had in fact exec'd. 40 # copy that had in fact exec'd.
41 UPBIN=$(readlink -f "${TMPDIR:-/tmp}/mux-e2e-upcand-$$") 41 UPBIN=$(real_path "${TMPDIR:-/tmp}/mux-e2e-upcand-$$")
42 defer_rm "$UPBIN" 42 defer_rm "$UPBIN"
43 UPAGENT="${TMPDIR:-/tmp}/mux-e2e-upagent-$$.sock" 43 UPAGENT="${TMPDIR:-/tmp}/mux-e2e-upagent-$$.sock"
44 defer_rm "$UPAGENT" 44 defer_rm "$UPAGENT"
@@ -545,7 +545,7 @@ await_out "$OUT.uqc" "uq-pre" "quic-upgrade: the QUIC client never got its pre-u
545 545
546 UPATT1=$("$MUX" d stats --sock "$SOCK72" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p') 546 UPATT1=$("$MUX" d stats --sock "$SOCK72" | sed -n 's/.*attaches=\([0-9]*\).*/\1/p')
547 [ -n "$UPATT1" ] || { echo "e2e FAIL: quic-upgrade: no attaches counter to compare against"; exit 1; } 547 [ -n "$UPATT1" ] || { echo "e2e FAIL: quic-upgrade: no attaches counter to compare against"; exit 1; }
548 UPT0=$(date +%s%N) 548 UPT0=$(now_ms)
549 set +e 549 set +e
550 "$MUX" d upgrade --sock "$SOCK72" --allow-same-version > "$OUT.uqup" 2>&1 550 "$MUX" d upgrade --sock "$SOCK72" --allow-same-version > "$OUT.uqup" 2>&1
551 UPRC=$? 551 UPRC=$?
@@ -567,8 +567,8 @@ while : ; do
567 cat "$OUT.uqc.d"; exit 1; } 567 cat "$OUT.uqc.d"; exit 1; }
568 sleep 0.05 568 sleep 0.05
569 done 569 done
570 UPT1=$(date +%s%N) 570 UPT1=$(now_ms)
571 UPMS=$(( (UPT1 - UPT0) / 1000000 )) 571 UPMS=$(( UPT1 - UPT0 ))
572 # Half the idle timeout, which is the only bound that means anything here: 572 # Half the idle timeout, which is the only bound that means anything here:
573 # a client that had waited the connection out would arrive at 15000ms and 573 # a client that had waited the connection out would arrive at 15000ms and
574 # still pass a generous absolute budget. Measured at ~60ms. 574 # still pass a generous absolute budget. Measured at ~60ms.
test/e2e_16_push.sh
Old New
@@ -23,12 +23,12 @@ PUSHINST="$PUSHROOT/.local/bin/mux"
23 23
24 mkdir -p "$PUSHROOT/.local/bin" "$PUSHROOT/run" "$PUSHROOT/state" \ 24 mkdir -p "$PUSHROOT/.local/bin" "$PUSHROOT/run" "$PUSHROOT/state" \
25 "$PUSHBARE" "$PUSHIM_DIR/fakebin" 25 "$PUSHBARE" "$PUSHIM_DIR/fakebin"
26 # The path the daemon's image is compared against, resolved ONCE and the 26 # The path the daemon's image is compared against, resolved ONCE and
27 # same way the oracle resolves the link: pid_exe is `readlink -f`, so a 27 # through the oracle, which is where "resolved" is spelled per OS: pid_exe
28 # $TMPDIR reached through a symlink would fail a correct push if only one 28 # answers the canonical image, so a $TMPDIR reached through a symlink would
29 # side were canonical. After the mkdir, because `readlink -f` needs every 29 # fail a correct push if only one side were canonical. After the mkdir,
30 # component but the last to exist. 30 # because real_path needs every component but the last to exist.
31 PUSHWANT=$(readlink -f "$PUSHINST") 31 PUSHWANT=$(real_path "$PUSHINST")
32 32
33 # The shim IS ssh as far as the pusher can tell (e2e_04 explains that 33 # The shim IS ssh as far as the pusher can tell (e2e_04 explains that
34 # contract), and here it also IS the remote box: it swaps in the fake box's 34 # contract), and here it also IS the remote box: it swaps in the fake box's
test/e2e_lib.sh
Old New
@@ -12,6 +12,26 @@
12 # What is NOT here: the scenario count. ok() counts, and the pin that 12 # What is NOT here: the scenario count. ok() counts, and the pin that
13 # gates the count is the runner's, at the bottom of test/e2e.sh — a group 13 # gates the count is the runner's, at the bottom of test/e2e.sh — a group
14 # file knows nothing about how many scenarios the suite has. 14 # file knows nothing about how many scenarios the suite has.
15 # ---- $TMPDIR, trailing slash removed -----------------------------------
16 # Every temporary path in this suite is spelled `${TMPDIR:-/tmp}/name`, and
17 # a good few of them are then compared as STRINGS — a layout leaf against
18 # the socket the daemon bound, a hosts line against the daemon the client
19 # attached to. macOS sets $TMPDIR to a per-user directory ending in `/`, so
20 # those paths came out with `//` in the middle: mux records the spelling it
21 # was handed, the suite spells its expectation the same way, and the two
22 # still differ when only one of them went through a normalizing step. Two
23 # groups failed on exactly that. Stripped once here rather than at every
24 # call site that spells it, and before the first of them.
25 while :; do
26 case "${TMPDIR:-}" in
27 */) TMPDIR="${TMPDIR%/}" ;;
28 *) break ;;
29 esac
30 done
31 # An empty $TMPDIR is not a directory; leave it unset so `${TMPDIR:-/tmp}`
32 # falls back the way every caller here expects.
33 [ -n "${TMPDIR:-}" ] && export TMPDIR || unset TMPDIR
34
15 # ---- the cleanup registry --------------------------------------------- 35 # ---- the cleanup registry ---------------------------------------------
16 # Every process and every artifact a leg creates is REGISTERED where it is 36 # Every process and every artifact a leg creates is REGISTERED where it is
17 # created, and the EXIT trap walks the registers. Before this, cleanup() 37 # created, and the EXIT trap walks the registers. Before this, cleanup()
@@ -344,7 +364,14 @@ softkill() {
344 # and its copies were a second spelling of a question this repo means to 364 # and its copies were a second spelling of a question this repo means to
345 # ask in one place. That file is trap-free on purpose: the registry and the 365 # ask in one place. That file is trap-free on purpose: the registry and the
346 # EXIT trap below are this file's, and it must not bring a second of either. 366 # EXIT trap below are this file's, and it must not bring a second of either.
367 # shellcheck source=test/os_oracle.sh
347 . "$(dirname "$0")/os_oracle.sh" 368 . "$(dirname "$0")/os_oracle.sh"
369 # And the oracle's own pin, which moved out of this file for the same
370 # reason: it has to be runnable on a box where nothing else here works
371 # yet, which is the first thing a new OS arm needs. It defines
372 # oracle_selftest and nothing else on this path.
373 # shellcheck source=test/oracle_selftest.sh
374 . "$(dirname "$0")/oracle_selftest.sh"
348 375
349 # Poll until nothing answers on a socket path (2s). Keyed off the daemon's 376 # Poll until nothing answers on a socket path (2s). Keyed off the daemon's
350 # own liveness rather than a fixed sleep, same reasoning as wait_for. 377 # own liveness rather than a fixed sleep, same reasoning as wait_for.
@@ -957,9 +984,17 @@ assert_stopped() {
957 # anywhere in the line, because one binary means the CLIENT is a `mux` too 984 # anywhere in the line, because one binary means the CLIENT is a `mux` too
958 # and the delayed-link scripts name the same socket — a pattern match here 985 # and the delayed-link scripts name the same socket — a pattern match here
959 # takes out the very client under test, and so would `pkill -f proxy`. 986 # takes out the very client under test, and so would `pkill -f proxy`.
987 #
988 # Read out of `args` alone, and the program name taken as a BASENAME of its
989 # first word. A `comm` column cannot carry this question across the two
990 # OSes: BSD ps prints comm as the executable's full path where Linux prints
991 # the basename, and in a multi-column format it truncates that path to the
992 # column width, so `$2=="mux"` matched nothing at all on macOS and every
993 # scenario that tears a transport failed as "could not find the proxy".
960 proxy_pid() { 994 proxy_pid() {
961 ps -eo pid,comm,args | 995 ps -eo pid,args |
962 awk -v s="$1" '$2=="mux" && $4=="d" && $5=="proxy" && index($0,s) {print $1}' | 996 awk -v s="$1" '{ n = split($2, _p, "/") }
997 _p[n]=="mux" && $3=="d" && $4=="proxy" && index($0,s) {print $1}' |
963 head -1 998 head -1
964 } 999 }
965 1000
@@ -1124,13 +1159,15 @@ ok() {
1124 # test/coverage.sh maps each kcov database to the scenario that was 1159 # test/coverage.sh maps each kcov database to the scenario that was
1125 # running when the traced process wrote it, and the timestamps are also 1160 # running when the traced process wrote it, and the timestamps are also
1126 # the only per-scenario timing this suite has ever been able to report. 1161 # the only per-scenario timing this suite has ever been able to report.
1162 # Milliseconds through the oracle's now_ms, not `date +%s.%N`: BSD date
1163 # has no %N and printed a literal N into the column on a Mac.
1127 # 1164 #
1128 # An `if` rather than `[ -n ... ] && printf`, for wait_sock's reason: a 1165 # An `if` rather than `[ -n ... ] && printf`, for wait_sock's reason: a
1129 # false guard as the last command in a function becomes that function's 1166 # false guard as the last command in a function becomes that function's
1130 # exit status, and under `set -e` every unstamped run would abort at its 1167 # exit status, and under `set -e` every unstamped run would abort at its
1131 # first passing scenario. 1168 # first passing scenario.
1132 if [ -n "${E2E_OK_LOG:-}" ]; then 1169 if [ -n "${E2E_OK_LOG:-}" ]; then
1133 printf '%s\t%s\t%s\n' "$OK_COUNT" "$(date +%s.%N)" "$1" >> "$E2E_OK_LOG" 1170 printf '%s\t%s\t%s\n' "$OK_COUNT" "$(now_ms)" "$1" >> "$E2E_OK_LOG"
1134 fi 1171 fi
1135 # Prefix slicing. This suite is linear and stateful — the M13 scenarios 1172 # Prefix slicing. This suite is linear and stateful — the M13 scenarios
1136 # run inside sessions the M10 scenarios created — so a prefix is the only 1173 # run inside sessions the M10 scenarios created — so a prefix is the only
@@ -1403,14 +1440,22 @@ cleanup() {
1403 # 1440 #
1404 # What it catches now is a path that is neither registered nor spelled 1441 # What it catches now is a path that is neither registered nor spelled
1405 # from $OUT — the one shape both mechanisms above are blind to. 1442 # from $OUT — the one shape both mechanisms above are blind to.
1443 #
1444 # BOTH directories when they differ. Two legs put their daemon sockets
1445 # in /tmp on purpose — a socket path has to fit a label bar as well as
1446 # sun_path — and a sweep that only read $TMPDIR would be blind to
1447 # exactly the paths that were moved out of it.
1406 _stray=0 1448 _stray=0
1407 if [ "$_rc" -eq 0 ]; then 1449 if [ "$_rc" -eq 0 ]; then
1408 _left=$(find "${TMPDIR:-/tmp}" -maxdepth 1 \ 1450 _sweep="${TMPDIR:-/tmp}"
1451 [ "$_sweep" = /tmp ] || _sweep="$_sweep /tmp"
1452 # shellcheck disable=SC2086 # two directory words, and split is the point
1453 _left=$(find $_sweep -maxdepth 1 \
1409 \( -name "mux*-$$" -o -name "mux*-$$.*" \) 2>/dev/null) 1454 \( -name "mux*-$$" -o -name "mux*-$$.*" \) 2>/dev/null)
1410 if [ -n "$_left" ]; then 1455 if [ -n "$_left" ]; then
1411 _n=$(printf '%s\n' "$_left" | wc -l) 1456 _n=$(printf '%s\n' "$_left" | wc -l)
1412 if [ "$_n" -eq 1 ]; then _w=file; else _w=files; fi 1457 if [ "$_n" -eq 1 ]; then _w=file; else _w=files; fi
1413 echo "e2e FAIL: the suite passed but left $_n $_w in ${TMPDIR:-/tmp}:" 1458 echo "e2e FAIL: the suite passed but left $_n $_w in $_sweep:"
1414 printf '%s\n' "$_left" | sed 's/^/ /' 1459 printf '%s\n' "$_left" | sed 's/^/ /'
1415 echo " A capture is spelled from \$OUT and swept by pattern;" 1460 echo " A capture is spelled from \$OUT and swept by pattern;"
1416 echo " anything else is registered where it is created, with" 1461 echo " anything else is registered where it is created, with"
@@ -1429,146 +1474,3 @@ cleanup() {
1429 fi 1474 fi
1430 } 1475 }
1431 trap cleanup EXIT INT TERM 1476 trap cleanup EXIT INT TERM
1432
1433 # ---- the oracle's own pin -----------------------------------------------
1434 # The runner calls this once, before the first group. Every helper above is
1435 # a question a pin asks INSTEAD of asking the daemon, so a helper that
1436 # quietly stopped answering would not fail a pin loudly — it would make the
1437 # pin agree with anything, and the suite would go green having tested
1438 # nothing. This asks each helper something the box can independently
1439 # confirm.
1440 #
1441 # Off-origin on every dimension the helpers could accidentally hold
1442 # constant, because a fixture that pins N=1 or offset=0 is blind to N and
1443 # to the offset (CLAUDE.md): the subject is a child that is not pid 1, it
1444 # holds MORE than the three fds any fixture would, it has an argv worth
1445 # losing, and it has a child of its own. The socket arm is asked in both
1446 # directions — a listener that HOLDS the path and a shell that does not —
1447 # because a `pid_holds_unix_sock` that answered yes to everything would
1448 # pass the one pin that reads it just as happily as a correct one.
1449 #
1450 # The `sh -c` body ends in `:` on purpose. With nothing after it, a shell
1451 # execs the last command in place rather than forking it — bash measured
1452 # doing exactly that — and the subject would be `sleep`, wearing sleep's
1453 # comm, exe and argv and having no child at all. That is a different
1454 # process from the one this means to ask about.
1455 oracle_selftest() {
1456 _osock="$OUT.oracle.sock"
1457 rm -f "$_osock"
1458 printf 'hello' > "$OUT.oracle.probe"
1459 # python3 rather than sh: nothing in POSIX sh can bind a unix socket,
1460 # and the runner has already refused a box without python3.
1461 python3 -c 'import socket,sys,time
1462 s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(1); time.sleep(300)' \
1463 "$_osock" &
1464 _olpid=$!
1465 sh -c 'exec 5>/dev/null 6>/dev/null; sleep 300; :' &
1466 _opid=$!
1467 # Registered, not merely killed at the bottom: an assertion below that
1468 # fails exits through the trap, and an unregistered `sleep 300` would
1469 # outlive the run by five minutes — ten of them under `make soak`.
1470 defer_kill "$_olpid" "$_opid"
1471 _oi=0
1472 while [ ! -S "$_osock" ] && [ "$_oi" -lt $(( 50 * TIME_SCALE )) ]; do
1473 sleep 0.1; _oi=$((_oi + 1))
1474 done
1475 [ -S "$_osock" ] || {
1476 echo "e2e FAIL: oracle: the listener bound no socket at $_osock"; exit 1; }
1477
1478 # The subject is only the process this means to ask about once it has
1479 # opened fds 5 and 6 and forked `sleep`; until then `pid_fd_count` and
1480 # `pid_children` would be graded against a shell still starting up, and
1481 # the self-test would fail for a reason that is nothing to do with the
1482 # helpers. A child is the LAST of those steps, so waiting for one waits
1483 # for all of them.
1484 wait_until 50 "oracle: the subject shell never forked its sleep" \
1485 '[ -n "$(pid_children "$_opid")" ]'
1486
1487 pid_alive "$_opid" || {
1488 echo "e2e FAIL: oracle: pid_alive says a live shell is dead"; exit 1; }
1489 [ "$(pid_comm "$_opid")" = sh ] || {
1490 echo "e2e FAIL: oracle: pid_comm of an sh is '$(pid_comm "$_opid")'"; exit 1; }
1491 pid_args "$_opid" | grep -q 'sleep 300' || {
1492 echo "e2e FAIL: oracle: pid_args lost the argv: [$(pid_args "$_opid")]"; exit 1; }
1493 [ "$(pid_fd_count "$_opid")" -ge 5 ] || {
1494 echo "e2e FAIL: oracle: pid_fd_count is $(pid_fd_count "$_opid") for a shell"
1495 echo " holding fd 5 and fd 6 on top of the usual three"; exit 1; }
1496 [ "$(pid_exe "$_opid")" = "$(readlink -f "$(command -v sh)")" ] || {
1497 echo "e2e FAIL: oracle: pid_exe is '$(pid_exe "$_opid")', want the resolved"
1498 echo " $(readlink -f "$(command -v sh)")"; exit 1; }
1499 # head -1 because this is a pid to kill as well as an answer to check,
1500 # and the registry and kill(1) below both take one word.
1501 _okid=$(pid_children "$_opid" | head -1)
1502 [ -n "$_okid" ] || {
1503 echo "e2e FAIL: oracle: pid_children names no child of a shell running sleep"
1504 exit 1; }
1505 defer_kill "$_okid"
1506 # Through a variable and a case rather than straight into test(1): a
1507 # helper that answered with nothing would otherwise abort the run with
1508 # a bare `[: -gt: unary operator expected` and no e2e FAIL line saying
1509 # which helper went quiet.
1510 _orss=$(pid_rss_kb "$_opid")
1511 case "$_orss" in ''|*[!0-9]*) _orss=0 ;; esac
1512 [ "$_orss" -gt 0 ] || {
1513 echo "e2e FAIL: oracle: pid_rss_kb is '$(pid_rss_kb "$_opid")'"; exit 1; }
1514 pid_holds_unix_sock "$_olpid" "$_osock" || {
1515 echo "e2e FAIL: oracle: pid_holds_unix_sock cannot see $_osock among the"
1516 echo " fds of the pid $_olpid that bound it"; exit 1; }
1517 # The `socket` KIND off a real fd: the listener bound one, so its count
1518 # is at least one. Only that KIND is asserted here. `carrier` and
1519 # `ptymaster` would each need a process holding an anonymous memory file
1520 # or a pty master, which is a daemon — and e2e_14_upgrade already asks
1521 # both of a real one, where a wrong answer fails that pin.
1522 _ofdk=$(pid_holds_fd_kind "$_olpid" socket)
1523 case "$_ofdk" in ''|*[!0-9]*) _ofdk=0 ;; esac
1524 [ "$_ofdk" -ge 1 ] || {
1525 echo "e2e FAIL: oracle: pid_holds_fd_kind says $_olpid holds"
1526 echo " '$(pid_holds_fd_kind "$_olpid" socket)' fds of kind socket, and it"
1527 echo " bound $_osock"; exit 1; }
1528 # `! cmd || {}` rather than `cmd && {}`, for the reason spelled out
1529 # below: an AND-list whose left side fails is a failing command under
1530 # `set -e`, so the passing case would end the run.
1531 ! pid_holds_fd_kind "$_opid" nosuchkind 2>/dev/null || {
1532 echo "e2e FAIL: oracle: pid_holds_fd_kind accepted a kind it does not know,"
1533 echo " so a typo in a group file would count zero and pass"; exit 1; }
1534 ! pid_holds_unix_sock "$_opid" "$_osock" || {
1535 echo "e2e FAIL: oracle: pid_holds_unix_sock says the shell $_opid holds"
1536 echo " $_osock, which only $_olpid ever opened"; exit 1; }
1537 # `! cmd || {}` rather than `cmd && {}`, for wait_sock's reason: an
1538 # AND-list whose left side fails is a failing command under `set -e`,
1539 # so the passing case would end the run.
1540 ! udp_local_bound 'ZZZZZZZZ:ZZZZ' || {
1541 echo "e2e FAIL: oracle: udp_local_bound found a socket at an address no"
1542 echo " kernel can spell"; exit 1; }
1543 [ -n "$(udp_table)" ] || {
1544 echo "e2e FAIL: oracle: udp_table is empty, so a bind diagnostic would"
1545 echo " print nothing"; exit 1; }
1546 # Against what the probe file IS, never against a second spelling of the
1547 # same command: comparing file_mode with `stat -c %a` would agree with
1548 # itself on any OS and would have to be ported alongside the helper. A
1549 # chmod the kernel performed, five bytes written, and the published
1550 # SHA-256 of "hello" are answers no arm of the oracle can define away.
1551 chmod 640 "$OUT.oracle.probe"
1552 [ "$(file_mode "$OUT.oracle.probe")" = "640" ] || {
1553 echo "e2e FAIL: oracle: file_mode says '$(file_mode "$OUT.oracle.probe")' for a"
1554 echo " file the kernel just chmod'd to 640"; exit 1; }
1555 [ "$(file_size "$OUT.oracle.probe")" = "5" ] || {
1556 echo "e2e FAIL: oracle: file_size says '$(file_size "$OUT.oracle.probe")' for five bytes"
1557 exit 1; }
1558 [ "$(sha256_of "$OUT.oracle.probe")" = \
1559 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ] || {
1560 echo "e2e FAIL: oracle: sha256_of hashes 'hello' to"
1561 echo " '$(sha256_of "$OUT.oracle.probe")'"; exit 1; }
1562
1563 kill "$_olpid" "$_opid" "$_okid" 2>/dev/null || true
1564 wait "$_olpid" 2>/dev/null || true
1565 wait "$_opid" 2>/dev/null || true
1566 _oi=0
1567 while pid_alive "$_opid" && [ "$_oi" -lt $(( 50 * TIME_SCALE )) ]; do
1568 sleep 0.05; _oi=$((_oi + 1))
1569 done
1570 ! pid_alive "$_opid" || {
1571 echo "e2e FAIL: oracle: pid_alive says a killed shell still lives"; exit 1; }
1572 rm -f "$_osock" "$OUT.oracle.probe"
1573 ok "oracle: the OS answers the helpers by name"
1574 }
test/mac.sh
Old New
@@ -0,0 +1,376 @@
1 #!/bin/sh
2 # test/mac.sh — the `make mac` gate: real user journeys on a real macOS.
3 #
4 # What `make vm` is to the Linux port, this is to the macOS one. The unit
5 # suite and the e2e suite grade the code; this grades the PRODUCT on the
6 # operating system it was ported to, spelled exactly as a user spells it.
7 #
8 # Two boxes, taken by name, and the split is the point (test/box_lib.sh):
9 #
10 # $MAC_BUILDER a Mac with the toolchain and the shadow SDK on it. It
11 # BUILDS, and the unit suite runs there for the same reason:
12 # both need zig, the SDK and the source tree.
13 # $MAC_BOX the macOS box under test, with no developer tooling on it
14 # at all. It only RUNS what the builder built, which is the
15 # stronger claim: a bare Mac and not the machine that
16 # compiled the binary. Normally a pristine tart guest from
17 # test/provision-mac.sh, which this gate does not create,
18 # boot or destroy — a gate that owned a VM's lifecycle could
19 # not be pointed at a Mac somebody already had.
20 #
21 # MAC_BOX is reached DIRECTLY from here: provision-mac.sh bridges the guest
22 # onto the LAN, so there is no ssh-through-ssh string in this file any more.
23 # It is a box the gate MAY scrub, and box_scrub is the whole of what that
24 # means; the scrub runs at the start and again on the way out, so a failed
25 # run leaves nothing behind and the next one starts from the same place
26 # whatever happened. Hermeticity used to come from a per-run clone; it comes
27 # from the scrub now, and the daemon leg still asserts it rather than
28 # assuming it.
29 #
30 # Every claim about MAC_BOX is asserted by a separate ssh reading it through
31 # test/os_oracle.sh, never by trusting the daemon's own report over the
32 # connection under test ("ask the OS about the OS").
33 set -eu
34
35 BOX_GATE=mac
36
37 MAC_BOX=${MAC_BOX:-}
38 # The one Mac that is both is the common case, so the builder follows the
39 # box under test unless it is named. It is never scrubbed and never has a
40 # mux installed on it: it builds, and that is all.
41 MAC_BUILDER=${MAC_BUILDER:-$MAC_BOX}
42 # Relative to the BUILDER's $HOME, because that is how ssh's scp-like remote
43 # spelling (`HOST:code/rad/mux`) resolves a path.
44 REPO="${MAC_BUILDER_REPO:-code/rad/mux}"
45 BRANCH=mac-journey
46
47 # now_ms, and the file_mode/pid_* names the MAC_BOX legs source for
48 # themselves. The harness spells time and paths through the oracle so this
49 # script would still answer if it were ever run FROM a Mac.
50 . "$(dirname "$0")/os_oracle.sh"
51 . "$(dirname "$0")/box_lib.sh"
52
53 # A provisioned guest has a new host key every clone, so the harness's own
54 # ssh to it records none. This gate never dials MAC_BOX with mux, so nothing
55 # here needs that key in the real known_hosts.
56 BOX_EPHEMERAL=$MAC_BOX
57
58 OK_COUNT=0
59
60 ok() {
61 OK_COUNT=$((OK_COUNT + 1))
62 echo "$1 ok${2:+ ($2)}"
63 }
64
65 fail() {
66 echo "mac FAIL: $1: $2"
67 exit 1
68 }
69
70 # What every command on MAC_BOX runs under. XDG_STATE_HOME is isolated
71 # because every mux that attaches records its daemon in it (CLAUDE.md), and
72 # XDG_RUNTIME_DIR is UNSET on purpose: a fresh ssh login to a Mac has no such
73 # variable, and the Darwin fallback /tmp/mux-<uid> is exactly what the daemon
74 # leg is here to exercise end to end.
75 #
76 # The builder gets the same prelude, where its only effect is a scratch
77 # directory the cleanup removes: the build legs cd into the repo themselves,
78 # and no mux ever runs there.
79 PRELUDE='
80 export XDG_STATE_HOME=$HOME/mux-e2e/state
81 unset XDG_RUNTIME_DIR
82 mkdir -p $HOME/mux-e2e
83 cd $HOME/mux-e2e
84 '
85
86 # ---- preflight: every box answers, before anything is written ----------
87 box_preflight MAC_BOX "$MAC_BOX" aarch64 \
88 'Provision one with: eval "$(test/provision-mac.sh)"'
89 box_preflight MAC_BUILDER "$MAC_BUILDER" aarch64 \
90 'A Mac holding this repo with zig and deps/mac-sdk. Defaults to $MAC_BOX.'
91
92 # The start scrub, and the one on the way out. The builder is not scrubbed —
93 # it is not the gate's machine to reset — so its scratch directory is all
94 # there is to take back.
95 box_scrub "$MAC_BOX"
96 cleanup() {
97 _rc=$?
98 box_scrub "$MAC_BOX" >/dev/null 2>&1 || true
99 box_ssh 60 "$MAC_BUILDER" >/dev/null 2>&1 <<'B' || true
100 rm -rf "$HOME/mux-e2e"
101 B
102 exit "$_rc"
103 }
104 trap cleanup EXIT
105
106 # ---- leg 1: push — the builder builds THIS commit ----------------------
107 HEAD_SHA=$(git rev-parse HEAD)
108 PUSHED=no
109 HAVE=$(box_ssh 60 "$MAC_BUILDER" <<H || true
110 cd \$HOME/$REPO 2>/dev/null || exit 0
111 git rev-parse -q --verify refs/heads/$BRANCH 2>/dev/null || true
112 H
113 )
114 if [ "$HAVE" != "$HEAD_SHA" ]; then
115 # A push cannot update the branch the remote has checked out, and the
116 # run before this one left it checked out. Detaching first is what
117 # makes a re-run work; it changes no commit and no file.
118 box_ssh 60 "$MAC_BUILDER" >/dev/null <<H || fail push "cannot detach $MAC_BUILDER:$REPO before the push"
119 set -e
120 cd \$HOME/$REPO
121 if [ "\$(git rev-parse --abbrev-ref HEAD)" = "$BRANCH" ]; then
122 git checkout -q --detach
123 fi
124 H
125 # --force because this branch is a scratch mirror of whatever this tree
126 # is and never shared history: a local amend or rebase leaves the
127 # builder's ref on a commit the new HEAD does not descend from, and a
128 # plain push would be rejected as a non-fast-forward for the rest of the
129 # branch's life.
130 git push --force "$MAC_BUILDER:$REPO" "HEAD:refs/heads/$BRANCH" >/dev/null 2>&1 ||
131 fail push "git push $MAC_BUILDER:$REPO HEAD:refs/heads/$BRANCH was refused"
132 PUSHED=yes
133 fi
134 box_ssh 60 "$MAC_BUILDER" >/dev/null <<H || fail push "the builder could not check out $BRANCH"
135 set -e
136 cd \$HOME/$REPO
137 git checkout -q $BRANCH
138 git reset -q --hard $BRANCH
139 H
140 BUILDER_SHA=$(box_ssh 60 "$MAC_BUILDER" <<H
141 cd \$HOME/$REPO && git rev-parse HEAD
142 H
143 )
144 [ "$BUILDER_SHA" = "$HEAD_SHA" ] ||
145 fail push "the builder is at $BUILDER_SHA, this tree is at $HEAD_SHA"
146 ok push "$MAC_BUILDER:$REPO on $BRANCH at $(git rev-parse --short HEAD), pushed=$PUSHED"
147
148 # ---- leg 2: build — the toolchain lives on the builder -----------------
149 # The unit suite runs HERE and not on MAC_BOX for the same reason the build
150 # does: it needs zig, the shadow SDK and the source tree, and the box under
151 # test has none of them. `make test` rather than a bare `deps/zig/zig build
152 # test` so the mac-sdk shim is on PATH the way every other target gets it.
153 BOUT=$(box_ssh 2400 "$MAC_BUILDER" <<H
154 cd \$HOME/$REPO || { echo "RC_BUILD=90"; exit 0; }
155 export PATH=\$HOME/$REPO/deps/mac-sdk/bin:/opt/homebrew/bin:\$PATH
156 make build >/tmp/mac-build.log 2>&1; echo "RC_BUILD=\$?"
157 make check >/tmp/mac-check.log 2>&1; echo "RC_CHECK=\$?"
158 make test >/tmp/mac-test.log 2>&1; echo "RC_TEST=\$?"
159 H
160 )
161 for _step in BUILD CHECK TEST; do
162 _rc=$(printf '%s\n' "$BOUT" | sed -n "s/^RC_$_step=//p")
163 [ "$_rc" = "0" ] && continue
164 _log=$(printf '%s' "$_step" | tr 'A-Z' 'a-z')
165 # The verdict first and the reading under it, test/vm.sh's shape: a
166 # reader scrolling back finds the sentence that says what broke, not
167 # the tail of somebody else's stack trace.
168 echo "mac FAIL: build: make $_log on $MAC_BUILDER exited ${_rc:-<no answer>}"
169 box_ssh 60 "$MAC_BUILDER" <<H | sed 's/^/ /' || true
170 tail -30 /tmp/mac-$_log.log 2>/dev/null || true
171 H
172 exit 1
173 done
174 ok build "make build, check and test on $MAC_BUILDER"
175
176 # ---- leg 3: install — the builder's build, onto a bare Mac -------------
177 # Streamed through this box rather than copied between the two, because
178 # neither Mac needs a key of the other's for that and only this one can
179 # reach both. `cat` carries no mode, so box_stream chmods and the two hashes
180 # are compared below: the bytes that run on MAC_BOX are the bytes the
181 # builder produced, asserted rather than assumed.
182 box_ssh 60 "$MAC_BOX" >/dev/null <<'G' || fail install "could not make the scratch directory on $MAC_BOX"
183 set -e
184 mkdir -p "$HOME/mux-e2e"
185 G
186 for _f in mux ptyclient; do
187 box_stream "$MAC_BUILDER" "\$HOME/$REPO/zig-out/bin/$_f" "$MAC_BOX" "\$HOME/mux-e2e/$_f" ||
188 fail install "could not stream $_f from $MAC_BUILDER to $MAC_BOX"
189 done
190 box_scp "$MAC_BOX" "$(dirname "$0")/os_oracle.sh" mux-e2e/os_oracle.sh ||
191 fail install "could not copy the oracle to $MAC_BOX"
192 WANT=$(box_ssh 60 "$MAC_BUILDER" <<H
193 cd \$HOME/$REPO
194 . ./test/os_oracle.sh
195 echo "ver=\$(./zig-out/bin/mux --version)"
196 echo "sha=\$(sha256_of ./zig-out/bin/mux)"
197 H
198 )
199 WANT_VER=$(printf '%s\n' "$WANT" | sed -n 's/^ver=//p')
200 WANT_SHA=$(printf '%s\n' "$WANT" | sed -n 's/^sha=//p')
201 GOT=$(box_ssh 60 "$MAC_BOX" <<'G'
202 . ./os_oracle.sh
203 echo "ver=$(./mux --version)"
204 echo "sha=$(sha256_of ./mux)"
205 echo "mode=$(file_mode ./mux)"
206 G
207 )
208 GOT_VER=$(printf '%s\n' "$GOT" | sed -n 's/^ver=//p')
209 GOT_SHA=$(printf '%s\n' "$GOT" | sed -n 's/^sha=//p')
210 GOT_MODE=$(printf '%s\n' "$GOT" | sed -n 's/^mode=//p')
211 [ -n "$WANT_VER" ] && [ "$WANT_VER" = "$GOT_VER" ] ||
212 fail install "$MAC_BOX answers '$GOT_VER', $MAC_BUILDER built '$WANT_VER'"
213 [ -n "$WANT_SHA" ] && [ "$WANT_SHA" = "$GOT_SHA" ] ||
214 fail install "$MAC_BOX holds a mux hashing $GOT_SHA, the builder's is $WANT_SHA"
215 [ "$GOT_MODE" = "755" ] ||
216 fail install "the streamed binary is mode $GOT_MODE, want 755"
217 ok install "a bare macOS runs the builder's own bytes and answers '$GOT_VER'"
218
219 # ---- leg 4: daemon — no XDG_RUNTIME_DIR, so /tmp/mux-<uid> ------------
220 # The scrubbed claim, asserted rather than assumed: this is what the scrub
221 # buys, and a box that silently carried a previous run's daemon would make
222 # every leg below it a check on somebody else's state.
223 PRE=$(box_ssh 60 "$MAC_BOX" <<'G'
224 echo "uid=$(id -u)"
225 echo "muxes=$(pgrep -x mux 2>/dev/null | wc -l | tr -d ' ')"
226 [ -e /tmp/mux-$(id -u) ] && echo "rt=present" || echo "rt=absent"
227 G
228 )
229 GUID=$(printf '%s\n' "$PRE" | sed -n 's/^uid=//p')
230 printf '%s\n' "$PRE" | grep -qx 'muxes=0' ||
231 fail daemon "$MAC_BOX still runs a mux after the scrub: $PRE"
232 printf '%s\n' "$PRE" | grep -qx 'rt=absent' ||
233 fail daemon "$MAC_BOX still has /tmp/mux-$GUID after the scrub: $PRE"
234 [ -n "$GUID" ] || fail daemon "$MAC_BOX would not say its uid"
235
236 # MUX_SHELL_INTEGRATION=1 because the session leg below reads a real exit
237 # code, and marks are opt-in (README). It also puts the daemon's shellint
238 # shim directory inside the runtime directory this leg is about.
239 box_ssh 120 "$MAC_BOX" >/dev/null <<'G' || fail daemon "mux d start -d refused on $MAC_BOX"
240 MUX_SHELL_INTEGRATION=1 ./mux d start -d
241 G
242 DINFO=$(box_ssh 60 "$MAC_BOX" <<'G'
243 echo "n=$(pgrep -x mux | wc -l | tr -d ' ')"
244 echo "pid=$(pgrep -x mux | head -1)"
245 pgrep -lx mux 2>/dev/null | sed 's/^/ps=/'
246 G
247 )
248 DN=$(printf '%s\n' "$DINFO" | sed -n 's/^n=//p')
249 DPID=$(printf '%s\n' "$DINFO" | sed -n 's/^pid=//p')
250 # The COUNT and not just the first pid, test/vm.sh's shape at its own daemon
251 # leg: with a second mux alive — a stuck starter, a leaked client — `head -1`
252 # picks one of them, and the socket, exe and stop assertions below then all
253 # grade whichever it happened to pick, reporting a coin toss as "daemon N
254 # does not hold the socket". A wrong count also covers "no daemon at all",
255 # so there is no separate emptiness check.
256 [ "$DN" = "1" ] || {
257 echo "mac FAIL: daemon: $MAC_BOX runs ${DN:-<no answer>} mux processes after mux d start -d, want 1:"
258 printf '%s\n' "$DINFO" | sed -n 's/^ps=/ /p'
259 exit 1; }
260
261 SOCK="/tmp/mux-$GUID/muxd.sock"
262 box_ssh 60 "$MAC_BOX" >/dev/null <<G || fail daemon "daemon $DPID does not hold $SOCK"
263 . ./os_oracle.sh
264 pid_holds_unix_sock $DPID $SOCK
265 G
266 # Both sides canonical, and both through the oracle: pid_exe answers what
267 # lsof reports, which resolves every symlink on the way, so a home directory
268 # or a $TMPDIR reached through one would fail a correct exec unless the
269 # expected path is resolved the same way.
270 DEXE=$(box_ssh 60 "$MAC_BOX" <<G
271 . ./os_oracle.sh
272 echo "is=\$(pid_exe $DPID)"
273 echo "want=\$(real_path ./mux)"
274 G
275 )
276 _is=$(printf '%s\n' "$DEXE" | sed -n 's/^is=//p')
277 _want=$(printf '%s\n' "$DEXE" | sed -n 's/^want=//p')
278 [ -n "$_want" ] && [ "$_is" = "$_want" ] ||
279 fail daemon "daemon $DPID execs '$_is', not the installed '$_want'"
280 RTMODE=$(box_ssh 60 "$MAC_BOX" <<G
281 . ./os_oracle.sh
282 file_mode /tmp/mux-$GUID
283 G
284 )
285 [ "$RTMODE" = "700" ] ||
286 fail daemon "/tmp/mux-$GUID is mode $RTMODE, want 700"
287 ok daemon "daemon $DPID holds $SOCK, a 700 directory $MAC_BOX had no \$XDG_RUNTIME_DIR for"
288
289 # ---- leg 5: attach — a real pty, a real session ------------------------
290 # Before the session leg and not after it, though the brief lists them the
291 # other way round: `mux a` attaches at 0x0 and JOINS, so it can only speak
292 # to a session that already exists (README). The pty attach is what makes
293 # one, which makes it the leg that has to run first.
294 ATT=$(box_ssh 240 "$MAC_BOX" <<'G'
295 ./ptyclient --cols 80 --rows 24 --out /tmp/mac-attach.cap --err /tmp/mac-attach.err -- ./mux <<'PC'
296 expect \x1b[?1049h 30000
297 settle 900 20000
298 send printf "JOURNEY-%s\\n" ok\n
299 expect JOURNEY-ok 20000
300 settle 600 15000
301 send \x1cd
302 waitexit 15000
303 PC
304 echo "PTYRC=$?"
305 G
306 ) || fail attach "$MAC_BOX would not run ptyclient"
307 printf '%s\n' "$ATT" | grep -qx 'PTYRC=0' || {
308 echo "mac FAIL: attach: ptyclient did not reach the JOURNEY-ok snapshot: $ATT"
309 box_ssh 60 "$MAC_BOX" <<'G' | sed 's/^/ /' || true
310 cat /tmp/mac-attach.err 2>/dev/null
311 G
312 exit 1; }
313 # The detach was a goodbye and not a tear: the session it typed into is
314 # still on the daemon, holding what it painted.
315 box_ssh 60 "$MAC_BOX" >/dev/null <<'G' || fail attach "the session did not survive the detach"
316 ./mux d dump | grep -q JOURNEY-ok
317 G
318 ok attach "a pty client typed into a session and detached, the grid kept it"
319
320 # ---- leg 6: session — one shot through the agent surface ---------------
321 # $$ expands in the session's OWN shell, so the pid it prints is a pid only
322 # the box's process table can confirm — which is the assert nothing on
323 # this side can fake.
324 AREPLY=$(box_ssh 90 "$MAC_BOX" <<'G'
325 ./mux a run --timeout 20000 'echo mac-$$'
326 G
327 ) || fail session "mux a run did not answer on $MAC_BOX"
328 case "$AREPLY" in
329 *'"mechanism":"marks"'*) ;;
330 *) fail session "mux a run answered under no mechanism this leg can read: $AREPLY" ;;
331 esac
332 case "$AREPLY" in
333 *'"exit_code":0'*) ;;
334 *) fail session "mux a run reported no clean exit code: $AREPLY" ;;
335 esac
336 SPID=$(printf '%s\n' "$AREPLY" | sed -n 's/.*mac-\([0-9][0-9]*\).*/\1/p')
337 [ -n "$SPID" ] || fail session "the reply carries no shell pid: $AREPLY"
338 box_ssh 60 "$MAC_BOX" >/dev/null <<G || fail session "shell $SPID is not a child of daemon $DPID"
339 . ./os_oracle.sh
340 pid_children $DPID | grep -qx $SPID
341 G
342 ok session "mux a run answered under marks; shell $SPID is a child of daemon $DPID"
343
344 # ---- leg 7: stop — and the runtime socket goes with it -----------------
345 box_ssh 60 "$MAC_BOX" >/dev/null <<'G' || fail stop "mux d stop was refused on $MAC_BOX"
346 ./mux d stop
347 G
348 END=$(box_ssh 60 "$MAC_BOX" <<G
349 . ./os_oracle.sh
350 _i=0
351 while [ \$_i -lt 30 ]; do
352 pid_alive $DPID || break
353 sleep 0.1; _i=\$((_i + 1))
354 done
355 pid_alive $DPID && echo "alive=yes" || echo "alive=no"
356 [ -e $SOCK ] && echo "sock=present" || echo "sock=gone"
357 G
358 )
359 printf '%s\n' "$END" | grep -qx 'alive=no' ||
360 fail stop "daemon $DPID survives mux d stop by 3 s: $END"
361 printf '%s\n' "$END" | grep -qx 'sock=gone' ||
362 fail stop "$SOCK outlives the daemon that bound it: $END"
363 ok stop "daemon $DPID is gone within 3 s and took $SOCK with it"
364
365 # ---- the pin ----------------------------------------------------------
366 # A literal, test/vm.sh's style: adding a leg means editing this by hand,
367 # so a leg that stops running cannot pass by not being counted. Seven and
368 # not eight since 2026-09-04: the boot-a-clone leg left with the VM
369 # lifecycle, and what replaced it is the preflight line above, which is not
370 # an `ok`.
371 [ "$OK_COUNT" = "7" ] || {
372 echo "mac FAIL: $OK_COUNT legs ran, the pin says 7 —"
373 echo " a leg was added (update the pin) or silently lost"
374 exit 1
375 }
376 echo "mac OK ($OK_COUNT legs on $MAC_BOX, a bare macOS running what $MAC_BUILDER built)"
test/oracle_selftest.sh
Old New
@@ -0,0 +1,421 @@
1 # shellcheck shell=sh
2 # oracle_selftest.sh — the OS oracle's own pin, sourced and also runnable.
3 #
4 # It lives beside test/os_oracle.sh rather than inside test/e2e_lib.sh so
5 # that a new OS arm can be graded on a box where nothing else in the
6 # harness works yet:
7 #
8 # sh test/oracle_selftest.sh
9 #
10 # needs a shell, python3 and the oracle's own tools, and answers ORACLE_OK
11 # or an `e2e FAIL:` line. Sourced into the suite it is the same function,
12 # called by test/e2e.sh with the lib's registry, counter and time scale
13 # already up. It is one function either way, never a copy: a self-test with
14 # two spellings would be the second one that goes stale.
15 #
16 # Trap-free when sourced, for os_oracle.sh's reason: e2e_lib.sh arms the
17 # EXIT trap this suite cleans up through, and a second one here would
18 # replace it. The standalone arm at the bottom arms its own, because on
19 # that path there is no other.
20 #
21 # The runner calls this once, before the first group. Every helper in
22 # os_oracle.sh is a question a pin asks INSTEAD of asking the daemon, so a
23 # helper that quietly stopped answering would not fail a pin loudly — it
24 # would make the pin agree with anything, and the suite would go green
25 # having tested nothing. This asks each helper something the box can
26 # independently confirm.
27 #
28 # Off-origin on every dimension the helpers could accidentally hold
29 # constant, because a fixture that pins N=1 or offset=0 is blind to N and
30 # to the offset (CLAUDE.md): the subject is a child that is not pid 1, it
31 # holds MORE than the three fds any fixture would, it has an argv worth
32 # losing, and it has a child of its own. The socket arm is asked in both
33 # directions — a listener that HOLDS the path and a shell that does not —
34 # because a `pid_holds_unix_sock` that answered yes to everything would
35 # pass the one pin that reads it just as happily as a correct one.
36 #
37 # The `sh -c` body ends in `:` on purpose. With nothing after it, a shell
38 # execs the last command in place rather than forking it — bash measured
39 # doing exactly that — and the subject would be `sleep`, wearing sleep's
40 # comm, exe and argv and having no child at all. That is a different
41 # process from the one this means to ask about.
42 oracle_selftest() {
43 _osock="$OUT.oracle.sock"
44 _oport_f="$OUT.oracle.port"
45 rm -f "$_osock" "$_oport_f"
46 printf 'hello' > "$OUT.oracle.probe"
47 # python3 rather than sh: nothing in POSIX sh can bind a unix socket,
48 # and the runner has already refused a box without python3.
49 #
50 # It holds one fd of every KIND a pin asks about, so that each of them
51 # can be asked in BOTH directions. Before this the listener held only a
52 # unix socket, and `carrier`, `ptymaster` and `udp_local_bound` were
53 # asked nothing but negatives anywhere in the tree — an arm that
54 # answered 0 and false forever passed every gate on both OSes, which is
55 # a check that never runs rather than a check that fails.
56 #
57 # The carrier is opened in the local shape, which carrier_kind names:
58 # a memfd on Linux and an unlinked mkstemp file on Darwin, matching what
59 # server_os_linux.anonFd and server_os_macos.anonFd each make. Asking
60 # the oracle rather than reading `uname` here keeps every "which OS"
61 # answer in the one file that is allowed to know.
62 #
63 # The UDP port is EPHEMERAL and reported back rather than picked here:
64 # a number this file chose would be a number some other process on the
65 # box may already hold, and the pin would fail for a reason that is
66 # nothing to do with the helper.
67 python3 -c 'import os, socket, sys, tempfile, time
68 s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(1)
69 u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); u.bind(("127.0.0.1", 0))
70 if sys.argv[2] == "memfd":
71 carrier = os.memfd_create("mux-upgrade")
72 else:
73 carrier, path = tempfile.mkstemp(prefix="mux-upgrade-", dir="/tmp")
74 os.unlink(path)
75 master, slave = os.openpty()
76 # Last, and to a temporary name renamed into place, so that the file
77 # existing means every fd above is open and not that some of them are.
78 with open(sys.argv[3] + ".part", "w") as f:
79 f.write("%d" % u.getsockname()[1])
80 os.rename(sys.argv[3] + ".part", sys.argv[3])
81 time.sleep(300)' \
82 "$_osock" "$(carrier_kind)" "$_oport_f" &
83 _olpid=$!
84 sh -c 'exec 5>/dev/null 6>/dev/null; sleep 300; :' &
85 _opid=$!
86 # Registered, not merely killed at the bottom: an assertion below that
87 # fails exits through the trap, and an unregistered `sleep 300` would
88 # outlive the run by five minutes — ten of them under `make soak`.
89 defer_kill "$_olpid" "$_opid"
90 _oi=0
91 while [ ! -f "$_oport_f" ] && [ "$_oi" -lt $(( 50 * TIME_SCALE )) ]; do
92 sleep 0.1; _oi=$((_oi + 1))
93 done
94 [ -f "$_oport_f" ] || {
95 echo "e2e FAIL: oracle: the listener never reported its port, so one of"
96 echo " the fds the pins below read was never opened"; exit 1; }
97 [ -S "$_osock" ] || {
98 echo "e2e FAIL: oracle: the listener bound no socket at $_osock"; exit 1; }
99 _oport=$(cat "$_oport_f")
100
101 # The subject is only the process this means to ask about once it has
102 # opened fds 5 and 6 and forked `sleep`; until then `pid_fd_count` and
103 # `pid_children` would be graded against a shell still starting up, and
104 # the self-test would fail for a reason that is nothing to do with the
105 # helpers. A child is the LAST of those steps, so waiting for one waits
106 # for all of them.
107 wait_until 50 "oracle: the subject shell never forked its sleep" \
108 '[ -n "$(pid_children "$_opid")" ]'
109
110 pid_alive "$_opid" || {
111 echo "e2e FAIL: oracle: pid_alive says a live shell is dead"; exit 1; }
112 # `sh` on both OSes, measured: Linux reads /proc/PID/comm, which is the
113 # last component of the path execve was handed, and Darwin's ps prints
114 # the bare name for a binary under /bin. It is only the IMAGE behind
115 # that name that differs between them, which is pid_exe's business
116 # below and not this line's.
117 [ "$(pid_comm "$_opid")" = sh ] || {
118 echo "e2e FAIL: oracle: pid_comm of an sh is '$(pid_comm "$_opid")'"; exit 1; }
119 pid_args "$_opid" | grep -q 'sleep 300' || {
120 echo "e2e FAIL: oracle: pid_args lost the argv: [$(pid_args "$_opid")]"; exit 1; }
121 # The SHAPE too, because the one caller that compares a whole argv does
122 # it with `=`: one space between words and none at the end. Linux reads
123 # a NUL-terminated /proc entry and Darwin reads ps, and the two agreed
124 # on every word and disagreed on the last byte until this was pinned.
125 case "$(pid_args "$_opid")" in
126 *' ') echo "e2e FAIL: oracle: pid_args ends in a space: [$(pid_args "$_opid")]"; exit 1 ;;
127 *' '*) echo "e2e FAIL: oracle: pid_args doubles a space: [$(pid_args "$_opid")]"; exit 1 ;;
128 esac
129 [ "$(pid_fd_count "$_opid")" -ge 5 ] || {
130 echo "e2e FAIL: oracle: pid_fd_count is $(pid_fd_count "$_opid") for a shell"
131 echo " holding fd 5 and fd 6 on top of the usual three"; exit 1; }
132 # head -1 because this is a pid to kill as well as an answer to check,
133 # and the registry and kill(1) below both take one word.
134 _okid=$(pid_children "$_opid" | head -1)
135 [ -n "$_okid" ] || {
136 echo "e2e FAIL: oracle: pid_children names no child of a shell running sleep"
137 exit 1; }
138 defer_kill "$_okid"
139 # pid_exe is asked about the CHILD and not the shell, and the shell is
140 # the reason: macOS ships /bin/sh as a stub in front of the bash image,
141 # so the running image of a process this file started as `sh` is
142 # /bin/bash there — measured, and true of a copy of /bin/sh as well, so
143 # it is the image and not the name that differs. `sleep` is an ordinary
144 # binary on both OSes and its own path is what either arm answers, so
145 # the assertion stays exact rather than being weakened to fit. Through
146 # real_path on both sides because a distribution that puts /bin on a
147 # symlink to /usr/bin, and a Darwin lsof that canonicalizes /tmp to
148 # /private/tmp, both answer the resolved path and not the spelling.
149 _oexe=$(pid_exe "$_okid") || _oexe=''
150 _owant=$(real_path "$(command -v sleep)") || _owant=''
151 # The non-empty guard is the point of the first test: with `=` alone, a
152 # box where lsof answered nothing AND perl answered nothing would compare
153 # "" with "" and read ORACLE_OK — two dead helpers agreeing.
154 [ -n "$_owant" ] && [ "$_oexe" = "$_owant" ] || {
155 echo "e2e FAIL: oracle: pid_exe of the subject's sleep is '$_oexe',"
156 echo " want the resolved '$_owant'"; exit 1; }
157 # Through a variable and a case rather than straight into test(1): a
158 # helper that answered with nothing would otherwise abort the run with
159 # a bare `[: -gt: unary operator expected` and no e2e FAIL line saying
160 # which helper went quiet.
161 _orss=$(pid_rss_kb "$_opid")
162 case "$_orss" in ''|*[!0-9]*) _orss=0 ;; esac
163 [ "$_orss" -gt 0 ] || {
164 echo "e2e FAIL: oracle: pid_rss_kb is '$(pid_rss_kb "$_opid")'"; exit 1; }
165 pid_holds_unix_sock "$_olpid" "$_osock" || {
166 echo "e2e FAIL: oracle: pid_holds_unix_sock cannot see $_osock among the"
167 echo " fds of the pid $_olpid that bound it"; exit 1; }
168 # Every KIND, off a real fd, in both directions: the listener holds one
169 # of each and the subject shell holds none of the two that are not a
170 # socket. Both halves matter. e2e_14_upgrade asks `carrier` and
171 # `ptymaster` too, but every one of its call sites asserts ZERO — so an
172 # arm that answered 0 forever would pass that pin as happily as a
173 # correct one, and on Darwin the carrier body is a heuristic of its own
174 # (an unlinked regular file) rather than a name the kernel hands over.
175 for _ok in socket carrier ptymaster; do
176 _ofdk=$(pid_holds_fd_kind "$_olpid" "$_ok")
177 case "$_ofdk" in ''|*[!0-9]*) _ofdk=0 ;; esac
178 [ "$_ofdk" -ge 1 ] || {
179 echo "e2e FAIL: oracle: pid_holds_fd_kind says $_olpid holds"
180 echo " '$(pid_holds_fd_kind "$_olpid" "$_ok")' fds of kind $_ok, and it"
181 echo " opened one of each before it reported its port"; exit 1; }
182 done
183 # The subject shell opened two /dev/null fds and forked a sleep, and
184 # nothing else. A helper that counted any open file as a carrier, or any
185 # character device as a pty master, would answer here.
186 for _ok in carrier ptymaster; do
187 _ofdk=$(pid_holds_fd_kind "$_opid" "$_ok")
188 case "$_ofdk" in ''|*[!0-9]*) _ofdk=-1 ;; esac
189 [ "$_ofdk" -eq 0 ] || {
190 echo "e2e FAIL: oracle: pid_holds_fd_kind says the shell $_opid holds"
191 echo " '$(pid_holds_fd_kind "$_opid" "$_ok")' fds of kind $_ok, and it"
192 echo " opened nothing but /dev/null"; exit 1; }
193 done
194 # `! cmd || {}` rather than `cmd && {}`, for the reason spelled out
195 # below: an AND-list whose left side fails is a failing command under
196 # `set -e`, so the passing case would end the run.
197 ! pid_holds_fd_kind "$_opid" nosuchkind 2>/dev/null || {
198 echo "e2e FAIL: oracle: pid_holds_fd_kind accepted a kind it does not know,"
199 echo " so a typo in a group file would count zero and pass"; exit 1; }
200 ! pid_holds_unix_sock "$_opid" "$_osock" || {
201 echo "e2e FAIL: oracle: pid_holds_unix_sock says the shell $_opid holds"
202 echo " $_osock, which only $_olpid ever opened"; exit 1; }
203 # The port the listener really bound, asked in the ONE spelling both
204 # arms take: /proc/net/udp's local address, which is the host-order u32
205 # in hex — 0100007F for 127.0.0.1 on a little-endian box, the same
206 # constant e2e_01_boot and e2e_04_handoff already print. The Darwin arm
207 # converts it to lsof's dotted form, and until this line nothing in the
208 # tree asked that conversion for an address that exists.
209 _oudp=$(printf '0100007F:%04X' "$_oport")
210 udp_local_bound "$_oudp" || {
211 echo "e2e FAIL: oracle: udp_local_bound cannot see the socket the listener"
212 echo " bound at 127.0.0.1:$_oport ($_oudp)"
213 udp_table | head -20; exit 1; }
214 # `! cmd || {}` rather than `cmd && {}`, for wait_sock's reason: an
215 # AND-list whose left side fails is a failing command under `set -e`,
216 # so the passing case would end the run.
217 #
218 # The SAME port at a different local address. This is the one that pins
219 # the local-address rule both arms are built around: a helper matching
220 # the port anywhere on its line — the remote half of a connected
221 # socket's row included — answers yes here.
222 ! udp_local_bound "$(printf '0200007F:%04X' "$_oport")" || {
223 echo "e2e FAIL: oracle: udp_local_bound reads the socket bound at"
224 echo " 127.0.0.1:$_oport as bound at 127.0.0.2 as well, so it is"
225 echo " matching the port and not the local address"; exit 1; }
226 ! udp_local_bound 'ZZZZZZZZ:ZZZZ' || {
227 echo "e2e FAIL: oracle: udp_local_bound found a socket at an address no"
228 echo " kernel can spell"; exit 1; }
229 [ -n "$(udp_table)" ] || {
230 echo "e2e FAIL: oracle: udp_table is empty, so a bind diagnostic would"
231 echo " print nothing"; exit 1; }
232 # Against what the probe file IS, never against a second spelling of the
233 # same command: comparing file_mode with `stat -c %a` would agree with
234 # itself on any OS and would have to be ported alongside the helper. A
235 # chmod the kernel performed, five bytes written, and the published
236 # SHA-256 of "hello" are answers no arm of the oracle can define away.
237 chmod 640 "$OUT.oracle.probe"
238 [ "$(file_mode "$OUT.oracle.probe")" = "640" ] || {
239 echo "e2e FAIL: oracle: file_mode says '$(file_mode "$OUT.oracle.probe")' for a"
240 echo " file the kernel just chmod'd to 640"; exit 1; }
241 [ "$(file_size "$OUT.oracle.probe")" = "5" ] || {
242 echo "e2e FAIL: oracle: file_size says '$(file_size "$OUT.oracle.probe")' for five bytes"
243 exit 1; }
244 [ "$(sha256_of "$OUT.oracle.probe")" = \
245 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ] || {
246 echo "e2e FAIL: oracle: sha256_of hashes 'hello' to"
247 echo " '$(sha256_of "$OUT.oracle.probe")'"; exit 1; }
248
249 # real_path's two branches, against the rule `readlink -f` states and
250 # both arms owe: every component but the LAST must exist.
251 #
252 # The unwritten leaf first. e2e_14_upgrade canonicalizes the name of a
253 # candidate binary before anything writes one, so an arm that refused a
254 # path it cannot stat would fail that leg — which is what a bare
255 # Cwd::realpath does.
256 _orpl=$(real_path "$OUT.oracle.notyet") || _orpl=''
257 case "$_orpl" in
258 /*.oracle.notyet) ;;
259 *) echo "e2e FAIL: oracle: real_path answers '$_orpl' for a name whose parent"
260 echo " exists and whose last component does not, where it owes an"
261 echo " absolute path ending in that name"; exit 1 ;;
262 esac
263 # And the missing PARENT, which must be no answer at all. This is the
264 # half only one arm gets for free: readlink -f refuses it, while
265 # Cwd::realpath on some perls hands back the spelling of a directory that
266 # is not there, so the Darwin arm has to check that the parent is a
267 # directory rather than that the call answered.
268 # The status kept through `||` and not read after the fact: an
269 # assignment whose command substitution fails IS a failing command, and
270 # under `set -e` this pin would end the run with no line at all rather
271 # than grade anything. stderr goes to /dev/null rather than into the
272 # value, because the contract is about what reaches STDOUT and a traced
273 # run would otherwise capture its own xtrace.
274 _orpmrc=0
275 _orpm=$(real_path "$OUT.oracle.nodir/x" 2>/dev/null) || _orpmrc=$?
276 [ "$_orpmrc" -ne 0 ] && [ -z "$_orpm" ] || {
277 echo "e2e FAIL: oracle: real_path answers '$_orpm' (rc $_orpmrc) for a path"
278 echo " whose PARENT does not exist, where it owes nothing and rc 1"
279 exit 1; }
280
281 # The `timeout` a group file spells, which is GNU's binary here, gtimeout
282 # on a Mac that has coreutils and perl's alarm on one that does not.
283 # Nothing else committed calls it on that last arm, so without these five
284 # lines the bare-Mac fallback ships unrun — and every one of these codes
285 # is one a caller reads: refuse() and half the daemon legs turn on
286 # telling 124 from 1.
287 #
288 # 0.3s for the expiry, because that is the only case that has to wait for
289 # the alarm and every arm takes a fractional budget; the whole block
290 # costs about a third of a second.
291 _otimeout_is() {
292 _otw=$1; shift
293 _otrc=0
294 # A redirected subshell that ENDS IN `exit`, and both halves matter.
295 # GNU timeout re-raises the signal that killed its child so as to
296 # report 128+N, and a shell whose foreground command died of a signal
297 # prints "Terminated" to its own stderr — a line that is not this
298 # suite's and would land in the middle of an e2e log. The subshell
299 # moves that report inside the redirection; the trailing `exit` stops
300 # the shell exec'ing timeout in place of the subshell, which would
301 # leave the signal to be reported by the shell outside it after all.
302 ( timeout "$@"; exit $? ) >/dev/null 2>&1 || _otrc=$?
303 [ "$_otrc" -eq "$_otw" ] || {
304 echo "e2e FAIL: oracle: timeout $* exited $_otrc, want $_otw"
305 echo " (124 the budget expiring, 127 a command that is not there,"
306 echo " 128+N a signal, anything else the child's own status)"
307 exit 1; }
308 }
309 _otimeout_is 0 5 true
310 _otimeout_is 124 0.3 sleep 5
311 _otimeout_is 7 5 sh -c 'exit 7'
312 _otimeout_is 127 5 /no/such/binary-for-the-oracle
313 _otimeout_is 143 5 sh -c 'kill -TERM $$'
314
315 # The clock, asked of the CALENDAR and then of itself. Thirteen digits is
316 # what epoch milliseconds have been since 2001 and will be until 2286, so
317 # it separates milliseconds from the seconds and the nanoseconds either
318 # side of them — the one mistake a `date +%s%N` bracket that lost or kept
319 # a /1000000 would make. Then a real second has to pass for the delta to
320 # be a second: a clock stuck at a constant would satisfy the digit count
321 # and nothing else.
322 #
323 # A band and not a number, and a generous one, because the sleep and two
324 # process starts are inside it and this runs on loaded boxes and under
325 # test/coverage.sh's tracing.
326 # Captured with its stderr and with the failure swallowed, then graded
327 # out of the variable: a now_ms whose clock is not installed exits 127,
328 # and an assignment that fails ends the run under `set -e` with the
329 # shell's own "not found" and no line saying which helper it was.
330 _onow=$(now_ms 2>&1) || _onow="$_onow (exit $?)"
331 _onowsaid="$_onow"
332 case "$_onow" in ''|*[!0-9]*) _onow='' ;; esac
333 [ "${#_onow}" -eq 13 ] || {
334 echo "e2e FAIL: oracle: now_ms answers '$_onowsaid', which is not the 13"
335 echo " digits epoch MILLISECONDS have — seconds or nanoseconds"
336 echo " would make every timing bracket read it wrong"; exit 1; }
337 sleep 1
338 _oelapsed=$(( $(now_ms) - _onow ))
339 [ "$_oelapsed" -ge 900 ] && [ "$_oelapsed" -le $(( 3000 * TIME_SCALE )) ] || {
340 echo "e2e FAIL: oracle: now_ms moved ${_oelapsed}ms across a 1s sleep"; exit 1; }
341
342 # And the TEXT of that clock, run in a shell this one did not configure,
343 # which is exactly how the throughput legs and the agent-mute leg use it.
344 # On Darwin it is a perl one-liner, so a Mac whose perl has lost
345 # Time::HiRes fails HERE rather than inside a $(( )) partway through a
346 # group file, which is where an unpinned snippet would have failed.
347 _osnip=$(sh -c "$(now_ms_snippet); now_ms" 2>&1 || true)
348 _osnipsaid="$_osnip"
349 case "$_osnip" in ''|*[!0-9]*) _osnip='' ;; esac
350 [ "${#_osnip}" -eq 13 ] || {
351 echo "e2e FAIL: oracle: now_ms_snippet run in a fresh shell answers"
352 echo " '$_osnipsaid'"
353 echo " rather than 13 digits of epoch milliseconds"; exit 1; }
354
355 kill "$_olpid" "$_opid" "$_okid" 2>/dev/null || true
356 wait "$_olpid" 2>/dev/null || true
357 wait "$_opid" 2>/dev/null || true
358 _oi=0
359 while pid_alive "$_opid" && [ "$_oi" -lt $(( 50 * TIME_SCALE )) ]; do
360 sleep 0.05; _oi=$((_oi + 1))
361 done
362 ! pid_alive "$_opid" || {
363 echo "e2e FAIL: oracle: pid_alive says a killed shell still lives"; exit 1; }
364 rm -f "$_osock" "$_oport_f" "$OUT.oracle.probe" "$OUT.oracle.notyet"
365 ok "oracle: the OS answers the helpers by name"
366 # One fixed word, on top of ok()'s sentence: a port is graded over ssh
367 # and the run that grades it has one line to look for.
368 echo "ORACLE_OK"
369 }
370
371 # ---- the standalone arm -------------------------------------------------
372 # `$0` is this file only when it was RUN. Sourced from e2e_lib.sh it is the
373 # runner's path, and sourced by hand it is the shell's, so neither takes
374 # this branch and neither gets a second EXIT trap.
375 #
376 # The four names oracle_selftest reads out of the suite are spelled here in
377 # their smallest honest form. They are not a copy of the lib's: standalone
378 # there is no cleanup registry to share, no scenario count to keep and no
379 # traced run to scale timeouts for, so each is the whole of that duty on
380 # this path rather than a reimplementation of the suite's.
381 case "$0" in
382 *oracle_selftest.sh)
383 set -e
384 . "$(dirname "$0")/os_oracle.sh"
385 : "${TIME_SCALE:=1}"
386 : "${OUT:=${TMPDIR:-/tmp}/mux-oracle-$$}"
387 _SELF_KILL=""
388 defer_kill() {
389 for _dk in "$@"; do
390 if [ -n "$_dk" ]; then _SELF_KILL="$_dk $_SELF_KILL"; fi
391 done
392 return 0
393 }
394 ok() { echo "e2e OK: $1"; }
395 wait_until() {
396 _i=0
397 while [ "$_i" -lt $(( $1 * TIME_SCALE )) ]; do
398 # First command of an AND-OR list, which set -e exempts: a
399 # predicate that is false on this tick is the normal case.
400 eval "$3" && return 0
401 sleep 0.1; _i=$((_i + 1))
402 done
403 echo "e2e FAIL: $2"; exit 1
404 }
405 # Children first, and through the oracle's own pid_children: the subject
406 # is a shell whose `sleep 300` is registered only after the assertions
407 # that come before it, so a run that fails early leaves a grandchild
408 # that no list names. It inherits this script's stdout, and an ssh
409 # reading that pipe waits on the fd rather than on the process — so the
410 # leak does not merely outlive the run, it hangs whoever asked for it,
411 # which is exactly how a port gets graded from another box.
412 trap 'for _k in $_SELF_KILL; do
413 for _kc in $(pid_children "$_k" 2>/dev/null); do
414 kill "$_kc" 2>/dev/null || true
415 done
416 kill "$_k" 2>/dev/null || true
417 done
418 rm -f "$OUT".oracle.*' EXIT INT TERM
419 oracle_selftest
420 ;;
421 esac
test/os_oracle.sh
Old New
@@ -1,65 +1,356 @@
1 # shellcheck shell=sh 1 # shellcheck shell=sh
2 # os_oracle.sh — the OS oracle, sourced and never run. 2 # os_oracle.sh — the OS oracle, sourced and never run.
3 # 3 #
4 # Sourced by test/e2e_lib.sh, where these helpers used to live, and by 4 # Sourced by test/e2e_lib.sh, where these helpers used to live, by
5 # test/soak.sh, which reads two of them between runs. It is deliberately 5 # test/oracle_selftest.sh, which is their own pin, and by test/soak.sh,
6 # trap-free and state-free: both readers bring a cleanup registry and an 6 # which reads two of them between runs. It is deliberately trap-free and
7 # EXIT trap of their own, and a second trap here would replace one of 7 # state-free: its readers bring a cleanup registry and an EXIT trap of
8 # theirs. soak.sh used to carry copies of the two it needs, and a copy is a 8 # their own, and a second trap here would replace one of theirs. soak.sh
9 # second spelling to port the day a second OS arrives. 9 # used to carry copies of the two it needs, and a copy is a second spelling
10 # to port the day a second OS arrives.
10 # 11 #
11 # "Ask the OS about the OS, not the daemon" (CLAUDE.md). Every pin that 12 # "Ask the OS about the OS, not the daemon" (CLAUDE.md). Every pin that
12 # reads a pid, an fd table, a bound UDP port or a file's mode asks through 13 # reads a pid, an fd table, a bound UDP port or a file's mode asks through
13 # these names, so the SPELLING of the question lives in one place per OS 14 # these names, so the SPELLING of the question lives in one place per OS
14 # and the question itself lives in the group file. The Linux arm is /proc, 15 # and the question itself lives in the group file. Each prints its answer
15 # GNU stat and sha256sum; a second OS adds a `case "$(uname)"` arm here and 16 # on stdout and returns 1 when the OS will not say, so a caller may treat
16 # nothing in a group file changes. Each prints its answer on stdout and 17 # "no answer" and "the wrong answer" as the same failure.
17 # returns 1 when the OS will not say, so a caller may treat "no answer"
18 # and "the wrong answer" as the same failure.
19 # 18 #
20 # oracle_selftest in e2e_lib.sh is these helpers' own pin. A helper that 19 # There are two arms below and a group file must never learn which one it
21 # stopped answering — a missing binary, a /proc the sandbox will not show — 20 # is on. That is why udp_local_bound still takes the Linux hex spelling on
22 # would otherwise turn every pin that reads it into a check that passes 21 # Darwin and converts it here, and why now_ms and real_path exist at all:
23 # without running, which is the one failure a green tree cannot show. 22 # `date +%s%N` and `readlink -f` are GNU, and a group file that spelled
24 pid_alive() { kill -0 "$1" 2>/dev/null || [ -d "/proc/$1" ]; } 23 # either would be a Linux group file.
25 pid_exe() { readlink -f "/proc/$1/exe" 2>/dev/null; } 24 #
26 pid_comm() { cat "/proc/$1/comm" 2>/dev/null; } 25 # oracle_selftest (test/oracle_selftest.sh) is these helpers' own pin. A
27 pid_args() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null; } 26 # helper that stopped answering — a missing binary, a /proc the sandbox
28 pid_children() { ps -o pid= --ppid "$1" 2>/dev/null | tr -d ' '; } 27 # will not show — would otherwise turn every pin that reads it into a check
29 pid_fd_count() { find "/proc/$1/fd" -mindepth 1 2>/dev/null | wc -l | tr -d ' '; } 28 # that passes without running, which is the one failure a green tree cannot
30 pid_fd_targets() { readlink "/proc/$1"/fd/* 2>/dev/null; } 29 # show.
31 # pid_holds_fd_kind PID KIND — how many of PID's open fds are of KIND, which 30
32 # is one of `socket`, `carrier` (the upgrade manifest's anonymous memory 31 case "$(uname)" in
33 # file) or `ptymaster`. Prints the count. These are the three KINDS an 32 Darwin)
34 # upgrade pin asks about, and naming them is the point: a second OS's arm 33 # No /proc here, so lsof is the fd oracle and ps answers the process
35 # counts the same three things under whatever that OS calls them, and the 34 # questions. Both read the kernel through libproc, so this arm still
36 # group file keeps asking the same question. `|| true` because grep -c 35 # asks the OS about the OS and never a daemon. lsof's -F mode prints
37 # prints 0 AND exits 1 when nothing matches, and callers run under `set -e`. 36 # one FIELD per line — `f` an fd, `t` its type, `n` its name — which is
38 pid_holds_fd_kind() { 37 # what the helpers below parse, and -a ANDs its selection flags where
39 case "$2" in 38 # lsof would otherwise OR them.
40 socket) _fdk='socket:' ;; 39
41 carrier) _fdk='memfd:' ;; 40 # _fd_rows PID — one `TYPE NAME` line per NUMBERED fd. lsof reports the
42 ptymaster) _fdk='/dev/ptmx' ;; 41 # cwd, the executable and the root directory through the same stream
43 *) echo "pid_holds_fd_kind: no such fd kind '$2'" >&2; return 1 ;; 42 # with non-numeric fd ids (`fcwd`, `ftxt`, `frtd`), and no question
44 esac 43 # below asks about those. The TYPE has to come along because an lsof
45 pid_fd_targets "$1" | grep -c -F "$_fdk" || true 44 # name alone does not say what an fd is: a unix socket and a regular
46 } 45 # file both read as a path, where a /proc symlink target says which.
47 # By INODE, never by name: a process running the right program while 46 _fd_rows() {
48 # binding something else entirely would satisfy a name check. The kernel's 47 lsof -p "$1" -Ftn 2>/dev/null | awk '
49 # listening inode for PATH, found among that pid's open fds, cannot. 48 /^f/ { fd = substr($0, 2); ty = "" }
50 pid_holds_unix_sock() { 49 /^t/ { ty = substr($0, 2) }
51 _ino=$(awk -v p="$2" '$NF == p {print $7}' /proc/net/unix | head -1) 50 /^n/ { if (fd ~ /^[0-9]+$/) print ty " " substr($0, 2) }'
52 [ -n "$_ino" ] && pid_fd_targets "$1" | grep -qx "socket:\[$_ino\]" 51 }
52 # No `[ -d /proc/$1 ]` behind it, because there is no /proc — which
53 # leaves one behavioural difference between the arms: kill(2) on a pid
54 # this uid does not own answers EPERM, so a live process belonging to
55 # somebody else reads as dead here where Linux's directory check would
56 # still see it. Every pid this suite asks about is one it started.
57 pid_alive() { kill -0 "$1" 2>/dev/null; }
58 # The FIRST txt vnode is the executable; the ones after it are dyld and
59 # the dylibs it mapped. Two things this answers that a caller has to
60 # know (both measured on macOS 26.6, lsof 4.91): lsof canonicalizes, so
61 # a binary opened as /tmp/x reads /private/tmp/x, which is exactly what
62 # real_path answers for the same path; and a process that exec'd
63 # /bin/sh reports /bin/bash, because Apple ships /bin/sh as a stub in
64 # front of the bash image. No binary mux runs is such a stub, so the
65 # pins that read this get back the path they asked about — but the
66 # oracle's own self-test asks about a `sleep` for that reason.
67 # Through a variable so that no answer is a FAILURE and not an empty
68 # success: the pipeline's exit status is head's, which is 0 for a pid
69 # that is gone, and this file's header promises a 1 when the OS will not
70 # say. Linux's readlink already answers that way.
71 pid_exe() {
72 _pe=$(lsof -p "$1" -a -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)
73 [ -n "$_pe" ] || return 1
74 printf '%s\n' "$_pe"
75 }
76 # ps prints the full path for a binary outside the system directories
77 # and the bare name for one inside them, so the last path component is
78 # taken either way. Through a variable rather than basename(1): an
79 # empty answer must be no answer, and `basename ""` prints a line.
80 pid_comm() {
81 _pc=$(ps -o comm= -p "$1" 2>/dev/null) || return 1
82 [ -n "$_pc" ] || return 1
83 printf '%s\n' "${_pc##*/}"
84 }
85 pid_args() { ps -o args= -p "$1" 2>/dev/null; }
86 pid_children() { pgrep -P "$1" 2>/dev/null; }
87 pid_fd_count() { lsof -p "$1" -Ff 2>/dev/null | grep -c '^f[0-9]' || true; }
88 pid_fd_targets() { _fd_rows "$1"; }
89 pid_holds_fd_kind() {
90 case "$2" in
91 # Every socket family, which is what the Linux arm's `socket:`
92 # prefix counts. PIPE is not among them, because `pipe:[N]` is
93 # not on that side either.
94 socket) _fd_rows "$1" | grep -cE '^(unix|IPv4|IPv6|sock) ' || true ;;
95 # The upgrade manifest is an unlinked mkstemp file here, not a
96 # memfd (server_os_macos.anonFd). lsof still prints the path it
97 # had before the unlink, canonicalized and with no marker of
98 # its own, so the NAME cannot tell a carrier from an ordinary
99 # open file — the suite's own captures live under /tmp with the
100 # same `mux-` prefix and would be counted as carriers. What
101 # does tell them apart is that a carrier's path names nothing
102 # any more, which is the whole of what "anonymous" means here.
103 carrier)
104 _fd_rows "$1" | sed -n 's/^REG //p' | {
105 _fdn=0
106 while IFS= read -r _fdp; do
107 [ -e "$_fdp" ] || _fdn=$((_fdn + 1))
108 done
109 echo "$_fdn"
110 } ;;
111 ptymaster) _fd_rows "$1" | grep -c '^CHR /dev/ptmx$' || true ;;
112 *) echo "pid_holds_fd_kind: no such fd kind '$2'" >&2; return 1 ;;
113 esac
114 }
115 # What `server_os_macos.anonFd` makes, in one word, so a test that has to
116 # OPEN a carrier of the local shape can ask for it without asking which
117 # OS it is on. `unlinked` is an unlinked mkstemp file.
118 carrier_kind() { echo unlinked; }
119 # By PATH, where the Linux arm matches the listening inode: lsof answers
120 # a unix socket's bound path and no inode, so there is no inode to
121 # match. It is still this pid's own fd table being read and not the
122 # filesystem, so a process that merely runs the right program does not
123 # satisfy it; what it cannot rule out, and Linux can, is a second
124 # socket bound at the same path. -F because a path holds regex
125 # metacharacters and this is a literal comparison.
126 pid_holds_unix_sock() { _fd_rows "$1" | grep -qxF "unix $2"; }
127 pid_rss_kb() { ps -o rss= -p "$1" 2>/dev/null | tr -d ' '; }
128 # _udp_dotted HEX — the Linux argument spelling turned into the dotted
129 # form lsof prints. Anything that is not eight hex digits, a colon and
130 # four more is no address at all and gets no answer, which is what the
131 # self-test asks for when it hands this a spelling no kernel can hold.
132 # Shell arithmetic reads a 0x constant by POSIX, so this forks nothing:
133 # udp_local_bound is polled in a loop and an interpreter start per tick
134 # would be most of the wait.
135 _udp_dotted() {
136 # Eight hex digits, a colon and four more. Asked as "no byte outside
137 # the alphabet, one colon, and these two lengths" rather than as one
138 # bracket pattern built from a variable, because a case pattern that
139 # came out of an expansion is not treated as a pattern by every
140 # shell — zsh needs an option set for it, and this file is sourced
141 # by whatever the caller runs.
142 case "$1" in
143 *[!0-9A-Fa-f:]* | *:*:* | *[!0-9A-Fa-f]) return 1 ;;
144 esac
145 _uh=${1%:*}
146 _ut=${1#*:}
147 [ "${#_uh}" -eq 8 ] && [ "${#_ut}" -eq 4 ] || return 1
148 _up=$(( 0x$_ut ))
149 # A wildcard bind reads `*` to lsof and not 0.0.0.0, and that is the
150 # spelling the handoff group hands over for the daemon's lazy bind.
151 if [ "$_uh" = 00000000 ]; then echo "*:$_up"; return 0; fi
152 # /proc/net/udp writes the local address as the host-order u32 in
153 # hex, which on a little-endian box puts the last octet first, so
154 # the four bytes come back out in reverse.
155 _u1=${_uh%??????}
156 _ur=${_uh#??}; _u2=${_ur%????}
157 _ur=${_uh#????}; _u3=${_ur%??}
158 _u4=${_uh#??????}
159 echo "$(( 0x$_u4 )).$(( 0x$_u3 )).$(( 0x$_u2 )).$(( 0x$_u1 )):$_up"
160 }
161 # Matched on the LOCAL address for the Linux arm's reason: lsof prints
162 # a connected socket as LOCAL->REMOTE, so a client dialling this port
163 # answers a match that reads anywhere on the line. The arrow and
164 # everything after it goes before the comparison.
165 udp_local_bound() {
166 _ua=$(_udp_dotted "$1") || return 1
167 lsof -iUDP -P -n -Fn 2>/dev/null | sed -n 's/^n//p' | sed 's/->.*$//' |
168 grep -qxF "$_ua"
169 }
170 udp_table() { lsof -iUDP -P -n 2>/dev/null; }
171 file_mode() { stat -f %Lp "$1"; }
172 file_size() { stat -f %z "$1"; }
173 sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
174 # perl, and not GNU coreutils under their g-names. A pristine macOS has
175 # no Homebrew on it and this arm has to answer there — test/mac.sh runs
176 # the oracle INSIDE a freshly cloned guest, and installing a package to
177 # answer one question would make that guest something other than the
178 # bare Mac the gate is about. perl ships with macOS and needs nothing.
179 #
180 # Cwd::realpath refuses a path whose last component does not exist,
181 # where `readlink -f` resolves it — and e2e_14_upgrade canonicalizes the
182 # name of a candidate binary it has not written yet. So the whole path
183 # is tried first and the parent alone second, which is GNU's rule: every
184 # component but the last must exist.
185 #
186 # The parent is checked with -d and not merely for a defined answer,
187 # because Cwd::realpath is not the same function on every perl. On 5.42
188 # with Cwd 3.94 it hands BACK the spelling of a directory that is not
189 # there — realpath("/nope") is "/nope" — so a `defined` test alone let
190 # real_path answer a path whose parent does not exist, where readlink -f
191 # answers nothing with rc 1. oracle_selftest pins both branches, because
192 # only the perl arm can get this wrong and only on some perls.
193 real_path() {
194 perl -e '
195 use Cwd ();
196 my $p = $ARGV[0];
197 my $r = Cwd::realpath($p);
198 unless (defined $r) {
199 my ($d, $b) = $p =~ m{^(.*)/([^/]*)$} ? ($1 eq "" ? "/" : $1, $2) : (".", $p);
200 my $rd = Cwd::realpath($d);
201 exit 1 unless defined $rd && -d $rd;
202 $r = $rd eq "/" ? "/$b" : "$rd/$b";
53 } 203 }
54 pid_rss_kb() { awk '/VmRSS/{print $2}' "/proc/$1/status" 2>/dev/null || echo 0; } 204 print "$r\n";' "$1"
55 # Matched on the LOCAL address ($2) rather than anywhere on the line: the 205 }
56 # remote address of a client dialling this port is the same hex, and a 206 # macOS 26's /bin/date does answer %N and older ones do not, so spelling
57 # whole-line match read a reconnecting client as a bound listener. 207 # the system date here would make the suite's clock depend on how new
58 udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; } 208 # the OS is — and its failure on an older one is a literal N inside an
59 udp_table() { cat /proc/net/udp 2>/dev/null; } 209 # arithmetic expansion partway through a run. Time::HiRes is core perl
60 file_mode() { stat -c %a "$1"; } 210 # and answers on every version.
61 file_size() { stat -c %s "$1"; } 211 #
62 sha256_of() { sha256sum "$1" | cut -d' ' -f1; } 212 # It costs 4.5ms a call against gdate's 2.1 and python3's 21.6 (measured
63 # GNU timeout is a binary here; a box without one names it gtimeout, and a 213 # 2026-09-03). That is inside the noise of every bracket in the e2e,
64 # group file keeps spelling `timeout` either way. 214 # whose tightest budget is 1500ms. It is NOT inside the noise of the
65 command -v timeout >/dev/null 2>&1 || timeout() { gtimeout "$@"; } 215 # throughput gate's 10ms ceiling around a 6ms leg, so a `make throughput`
216 # run on a Mac will need SOLO_MAX_MS raised — which that gate already
217 # takes from the environment for exactly this kind of reason.
218 #
219 # No backslash anywhere in the snippet, hence -l rather than a printed
220 # "\n": this text is TYPED into a session by the throughput and
221 # agent-mute legs, and the pty fixture reads a backslash as the start of
222 # an escape rather than as a byte to send.
223 now_ms_snippet() {
224 echo 'now_ms() { perl -MTime::HiRes -le "print int(Time::HiRes::time() * 1000)"; }'
225 }
226 ;;
227 *)
228 pid_alive() { kill -0 "$1" 2>/dev/null || [ -d "/proc/$1" ]; }
229 pid_exe() { readlink -f "/proc/$1/exe" 2>/dev/null; }
230 pid_comm() { cat "/proc/$1/comm" 2>/dev/null; }
231 # The words with ONE space between them and none at the end. Every
232 # entry in /proc/PID/cmdline is NUL-TERMINATED, so a straight
233 # translation leaves a trailing separator that the Darwin arm's
234 # `ps -o args=` does not, and the one caller that compares the whole
235 # argv as a string then has to spell a space it can only have learned
236 # from Linux. Trimmed here so both arms answer the same shape.
237 pid_args() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null | sed 's/ *$//'; }
238 pid_children() { ps -o pid= --ppid "$1" 2>/dev/null | tr -d ' '; }
239 pid_fd_count() { find "/proc/$1/fd" -mindepth 1 2>/dev/null | wc -l | tr -d ' '; }
240 pid_fd_targets() { readlink "/proc/$1"/fd/* 2>/dev/null; }
241 # pid_holds_fd_kind PID KIND — how many of PID's open fds are of KIND,
242 # which is one of `socket`, `carrier` (the upgrade manifest's anonymous
243 # memory file) or `ptymaster`. Prints the count. These are the three
244 # KINDS an upgrade pin asks about, and naming them is the point: the
245 # other arm counts the same three things under whatever that OS calls
246 # them, and the group file keeps asking the same question. `|| true`
247 # because grep -c prints 0 AND exits 1 when nothing matches, and
248 # callers run under `set -e`.
249 pid_holds_fd_kind() {
250 case "$2" in
251 socket) _fdk='socket:' ;;
252 carrier) _fdk='memfd:' ;;
253 ptymaster) _fdk='/dev/ptmx' ;;
254 *) echo "pid_holds_fd_kind: no such fd kind '$2'" >&2; return 1 ;;
255 esac
256 pid_fd_targets "$1" | grep -c -F "$_fdk" || true
257 }
258 # What `server_os_linux.anonFd` makes; see the Darwin arm's carrier_kind.
259 carrier_kind() { echo memfd; }
260 # By INODE, never by name: a process running the right program while
261 # binding something else entirely would satisfy a name check. The
262 # kernel's listening inode for PATH, found among that pid's open fds,
263 # cannot.
264 pid_holds_unix_sock() {
265 _ino=$(awk -v p="$2" '$NF == p {print $7}' /proc/net/unix | head -1)
266 [ -n "$_ino" ] && pid_fd_targets "$1" | grep -qx "socket:\[$_ino\]"
267 }
268 pid_rss_kb() { awk '/VmRSS/{print $2}' "/proc/$1/status" 2>/dev/null || echo 0; }
269 # Matched on the LOCAL address ($2) rather than anywhere on the line:
270 # the remote address of a client dialling this port is the same hex,
271 # and a whole-line match read a reconnecting client as a bound
272 # listener.
273 udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; }
274 udp_table() { cat /proc/net/udp 2>/dev/null; }
275 file_mode() { stat -c %a "$1"; }
276 file_size() { stat -c %s "$1"; }
277 sha256_of() { sha256sum "$1" | cut -d' ' -f1; }
278 real_path() { readlink -f "$1"; }
279 now_ms_snippet() { echo 'now_ms() { echo $(( $(date +%s%N) / 1000000 )); }'; }
280 ;;
281 esac
282
283 # Each arm above defines now_ms_snippet — the TEXT of a now_ms — and this is
284 # where the harness's own now_ms comes from. The throughput legs and the
285 # agent-mute leg time themselves from INSIDE a mux session, so their clock
286 # has to be typed into the bytes they send rather than called, and a group
287 # file that spelled `date +%s%N` into a session would be a Linux group file
288 # however OS-blind the rest of it is.
289 #
290 # Defined THROUGH the snippet rather than beside it so the clock this shell
291 # reads and the clock it types into another shell are the same text and
292 # cannot drift into two answers on one box.
293 eval "$(now_ms_snippet)"
294
295 # GNU timeout is a binary on Linux. A Mac has it only if somebody installed
296 # coreutils, where it answers to gtimeout, and a pristine one has neither —
297 # so the last resort is perl, which every macOS ships. A group file keeps
298 # spelling `timeout` through all three.
299 #
300 # The perl arm answers GNU's EXIT contract for the five cases this harness
301 # asks, and oracle_selftest pins all five: 0 for a command that returns in
302 # time, 124 when the budget expired, the child's own status when it exited,
303 # 127 for a command that is not there, and 128+N for one a signal took. A
304 # harness that read a timeout as a pass, or a real failure as a timeout,
305 # would grade the wrong thing — `refuse()` and half the e2e's daemon legs
306 # turn on telling 124 from 1.
307 #
308 # It is not GNU timeout in three ways, each measured and none of them
309 # reached by a caller here: a command that exists but is not executable
310 # answers 127 where GNU answers 126; a signal sent to the WRAPPER is not
311 # relayed to the child, where GNU forwards TERM, INT and HUP; and the child
312 # is not put in a process group, so GNU's kill-the-group on expiry becomes
313 # kill-the-child and a grandchild outlives the budget.
314 #
315 # Almost every caller in this tree runs `timeout <seconds> CMD` in the
316 # foreground over a single mux or ptyclient child, with no flags, and none
317 # of the three differences can reach one. The exceptions, and why they are
318 # still safe: e2e_01_boot.sh and e2e_10_agent.sh each background one
319 # timeout-wrapped client, and both reap it with `wait` on the wrapper's own
320 # pid rather than by signalling it — the agent group's `defer_kill` on that
321 # pid runs only when the leg has already failed, and on this arm it would
322 # leave the child to finish its own budget, which is a slower cleanup and
323 # not a different verdict; e2e_05_session.sh, e2e_06_web.sh and
324 # e2e_13_birth.sh wrap a wsclient, which is one child like the others; and
325 # test/vm.sh, test/mac.sh and test/xos.sh wrap ssh, but those three drivers
326 # run on the Linux side of a gate, where `timeout` is the GNU binary and
327 # this arm is never defined.
328 if command -v timeout >/dev/null 2>&1; then
329 :
330 elif command -v gtimeout >/dev/null 2>&1; then
331 timeout() { gtimeout "$@"; }
332 else
333 timeout() {
334 _tsecs=$1; shift
335 # waitpid is restarted around the handler rather than trusted once:
336 # perl defers a signal to a safe point and the wait returns EINTR
337 # when it does, and a status read from that return is not the
338 # child's.
339 perl -e '
340 use POSIX ();
341 use Time::HiRes ();
342 my $secs = shift @ARGV;
343 my $pid = fork();
344 defined $pid or exit 125;
345 unless ($pid) { exec { $ARGV[0] } @ARGV; exit 127; }
346 my $fired = 0;
347 $SIG{ALRM} = sub { $fired = 1; kill "TERM", $pid };
348 Time::HiRes::alarm($secs);
349 my $r;
350 do { $r = waitpid($pid, 0) } while ($r == -1 && $! == POSIX::EINTR());
351 my $st = $?;
352 Time::HiRes::alarm(0);
353 exit 124 if $fired;
354 exit(($st & 127) ? 128 + ($st & 127) : $st >> 8);' "$_tsecs" "$@"
355 }
356 fi
test/provision-mac.sh
Old New
@@ -0,0 +1,194 @@
1 #!/bin/sh
2 # test/provision-mac.sh — make the macOS box the gates take by name.
3 #
4 # `make mac` and `make xos` grade a Mac they are TOLD about (MAC_BOX). This
5 # is what produces one: a pristine macOS virtual machine, on the LAN, that
6 # this box can ssh into directly. Run it from here, and eval its one line:
7 #
8 # eval "$(test/provision-mac.sh)" # export MAC_BOX=admin@192.168.0.x
9 # MAC_BUILDER=squirtle make xos
10 # test/provision-mac.sh --down # when the run is over
11 #
12 # The clone lives on a tart HOST — a Mac with tart installed, named by
13 # --host or by $MAC_BUILDER, since the machine that builds is normally the
14 # machine that has the VMs. Everything here is on the host EXCEPT the last
15 # step, which proves the guest answers THIS box.
16 #
17 # Bridged, not NAT. `tart run --net-bridged=en0` puts the guest on the same
18 # LAN as everything else (measured 2026-09-04: it took 192.168.0.170), so
19 # this box reaches it without a hop through the host and the Linux VM the
20 # cross-OS gate uses can see its port 22. Under tart's default NAT the guest
21 # is reachable only from the host, which is why both gates used to spell an
22 # ssh-through-ssh string for every command they ran there.
23 #
24 # The lifecycle is HERE and not in a gate on purpose. The pristine base
25 # image is a human action (test/vm.sh's rule) and so is the decision to
26 # spend a machine; a gate that cloned a VM for itself would be a gate that
27 # could not be pointed at a Mac somebody already had.
28 #
29 # The base image is made ONCE, by hand, on the host:
30 #
31 # brew trust cirruslabs/cli && brew install cirruslabs/cli/tart
32 # tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest mux-mac-base
33 # tart run --no-graphics mux-mac-base & # user admin, password admin
34 # tart exec mux-mac-base sh -c 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "PUBKEY" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
35 # tart stop mux-mac-base
36 #
37 # PUBKEY is the HOST's own ~/.ssh/id_ed25519.pub, so the host can ssh into
38 # any clone without a password; `tart exec` needs the guest agent the
39 # cirruslabs images ship. That key is what step 4 below rides to install
40 # THIS box's key, and it is the only reason the host is in the picture after
41 # the boot.
42 set -eu
43
44 usage() {
45 echo "usage: test/provision-mac.sh [--host SSHNAME] [--name VM] [--iface en0] [--down]"
46 echo " --host the Mac holding tart and the base image (default: \$MAC_BUILDER)"
47 echo " --name the clone to make and boot (default: mux-mac)"
48 echo " --iface the host interface to bridge onto (default: en0)"
49 echo " --down stop and delete the clone, and print nothing"
50 }
51
52 HOST=${MAC_BUILDER:-}
53 NAME=mux-mac
54 IFACE=en0
55 DOWN=no
56 VM_BASE=mux-mac-base
57 GUSER="admin"
58
59 while [ $# -gt 0 ]; do
60 case "$1" in
61 --host) [ $# -ge 2 ] || { usage >&2; exit 2; }; HOST=$2; shift 2 ;;
62 --name) [ $# -ge 2 ] || { usage >&2; exit 2; }; NAME=$2; shift 2 ;;
63 --iface) [ $# -ge 2 ] || { usage >&2; exit 2; }; IFACE=$2; shift 2 ;;
64 --down) DOWN=yes; shift ;;
65 -h|--help) usage; exit 0 ;;
66 *) echo "provision-mac: unknown argument '$1'" >&2; usage >&2; exit 2 ;;
67 esac
68 done
69
70 [ -n "$HOST" ] || {
71 echo "provision-mac: no tart host. Pass --host SSHNAME or set MAC_BUILDER." >&2
72 exit 2
73 }
74
75 # Everything this script says goes to STDERR. Stdout carries exactly one
76 # line, the `export MAC_BOX=...` a caller evals, so a message that wandered
77 # onto it would be evaluated as shell.
78 say() { echo "provision-mac: $*" >&2; }
79
80 SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new"
81 # The guest's host key is new on every clone, so this box neither records it
82 # nor checks it; LogLevel=ERROR keeps ssh from announcing the new key on
83 # stderr every time. The GATE re-learns that key deliberately, because mux's
84 # own entry dial spawns a plain ssh that reads the real known_hosts.
85 GUEST_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
86
87 # hssh SECONDS — a script on stdin, on the tart host, under /bin/sh.
88 # /bin/sh and not the host's login zsh: an options string pasted into a
89 # command line there arrives as one argument, because zsh does not
90 # word-split an unquoted variable.
91 hssh() {
92 _ht=$1
93 # shellcheck disable=SC2086 # SSH_OPTS is words on purpose
94 timeout "$_ht" ssh $SSH_OPTS "$HOST" /bin/sh -s
95 }
96
97 if [ "$DOWN" = yes ]; then
98 hssh 180 >/dev/null 2>&1 <<H || true
99 export PATH=/opt/homebrew/bin:\$PATH
100 tart stop $NAME 2>/dev/null || true
101 tart delete $NAME 2>/dev/null || true
102 H
103 say "$NAME stopped and deleted on $HOST"
104 exit 0
105 fi
106
107 # ---- 1: the host has tart and the base image --------------------------
108 hssh 60 >/dev/null <<H || {
109 export PATH=/opt/homebrew/bin:\$PATH
110 command -v tart >/dev/null || exit 1
111 tart list 2>/dev/null | awk '{print \$2}' | grep -qx $VM_BASE
112 H
113 echo "provision-mac: $HOST has no tart VM called $VM_BASE (or no tart at all)." >&2
114 echo " Make it once by hand; the recipe is this script's header." >&2
115 exit 1
116 }
117
118 # THIS box's public key is what the guest must end up trusting. Refused
119 # rather than generated: a key made behind somebody's back is a key they do
120 # not know they are using.
121 [ -f "$HOME/.ssh/id_ed25519.pub" ] || {
122 echo "provision-mac: this box has no ~/.ssh/id_ed25519.pub to give the guest." >&2
123 echo " Make one with: ssh-keygen -t ed25519" >&2
124 exit 1
125 }
126 PUB=$(cat "$HOME/.ssh/id_ed25519.pub")
127
128 # ---- 2: a fresh clone, booted on the LAN ------------------------------
129 say "cloning $VM_BASE to $NAME on $HOST and booting it bridged on $IFACE"
130 hssh 600 >/dev/null <<H || { say "could not clone and boot $NAME on $HOST"; exit 1; }
131 export PATH=/opt/homebrew/bin:\$PATH
132 tart stop $NAME >/dev/null 2>&1 || true
133 tart delete $NAME >/dev/null 2>&1 || true
134 tart clone $VM_BASE $NAME || exit 1
135 nohup tart run --no-graphics --net-bridged=$IFACE $NAME >/tmp/$NAME.log 2>&1 &
136 sleep 1
137 exit 0
138 H
139
140 # ---- 3: its address, off the host's own arp table ---------------------
141 # --resolver=arp because a bridged guest takes its lease from the LAN's DHCP
142 # server and not from tart, so tart has no lease file to read and learns the
143 # address the same way anything else on the wire does. Polled on the HOST
144 # rather than one ssh per tick: a tick costs a whole connection setup from
145 # here, and the address arrived in 6 s when this was measured (2026-09-04).
146 IP=$(hssh 120 <<H
147 export PATH=/opt/homebrew/bin:\$PATH
148 _i=0
149 while [ \$_i -lt 60 ]; do
150 _ip=\$(tart ip $NAME --resolver=arp 2>/dev/null) && [ -n "\$_ip" ] && { echo "\$_ip"; exit 0; }
151 sleep 1; _i=\$((_i + 1))
152 done
153 exit 1
154 H
155 ) || { say "$NAME took no address within 60 s (host log: /tmp/$NAME.log)"; exit 1; }
156 say "$NAME is at $IP"
157
158 # ---- 4: the guest trusts this box, through the host's own key ---------
159 # The host is the only machine the fresh clone already trusts, so the key
160 # that lets everything else in rides in over the host's. Idempotent, because
161 # a re-provision of a clone that survived is an ordinary thing to do.
162 _i=0
163 while :; do
164 if hssh 60 >/dev/null 2>&1 <<H
165 export PATH=/opt/homebrew/bin:\$PATH
166 ssh $GUEST_OPTS -i \$HOME/.ssh/id_ed25519 $GUSER@$IP /bin/sh -s <<'G'
167 set -eu
168 mkdir -p \$HOME/.ssh
169 chmod 700 \$HOME/.ssh
170 touch \$HOME/.ssh/authorized_keys
171 chmod 600 \$HOME/.ssh/authorized_keys
172 grep -qxF '$PUB' \$HOME/.ssh/authorized_keys ||
173 printf '%s\n' '$PUB' >> \$HOME/.ssh/authorized_keys
174 G
175 H
176 then break; fi
177 _i=$((_i + 1))
178 [ "$_i" -lt 40 ] || { say "$NAME at $IP never answered the host's ssh within 120 s"; exit 1; }
179 sleep 3
180 done
181
182 # ---- 5: and this box can reach it DIRECTLY ----------------------------
183 # The claim the gates rest on, made here where it can still be fixed rather
184 # than in the middle of a run. -n on the ssh: `timeout` runs its child in a
185 # background process group, and an ssh reading a terminal's stdin from there
186 # is stopped by SIGTTIN where the timeout's SIGTERM cannot reach it.
187 # shellcheck disable=SC2086 # GUEST_OPTS is words on purpose
188 GARCH=$(timeout 30 ssh -n $GUEST_OPTS "$GUSER@$IP" 'uname -m') || {
189 say "$GUSER@$IP does not answer ssh from this box — is the LAN bridged onto $IFACE?"
190 exit 1
191 }
192 [ "$GARCH" = "arm64" ] || { say "$GUSER@$IP says it is $GARCH, and the gates want an Apple-silicon Mac"; exit 1; }
193 say "$GUSER@$IP answers this box directly and is an $GARCH macOS"
194 echo "export MAC_BOX=$GUSER@$IP"
test/throughput.sh
Old New
@@ -35,6 +35,13 @@ set -eu
35 MUX="$1" 35 MUX="$1"
36 PTYCLIENT="$2" 36 PTYCLIENT="$2"
37 37
38 # The oracle, for now_ms_snippet alone. Every leg below times itself from
39 # INSIDE the session with the shell's own clock, and `date +%s%N` is GNU —
40 # so the text of that clock comes from the one place per OS that spells it,
41 # the same way soak.sh takes the two helpers it reads.
42 # shellcheck source=test/os_oracle.sh
43 . "$(dirname "$0")/os_oracle.sh"
44
38 # Lines of `yes`, and the wall-clock ceiling each leg must come in under. 45 # Lines of `yes`, and the wall-clock ceiling each leg must come in under.
39 # 46 #
40 # Every bound is overridable from the environment. All of them were 47 # Every bound is overridable from the environment. All of them were
@@ -115,7 +122,7 @@ done
115 # The `done` marker is a separate file on purpose: polling the timings file 122 # The `done` marker is a separate file on purpose: polling the timings file
116 # itself would read it after the first rep and call that the answer. 123 # itself would read it after the first rep and call that the answer.
117 "$MUX" a send --sock "$SOCK" \ 124 "$MUX" a send --sock "$SOCK" \
118 "for k in \$(seq $REPS); do A=\$(date +%s%N); yes | head -n $SOLO_LINES; B=\$(date +%s%N); echo \$(( (B-A)/1000000 )) >> $TMPD/solo; done; echo ok > $TMPD/solo.done\n" \ 125 "$(now_ms_snippet); for k in \$(seq $REPS); do A=\$(now_ms); yes | head -n $SOLO_LINES; B=\$(now_ms); echo \$(( B-A )) >> $TMPD/solo; done; echo ok > $TMPD/solo.done\n" \
119 > /dev/null 126 > /dev/null
120 i=0 127 i=0
121 while [ ! -s "$TMPD/solo.done" ] && [ "$i" -lt 60 ]; do sleep 0.5; i=$((i+1)); done 128 while [ ! -s "$TMPD/solo.done" ] && [ "$i" -lt 60 ]; do sleep 0.5; i=$((i+1)); done
@@ -147,7 +154,7 @@ echo "solo: $SOLO_LINES lines, no client: ${SOLO_MS}ms (max ${SOLO_MAX_MS}ms)
147 # an engine of its own and can regress the same way the daemon did. 154 # an engine of its own and can regress the same way the daemon did.
148 cat > "$TMPD/script" <<EOF 155 cat > "$TMPD/script" <<EOF
149 settle 500 8000 156 settle 500 8000
150 send for k in \$(seq $REPS); do A=\$(date +%s%N); yes | head -n $CLIENT_LINES; B=\$(date +%s%N); echo \$(( (B-A)/1000000 )) >> $TMPD/client; done; echo DON""E\r 157 send $(now_ms_snippet); for k in \$(seq $REPS); do A=\$(now_ms); yes | head -n $CLIENT_LINES; B=\$(now_ms); echo \$(( B-A )) >> $TMPD/client; done; echo DON""E\r
151 expect DONE 90000 158 expect DONE 90000
152 send \x1c\x1c 159 send \x1c\x1c
153 waitexit 5000 160 waitexit 5000
@@ -232,7 +239,7 @@ REPAINT_BYTES=$(wc -c < "$TMPD/big")
232 239
233 rm -f "$TMPD/repaint" "$TMPD/repaint.done" 240 rm -f "$TMPD/repaint" "$TMPD/repaint.done"
234 "$MUX" a send --sock "$SOCK" \ 241 "$MUX" a send --sock "$SOCK" \
235 "for k in \$(seq $REPS); do A=\$(date +%s%N); cat $TMPD/big; B=\$(date +%s%N); echo \$(( (B-A)/1000000 )) >> $TMPD/repaint; done; echo ok > $TMPD/repaint.done\n" \ 242 "$(now_ms_snippet); for k in \$(seq $REPS); do A=\$(now_ms); cat $TMPD/big; B=\$(now_ms); echo \$(( B-A )) >> $TMPD/repaint; done; echo ok > $TMPD/repaint.done\n" \
236 > /dev/null 243 > /dev/null
237 i=0 244 i=0
238 while [ ! -s "$TMPD/repaint.done" ] && [ "$i" -lt 120 ]; do sleep 0.5; i=$((i+1)); done 245 while [ ! -s "$TMPD/repaint.done" ] && [ "$i" -lt 120 ]; do sleep 0.5; i=$((i+1)); done
test/xos.sh
Old New
@@ -0,0 +1,869 @@
1 #!/bin/sh
2 # test/xos.sh — the `make xos` gate: the wire, across the OS seam.
3 #
4 # `make vm` grades mux on Linux and `make mac` grades it on macOS, and both
5 # run one operating system on BOTH ends of every connection. This gate is
6 # the one that does not: a macOS client attaches to a Linux daemon and a
7 # Linux client attaches to a macOS daemon, over the ssh handoff and over
8 # QUIC, and a cross-architecture `mux d upgrade` is refused in both
9 # directions before a byte of the image moves.
10 #
11 # Four machines, taken by name (test/box_lib.sh), and each one has a job:
12 #
13 # THIS BOX Linux x86_64, the orchestrator and the Linux CLIENT. It
14 # drives the Linux→Mac legs with this tree's own `mux` and
15 # `ptyclient`, and it builds the static-musl image the Linux
16 # daemon box installs.
17 # $MAC_BUILDER a Mac with the toolchain and the shadow SDK. It BUILDS the
18 # arm64 image and does nothing else: no mux is installed
19 # there and nothing is scrubbed there.
20 # $MAC_BOX an Apple-silicon macOS box with no toolchain on it, which
21 # is where that image is RUN. Normally a bridged tart guest
22 # from test/provision-mac.sh.
23 # $LINUX_BOX an x86_64 Linux VM both this box and $MAC_BOX can reach.
24 # The e2e VM is on this box's private network and a Mac
25 # cannot see it, which is the whole reason a second Linux
26 # box exists.
27 #
28 # MAC_BOX and LINUX_BOX are VMs the gate MAY scrub, and box_scrub is the
29 # whole of what that means; it runs at the start of the run and again on the
30 # way out. The boxes themselves are a human action, never harness code
31 # (test/vm.sh's rule): the Linux VM is made once with eitri, and the Mac
32 # guest by `eval "$(test/provision-mac.sh)"`. What the harness owns is the
33 # scrub, the pairing, the install and the key.
34 #
35 # Every claim about a box is asserted by a SEPARATE ssh reading that box
36 # through test/os_oracle.sh — never by trusting the connection under test,
37 # which is the connection this gate exists to doubt ("ask the OS about the
38 # OS", CLAUDE.md).
39 set -eu
40
41 BOX_GATE=xos
42
43 MUSL_MUX="$1" # x86_64-linux-musl ReleaseSafe: the image installed on $LINUX_BOX
44 MUX="$2" # this tree's own build: the Linux client for the Linux→Mac legs
45 PTYCLIENT="$3" # the real-pty fixture, driving a client the way hands do
46
47 MAC_BOX=${MAC_BOX:-}
48 LINUX_BOX=${LINUX_BOX:-}
49 # The one Mac that is both is the common case, so the builder follows the
50 # box under test unless it is named.
51 MAC_BUILDER=${MAC_BUILDER:-$MAC_BOX}
52 # The host halves are what the QUIC legs dial: QUIC has no ssh config to
53 # read, so a MAC_BOX or LINUX_BOX spelled with a name rather than an address
54 # must be a name this box resolves.
55 MAC_IP=${MAC_BOX#*@}
56 LAN_IP=${LINUX_BOX#*@}
57 # Relative to the BUILDER's $HOME, because that is how ssh's scp-like remote
58 # spelling (`HOST:code/rad/mux`) resolves a path.
59 REPO="${MAC_BUILDER_REPO:-code/rad/mux}"
60 BRANCH=mac-journey
61 QUIC_PORT=4433
62 # The port as /proc/net/udp writes a wildcard bind, which is the spelling
63 # os_oracle.sh's udp_local_bound takes on BOTH arms — the Darwin arm converts
64 # it, so a leg never learns which OS it is asking. Derived rather than written
65 # out, because a hand-typed hex goes stale the moment somebody edits the port
66 # above it; leg 7 spells the same derivation for the port a Mac announces.
67 QUIC_HEX=$(printf '00000000:%04X' "$QUIC_PORT")
68 WANT_MAC_ARCH=aarch64
69 WANT_LAN_ARCH=x86_64
70
71 # now_ms for the leg clock, and sha256_of for the installed-image hashes.
72 # The pid_*, file_* and udp_* names are sourced by the REMOTE legs from the
73 # copy beside the binary on each box, which is what keeps a leg from having to
74 # know which OS is answering it.
75 . "$(dirname "$0")/os_oracle.sh"
76 . "$(dirname "$0")/box_lib.sh"
77
78 # A provisioned Mac guest has a new host key every clone, so the harness's
79 # own ssh to it keeps none. mux's ssh is the exception handled below.
80 BOX_EPHEMERAL=$MAC_BOX
81
82 OK_COUNT=0
83 LEG_T0=0
84
85 leg() { LEG_T0=$(now_ms); }
86
87 ok() {
88 OK_COUNT=$((OK_COUNT + 1))
89 echo "$1 ok [$(( $(now_ms) - LEG_T0 ))ms]${2:+ ($2)}"
90 }
91
92 fail() {
93 echo "xos FAIL: $1: $2"
94 exit 1
95 }
96
97 # What every mux the HARNESS runs on a remote box runs under.
98 #
99 # XDG_STATE_HOME is isolated because every mux that attaches records its
100 # daemon in the hosts file under it (CLAUDE.md), and this gate must not
101 # write a throwaway host onto somebody's real wall. XDG_CACHE_HOME is
102 # isolated for the handoff's per-host endpoint cache and XDG_CONFIG_HOME so
103 # that no default ~/.config/mux/key can be picked up by accident — the key
104 # this run uses is named explicitly on every command line.
105 #
106 # XDG_RUNTIME_DIR is left exactly as each box's ssh login leaves it: set on
107 # Ubuntu (/run/user/1000) and UNSET on macOS, where the daemon falls back to
108 # /tmp/mux-<uid>. That fallback is what the Linux→Mac legs exercise, and the
109 # entry dial's own `mux d endpoint --start` will look nowhere else.
110 #
111 # The builder gets the same prelude, where its only effect is a scratch
112 # directory the cleanup removes: the build legs cd into the repo themselves,
113 # and no mux ever runs there.
114 PRELUDE='
115 export XDG_STATE_HOME=$HOME/xos/state
116 export XDG_CACHE_HOME=$HOME/xos/cache
117 export XDG_CONFIG_HOME=$HOME/xos/config
118 mkdir -p $HOME/xos
119 cd $HOME
120 '
121
122 # ---- preflight: every box answers, before anything is written ----------
123 box_preflight MAC_BOX "$MAC_BOX" "$WANT_MAC_ARCH" \
124 'Provision one with: eval "$(test/provision-mac.sh)"'
125 box_preflight LINUX_BOX "$LINUX_BOX" "$WANT_LAN_ARCH" \
126 'An x86_64 Linux VM this box and MAC_BOX can both ssh to.'
127 box_preflight MAC_BUILDER "$MAC_BUILDER" "$WANT_MAC_ARCH" \
128 'A Mac holding this repo with zig and deps/mac-sdk. Defaults to $MAC_BOX.'
129
130 # The entry dial in leg 7 spawns a PLAIN `ssh $MAC_BOX` — mux's own, with
131 # mux's own options — and that one reads the real ~/.ssh/known_hosts. A
132 # freshly provisioned guest has a host key nobody has seen, at an address a
133 # previous guest may have held under a different one, so the stale entry is
134 # forgotten and the new key learned once, here, where a failure is a
135 # preflight and not a leg timing out with nothing to say.
136 ssh-keygen -R "$MAC_IP" >/dev/null 2>&1 || true
137 timeout 20 ssh -n -o BatchMode=yes -o ConnectTimeout=10 \
138 -o StrictHostKeyChecking=accept-new "$MAC_BOX" true ||
139 fail preflight "cannot record $MAC_BOX's host key for the plain ssh mux spawns"
140
141 # Both directions of this gate dial the other box from INSIDE a box: the
142 # macOS client's entry dial spawns ssh at the Linux daemon, and leg 9's
143 # `mux d upgrade` spawns ssh back the other way.
144 box_pair "$MAC_BOX" "$LINUX_BOX"
145 box_pair "$LINUX_BOX" "$MAC_BOX"
146
147 # ---- the local side is a hermetic laptop ------------------------------
148 # Every XDG dir mux reads, isolated. Short root because a unix socket path
149 # caps at 107 bytes.
150 SCRATCH=$(mktemp -d /tmp/muxxos.XXXXXX)
151 export XDG_STATE_HOME="$SCRATCH/st" XDG_RUNTIME_DIR="$SCRATCH/rt"
152 export XDG_CONFIG_HOME="$SCRATCH/cf" XDG_CACHE_HOME="$SCRATCH/ca"
153 mkdir -p "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
154
155 # Both boxes back to the mux-less state a new user has, on every exit. The
156 # builder is not scrubbed — it is not this gate's machine to reset — so its
157 # scratch directory is all there is to take back there.
158 cleanup() {
159 _rc=$?
160 box_scrub "$LINUX_BOX" >/dev/null 2>&1 || true
161 box_scrub "$MAC_BOX" >/dev/null 2>&1 || true
162 box_ssh 60 "$MAC_BUILDER" >/dev/null 2>&1 <<'B' || true
163 rm -rf "$HOME/xos"
164 B
165 rm -rf "$SCRATCH"
166 exit "$_rc"
167 }
168 trap cleanup EXIT
169
170 # ---- leg 1: push — the builder builds THIS commit -----------------------
171 leg
172 HEAD_SHA=$(git rev-parse HEAD)
173 PUSHED=no
174 HAVE=$(box_ssh 60 "$MAC_BUILDER" <<H || true
175 cd \$HOME/$REPO 2>/dev/null || exit 0
176 git rev-parse -q --verify refs/heads/$BRANCH 2>/dev/null || true
177 H
178 )
179 if [ "$HAVE" != "$HEAD_SHA" ]; then
180 # A push cannot update the branch the remote has checked out, and the
181 # run before this one left it checked out. Detaching first is what makes
182 # a re-run work; it changes no commit and no file.
183 box_ssh 60 "$MAC_BUILDER" >/dev/null <<H || fail push "cannot detach $MAC_BUILDER:$REPO before the push"
184 set -e
185 cd \$HOME/$REPO
186 if [ "\$(git rev-parse --abbrev-ref HEAD)" = "$BRANCH" ]; then
187 git checkout -q --detach
188 fi
189 H
190 # --force because this branch is a scratch mirror of whatever this tree
191 # is and never shared history: a local amend or rebase leaves the
192 # builder's ref on a commit the new HEAD does not descend from, and a
193 # plain push would be rejected as a non-fast-forward for the rest of the
194 # branch's life.
195 git push --force "$MAC_BUILDER:$REPO" "HEAD:refs/heads/$BRANCH" >/dev/null 2>&1 ||
196 fail push "git push $MAC_BUILDER:$REPO HEAD:refs/heads/$BRANCH was refused"
197 PUSHED=yes
198 fi
199 box_ssh 60 "$MAC_BUILDER" >/dev/null <<H || fail push "the builder could not check out $BRANCH"
200 set -e
201 cd \$HOME/$REPO
202 git checkout -q $BRANCH
203 git reset -q --hard $BRANCH
204 H
205 BUILDER_SHA_GIT=$(box_ssh 60 "$MAC_BUILDER" <<H
206 cd \$HOME/$REPO && git rev-parse HEAD
207 H
208 )
209 [ "$BUILDER_SHA_GIT" = "$HEAD_SHA" ] ||
210 fail push "the builder is at $BUILDER_SHA_GIT, this tree is at $HEAD_SHA"
211 ok push "$MAC_BUILDER:$REPO on $BRANCH at $(git rev-parse --short HEAD), pushed=$PUSHED"
212
213 # ---- leg 2: build — one image per architecture -------------------------
214 # The builder builds its own arm64 binary and leg 3 puts it on the box that
215 # has no toolchain. The shim PATH is required for the link and Homebrew is
216 # not on a non-interactive PATH, so both are spelled here.
217 leg
218 # XDG_CACHE_HOME back to the builder's own for this one: zig's GLOBAL package
219 # cache lives under it, and building into a fresh one re-fetches ghostty and
220 # the rest of the package set on every run — minutes, for isolation this leg
221 # does not need. What the prelude isolates is mux's own cache directory, and
222 # no mux runs here.
223 BOUT=$(box_ssh 2400 "$MAC_BUILDER" <<H
224 cd \$HOME/$REPO || { echo "RC_BUILD=90"; exit 0; }
225 unset XDG_CACHE_HOME
226 export PATH=\$HOME/$REPO/deps/mac-sdk/bin:/opt/homebrew/bin:\$PATH
227 make build >/tmp/xos-build.log 2>&1; echo "RC_BUILD=\$?"
228 H
229 )
230 BRC=$(printf '%s\n' "$BOUT" | sed -n 's/^RC_BUILD=//p')
231 [ "$BRC" = "0" ] || {
232 # The verdict first and the reading under it, test/vm.sh's shape: a
233 # reader scrolling back finds the sentence that says what broke, not the
234 # tail of somebody else's build log.
235 echo "xos FAIL: build: make build on $MAC_BUILDER exited ${BRC:-<no answer>}"
236 box_ssh 60 "$MAC_BUILDER" <<'H' | sed 's/^/ /' || true
237 tail -30 /tmp/xos-build.log 2>/dev/null || true
238 H
239 exit 1; }
240 BINFO=$(box_ssh 120 "$MAC_BUILDER" <<H
241 set -e
242 cd \$HOME/$REPO
243 . ./test/os_oracle.sh
244 echo "ver=\$(./zig-out/bin/mux --version)"
245 echo "sha=\$(sha256_of ./zig-out/bin/mux)"
246 H
247 )
248 BUILT_VER=$(printf '%s\n' "$BINFO" | sed -n 's/^ver=//p')
249 MAC_BIN_SHA=$(printf '%s\n' "$BINFO" | sed -n 's/^sha=//p')
250 WANT_VER=$("$MUX" --version)
251 [ -n "$BUILT_VER" ] && [ "$BUILT_VER" = "$WANT_VER" ] ||
252 fail build "$MAC_BUILDER built '$BUILT_VER', this tree built '$WANT_VER'"
253 [ -n "$MAC_BIN_SHA" ] || fail build "$MAC_BUILDER would not hash the binary it built"
254 ok build "$MAC_BUILDER built an $WANT_MAC_ARCH '$BUILT_VER'"
255
256 # ---- leg 3: reset — two mux-less boxes, each given one image -----------
257 # The scrub is the hermeticity: each run starts from the mux-less box a new
258 # user has, and box_scrub is the one place that says what that means. It
259 # writes no shell rc file on either box — the product carries its own PATH
260 # prefix on every remote word it sends — so what the legs below meet is a
261 # box this gate has configured in no way at all.
262 leg
263 box_scrub "$LINUX_BOX"
264 box_scrub "$MAC_BOX"
265 box_ssh 60 "$LINUX_BOX" >/dev/null <<'L' || fail reset "could not make the scratch directories on $LINUX_BOX"
266 set -e
267 mkdir -p "$HOME/.local/bin" "$HOME/xos"
268 L
269 box_ssh 60 "$MAC_BOX" >/dev/null <<'M' || fail reset "could not make the scratch directories on $MAC_BOX"
270 set -e
271 mkdir -p "$HOME/.local/bin" "$HOME/xos"
272 M
273 # The oracle goes beside the binary on each box rather than being read out of
274 # a repo, so both boxes answer through the same path and a leg never has to
275 # know which one it is talking to.
276 box_scp "$LINUX_BOX" "$MUSL_MUX" .local/bin/mux ||
277 fail reset "could not copy the musl build to $LINUX_BOX"
278 box_scp "$LINUX_BOX" "$(dirname "$0")/os_oracle.sh" xos/os_oracle.sh ||
279 fail reset "could not copy the oracle to $LINUX_BOX"
280 box_scp "$MAC_BOX" "$(dirname "$0")/os_oracle.sh" xos/os_oracle.sh ||
281 fail reset "could not copy the oracle to $MAC_BOX"
282 # Streamed through this box rather than copied between the two Macs: neither
283 # needs a key of the other's for that, and only this box can reach both.
284 box_stream "$MAC_BUILDER" "\$HOME/$REPO/zig-out/bin/mux" "$MAC_BOX" "\$HOME/.local/bin/mux" ||
285 fail reset "could not stream the arm64 mux from $MAC_BUILDER to $MAC_BOX"
286 box_stream "$MAC_BUILDER" "\$HOME/$REPO/zig-out/bin/ptyclient" "$MAC_BOX" "\$HOME/xos/ptyclient" ||
287 fail reset "could not stream ptyclient from $MAC_BUILDER to $MAC_BOX"
288
289 LAN_BIN_SHA=$(sha256_of "$MUSL_MUX")
290 LINFO=$(box_ssh 60 "$LINUX_BOX" <<'L'
291 . ~/xos/os_oracle.sh
292 echo "ver=$(~/.local/bin/mux --version)"
293 echo "sha=$(sha256_of ~/.local/bin/mux)"
294 echo "exe=$(real_path ~/.local/bin/mux)"
295 echo "rtd=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
296 L
297 )
298 LAN_VER=$(printf '%s\n' "$LINFO" | sed -n 's/^ver=//p')
299 LAN_GOT_SHA=$(printf '%s\n' "$LINFO" | sed -n 's/^sha=//p')
300 LAN_EXE=$(printf '%s\n' "$LINFO" | sed -n 's/^exe=//p')
301 LAN_RTD=$(printf '%s\n' "$LINFO" | sed -n 's/^rtd=//p')
302 [ -n "$LAN_EXE" ] || fail reset "$LINUX_BOX would not resolve the path of its installed mux"
303 [ "$LAN_VER" = "$WANT_VER" ] ||
304 fail reset "$LINUX_BOX answers '$LAN_VER', this tree built '$WANT_VER'"
305 [ "$LAN_GOT_SHA" = "$LAN_BIN_SHA" ] ||
306 fail reset "$LINUX_BOX holds a mux hashing $LAN_GOT_SHA, the pushed image is $LAN_BIN_SHA"
307 LAN_SOCK="$LAN_RTD/muxd.sock"
308
309 MINFO=$(box_ssh 60 "$MAC_BOX" <<'M'
310 . $HOME/xos/os_oracle.sh
311 echo "uid=$(id -u)"
312 echo "ver=$($HOME/.local/bin/mux --version)"
313 echo "sha=$(sha256_of $HOME/.local/bin/mux)"
314 echo "exe=$(real_path $HOME/.local/bin/mux)"
315 M
316 )
317 MAC_UID=$(printf '%s\n' "$MINFO" | sed -n 's/^uid=//p')
318 MAC_VER=$(printf '%s\n' "$MINFO" | sed -n 's/^ver=//p')
319 MAC_GOT_SHA=$(printf '%s\n' "$MINFO" | sed -n 's/^sha=//p')
320 MAC_EXE=$(printf '%s\n' "$MINFO" | sed -n 's/^exe=//p')
321 [ -n "$MAC_UID" ] || fail reset "$MAC_BOX would not say its uid"
322 [ -n "$MAC_EXE" ] || fail reset "$MAC_BOX would not resolve the path of its installed mux"
323 [ "$MAC_VER" = "$WANT_VER" ] ||
324 fail reset "$MAC_BOX answers '$MAC_VER', this tree built '$WANT_VER'"
325 # The bytes that RUN are the bytes the builder produced: `cat` down one pipe
326 # and up another is a stream nothing else checks.
327 [ "$MAC_GOT_SHA" = "$MAC_BIN_SHA" ] ||
328 fail reset "$MAC_BOX holds a mux hashing $MAC_GOT_SHA, the builder's is $MAC_BIN_SHA"
329 MAC_SOCK="/tmp/mux-$MAC_UID/muxd.sock"
330
331 # What an ssh handoff will find, asked in the handoff's own words. sshd runs
332 # a NON-LOGIN shell, so nothing on either box puts ~/.local/bin on that PATH
333 # and the product does not expect it to: every remote word mux sends is
334 # prefixed with `PATH="$PATH:$HOME/.local/bin"` — `handoff.local_bin_append`,
335 # carried by the entry dial, the upgrade preflight and the upgrade's push.
336 # The prefix below is that string character for character, so what this
337 # grades is the installed binary being findable the way mux finds it, and
338 # not a shell rc file the harness wrote. A bare `command -v mux` here would
339 # be asking a question the handoff never asks.
340 for _box in "$LINUX_BOX" "$MAC_BOX"; do
341 _opts=$(box_opts_for "$_box")
342 # shellcheck disable=SC2086,SC2016 # the options are words; $PATH is the REMOTE shell's
343 _which=$(timeout 20 ssh -n $_opts "$_box" 'PATH="$PATH:$HOME/.local/bin"; command -v mux' 2>/dev/null) || _which=""
344 case "$_which" in
345 */.local/bin/mux) ;;
346 *) fail reset "with the handoff's own PATH prefix, $_box finds mux at '${_which:-nothing}'; every remote dial would fail" ;;
347 esac
348 done
349 ok reset "$LINUX_BOX and $MAC_BOX scrubbed and each given '$WANT_VER' on its PATH"
350
351 # ---- leg 4: keys — one PSK for the run, never the user's ---------------
352 # `mux d keygen` always writes the default path, so the isolated
353 # XDG_CONFIG_HOME above is what puts it in the scratch tree. It is copied to
354 # ~/xos/key on both boxes and named on every QUIC command line.
355 #
356 # On the Linux box the same bytes ALSO go to that box's default key path,
357 # and leg 5's daemon is started with no `--key` at all, which is how a
358 # person starts one after `mux d keygen`. That is not decoration. `mux d
359 # endpoint` resolves the key it ANNOUNCES from $MUX_KEY_FILE or the default
360 # path — it never asks the daemon which key its listener actually holds — so
361 # a daemon started with `--quic ... --key SOMEWHERE_ELSE` announces a key
362 # that does not authenticate against itself, creating the default file on
363 # the spot if it has to. Every ssh handoff to such a daemon then dies in
364 # silence and falls back to the pipe. Measured on this fixture 2026-09-04
365 # and reported; it is not a macOS difference, and the two spellings agree
366 # again the moment the listener's key IS the default one.
367 leg
368 "$MUX" d keygen >/dev/null 2>&1 || fail keys "mux d keygen refused under $XDG_CONFIG_HOME"
369 cp "$XDG_CONFIG_HOME/mux/key" "$SCRATCH/key"
370 chmod 600 "$SCRATCH/key"
371 [ "$(file_size "$SCRATCH/key")" = "32" ] ||
372 fail keys "the generated key is $(file_size "$SCRATCH/key") bytes, want 32"
373 box_scp "$LINUX_BOX" "$SCRATCH/key" xos/key || fail keys "could not install the key on $LINUX_BOX"
374 box_scp "$MAC_BOX" "$SCRATCH/key" xos/key || fail keys "could not install the key on $MAC_BOX"
375 # BOTH default key paths on the Linux box, because two different
376 # environments resolve "the default key" there and they must not disagree:
377 # everything the harness runs sees the prelude's isolated XDG_CONFIG_HOME,
378 # while the `mux d endpoint` at the far end of MAC_BOX's entry dial is a
379 # fresh ssh with the box's own environment and resolves ~/.config/mux/key.
380 # Same bytes at both, so whichever one a process picks is this run's key.
381 box_ssh 30 "$LINUX_BOX" >/dev/null <<'L' || fail keys "could not install the key at the default paths on $LINUX_BOX"
382 set -e
383 for d in "$HOME/.config/mux" "${XDG_CONFIG_HOME:-$HOME/.config}/mux"; do
384 mkdir -p "$d"
385 chmod 700 "$d"
386 cp "$HOME/xos/key" "$d/key"
387 chmod 600 "$d/key"
388 done
389 L
390 # 0600 on both, because quic.Key.load refuses a group- or other-readable key
391 # exactly as ssh refuses a private key, and scp does not carry the mode.
392 KMODES=$(
393 box_ssh 30 "$LINUX_BOX" <<'L'
394 . ~/xos/os_oracle.sh
395 chmod 600 ~/xos/key
396 echo "lan=$(file_mode ~/xos/key)"
397 L
398 box_ssh 30 "$MAC_BOX" <<'M'
399 . $HOME/xos/os_oracle.sh
400 chmod 600 $HOME/xos/key
401 echo "mac=$(file_mode $HOME/xos/key)"
402 M
403 )
404 printf '%s\n' "$KMODES" | grep -qx 'lan=600' ||
405 fail keys "the key on $LINUX_BOX is not mode 600: $KMODES"
406 printf '%s\n' "$KMODES" | grep -qx 'mac=600' ||
407 fail keys "the key on $MAC_BOX is not mode 600: $KMODES"
408 ok keys "one 32-byte PSK at ~/xos/key, mode 600 on both boxes"
409
410 # ---- leg 5: mac-to-linux — a Mac client on a Linux daemon --------------
411 # The daemon carries a QUIC arm, so the Mac's entry dial is the whole
412 # product path: plain ssh to fetch the announce, then the session moves onto
413 # QUIC. MUX_SHELL_INTEGRATION=1 because leg 6 reads a real exit code off
414 # this same daemon and marks are opt-in.
415 leg
416 box_ssh 90 "$LINUX_BOX" >/dev/null <<L || fail mac-to-linux "mux d start -d --quic was refused on $LINUX_BOX"
417 MUX_SHELL_INTEGRATION=1 ~/.local/bin/mux d start -d --quic 0.0.0.0:$QUIC_PORT
418 L
419 # The listener's key and the key `mux d endpoint` announces, side by side.
420 # They are the same file here by construction (leg 4), and this reads them
421 # back rather than trusting that: an announce that names a key the listener
422 # does not hold is invisible from the client — the handshake simply never
423 # answers — and leg 5 would then be grading the ssh fallback while reporting
424 # QUIC.
425 #
426 # XDG_CONFIG_HOME is UNSET for this one read. The announce that matters is the
427 # one at the far end of the MAC's entry dial, which is a fresh ssh carrying
428 # this box's own environment and so resolves ~/.config/mux/key; asking under
429 # the prelude's isolated config dir would check a sibling file that happens to
430 # hold the same bytes and guard nothing.
431 ANN=$(box_ssh 30 "$LINUX_BOX" <<'L'
432 unset XDG_CONFIG_HOME
433 ~/.local/bin/mux d endpoint </dev/null
434 L
435 )
436 case "$ANN" in
437 "endpoint $QUIC_PORT $(od -An -tx1 -v < "$SCRATCH/key" | tr -d ' \n')") ;;
438 *) fail mac-to-linux "$LINUX_BOX announces '$ANN', which is not this run's key at port $QUIC_PORT" ;;
439 esac
440 LPID=$(box_ssh 30 "$LINUX_BOX" <<'L'
441 pgrep -x mux | head -1
442 L
443 )
444 [ -n "$LPID" ] || fail mac-to-linux "no mux process on $LINUX_BOX after mux d start -d"
445 # The image this daemon is running, recorded before anything is pushed at it.
446 # Leg 9 reads it again after the refused push: on Linux an upgrade keeps the
447 # pid across the exec, so the pid alone cannot tell an untouched daemon from
448 # one that swallowed a new image, and the exe is what can.
449 LIS=$(box_ssh 30 "$LINUX_BOX" <<L
450 . ~/xos/os_oracle.sh
451 pid_exe $LPID
452 L
453 )
454 [ "$LIS" = "$LAN_EXE" ] ||
455 fail mac-to-linux "daemon $LPID on $LINUX_BOX execs '$LIS', not the installed '$LAN_EXE'"
456 # Bound BEFORE the dial, asked of /proc and not of the daemon: a leg that
457 # read "no answer" from the Mac could otherwise be blaming the network for a
458 # listener that was never up.
459 box_ssh 60 "$LINUX_BOX" >/dev/null <<L || fail mac-to-linux "daemon $LPID binds no UDP $QUIC_PORT on $LINUX_BOX"
460 . ~/xos/os_oracle.sh
461 _i=0
462 while [ \$_i -lt 50 ]; do
463 udp_local_bound $QUIC_HEX && exit 0
464 sleep 0.2; _i=\$((_i + 1))
465 done
466 exit 1
467 L
468
469 # The marker carries the SESSION shell's own `uname -m` and its own \$\$, so
470 # `XOS-M2L-x86_64-` painted on a Mac's screen is the cross-OS fact itself:
471 # the bytes were produced by a shell only the Linux box's process table can
472 # account for.
473 M2L=$(box_ssh 240 "$MAC_BOX" <<M
474 cd \$HOME/xos
475 \$HOME/xos/ptyclient --cols 80 --rows 24 \
476 --out \$HOME/xos/m2l.cap --err \$HOME/xos/m2l.err -- \
477 \$HOME/.local/bin/mux $LINUX_BOX <<'PC'
478 expect \\x1b[?1049h 30000
479 settle 900 20000
480 send echo XOS-M2L-\$(uname -m)-\$\$\n
481 expect XOS-M2L-x86_64- 25000
482 settle 800 15000
483 send \\x1cd
484 waitexit 15000
485 PC
486 echo "PTYRC=\$?"
487 M
488 ) || fail mac-to-linux "$MAC_BOX would not run ptyclient"
489 printf '%s\n' "$M2L" | grep -qx 'PTYRC=0' || {
490 echo "xos FAIL: mac-to-linux: the Mac's client never painted XOS-M2L-x86_64-: $M2L"
491 box_ssh 60 "$MAC_BOX" <<'M' | sed 's/^/ /' || true
492 cat $HOME/xos/m2l.err 2>/dev/null
493 M
494 exit 1; }
495 M2L_READ=$(box_ssh 60 "$MAC_BOX" <<'M'
496 echo "pid=$(sed -n 's/.*XOS-M2L-x86_64-\([0-9][0-9]*\).*/\1/p' $HOME/xos/m2l.cap | head -1)"
497 grep -c 'attaching over ssh' $HOME/xos/m2l.err 2>/dev/null || true
498 M
499 )
500 M2L_PID=$(printf '%s\n' "$M2L_READ" | sed -n 's/^pid=//p')
501 [ -n "$M2L_PID" ] || fail mac-to-linux "the Mac's capture carries no shell pid: $M2L_READ"
502 # The handoff prints this one line and only when QUIC was tried and lost, so
503 # its ABSENCE is the pin that the session rode QUIC. The daemon's bound port
504 # above says the arm existed to move onto.
505 printf '%s\n' "$M2L_READ" | grep -qx '0' ||
506 fail mac-to-linux "the Mac's dial fell back to the ssh pipe; it said: $M2L_READ"
507 box_ssh 60 "$LINUX_BOX" >/dev/null <<L || fail mac-to-linux "shell $M2L_PID is not a child of daemon $LPID on $LINUX_BOX"
508 . ~/xos/os_oracle.sh
509 pid_children $LPID | grep -qx $M2L_PID
510 L
511 # The detach was a goodbye and not a tear: what the Mac typed is still on
512 # the Linux box's grid.
513 box_ssh 60 "$LINUX_BOX" >/dev/null <<'L' || fail mac-to-linux "the session did not survive the Mac's detach"
514 ~/.local/bin/mux d dump | grep -q XOS-M2L-x86_64-
515 L
516 ok mac-to-linux "a macOS client typed into shell $M2L_PID under daemon $LPID on $LINUX_BOX, over QUIC"
517
518 # ---- leg 6: mac-to-linux-quic — no ssh anywhere in the path ------------
519 # The Darwin wolfSSL build's PSK handshake against the musl build's, in one
520 # round trip, joining the session leg 5 made. `mux a` never creates.
521 #
522 # The exit code is read on the box and stderr kept there, because a `mux a`
523 # that dies by signal writes its JSON to neither stream and the shell that
524 # ran it is the only witness. One run on 2026-09-04 ended this leg with an
525 # empty stdout, nothing on stderr and a message that could not say what had
526 # exited how; every verb failure in muxa.zig prints `{"error":...}` before
527 # it exits, so an empty capture is a death and the code says which signal.
528 leg
529 AQ=$(box_ssh 120 "$MAC_BOX" <<M
530 MUX_KEY_FILE=\$HOME/xos/key \$HOME/.local/bin/mux a run --quic $LAN_IP:$QUIC_PORT \
531 --key \$HOME/xos/key --timeout 20000 'echo m2lq-\$\$' 2>\$HOME/xos/aq.err
532 echo "AQRC=\$?"
533 M
534 ) || fail mac-to-linux-quic "$MAC_BOX would not run mux a: $AQ"
535 AQ_RC=$(printf '%s\n' "$AQ" | sed -n 's/^AQRC=//p')
536 [ "$AQ_RC" = 0 ] || {
537 echo "xos FAIL: mac-to-linux-quic: mux a run --quic exited ${AQ_RC:-with no code} on $MAC_BOX (past 128 is a signal: 141 SIGPIPE, 139 SIGSEGV) and said: $AQ"
538 box_ssh 60 "$MAC_BOX" <<'M' | sed 's/^/ /' || true
539 cat $HOME/xos/aq.err 2>/dev/null
540 M
541 exit 1; }
542 case "$AQ" in
543 *'"mechanism":"marks"'*) ;;
544 *) fail mac-to-linux-quic "no marks mechanism in the reply: $AQ" ;;
545 esac
546 case "$AQ" in
547 *'"exit_code":0'*) ;;
548 *) fail mac-to-linux-quic "no clean exit code in the reply: $AQ" ;;
549 esac
550 AQ_PID=$(printf '%s\n' "$AQ" | sed -n 's/.*m2lq-\([0-9][0-9]*\).*/\1/p')
551 [ -n "$AQ_PID" ] || fail mac-to-linux-quic "the reply carries no shell pid: $AQ"
552 box_ssh 60 "$LINUX_BOX" >/dev/null <<L || fail mac-to-linux-quic "shell $AQ_PID is not a child of daemon $LPID"
553 . ~/xos/os_oracle.sh
554 pid_children $LPID | grep -qx $AQ_PID
555 L
556 ok mac-to-linux-quic "mux a over QUIC ran shell $AQ_PID under $LPID, marks, exit 0"
557
558 # ---- leg 7: linux-to-mac — the entry dial starts the Mac's daemon ------
559 # `mux $MAC_BOX` from here is the ONLY thing in this gate allowed to start a
560 # daemon on the Mac: `mux d endpoint --start` is the asked dial's alone. The
561 # Mac's ssh login has no XDG_RUNTIME_DIR, so the daemon it starts binds the
562 # /tmp/mux-<uid> fallback — the Darwin path, exercised by a real `mux HOST`
563 # rather than by a test that sets a variable.
564 leg
565 set +e
566 timeout 120 "$PTYCLIENT" --cols 80 --rows 24 \
567 --out "$SCRATCH/l2m.cap" --err "$SCRATCH/l2m.err" -- "$MUX" "$MAC_BOX" \
568 > "$SCRATCH/l2m.log" 2>&1 <<'EOF'
569 expect \x1b[?1049h 30000
570 settle 900 20000
571 send echo XOS-L2M-$(uname -m)-$$\n
572 expect XOS-L2M-arm64- 25000
573 settle 800 15000
574 send \x1cd
575 waitexit 15000
576 EOF
577 RC=$?
578 set -e
579 [ "$RC" -eq 0 ] || {
580 echo "xos FAIL: linux-to-mac: ptyclient exited $RC (did the dial or the remote start refuse?):"
581 sed 's/^/ /' "$SCRATCH/l2m.log" 2>/dev/null || true
582 sed 's/^/ /' "$SCRATCH/l2m.err" 2>/dev/null || true
583 exit 1; }
584 L2M_PID=$(sed -n 's/.*XOS-L2M-arm64-\([0-9][0-9]*\).*/\1/p' "$SCRATCH/l2m.cap" | head -1)
585 [ -n "$L2M_PID" ] || fail linux-to-mac "the capture carries no shell pid"
586
587 MSTATE=$(box_ssh 90 "$MAC_BOX" <<M
588 . \$HOME/xos/os_oracle.sh
589 echo "pid=\$(pgrep -x mux | head -1)"
590 echo "n=\$(pgrep -x mux | wc -l | tr -d ' ')"
591 echo "announce=\$(\$HOME/.local/bin/mux d endpoint </dev/null 2>&1 | head -1)"
592 udp_local_bound $QUIC_HEX && echo "udp=bound" || echo "udp=none"
593 M
594 )
595 MPID=$(printf '%s\n' "$MSTATE" | sed -n 's/^pid=//p')
596 MNPROC=$(printf '%s\n' "$MSTATE" | sed -n 's/^n=//p')
597 MANN=$(printf '%s\n' "$MSTATE" | sed -n 's/^announce=//p')
598 [ -n "$MPID" ] || fail linux-to-mac "no mux on $MAC_BOX after the entry dial: $MSTATE"
599 [ "$MNPROC" = "1" ] ||
600 fail linux-to-mac "$MAC_BOX runs $MNPROC mux processes after the detach, want 1 (the daemon): $MSTATE"
601 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac "daemon $MPID does not hold $MAC_SOCK"
602 . \$HOME/xos/os_oracle.sh
603 pid_holds_unix_sock $MPID $MAC_SOCK
604 M
605 # Both sides canonical, and both through the oracle: pid_exe answers what
606 # lsof reports, which resolves every symlink on the way, so a $HOME reached
607 # through one would fail a correct exec unless the expected path is resolved
608 # the same way.
609 MIS=$(box_ssh 60 "$MAC_BOX" <<M
610 . \$HOME/xos/os_oracle.sh
611 pid_exe $MPID
612 M
613 )
614 [ -n "$MAC_EXE" ] && [ "$MIS" = "$MAC_EXE" ] ||
615 fail linux-to-mac "daemon $MPID execs '$MIS', not the installed '$MAC_EXE'"
616 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac "shell $L2M_PID is not a child of daemon $MPID"
617 . \$HOME/xos/os_oracle.sh
618 pid_children $MPID | grep -qx $L2M_PID
619 M
620 # WHICH transport carried it, read off the announce rather than assumed.
621 # The entry dial starts this daemon BARE — no --quic and no --key on any
622 # command line — and what the Mac does with that is the product's own
623 # behaviour, not this gate's to arrange: `mux d endpoint` creates the
624 # default key file when the box has none and the daemon then binds a QUIC
625 # listener lazily, on an EPHEMERAL port rather than 4433, so the announce
626 # names a port nothing here could have predicted. Whichever of the two
627 # answers comes back, the leg asserts the matching OS fact and reports what
628 # carried the session; what it will not do is grade one transport while
629 # reporting the other.
630 #
631 # An `if` and not `grep ... && fail`: an AND-OR list whose first command
632 # fails is the ORDINARY case here, and reading that as the leg's verdict is
633 # the wrong way round.
634 case "$MANN" in
635 "endpoint none")
636 # No arm to move onto, so the pipe that carried the announce is the
637 # session. The daemon's own word is what says so — there is no
638 # positive line on the client side, which only speaks up when QUIC
639 # was tried and lost — and the OS fact beside it is that nothing is
640 # bound at the port this gate would otherwise expect.
641 printf '%s\n' "$MSTATE" | grep -qx 'udp=none' ||
642 fail linux-to-mac "$MAC_BOX announces no endpoint yet binds UDP $QUIC_PORT: $MSTATE"
643 if grep -q 'attaching over ssh' "$SCRATCH/l2m.err"; then
644 fail linux-to-mac "the client fell back from a QUIC arm the daemon says it has not got"
645 fi
646 L2M_WIRE="ssh handoff (the daemon announced 'endpoint none')" ;;
647 'endpoint '*)
648 MPORT=${MANN#endpoint }
649 MPORT=${MPORT%% *}
650 case "$MPORT" in
651 ''|*[!0-9]*) fail linux-to-mac "$MAC_BOX announces no readable port: '$MANN'" ;;
652 esac
653 # The announced port, as /proc/net/udp writes a wildcard bind — the
654 # spelling both arms of the oracle take. Asked of lsof on the Mac,
655 # so the claim "the daemon is listening there" is the OS's and not
656 # the daemon's own.
657 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac "$MAC_BOX announces '$MANN' but binds no UDP $MPORT"
658 . \$HOME/xos/os_oracle.sh
659 udp_local_bound $(printf '00000000:%04X' "$MPORT")
660 M
661 if grep -q 'attaching over ssh' "$SCRATCH/l2m.err"; then
662 fail linux-to-mac "$MAC_BOX offered QUIC on $MPORT and the dial fell back to ssh"
663 fi
664 L2M_WIRE="QUIC on the lazily-bound port $MPORT" ;;
665 *)
666 fail linux-to-mac "$MAC_BOX's daemon answers no announce this leg can read: '$MANN'" ;;
667 esac
668 ok linux-to-mac "shell $L2M_PID under daemon $MPID on $MAC_SOCK, over $L2M_WIRE"
669
670 # ---- leg 8: linux-to-mac-quic — inbound UDP to a Mac daemon ------------
671 # The question this leg asks and no other does: does macOS deliver inbound
672 # UDP $QUIC_PORT to a daemon a NON-GUI ssh session started? If the
673 # application firewall eats it, the dial times out in silence and this leg
674 # fails with the client's own words — which is a fact about the platform,
675 # not a harness problem to loosen away.
676 leg
677 box_ssh 60 "$MAC_BOX" >/dev/null <<'M' || fail linux-to-mac-quic "mux d stop was refused on $MAC_BOX"
678 $HOME/.local/bin/mux d stop
679 M
680 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac-quic "the bare daemon $MPID survives mux d stop by 3 s"
681 . \$HOME/xos/os_oracle.sh
682 _i=0
683 while [ \$_i -lt 30 ]; do
684 pid_alive $MPID || exit 0
685 sleep 0.1; _i=\$((_i + 1))
686 done
687 exit 1
688 M
689 box_ssh 90 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac-quic "mux d start -d --quic was refused on $MAC_BOX"
690 MUX_SHELL_INTEGRATION=1 \$HOME/.local/bin/mux d start -d --quic 0.0.0.0:$QUIC_PORT --key \$HOME/xos/key
691 M
692 MPID2=$(box_ssh 30 "$MAC_BOX" <<'M'
693 pgrep -x mux | head -1
694 M
695 )
696 [ -n "$MPID2" ] || fail linux-to-mac-quic "no mux on $MAC_BOX after the QUIC start"
697 [ "$MPID2" != "$MPID" ] ||
698 fail linux-to-mac-quic "$MAC_BOX still runs the pre-stop daemon $MPID; the restart did not happen"
699 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac-quic "daemon $MPID2 binds no UDP $QUIC_PORT on $MAC_BOX"
700 . \$HOME/xos/os_oracle.sh
701 _i=0
702 while [ \$_i -lt 50 ]; do
703 udp_local_bound $QUIC_HEX && exit 0
704 sleep 0.2; _i=\$((_i + 1))
705 done
706 exit 1
707 M
708
709 # A real pty attach first, because `mux a` joins and never creates.
710 export MUX_KEY_FILE="$SCRATCH/key"
711 set +e
712 timeout 120 "$PTYCLIENT" --cols 80 --rows 24 \
713 --out "$SCRATCH/l2mq.cap" --err "$SCRATCH/l2mq.err" -- \
714 "$MUX" "quic://$MAC_IP:$QUIC_PORT" \
715 > "$SCRATCH/l2mq.log" 2>&1 <<'EOF'
716 expect \x1b[?1049h 30000
717 settle 900 20000
718 send echo XOS-L2MQ-$(uname -m)-$$\n
719 expect XOS-L2MQ-arm64- 25000
720 settle 800 15000
721 send \x1cd
722 waitexit 15000
723 EOF
724 RC=$?
725 set -e
726 [ "$RC" -eq 0 ] || {
727 echo "xos FAIL: linux-to-mac-quic: the QUIC attach to $MAC_IP:$QUIC_PORT exited $RC."
728 echo " This is the leg that asks whether macOS delivers inbound UDP"
729 echo " $QUIC_PORT to a daemon a non-GUI ssh session started. The dial said:"
730 sed 's/^/ /' "$SCRATCH/l2mq.log" 2>/dev/null || true
731 sed 's/^/ /' "$SCRATCH/l2mq.err" 2>/dev/null || true
732 exit 1; }
733 L2MQ_PID=$(sed -n 's/.*XOS-L2MQ-arm64-\([0-9][0-9]*\).*/\1/p' "$SCRATCH/l2mq.cap" | head -1)
734 [ -n "$L2MQ_PID" ] || fail linux-to-mac-quic "the QUIC capture carries no shell pid"
735 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac-quic "shell $L2MQ_PID is not a child of daemon $MPID2"
736 . \$HOME/xos/os_oracle.sh
737 pid_children $MPID2 | grep -qx $L2MQ_PID
738 M
739 AQ2=$(set +e; timeout 60 "$MUX" a run --quic "$MAC_IP:$QUIC_PORT" --key "$SCRATCH/key" \
740 --timeout 20000 'echo l2mq-$$'; echo "RC=$?")
741 case "$AQ2" in
742 *RC=0*) ;;
743 *) fail linux-to-mac-quic "mux a run --quic to $MAC_BOX did not answer: $AQ2" ;;
744 esac
745 case "$AQ2" in
746 *'"mechanism":"marks"'*) ;;
747 *) fail linux-to-mac-quic "no marks mechanism in the reply from $MAC_BOX: $AQ2" ;;
748 esac
749 AQ2_PID=$(printf '%s\n' "$AQ2" | sed -n 's/.*l2mq-\([0-9][0-9]*\).*/\1/p')
750 [ -n "$AQ2_PID" ] || fail linux-to-mac-quic "the reply carries no shell pid: $AQ2"
751 box_ssh 60 "$MAC_BOX" >/dev/null <<M || fail linux-to-mac-quic "shell $AQ2_PID is not a child of daemon $MPID2"
752 . \$HOME/xos/os_oracle.sh
753 pid_children $MPID2 | grep -qx $AQ2_PID
754 M
755 unset MUX_KEY_FILE
756 ok linux-to-mac-quic "macOS delivered inbound UDP $QUIC_PORT: shells $L2MQ_PID and $AQ2_PID under $MPID2"
757
758 # ---- leg 9: upgrade-refused — the preflight is uname, both ways --------
759 # `uname -m` is the FIRST line of the preflight and the whole gate: the
760 # image is never opened and nothing is streamed. The pin is therefore both
761 # the refusal's own words and the installed binary's hash on the far side,
762 # unchanged from what leg 3 put there.
763 leg
764 set +e
765 timeout 120 "$MUX" d upgrade "$MAC_BOX" --allow-same-version > "$SCRATCH/up_mac.out" 2>&1
766 RC=$?
767 set -e
768 [ "$RC" -ne 0 ] ||
769 fail upgrade-refused "mux d upgrade $MAC_BOX exited 0; an x86_64 image must not reach an arm64 box"
770 grep -q "is arm64 and this image is x86_64; refusing to push a binary that cannot run there" \
771 "$SCRATCH/up_mac.out" || {
772 echo "xos FAIL: upgrade-refused: the Linux→Mac refusal is not the arch line; it said:"
773 sed 's/^/ /' "$SCRATCH/up_mac.out"
774 exit 1; }
775
776 # From the Mac, the mirror. The image there is aarch64 and the far side says
777 # x86_64, so the two words differ in BOTH spelling and machine — which is
778 # what makes this the mirror image and not the same test twice.
779 UPL=$(box_ssh 180 "$MAC_BOX" <<M
780 \$HOME/.local/bin/mux d upgrade $LINUX_BOX --allow-same-version 2>&1
781 echo "RC=\$?"
782 M
783 )
784 case "$UPL" in
785 *RC=0*) fail upgrade-refused "mux d upgrade $LINUX_BOX from $MAC_BOX exited 0; an aarch64 image must not reach an x86_64 box" ;;
786 esac
787 case "$UPL" in
788 *"is x86_64 and this image is aarch64; refusing to push a binary that cannot run there"*) ;;
789 *) fail upgrade-refused "the Mac→Linux refusal is not the arch line; it said: $UPL" ;;
790 esac
791
792 POST=$(
793 box_ssh 60 "$MAC_BOX" <<M
794 . \$HOME/xos/os_oracle.sh
795 echo "macsha=\$(sha256_of \$HOME/.local/bin/mux)"
796 echo "macpid=\$(pgrep -x mux | head -1)"
797 echo "macexe=\$(pid_exe \$(pgrep -x mux | head -1))"
798 M
799 box_ssh 60 "$LINUX_BOX" <<L
800 . ~/xos/os_oracle.sh
801 echo "lansha=\$(sha256_of ~/.local/bin/mux)"
802 echo "lanpid=\$(pgrep -x mux | head -1)"
803 echo "lanexe=\$(pid_exe $LPID)"
804 L
805 )
806 printf '%s\n' "$POST" | grep -qx "macsha=$MAC_BIN_SHA" ||
807 fail upgrade-refused "$MAC_BOX's installed mux changed across the refusal: $POST"
808 printf '%s\n' "$POST" | grep -qx "lansha=$LAN_BIN_SHA" ||
809 fail upgrade-refused "$LINUX_BOX's installed mux changed across the refusal: $POST"
810 printf '%s\n' "$POST" | grep -qx "macpid=$MPID2" ||
811 fail upgrade-refused "$MAC_BOX's daemon is no longer $MPID2: $POST"
812 printf '%s\n' "$POST" | grep -qx "lanpid=$LPID" ||
813 fail upgrade-refused "$LINUX_BOX's daemon is no longer $LPID: $POST"
814 printf '%s\n' "$POST" | grep -qx "macexe=$MAC_EXE" ||
815 fail upgrade-refused "$MAC_BOX's daemon execs something else now: $POST"
816 printf '%s\n' "$POST" | grep -qx "lanexe=$LAN_EXE" ||
817 fail upgrade-refused "$LINUX_BOX's daemon execs something else now: $POST"
818 ok upgrade-refused "both pushes refused on uname alone; both images and both daemons untouched"
819
820 # ---- leg 10: stop — and each box's socket goes with its daemon ---------
821 leg
822 box_ssh 60 "$MAC_BOX" >/dev/null <<'M' || fail stop "mux d stop was refused on $MAC_BOX"
823 $HOME/.local/bin/mux d stop
824 M
825 box_ssh 60 "$LINUX_BOX" >/dev/null <<'L' || fail stop "mux d stop was refused on $LINUX_BOX"
826 ~/.local/bin/mux d stop
827 L
828 MEND=$(box_ssh 60 "$MAC_BOX" <<M
829 . \$HOME/xos/os_oracle.sh
830 _i=0
831 while [ \$_i -lt 30 ]; do
832 pid_alive $MPID2 || break
833 sleep 0.1; _i=\$((_i + 1))
834 done
835 pid_alive $MPID2 && echo "alive=yes" || echo "alive=no"
836 [ -e $MAC_SOCK ] && echo "sock=present" || echo "sock=gone"
837 M
838 )
839 printf '%s\n' "$MEND" | grep -qx 'alive=no' ||
840 fail stop "$MAC_BOX's daemon $MPID2 survives mux d stop by 3 s: $MEND"
841 printf '%s\n' "$MEND" | grep -qx 'sock=gone' ||
842 fail stop "$MAC_SOCK outlives the daemon that bound it: $MEND"
843 LEND=$(box_ssh 60 "$LINUX_BOX" <<L
844 . ~/xos/os_oracle.sh
845 _i=0
846 while [ \$_i -lt 30 ]; do
847 pid_alive $LPID || break
848 sleep 0.1; _i=\$((_i + 1))
849 done
850 pid_alive $LPID && echo "alive=yes" || echo "alive=no"
851 [ -e $LAN_SOCK ] && echo "sock=present" || echo "sock=gone"
852 L
853 )
854 printf '%s\n' "$LEND" | grep -qx 'alive=no' ||
855 fail stop "$LINUX_BOX's daemon $LPID survives mux d stop by 3 s: $LEND"
856 printf '%s\n' "$LEND" | grep -qx 'sock=gone' ||
857 fail stop "$LAN_SOCK outlives the daemon that bound it: $LEND"
858 ok stop "both daemons gone within 3 s, each taking its own socket with it"
859
860 # ---- the pin ----------------------------------------------------------
861 # A literal, test/vm.sh's and test/mac.sh's style: adding a leg means
862 # editing this by hand, so a leg that stops running cannot pass by not being
863 # counted.
864 [ "$OK_COUNT" = "10" ] || {
865 echo "xos FAIL: $OK_COUNT legs ran, the pin says 10 —"
866 echo " a leg was added (update the pin) or silently lost"
867 exit 1
868 }
869 echo "xos OK ($OK_COUNT legs: $MAC_BOX ($WANT_MAC_ARCH) and $LINUX_BOX ($WANT_LAN_ARCH), each the other's client)"
tools/release-mac.sh
Old New
@@ -0,0 +1,96 @@
1 #!/bin/sh
2 # Cut and publish the macOS half of a release, from a Mac builder.
3 #
4 # There is no cross-compiling the Mac binary (README, "macOS"), so a Mac
5 # release is made on a Mac. This script is the whole of that: it checks that
6 # the tag exists here and is HEAD, pushes the tag, and then hands ONE remote
7 # shell script to the builder, which fetches that tag, detaches onto it, runs
8 # `make release` — the same recipe as everywhere, whose RELEASE_TARGET
9 # follows the host and so names the tarball aarch64-macos — and publishes it.
10 #
11 # The builder is MAC_BUILDER, the same box and the same name the macOS gates
12 # take (test/mac.sh, test/xos.sh), and MAC_BUILDER_REPO the path to this repo
13 # under its $HOME. There is no default for MAC_BUILDER: a release published
14 # to whatever machine happened to be in someone's ssh config is worse than a
15 # refusal.
16 #
17 # What this does NOT do: touch main, push anything but the tag, or run
18 # git-collab locally. The publish is the builder's, because the builder is
19 # the box holding the bytes.
20 #
21 # The remote script rides ssh's STDIN, so this ssh cannot take `-n` — that
22 # flag closes stdin and the remote `sh -s` would read EOF, do nothing and
23 # exit 0, which is a release that silently did not happen. box_lib.sh's
24 # `box_ssh` is the same shape for the same reason. The consequence is the
25 # rule that shape carries: nothing inside the remote script may read stdin,
26 # or it eats the lines after it. Nothing here does.
27 set -eu
28
29 die() {
30 echo "release-mac: $*" >&2
31 exit "${RC:-1}"
32 }
33
34 # Before any ssh, so an unset name costs no connection and no tag push.
35 if [ -z "${MAC_BUILDER:-}" ]; then
36 echo "release-mac: MAC_BUILDER is unset, and this recipe has no default for it (a Mac holding this repo with zig and deps/mac-sdk)" >&2
37 exit 2
38 fi
39 REPO=${MAC_BUILDER_REPO:-code/rad/mux}
40
41 # The same single source the Makefile greps, so the two can never disagree
42 # about which version is being cut.
43 V=$(sed -n 's/^[[:space:]]*const version = "\(.*\)";/\1/p' build.zig | head -1)
44 [ -n "$V" ] || die "no version found in build.zig"
45 TAG="v$V"
46 TAR="mux-$TAG-aarch64-macos.tar.gz"
47
48 # A release is cut from a tag, never from a branch tip: the builder checks
49 # out the tag, so anything not tagged is not what ships.
50 git rev-parse -q --verify "refs/tags/$TAG" >/dev/null 2>&1 ||
51 die "tag: $TAG does not exist here — tag the commit first (git tag $TAG)"
52 if [ "$(git rev-parse "refs/tags/$TAG^{commit}")" != "$(git rev-parse HEAD)" ]; then
53 die "tag: $TAG does not point at HEAD — tag this commit first (git tag $TAG)"
54 fi
55
56 # Pushing a tag already on the remote is a no-op, so this is safe to re-run;
57 # a tag that MOVED is refused by git itself, which is the check we want.
58 #
59 # `--no-follow-tags` and an explicit destination refspec, because ONE tag is
60 # the whole of what this script is allowed to push. A developer with
61 # `push.followTags = true` in their ~/.gitconfig — which is a common setting
62 # and was set on the box this was written on — otherwise sends every
63 # annotated tag reachable from what is being pushed, so a bare
64 # `git push origin refs/tags/vN` put five other tags on the remote in the
65 # hand check.
66 git push --no-follow-tags origin "refs/tags/$TAG:refs/tags/$TAG" ||
67 die "push: could not push $TAG to origin"
68
69 # One connection. `$V` and `$REPO` are expanded HERE (unquoted heredoc word)
70 # because the remote shell has neither.
71 ssh -o BatchMode=yes "$MAC_BUILDER" /bin/sh -s <<EOF || die "builder: $MAC_BUILDER refused or failed — see its output above"
72 set -eu
73 # git-collab lives in the product's own prefix, and a bare ssh to a Mac has
74 # a PATH of /usr/bin:/bin:/usr/sbin:/sbin and nothing else.
75 PATH="\$PATH:\$HOME/.local/bin"
76 cd "\$HOME/$REPO"
77 # A tag fetch and not a pull: the builder's checkout may be on any branch,
78 # and this must leave that branch exactly where it was.
79 git fetch -q origin "refs/tags/$TAG:refs/tags/$TAG"
80 git checkout -q --detach "$TAG"
81 make release
82 git-collab release publish "$TAG" "dist/$TAR"
83 EOF
84
85 # The URL the tarball is now at, derived from this checkout's own remote so
86 # a fork prints its own server and not this one's. Four strips, in order:
87 # the scheme, any `user@`, the scp-style colon that stands in for the path
88 # separator, and the `.git` suffix — what is left is what the release server
89 # serves under.
90 BASE=$(git remote get-url origin |
91 sed -e 's,^[A-Za-z][A-Za-z0-9+.-]*://,,' \
92 -e 's,^[^/@]*@,,' \
93 -e 's,^\([^/:]*\):,\1/,' \
94 -e 's,\.git$,,' \
95 -e 's,/*$,,')
96 echo "published: https://$BASE/releases/$TAG/$TAR"