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