8f8ed018
test: make xos attaches a Mac client to a Linux daemon and a Linux client to a Mac daemon, over ssh and QUIC
a73x 2026-09-04 10:16
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ make check # fmt + unit tests + shell syntax + comment-claim | |||
| 12 | make ci # check + e2e + agent + throughput — the delivery gate | 12 | make ci # check + e2e + agent + throughput — the delivery gate |
| 13 | make agent soak bench throughput | 13 | make agent soak bench throughput |
| 14 | make vm # real user journeys against the mux-e2e VM (test/vm.sh) | 14 | make vm # real user journeys against the mux-e2e VM (test/vm.sh) |
| 15 | make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh) | ||
| 15 | make install # the one user binary -> ~/.local/bin, ReleaseSafe | 16 | make install # the one user binary -> ~/.local/bin, ReleaseSafe |
| 16 | make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..) | 17 | make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..) |
| 17 | ``` | 18 | ``` |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -13,7 +13,7 @@ MUX_TARGET ?= aarch64-macos | |||
| 13 | endif | 13 | endif |
| 14 | MUX_TARGET ?= x86_64-linux-musl | 14 | MUX_TARGET ?= x86_64-linux-musl |
| 15 | 15 | ||
| 16 | .PHONY: build check ci test e2e soak bench agent throughput vm coverage deps clean clean-deps xversion xversion-build install release mac-sdk mac | 16 | .PHONY: build check ci test e2e soak bench agent throughput vm coverage deps clean clean-deps xversion xversion-build install release mac-sdk mac xos |
| 17 | 17 | ||
| 18 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target | 18 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target |
| 19 | # here needs to depend on this one. It exists to make the one-time cost | 19 | # here needs to depend on this one. It exists to make the one-time cost |
| @@ -165,6 +165,21 @@ mac-sdk: | |||
| 165 | mac: build | 165 | mac: build |
| 166 | sh ./test/mac.sh | 166 | sh ./test/mac.sh |
| 167 | 167 | ||
| 168 | # The cross-OS gate (test/xos.sh): a macOS client on a Linux daemon and a | ||
| 169 | # Linux client on a macOS daemon, over ssh and over QUIC. A hand gate beside | ||
| 170 | # vm and mac, never inside ci — it needs two other machines, and ci stays | ||
| 171 | # hermetic and offline. | ||
| 172 | # | ||
| 173 | # Two builds, because the two sides of the seam are two architectures. The | ||
| 174 | # musl one is what gets installed on the Linux box: static, for vm's reason | ||
| 175 | # — that box is a VM on a host whose CPU this one did not build for. The | ||
| 176 | # Debug zig-out build is the Linux CLIENT and the pty fixture, which is what | ||
| 177 | # `make build` already produced. | ||
| 178 | XOSDIR ?= dist/xosgate | ||
| 179 | xos: build | ||
| 180 | $(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p $(XOSDIR) | ||
| 181 | ./test/xos.sh $(XOSDIR)/bin/mux zig-out/bin/mux zig-out/bin/ptyclient | ||
| 182 | |||
| 168 | # `zig build check` grades the tree; bans.sh grades the grader. The folder | 183 | # `zig build check` grades the tree; bans.sh grades the grader. The folder |
| 169 | # rules are the one gate whose failure mode is silence — a needle list | 184 | # rules are the one gate whose failure mode is silence — a needle list |
| 170 | # edited down to nothing, or a folder dropped from a rule's list, leaves a | 185 | # edited down to nothing, or a folder dropped from a rule's list, leaves a |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -727,6 +727,18 @@ tart stop mux-mac-base | |||
| 727 | `mux-mac-base` to `mux-mac`, boots the clone, and stops it on the way out, | 727 | `mux-mac-base` to `mux-mac`, boots the clone, and stops it on the way out, |
| 728 | so no run can see the run before it. | 728 | so no run can see the run before it. |
| 729 | 729 | ||
| 730 | `make mac` and `make vm` each run one operating system on both ends of every | ||
| 731 | connection. `make xos` is the gate that does not: it attaches a macOS client | ||
| 732 | to a Linux daemon and a Linux client to a macOS daemon, over the ssh handoff | ||
| 733 | and over QUIC, and checks that a cross-architecture `mux d upgrade` is | ||
| 734 | refused in both directions before any of the image is streamed. It needs two | ||
| 735 | other machines — the Mac named by `MUX_MAC`, which builds and installs its | ||
| 736 | own arm64 binary, and a Linux box named by `MUX_LAN` (default | ||
| 737 | `ubuntu@192.168.0.37`) that the Mac can reach by plain ssh. The Linux box is | ||
| 738 | a throwaway and is reset to a mux-less state at the start of every run; the | ||
| 739 | Mac is somebody's machine, so the gate starts nothing there that it does not | ||
| 740 | stop and refuses to run at all if a mux is already going. | ||
| 741 | |||
| 730 | ## How it works | 742 | ## How it works |
| 731 | 743 | ||
| 732 | Both ends run a real terminal engine. The daemon's is authoritative: shell | 744 | Both ends run a real terminal engine. The daemon's is authoritative: shell |
test/xos.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,760 @@ | |||
| 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 | # Three machines, 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 | # $MUX_MAC an Apple-silicon Mac (default `squirtle`). It holds the | ||
| 18 | # toolchain and the shadow SDK, so it builds its own arm64 | ||
| 19 | # image; `make install` puts it at ~/.local/bin/mux, which is | ||
| 20 | # where the appended PATH of an ssh handoff looks for it. It is | ||
| 21 | # a person's real machine, so this script kills nothing on it | ||
| 22 | # and removes nothing under ~ but the directory it made. | ||
| 23 | # $MUX_LAN a Linux VM the Mac can reach (default ubuntu@192.168.0.37). | ||
| 24 | # The e2e VM is on this box's private network and the Mac | ||
| 25 | # cannot see it, which is the whole reason a LAN box exists. | ||
| 26 | # This one IS a throwaway, so it gets test/vm.sh's | ||
| 27 | # scorched-earth reset. | ||
| 28 | # | ||
| 29 | # The fixture is a human action, never harness code (test/vm.sh's rule): | ||
| 30 | # the VM is made once with eitri `vm_create {name: mux-lan, host: onyx, | ||
| 31 | # network: lan}`, and the Mac is somebody's laptop. What the harness owns is | ||
| 32 | # the reset, the install and the key. | ||
| 33 | # | ||
| 34 | # Every claim about a box is asserted by a SEPARATE ssh reading that box | ||
| 35 | # through test/os_oracle.sh — never by trusting the connection under test, | ||
| 36 | # which is the connection this gate exists to doubt ("ask the OS about the | ||
| 37 | # OS", CLAUDE.md). | ||
| 38 | set -eu | ||
| 39 | |||
| 40 | MUSL_MUX="$1" # x86_64-linux-musl ReleaseSafe: the image installed on $MUX_LAN | ||
| 41 | MUX="$2" # this tree's own build: the Linux client for the Linux→Mac legs | ||
| 42 | PTYCLIENT="$3" # the real-pty fixture, driving a client the way hands do | ||
| 43 | |||
| 44 | MAC="${MUX_MAC:-squirtle}" | ||
| 45 | MAC_IP="${MUX_MAC_IP:-192.168.0.47}" | ||
| 46 | LAN="${MUX_LAN:-ubuntu@192.168.0.37}" | ||
| 47 | LAN_IP=${LAN#*@} | ||
| 48 | # Relative to the Mac's $HOME, because that is how ssh's scp-like remote | ||
| 49 | # spelling (`squirtle:code/rad/mux`) resolves a path. | ||
| 50 | REPO="${MUX_MAC_REPO:-code/rad/mux}" | ||
| 51 | BRANCH=mac-journey | ||
| 52 | QUIC_PORT=4433 | ||
| 53 | # 4433 as /proc/net/udp writes a wildcard bind, which is the spelling | ||
| 54 | # os_oracle.sh's udp_local_bound takes on BOTH arms — the Darwin arm | ||
| 55 | # converts it, so a leg never learns which OS it is asking. | ||
| 56 | QUIC_HEX=00000000:1151 | ||
| 57 | |||
| 58 | # BatchMode so a broken auth fails in seconds rather than prompting a | ||
| 59 | # harness nobody sits in front of; accept-new so the first run records a | ||
| 60 | # box's host key where PLAIN ssh will find it, because mux's own entry dial | ||
| 61 | # spawns plain `ssh $HOST` and reads that same file. | ||
| 62 | SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" | ||
| 63 | |||
| 64 | # now_ms and the pid_*/file_* names the remote legs source for themselves. | ||
| 65 | # The harness spells time and paths through the oracle so this script would | ||
| 66 | # still answer if it were ever run FROM a Mac. | ||
| 67 | . "$(dirname "$0")/os_oracle.sh" | ||
| 68 | |||
| 69 | OK_COUNT=0 | ||
| 70 | LEG_T0=0 | ||
| 71 | |||
| 72 | leg() { LEG_T0=$(now_ms); } | ||
| 73 | |||
| 74 | ok() { | ||
| 75 | OK_COUNT=$((OK_COUNT + 1)) | ||
| 76 | echo "$1 ok [$(( $(now_ms) - LEG_T0 ))ms]${2:+ ($2)}" | ||
| 77 | } | ||
| 78 | |||
| 79 | fail() { | ||
| 80 | echo "xos FAIL: $1: $2" | ||
| 81 | exit 1 | ||
| 82 | } | ||
| 83 | |||
| 84 | # What every mux the HARNESS runs on a remote box runs under. | ||
| 85 | # | ||
| 86 | # XDG_STATE_HOME is isolated because every mux that attaches records its | ||
| 87 | # daemon in the hosts file under it (CLAUDE.md), and this gate must not | ||
| 88 | # write a throwaway host onto somebody's real wall. XDG_CACHE_HOME is | ||
| 89 | # isolated for the handoff's per-host endpoint cache and XDG_CONFIG_HOME so | ||
| 90 | # that no default ~/.config/mux/key can be picked up by accident — the key | ||
| 91 | # this run uses is named explicitly on every command line. | ||
| 92 | # | ||
| 93 | # XDG_RUNTIME_DIR is left exactly as each box's ssh login leaves it: set on | ||
| 94 | # Ubuntu (/run/user/1000) and UNSET on macOS, where the daemon falls back to | ||
| 95 | # /tmp/mux-<uid>. That fallback is what the Linux→Mac legs exercise, and the | ||
| 96 | # entry dial's own `mux d endpoint --start` will look nowhere else. | ||
| 97 | PRELUDE=' | ||
| 98 | export XDG_STATE_HOME=$HOME/xos/state | ||
| 99 | export XDG_CACHE_HOME=$HOME/xos/cache | ||
| 100 | export XDG_CONFIG_HOME=$HOME/xos/config | ||
| 101 | mkdir -p $HOME/xos | ||
| 102 | cd $HOME | ||
| 103 | ' | ||
| 104 | |||
| 105 | # mssh SECONDS — run the script on stdin on the Mac, under /bin/sh. | ||
| 106 | # | ||
| 107 | # /bin/sh and not the Mac's login shell (zsh): `$VAR` holding several words | ||
| 108 | # does not word-split in zsh, so an options string pasted into a command | ||
| 109 | # line there arrives as ONE argument and ssh answers "keyword batchmode | ||
| 110 | # extra arguments at end of line". | ||
| 111 | # | ||
| 112 | # The rule for a script handed to this: any ssh INSIDE it takes -n. The | ||
| 113 | # remote sh is reading its own script off stdin, and an ssh that does not | ||
| 114 | # close its stdin swallows the rest of that script — silently, with a 0 | ||
| 115 | # exit, so the lines after it simply never run. | ||
| 116 | # | ||
| 117 | # The quoting rule is that there is NO quoting: the script rides this | ||
| 118 | # shell's stdin all the way to the remote `sh -s` and no shell in between | ||
| 119 | # parses it as a command line. The one thing a caller owes it is a heredoc; | ||
| 120 | # called with no redirect it would read the enclosing script's stdin. | ||
| 121 | mssh() { | ||
| 122 | _mt=$1 | ||
| 123 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 124 | { printf '%s\n' "$PRELUDE"; cat; } | timeout "$_mt" ssh $SSH_OPTS "$MAC" /bin/sh -s | ||
| 125 | } | ||
| 126 | |||
| 127 | # lssh SECONDS — the same, onto the Linux daemon box. | ||
| 128 | lssh() { | ||
| 129 | _lt=$1 | ||
| 130 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 131 | { printf '%s\n' "$PRELUDE"; cat; } | timeout "$_lt" ssh $SSH_OPTS "$LAN" /bin/sh -s | ||
| 132 | } | ||
| 133 | |||
| 134 | # ---- the local side is a hermetic laptop ------------------------------ | ||
| 135 | # Every XDG dir mux reads, isolated. Short root because a unix socket path | ||
| 136 | # caps at 107 bytes. | ||
| 137 | SCRATCH=$(mktemp -d /tmp/muxxos.XXXXXX) | ||
| 138 | export XDG_STATE_HOME="$SCRATCH/st" XDG_RUNTIME_DIR="$SCRATCH/rt" | ||
| 139 | export XDG_CONFIG_HOME="$SCRATCH/cf" XDG_CACHE_HOME="$SCRATCH/ca" | ||
| 140 | mkdir -p "$XDG_STATE_HOME" "$XDG_RUNTIME_DIR" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" | ||
| 141 | |||
| 142 | # Whatever this run started, stopped — on both boxes, on every exit. The | ||
| 143 | # Mac's ~/.local/bin/mux is deliberately NOT removed: `make install` owns | ||
| 144 | # it, and a person may want the binary this run put there. | ||
| 145 | cleanup() { | ||
| 146 | _rc=$? | ||
| 147 | lssh 60 >/dev/null 2>&1 <<'L' || true | ||
| 148 | ~/.local/bin/mux d stop 2>/dev/null || true | ||
| 149 | sleep 0.3 | ||
| 150 | pkill -x mux 2>/dev/null || true | ||
| 151 | rm -rf ~/xos | ||
| 152 | L | ||
| 153 | mssh 60 >/dev/null 2>&1 <<'M' || true | ||
| 154 | $HOME/.local/bin/mux d stop 2>/dev/null || true | ||
| 155 | rm -rf $HOME/xos | ||
| 156 | M | ||
| 157 | rm -rf "$SCRATCH" | ||
| 158 | exit "$_rc" | ||
| 159 | } | ||
| 160 | trap cleanup EXIT | ||
| 161 | |||
| 162 | # ---- leg 1: push — the Mac builds THIS commit -------------------------- | ||
| 163 | leg | ||
| 164 | HEAD_SHA=$(git rev-parse HEAD) | ||
| 165 | PUSHED=no | ||
| 166 | HAVE=$(mssh 60 <<H || true | ||
| 167 | cd \$HOME/$REPO 2>/dev/null || exit 0 | ||
| 168 | git rev-parse -q --verify refs/heads/$BRANCH 2>/dev/null || true | ||
| 169 | H | ||
| 170 | ) | ||
| 171 | if [ "$HAVE" != "$HEAD_SHA" ]; then | ||
| 172 | # A push cannot update the branch the remote has checked out, and the | ||
| 173 | # run before this one left it checked out. Detaching first is what makes | ||
| 174 | # a re-run work; it changes no commit and no file. | ||
| 175 | mssh 60 >/dev/null <<H || fail push "cannot detach $MAC:$REPO before the push" | ||
| 176 | set -e | ||
| 177 | cd \$HOME/$REPO | ||
| 178 | if [ "\$(git rev-parse --abbrev-ref HEAD)" = "$BRANCH" ]; then | ||
| 179 | git checkout -q --detach | ||
| 180 | fi | ||
| 181 | H | ||
| 182 | # --force because this branch is a scratch mirror of whatever this tree | ||
| 183 | # is and never shared history: a local amend or rebase leaves the Mac's | ||
| 184 | # ref on a commit the new HEAD does not descend from, and a plain push | ||
| 185 | # would be rejected as a non-fast-forward for the rest of the branch's | ||
| 186 | # life. | ||
| 187 | git push --force "$MAC:$REPO" "HEAD:refs/heads/$BRANCH" >/dev/null 2>&1 || | ||
| 188 | fail push "git push $MAC:$REPO HEAD:refs/heads/$BRANCH was refused" | ||
| 189 | PUSHED=yes | ||
| 190 | fi | ||
| 191 | mssh 60 >/dev/null <<H || fail push "the Mac could not check out $BRANCH" | ||
| 192 | set -e | ||
| 193 | cd \$HOME/$REPO | ||
| 194 | git checkout -q $BRANCH | ||
| 195 | git reset -q --hard $BRANCH | ||
| 196 | H | ||
| 197 | MAC_SHA_GIT=$(mssh 60 <<H | ||
| 198 | cd \$HOME/$REPO && git rev-parse HEAD | ||
| 199 | H | ||
| 200 | ) | ||
| 201 | [ "$MAC_SHA_GIT" = "$HEAD_SHA" ] || | ||
| 202 | fail push "the Mac is at $MAC_SHA_GIT, this tree is at $HEAD_SHA" | ||
| 203 | ok push "$MAC:$REPO on $BRANCH at $(git rev-parse --short HEAD), pushed=$PUSHED" | ||
| 204 | |||
| 205 | # ---- leg 2: build — one image per architecture ------------------------- | ||
| 206 | # The Mac builds its own arm64 binary and installs it where an ssh | ||
| 207 | # handoff's appended PATH finds it. The shim PATH is required for the link | ||
| 208 | # and Homebrew is not on a non-interactive PATH, so both are spelled here. | ||
| 209 | leg | ||
| 210 | BOUT=$(mssh 2400 <<H | ||
| 211 | cd \$HOME/$REPO || { echo "RC_BUILD=90"; exit 0; } | ||
| 212 | export PATH=\$HOME/$REPO/deps/mac-sdk/bin:/opt/homebrew/bin:\$PATH | ||
| 213 | make build >/tmp/xos-build.log 2>&1; echo "RC_BUILD=\$?" | ||
| 214 | make install >/tmp/xos-install.log 2>&1; echo "RC_INSTALL=\$?" | ||
| 215 | H | ||
| 216 | ) | ||
| 217 | for _step in BUILD INSTALL; do | ||
| 218 | _rc=$(printf '%s\n' "$BOUT" | sed -n "s/^RC_$_step=//p") | ||
| 219 | [ "$_rc" = "0" ] && continue | ||
| 220 | _log=$(printf '%s' "$_step" | tr 'A-Z' 'a-z') | ||
| 221 | # The verdict first and the reading under it, test/vm.sh's shape: a | ||
| 222 | # reader scrolling back finds the sentence that says what broke, not the | ||
| 223 | # tail of somebody else's build log. | ||
| 224 | echo "xos FAIL: build: make $_log on $MAC exited ${_rc:-<no answer>}" | ||
| 225 | mssh 60 <<H | sed 's/^/ /' || true | ||
| 226 | tail -30 /tmp/xos-$_log.log 2>/dev/null || true | ||
| 227 | H | ||
| 228 | exit 1 | ||
| 229 | done | ||
| 230 | # The oracle goes beside the binary rather than being read out of the repo, | ||
| 231 | # so both remote boxes answer through the same path and a leg never has to | ||
| 232 | # know which one it is talking to. | ||
| 233 | MAC_INFO=$(mssh 120 <<H | ||
| 234 | set -e | ||
| 235 | mkdir -p \$HOME/xos | ||
| 236 | cp \$HOME/$REPO/test/os_oracle.sh \$HOME/xos/os_oracle.sh | ||
| 237 | . \$HOME/xos/os_oracle.sh | ||
| 238 | echo "ver=\$(\$HOME/.local/bin/mux --version)" | ||
| 239 | echo "sha=\$(sha256_of \$HOME/.local/bin/mux)" | ||
| 240 | echo "exe=\$(real_path \$HOME/.local/bin/mux)" | ||
| 241 | echo "arch=\$(uname -m)" | ||
| 242 | H | ||
| 243 | ) | ||
| 244 | MAC_VER=$(printf '%s\n' "$MAC_INFO" | sed -n 's/^ver=//p') | ||
| 245 | MAC_BIN_SHA=$(printf '%s\n' "$MAC_INFO" | sed -n 's/^sha=//p') | ||
| 246 | MAC_EXE=$(printf '%s\n' "$MAC_INFO" | sed -n 's/^exe=//p') | ||
| 247 | MAC_ARCH=$(printf '%s\n' "$MAC_INFO" | sed -n 's/^arch=//p') | ||
| 248 | WANT_VER=$("$MUX" --version) | ||
| 249 | [ -n "$MAC_VER" ] && [ "$MAC_VER" = "$WANT_VER" ] || | ||
| 250 | fail build "$MAC answers '$MAC_VER', this tree built '$WANT_VER'" | ||
| 251 | [ "$MAC_ARCH" = "arm64" ] || | ||
| 252 | fail build "$MAC says it is '$MAC_ARCH'; this gate is about an arm64 Mac" | ||
| 253 | [ -n "$MAC_BIN_SHA" ] || fail build "$MAC would not hash its installed mux" | ||
| 254 | ok build "$MAC installed an $MAC_ARCH '$MAC_VER' at $MAC_EXE" | ||
| 255 | |||
| 256 | # ---- leg 3: reset — a mux-less Linux box, and a Mac left alone --------- | ||
| 257 | leg | ||
| 258 | # The Mac first, and it is a REFUSAL and not a reset. This is a person's | ||
| 259 | # machine: a stray mux here is theirs, and the Linux→Mac legs must meet the | ||
| 260 | # daemon THEY start, not one already running with unknown flags. | ||
| 261 | MPRE=$(mssh 60 <<'M' | ||
| 262 | echo "uid=$(id -u)" | ||
| 263 | echo "muxes=$(pgrep -x mux 2>/dev/null | wc -l | tr -d ' ')" | ||
| 264 | [ -e /tmp/mux-$(id -u) ] && echo "rt=present" || echo "rt=absent" | ||
| 265 | ifconfig 2>/dev/null | sed -n 's/^[[:space:]]*inet \([0-9.]*\) .*/ip=\1/p' | ||
| 266 | M | ||
| 267 | ) | ||
| 268 | MAC_UID=$(printf '%s\n' "$MPRE" | sed -n 's/^uid=//p') | ||
| 269 | [ -n "$MAC_UID" ] || fail reset "$MAC would not say its uid" | ||
| 270 | printf '%s\n' "$MPRE" | grep -qx 'muxes=0' || { | ||
| 271 | echo "xos FAIL: reset: $MAC already runs a mux, and this gate will not kill it." | ||
| 272 | echo " Stop it there (mux d stop) or run this against another Mac." | ||
| 273 | exit 1; } | ||
| 274 | printf '%s\n' "$MPRE" | grep -qx 'rt=absent' || | ||
| 275 | fail reset "$MAC already has /tmp/mux-$MAC_UID; the daemon this gate meets must be the one it starts" | ||
| 276 | # The address the QUIC legs dial, asserted against the Mac's own interface | ||
| 277 | # list rather than assumed from a variable: a stale MUX_MAC_IP would make | ||
| 278 | # leg 8 grade some other machine's silence. | ||
| 279 | printf '%s\n' "$MPRE" | grep -qx "ip=$MAC_IP" || | ||
| 280 | fail reset "$MAC does not hold $MAC_IP; set MUX_MAC_IP. It answered: $(printf '%s' "$MPRE" | tr '\n' ' ')" | ||
| 281 | MAC_SOCK="/tmp/mux-$MAC_UID/muxd.sock" | ||
| 282 | |||
| 283 | # The Linux box IS a throwaway, so it gets test/vm.sh's scorched earth. The | ||
| 284 | # reset is the hermeticity: each run starts from the mux-less box a new user | ||
| 285 | # has. The two standing arrangements it (re)makes: | ||
| 286 | # linger — without it logind may tear down /run/user/1000 (the daemon's | ||
| 287 | # socket dir) between ssh sessions and reap the daemon with it. | ||
| 288 | # PATH — an ssh handoff runs a non-interactive bash whose PATH lacks | ||
| 289 | # ~/.local/bin; the line goes ABOVE bashrc's interactivity | ||
| 290 | # return, where Debian's ssh-aware bash reads it. | ||
| 291 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 292 | LAN_ARCH=$(timeout 20 ssh $SSH_OPTS "$LAN" 'uname -m') || | ||
| 293 | fail reset "cannot ssh to $LAN — is the VM up, and has its DHCP lease moved?" | ||
| 294 | [ "$LAN_ARCH" = "x86_64" ] || | ||
| 295 | fail reset "$LAN is $LAN_ARCH, and this gate installs an x86_64-musl build there" | ||
| 296 | lssh 120 >/dev/null <<'L' || fail reset "the scorched-earth reset was refused on the Linux box" | ||
| 297 | set -eu | ||
| 298 | pkill -x mux 2>/dev/null || true | ||
| 299 | sleep 0.3 | ||
| 300 | pkill -9 -x mux 2>/dev/null || true | ||
| 301 | rm -rf ~/.local/bin/mux ~/.local/state/mux ~/.cache/mux ~/.config/mux ~/xos | ||
| 302 | rm -rf "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"/mux* | ||
| 303 | sudo -n loginctl enable-linger "$(id -un)" | ||
| 304 | grep -q '^export PATH="$HOME/.local/bin' ~/.bashrc 2>/dev/null || \ | ||
| 305 | sed -i '1i export PATH="$HOME/.local/bin:$PATH"' ~/.bashrc | ||
| 306 | mkdir -p ~/.local/bin ~/xos | ||
| 307 | L | ||
| 308 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 309 | scp -q $SSH_OPTS "$MUSL_MUX" "$LAN":.local/bin/mux || | ||
| 310 | fail reset "could not copy the musl build to $LAN" | ||
| 311 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 312 | scp -q $SSH_OPTS "$(dirname "$0")/os_oracle.sh" "$LAN":xos/os_oracle.sh || | ||
| 313 | fail reset "could not copy the oracle to $LAN" | ||
| 314 | LAN_BIN_SHA=$(sha256_of "$MUSL_MUX") | ||
| 315 | LINFO=$(lssh 60 <<'L' | ||
| 316 | . ~/xos/os_oracle.sh | ||
| 317 | echo "ver=$(~/.local/bin/mux --version)" | ||
| 318 | echo "sha=$(sha256_of ~/.local/bin/mux)" | ||
| 319 | echo "rtd=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" | ||
| 320 | L | ||
| 321 | ) | ||
| 322 | LAN_VER=$(printf '%s\n' "$LINFO" | sed -n 's/^ver=//p') | ||
| 323 | LAN_GOT_SHA=$(printf '%s\n' "$LINFO" | sed -n 's/^sha=//p') | ||
| 324 | LAN_RTD=$(printf '%s\n' "$LINFO" | sed -n 's/^rtd=//p') | ||
| 325 | [ "$LAN_VER" = "$WANT_VER" ] || | ||
| 326 | fail reset "$LAN answers '$LAN_VER', this tree built '$WANT_VER'" | ||
| 327 | [ "$LAN_GOT_SHA" = "$LAN_BIN_SHA" ] || | ||
| 328 | fail reset "$LAN holds a mux hashing $LAN_GOT_SHA, the pushed image is $LAN_BIN_SHA" | ||
| 329 | LAN_SOCK="$LAN_RTD/muxd.sock" | ||
| 330 | ok reset "$LAN reset to a mux-less box and given '$LAN_VER'; $MAC left running nothing" | ||
| 331 | |||
| 332 | # ---- leg 4: keys — one PSK for the run, never the user's --------------- | ||
| 333 | # `mux d keygen` always writes the default path, so the isolated | ||
| 334 | # XDG_CONFIG_HOME above is what puts it in the scratch tree. It is copied to | ||
| 335 | # ~/xos/key on both boxes and named on every QUIC command line; the Mac's | ||
| 336 | # own ~/.config/mux/key is never read and never written. | ||
| 337 | leg | ||
| 338 | "$MUX" d keygen >/dev/null 2>&1 || fail keys "mux d keygen refused under $XDG_CONFIG_HOME" | ||
| 339 | cp "$XDG_CONFIG_HOME/mux/key" "$SCRATCH/key" | ||
| 340 | chmod 600 "$SCRATCH/key" | ||
| 341 | [ "$(file_size "$SCRATCH/key")" = "32" ] || | ||
| 342 | fail keys "the generated key is $(file_size "$SCRATCH/key") bytes, want 32" | ||
| 343 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 344 | scp -q $SSH_OPTS "$SCRATCH/key" "$LAN":xos/key || fail keys "could not install the key on $LAN" | ||
| 345 | # shellcheck disable=SC2086 # SSH_OPTS is words on purpose | ||
| 346 | scp -q $SSH_OPTS "$SCRATCH/key" "$MAC":xos/key || fail keys "could not install the key on $MAC" | ||
| 347 | # 0600 on both, because quic.Key.load refuses a group- or other-readable key | ||
| 348 | # exactly as ssh refuses a private key, and scp does not carry the mode. | ||
| 349 | KMODES=$( | ||
| 350 | lssh 30 <<'L' | ||
| 351 | . ~/xos/os_oracle.sh | ||
| 352 | chmod 600 ~/xos/key | ||
| 353 | echo "lan=$(file_mode ~/xos/key)" | ||
| 354 | L | ||
| 355 | mssh 30 <<'M' | ||
| 356 | . $HOME/xos/os_oracle.sh | ||
| 357 | chmod 600 $HOME/xos/key | ||
| 358 | echo "mac=$(file_mode $HOME/xos/key)" | ||
| 359 | M | ||
| 360 | ) | ||
| 361 | printf '%s\n' "$KMODES" | grep -qx 'lan=600' || | ||
| 362 | fail keys "the key on $LAN is not mode 600: $KMODES" | ||
| 363 | printf '%s\n' "$KMODES" | grep -qx 'mac=600' || | ||
| 364 | fail keys "the key on $MAC is not mode 600: $KMODES" | ||
| 365 | ok keys "one 32-byte PSK at ~/xos/key, mode 600 on both boxes" | ||
| 366 | |||
| 367 | # ---- leg 5: mac-to-linux — a Mac client on a Linux daemon -------------- | ||
| 368 | # The daemon carries a QUIC arm, so the Mac's entry dial is the whole | ||
| 369 | # product path: plain ssh to fetch the announce, then the session moves onto | ||
| 370 | # QUIC. MUX_SHELL_INTEGRATION=1 because leg 6 reads a real exit code off | ||
| 371 | # this same daemon and marks are opt-in. | ||
| 372 | leg | ||
| 373 | lssh 90 >/dev/null <<L || fail mac-to-linux "mux d start -d --quic was refused on $LAN" | ||
| 374 | MUX_SHELL_INTEGRATION=1 ~/.local/bin/mux d start -d --quic 0.0.0.0:$QUIC_PORT --key ~/xos/key | ||
| 375 | L | ||
| 376 | LPID=$(lssh 30 <<'L' | ||
| 377 | pgrep -x mux | head -1 | ||
| 378 | L | ||
| 379 | ) | ||
| 380 | [ -n "$LPID" ] || fail mac-to-linux "no mux process on $LAN after mux d start -d" | ||
| 381 | # Bound BEFORE the dial, asked of /proc and not of the daemon: a leg that | ||
| 382 | # read "no answer" from the Mac could otherwise be blaming the network for a | ||
| 383 | # listener that was never up. | ||
| 384 | lssh 60 >/dev/null <<L || fail mac-to-linux "daemon $LPID binds no UDP $QUIC_PORT on $LAN" | ||
| 385 | . ~/xos/os_oracle.sh | ||
| 386 | _i=0 | ||
| 387 | while [ \$_i -lt 50 ]; do | ||
| 388 | udp_local_bound $QUIC_HEX && exit 0 | ||
| 389 | sleep 0.2; _i=\$((_i + 1)) | ||
| 390 | done | ||
| 391 | exit 1 | ||
| 392 | L | ||
| 393 | |||
| 394 | # The marker carries the SESSION shell's own `uname -m` and its own \$\$, so | ||
| 395 | # `XOS-M2L-x86_64-` painted on a Mac's screen is the cross-OS fact itself: | ||
| 396 | # the bytes were produced by a shell only the Linux box's process table can | ||
| 397 | # account for. | ||
| 398 | M2L=$(mssh 240 <<M | ||
| 399 | cd \$HOME/xos | ||
| 400 | \$HOME/$REPO/zig-out/bin/ptyclient --cols 80 --rows 24 \ | ||
| 401 | --out \$HOME/xos/m2l.cap --err \$HOME/xos/m2l.err -- \ | ||
| 402 | \$HOME/.local/bin/mux $LAN <<'PC' | ||
| 403 | expect \\x1b[?1049h 30000 | ||
| 404 | settle 900 20000 | ||
| 405 | send echo XOS-M2L-\$(uname -m)-\$\$\n | ||
| 406 | expect XOS-M2L-x86_64- 25000 | ||
| 407 | settle 800 15000 | ||
| 408 | send \\x1cd | ||
| 409 | waitexit 15000 | ||
| 410 | PC | ||
| 411 | echo "PTYRC=\$?" | ||
| 412 | M | ||
| 413 | ) || fail mac-to-linux "$MAC would not run ptyclient" | ||
| 414 | printf '%s\n' "$M2L" | grep -qx 'PTYRC=0' || { | ||
| 415 | echo "xos FAIL: mac-to-linux: the Mac's client never painted XOS-M2L-x86_64-: $M2L" | ||
| 416 | mssh 60 <<'M' | sed 's/^/ /' || true | ||
| 417 | cat $HOME/xos/m2l.err 2>/dev/null | ||
| 418 | M | ||
| 419 | exit 1; } | ||
| 420 | M2L_READ=$(mssh 60 <<'M' | ||
| 421 | echo "pid=$(sed -n 's/.*XOS-M2L-x86_64-\([0-9][0-9]*\).*/\1/p' $HOME/xos/m2l.cap | head -1)" | ||
| 422 | grep -c 'attaching over ssh' $HOME/xos/m2l.err 2>/dev/null || true | ||
| 423 | M | ||
| 424 | ) | ||
| 425 | M2L_PID=$(printf '%s\n' "$M2L_READ" | sed -n 's/^pid=//p') | ||
| 426 | [ -n "$M2L_PID" ] || fail mac-to-linux "the Mac's capture carries no shell pid: $M2L_READ" | ||
| 427 | # The handoff prints this one line and only when QUIC was tried and lost, so | ||
| 428 | # its ABSENCE is the pin that the session rode QUIC. The daemon's bound port | ||
| 429 | # above says the arm existed to move onto. | ||
| 430 | printf '%s\n' "$M2L_READ" | grep -qx '0' || | ||
| 431 | fail mac-to-linux "the Mac's dial fell back to the ssh pipe; it said: $M2L_READ" | ||
| 432 | lssh 60 >/dev/null <<L || fail mac-to-linux "shell $M2L_PID is not a child of daemon $LPID on $LAN" | ||
| 433 | . ~/xos/os_oracle.sh | ||
| 434 | pid_children $LPID | grep -qx $M2L_PID | ||
| 435 | L | ||
| 436 | # The detach was a goodbye and not a tear: what the Mac typed is still on | ||
| 437 | # the Linux box's grid. | ||
| 438 | lssh 60 >/dev/null <<'L' || fail mac-to-linux "the session did not survive the Mac's detach" | ||
| 439 | ~/.local/bin/mux d dump | grep -q XOS-M2L-x86_64- | ||
| 440 | L | ||
| 441 | ok mac-to-linux "a macOS client typed into shell $M2L_PID under daemon $LPID on $LAN, over QUIC" | ||
| 442 | |||
| 443 | # ---- leg 6: mac-to-linux-quic — no ssh anywhere in the path ------------ | ||
| 444 | # The Darwin wolfSSL build's PSK handshake against the musl build's, in one | ||
| 445 | # round trip, joining the session leg 5 made. `mux a` never creates. | ||
| 446 | leg | ||
| 447 | AQ=$(mssh 120 <<M | ||
| 448 | MUX_KEY_FILE=\$HOME/xos/key \$HOME/.local/bin/mux a run --quic $LAN_IP:$QUIC_PORT \ | ||
| 449 | --key \$HOME/xos/key --timeout 20000 'echo m2lq-\$\$' | ||
| 450 | M | ||
| 451 | ) || fail mac-to-linux-quic "mux a run --quic answered nothing from $MAC: $AQ" | ||
| 452 | case "$AQ" in | ||
| 453 | *'"mechanism":"marks"'*) ;; | ||
| 454 | *) fail mac-to-linux-quic "no marks mechanism in the reply: $AQ" ;; | ||
| 455 | esac | ||
| 456 | case "$AQ" in | ||
| 457 | *'"exit_code":0'*) ;; | ||
| 458 | *) fail mac-to-linux-quic "no clean exit code in the reply: $AQ" ;; | ||
| 459 | esac | ||
| 460 | AQ_PID=$(printf '%s\n' "$AQ" | sed -n 's/.*m2lq-\([0-9][0-9]*\).*/\1/p') | ||
| 461 | [ -n "$AQ_PID" ] || fail mac-to-linux-quic "the reply carries no shell pid: $AQ" | ||
| 462 | lssh 60 >/dev/null <<L || fail mac-to-linux-quic "shell $AQ_PID is not a child of daemon $LPID" | ||
| 463 | . ~/xos/os_oracle.sh | ||
| 464 | pid_children $LPID | grep -qx $AQ_PID | ||
| 465 | L | ||
| 466 | ok mac-to-linux-quic "mux a over QUIC ran shell $AQ_PID under $LPID, marks, exit 0" | ||
| 467 | |||
| 468 | # ---- leg 7: linux-to-mac — the entry dial starts the Mac's daemon ------ | ||
| 469 | # `mux $MAC` from here is the ONLY thing in this gate allowed to start a | ||
| 470 | # daemon on the Mac: `mux d endpoint --start` is the asked dial's alone. The | ||
| 471 | # Mac's ssh login has no XDG_RUNTIME_DIR, so the daemon it starts binds the | ||
| 472 | # /tmp/mux-<uid> fallback — the Darwin path, exercised by a real `mux HOST` | ||
| 473 | # rather than by a test that sets a variable. | ||
| 474 | leg | ||
| 475 | set +e | ||
| 476 | timeout 120 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 477 | --out "$SCRATCH/l2m.cap" --err "$SCRATCH/l2m.err" -- "$MUX" "$MAC" \ | ||
| 478 | > "$SCRATCH/l2m.log" 2>&1 <<'EOF' | ||
| 479 | expect \x1b[?1049h 30000 | ||
| 480 | settle 900 20000 | ||
| 481 | send echo XOS-L2M-$(uname -m)-$$\n | ||
| 482 | expect XOS-L2M-arm64- 25000 | ||
| 483 | settle 800 15000 | ||
| 484 | send \x1cd | ||
| 485 | waitexit 15000 | ||
| 486 | EOF | ||
| 487 | RC=$? | ||
| 488 | set -e | ||
| 489 | [ "$RC" -eq 0 ] || { | ||
| 490 | echo "xos FAIL: linux-to-mac: ptyclient exited $RC (did the dial or the remote start refuse?):" | ||
| 491 | sed 's/^/ /' "$SCRATCH/l2m.log" 2>/dev/null || true | ||
| 492 | sed 's/^/ /' "$SCRATCH/l2m.err" 2>/dev/null || true | ||
| 493 | exit 1; } | ||
| 494 | L2M_PID=$(sed -n 's/.*XOS-L2M-arm64-\([0-9][0-9]*\).*/\1/p' "$SCRATCH/l2m.cap" | head -1) | ||
| 495 | [ -n "$L2M_PID" ] || fail linux-to-mac "the capture carries no shell pid" | ||
| 496 | |||
| 497 | MSTATE=$(mssh 90 <<M | ||
| 498 | . \$HOME/xos/os_oracle.sh | ||
| 499 | echo "pid=\$(pgrep -x mux | head -1)" | ||
| 500 | echo "n=\$(pgrep -x mux | wc -l | tr -d ' ')" | ||
| 501 | echo "announce=\$(\$HOME/.local/bin/mux d endpoint 2>&1 | head -1)" | ||
| 502 | echo "want=\$(real_path \$HOME/.local/bin/mux)" | ||
| 503 | udp_local_bound $QUIC_HEX && echo "udp=bound" || echo "udp=none" | ||
| 504 | M | ||
| 505 | ) | ||
| 506 | MPID=$(printf '%s\n' "$MSTATE" | sed -n 's/^pid=//p') | ||
| 507 | MNPROC=$(printf '%s\n' "$MSTATE" | sed -n 's/^n=//p') | ||
| 508 | MANN=$(printf '%s\n' "$MSTATE" | sed -n 's/^announce=//p') | ||
| 509 | MWANT=$(printf '%s\n' "$MSTATE" | sed -n 's/^want=//p') | ||
| 510 | [ -n "$MPID" ] || fail linux-to-mac "no mux on $MAC after the entry dial: $MSTATE" | ||
| 511 | [ "$MNPROC" = "1" ] || | ||
| 512 | fail linux-to-mac "$MAC runs $MNPROC mux processes after the detach, want 1 (the daemon): $MSTATE" | ||
| 513 | mssh 60 >/dev/null <<M || fail linux-to-mac "daemon $MPID does not hold $MAC_SOCK" | ||
| 514 | . \$HOME/xos/os_oracle.sh | ||
| 515 | pid_holds_unix_sock $MPID $MAC_SOCK | ||
| 516 | M | ||
| 517 | # Both sides canonical, and both through the oracle: pid_exe answers what | ||
| 518 | # lsof reports, which resolves every symlink on the way, so a $HOME reached | ||
| 519 | # through one would fail a correct exec unless the expected path is resolved | ||
| 520 | # the same way. | ||
| 521 | MIS=$(mssh 60 <<M | ||
| 522 | . \$HOME/xos/os_oracle.sh | ||
| 523 | pid_exe $MPID | ||
| 524 | M | ||
| 525 | ) | ||
| 526 | [ -n "$MWANT" ] && [ "$MIS" = "$MWANT" ] || | ||
| 527 | fail linux-to-mac "daemon $MPID execs '$MIS', not the installed '$MWANT'" | ||
| 528 | mssh 60 >/dev/null <<M || fail linux-to-mac "shell $L2M_PID is not a child of daemon $MPID" | ||
| 529 | . \$HOME/xos/os_oracle.sh | ||
| 530 | pid_children $MPID | grep -qx $L2M_PID | ||
| 531 | M | ||
| 532 | # WHICH transport carried it, read off the announce rather than assumed. The | ||
| 533 | # entry dial started this daemon bare — no --quic, no key — so what it says | ||
| 534 | # is `endpoint none`, the handoff's explicit "no coordinates", and the | ||
| 535 | # session stays on the ssh pipe. There is no positive line for that case | ||
| 536 | # (the client only speaks up when QUIC was tried and lost), so the pin is | ||
| 537 | # three facts: the daemon offers no arm, it binds no UDP port, and the | ||
| 538 | # client printed no fallback sentence. If a future daemon does announce an | ||
| 539 | # arm here, this leg says so instead of quietly grading the other transport. | ||
| 540 | # An `if` and not `grep ... && fail`: an AND-OR list whose first command | ||
| 541 | # fails is the ordinary case here, and reading it as the leg's verdict is | ||
| 542 | # the wrong way round. | ||
| 543 | case "$MANN" in | ||
| 544 | "endpoint none") | ||
| 545 | printf '%s\n' "$MSTATE" | grep -qx 'udp=none' || | ||
| 546 | fail linux-to-mac "$MAC announces no endpoint yet binds UDP $QUIC_PORT: $MSTATE" | ||
| 547 | if grep -q 'attaching over ssh' "$SCRATCH/l2m.err"; then | ||
| 548 | fail linux-to-mac "the client fell back from a QUIC arm the daemon says it has not got" | ||
| 549 | fi | ||
| 550 | L2M_WIRE="ssh handoff (the daemon announced 'endpoint none')" ;; | ||
| 551 | 'endpoint '*) | ||
| 552 | printf '%s\n' "$MSTATE" | grep -qx 'udp=bound' || | ||
| 553 | fail linux-to-mac "$MAC announces '$MANN' but binds no UDP $QUIC_PORT: $MSTATE" | ||
| 554 | if grep -q 'attaching over ssh' "$SCRATCH/l2m.err"; then | ||
| 555 | fail linux-to-mac "$MAC offered QUIC ('$MANN') and the dial fell back to ssh" | ||
| 556 | fi | ||
| 557 | L2M_WIRE="QUIC (the daemon announced '$MANN')" ;; | ||
| 558 | *) | ||
| 559 | fail linux-to-mac "$MAC's daemon answers no announce this leg can read: '$MANN'" ;; | ||
| 560 | esac | ||
| 561 | ok linux-to-mac "shell $L2M_PID under daemon $MPID on $MAC_SOCK, over $L2M_WIRE" | ||
| 562 | |||
| 563 | # ---- leg 8: linux-to-mac-quic — inbound UDP to a Mac daemon ------------ | ||
| 564 | # The question this leg asks and no other does: does macOS deliver inbound | ||
| 565 | # UDP $QUIC_PORT to a daemon a NON-GUI ssh session started? If the | ||
| 566 | # application firewall eats it, the dial times out in silence and this leg | ||
| 567 | # fails with the client's own words — which is a fact about the platform, | ||
| 568 | # not a harness problem to loosen away. | ||
| 569 | leg | ||
| 570 | mssh 60 >/dev/null <<'M' || fail linux-to-mac-quic "mux d stop was refused on $MAC" | ||
| 571 | $HOME/.local/bin/mux d stop | ||
| 572 | M | ||
| 573 | mssh 60 >/dev/null <<M || fail linux-to-mac-quic "the bare daemon $MPID survives mux d stop by 3 s" | ||
| 574 | . \$HOME/xos/os_oracle.sh | ||
| 575 | _i=0 | ||
| 576 | while [ \$_i -lt 30 ]; do | ||
| 577 | pid_alive $MPID || exit 0 | ||
| 578 | sleep 0.1; _i=\$((_i + 1)) | ||
| 579 | done | ||
| 580 | exit 1 | ||
| 581 | M | ||
| 582 | mssh 90 >/dev/null <<M || fail linux-to-mac-quic "mux d start -d --quic was refused on $MAC" | ||
| 583 | MUX_SHELL_INTEGRATION=1 \$HOME/.local/bin/mux d start -d --quic 0.0.0.0:$QUIC_PORT --key \$HOME/xos/key | ||
| 584 | M | ||
| 585 | MPID2=$(mssh 30 <<'M' | ||
| 586 | pgrep -x mux | head -1 | ||
| 587 | M | ||
| 588 | ) | ||
| 589 | [ -n "$MPID2" ] || fail linux-to-mac-quic "no mux on $MAC after the QUIC start" | ||
| 590 | [ "$MPID2" != "$MPID" ] || | ||
| 591 | fail linux-to-mac-quic "$MAC still runs the pre-stop daemon $MPID; the restart did not happen" | ||
| 592 | mssh 60 >/dev/null <<M || fail linux-to-mac-quic "daemon $MPID2 binds no UDP $QUIC_PORT on $MAC" | ||
| 593 | . \$HOME/xos/os_oracle.sh | ||
| 594 | _i=0 | ||
| 595 | while [ \$_i -lt 50 ]; do | ||
| 596 | udp_local_bound $QUIC_HEX && exit 0 | ||
| 597 | sleep 0.2; _i=\$((_i + 1)) | ||
| 598 | done | ||
| 599 | exit 1 | ||
| 600 | M | ||
| 601 | |||
| 602 | # A real pty attach first, because `mux a` joins and never creates. | ||
| 603 | export MUX_KEY_FILE="$SCRATCH/key" | ||
| 604 | set +e | ||
| 605 | timeout 120 "$PTYCLIENT" --cols 80 --rows 24 \ | ||
| 606 | --out "$SCRATCH/l2mq.cap" --err "$SCRATCH/l2mq.err" -- \ | ||
| 607 | "$MUX" "quic://$MAC_IP:$QUIC_PORT" \ | ||
| 608 | > "$SCRATCH/l2mq.log" 2>&1 <<'EOF' | ||
| 609 | expect \x1b[?1049h 30000 | ||
| 610 | settle 900 20000 | ||
| 611 | send echo XOS-L2MQ-$(uname -m)-$$\n | ||
| 612 | expect XOS-L2MQ-arm64- 25000 | ||
| 613 | settle 800 15000 | ||
| 614 | send \x1cd | ||
| 615 | waitexit 15000 | ||
| 616 | EOF | ||
| 617 | RC=$? | ||
| 618 | set -e | ||
| 619 | [ "$RC" -eq 0 ] || { | ||
| 620 | echo "xos FAIL: linux-to-mac-quic: the QUIC attach to $MAC_IP:$QUIC_PORT exited $RC." | ||
| 621 | echo " This is the leg that asks whether macOS delivers inbound UDP" | ||
| 622 | echo " $QUIC_PORT to a daemon a non-GUI ssh session started. The dial said:" | ||
| 623 | sed 's/^/ /' "$SCRATCH/l2mq.log" 2>/dev/null || true | ||
| 624 | sed 's/^/ /' "$SCRATCH/l2mq.err" 2>/dev/null || true | ||
| 625 | exit 1; } | ||
| 626 | L2MQ_PID=$(sed -n 's/.*XOS-L2MQ-arm64-\([0-9][0-9]*\).*/\1/p' "$SCRATCH/l2mq.cap" | head -1) | ||
| 627 | [ -n "$L2MQ_PID" ] || fail linux-to-mac-quic "the QUIC capture carries no shell pid" | ||
| 628 | mssh 60 >/dev/null <<M || fail linux-to-mac-quic "shell $L2MQ_PID is not a child of daemon $MPID2" | ||
| 629 | . \$HOME/xos/os_oracle.sh | ||
| 630 | pid_children $MPID2 | grep -qx $L2MQ_PID | ||
| 631 | M | ||
| 632 | AQ2=$(set +e; timeout 60 "$MUX" a run --quic "$MAC_IP:$QUIC_PORT" --key "$SCRATCH/key" \ | ||
| 633 | --timeout 20000 'echo l2mq-$$'; echo "RC=$?") | ||
| 634 | case "$AQ2" in | ||
| 635 | *RC=0*) ;; | ||
| 636 | *) fail linux-to-mac-quic "mux a run --quic to $MAC did not answer: $AQ2" ;; | ||
| 637 | esac | ||
| 638 | case "$AQ2" in | ||
| 639 | *'"mechanism":"marks"'*) ;; | ||
| 640 | *) fail linux-to-mac-quic "no marks mechanism in the reply from $MAC: $AQ2" ;; | ||
| 641 | esac | ||
| 642 | AQ2_PID=$(printf '%s\n' "$AQ2" | sed -n 's/.*l2mq-\([0-9][0-9]*\).*/\1/p') | ||
| 643 | [ -n "$AQ2_PID" ] || fail linux-to-mac-quic "the reply carries no shell pid: $AQ2" | ||
| 644 | mssh 60 >/dev/null <<M || fail linux-to-mac-quic "shell $AQ2_PID is not a child of daemon $MPID2" | ||
| 645 | . \$HOME/xos/os_oracle.sh | ||
| 646 | pid_children $MPID2 | grep -qx $AQ2_PID | ||
| 647 | M | ||
| 648 | unset MUX_KEY_FILE | ||
| 649 | ok linux-to-mac-quic "macOS delivered inbound UDP $QUIC_PORT: shells $L2MQ_PID and $AQ2_PID under $MPID2" | ||
| 650 | |||
| 651 | # ---- leg 9: upgrade-refused — the preflight is uname, both ways -------- | ||
| 652 | # `uname -m` is the FIRST line of the preflight and the whole gate: the | ||
| 653 | # image is never opened and nothing is streamed. The pin is therefore both | ||
| 654 | # the refusal's own words and the installed binary's hash on the far side, | ||
| 655 | # unchanged from what legs 2 and 3 put there. | ||
| 656 | leg | ||
| 657 | set +e | ||
| 658 | timeout 120 "$MUX" d upgrade "$MAC" --allow-same-version > "$SCRATCH/up_mac.out" 2>&1 | ||
| 659 | RC=$? | ||
| 660 | set -e | ||
| 661 | [ "$RC" -ne 0 ] || | ||
| 662 | fail upgrade-refused "mux d upgrade $MAC exited 0; an x86_64 image must not reach an arm64 box" | ||
| 663 | grep -q "is arm64 and this image is x86_64; refusing to push a binary that cannot run there" \ | ||
| 664 | "$SCRATCH/up_mac.out" || { | ||
| 665 | echo "xos FAIL: upgrade-refused: the Linux→Mac refusal is not the arch line; it said:" | ||
| 666 | sed 's/^/ /' "$SCRATCH/up_mac.out" | ||
| 667 | exit 1; } | ||
| 668 | |||
| 669 | # From the Mac, the mirror. The image there is aarch64 and the far side says | ||
| 670 | # x86_64, so the two words differ in BOTH spelling and machine — which is | ||
| 671 | # what makes this the mirror image and not the same test twice. | ||
| 672 | UPL=$(mssh 180 <<M | ||
| 673 | \$HOME/.local/bin/mux d upgrade $LAN --allow-same-version 2>&1 | ||
| 674 | echo "RC=\$?" | ||
| 675 | M | ||
| 676 | ) | ||
| 677 | case "$UPL" in | ||
| 678 | *RC=0*) fail upgrade-refused "mux d upgrade $LAN from $MAC exited 0; an aarch64 image must not reach an x86_64 box" ;; | ||
| 679 | esac | ||
| 680 | case "$UPL" in | ||
| 681 | *"is x86_64 and this image is aarch64; refusing to push a binary that cannot run there"*) ;; | ||
| 682 | *) fail upgrade-refused "the Mac→Linux refusal is not the arch line; it said: $UPL" ;; | ||
| 683 | esac | ||
| 684 | |||
| 685 | POST=$( | ||
| 686 | mssh 60 <<M | ||
| 687 | . \$HOME/xos/os_oracle.sh | ||
| 688 | echo "macsha=\$(sha256_of \$HOME/.local/bin/mux)" | ||
| 689 | echo "macpid=\$(pgrep -x mux | head -1)" | ||
| 690 | echo "macexe=\$(pid_exe \$(pgrep -x mux | head -1))" | ||
| 691 | M | ||
| 692 | lssh 60 <<L | ||
| 693 | . ~/xos/os_oracle.sh | ||
| 694 | echo "lansha=\$(sha256_of ~/.local/bin/mux)" | ||
| 695 | echo "lanpid=\$(pgrep -x mux | head -1)" | ||
| 696 | echo "lanexe=\$(pid_exe $LPID)" | ||
| 697 | L | ||
| 698 | ) | ||
| 699 | printf '%s\n' "$POST" | grep -qx "macsha=$MAC_BIN_SHA" || | ||
| 700 | fail upgrade-refused "$MAC's installed mux changed across the refusal: $POST" | ||
| 701 | printf '%s\n' "$POST" | grep -qx "lansha=$LAN_BIN_SHA" || | ||
| 702 | fail upgrade-refused "$LAN's installed mux changed across the refusal: $POST" | ||
| 703 | printf '%s\n' "$POST" | grep -qx "macpid=$MPID2" || | ||
| 704 | fail upgrade-refused "$MAC's daemon is no longer $MPID2: $POST" | ||
| 705 | printf '%s\n' "$POST" | grep -qx "lanpid=$LPID" || | ||
| 706 | fail upgrade-refused "$LAN's daemon is no longer $LPID: $POST" | ||
| 707 | printf '%s\n' "$POST" | grep -qx "macexe=$MAC_EXE" || | ||
| 708 | fail upgrade-refused "$MAC's daemon execs something else now: $POST" | ||
| 709 | ok upgrade-refused "both pushes refused on uname alone; both images and both daemons untouched" | ||
| 710 | |||
| 711 | # ---- leg 10: stop — and each box's socket goes with its daemon --------- | ||
| 712 | leg | ||
| 713 | mssh 60 >/dev/null <<'M' || fail stop "mux d stop was refused on $MAC" | ||
| 714 | $HOME/.local/bin/mux d stop | ||
| 715 | M | ||
| 716 | lssh 60 >/dev/null <<'L' || fail stop "mux d stop was refused on $LAN" | ||
| 717 | ~/.local/bin/mux d stop | ||
| 718 | L | ||
| 719 | MEND=$(mssh 60 <<M | ||
| 720 | . \$HOME/xos/os_oracle.sh | ||
| 721 | _i=0 | ||
| 722 | while [ \$_i -lt 30 ]; do | ||
| 723 | pid_alive $MPID2 || break | ||
| 724 | sleep 0.1; _i=\$((_i + 1)) | ||
| 725 | done | ||
| 726 | pid_alive $MPID2 && echo "alive=yes" || echo "alive=no" | ||
| 727 | [ -e $MAC_SOCK ] && echo "sock=present" || echo "sock=gone" | ||
| 728 | M | ||
| 729 | ) | ||
| 730 | printf '%s\n' "$MEND" | grep -qx 'alive=no' || | ||
| 731 | fail stop "$MAC's daemon $MPID2 survives mux d stop by 3 s: $MEND" | ||
| 732 | printf '%s\n' "$MEND" | grep -qx 'sock=gone' || | ||
| 733 | fail stop "$MAC_SOCK outlives the daemon that bound it: $MEND" | ||
| 734 | LEND=$(lssh 60 <<L | ||
| 735 | . ~/xos/os_oracle.sh | ||
| 736 | _i=0 | ||
| 737 | while [ \$_i -lt 30 ]; do | ||
| 738 | pid_alive $LPID || break | ||
| 739 | sleep 0.1; _i=\$((_i + 1)) | ||
| 740 | done | ||
| 741 | pid_alive $LPID && echo "alive=yes" || echo "alive=no" | ||
| 742 | [ -e $LAN_SOCK ] && echo "sock=present" || echo "sock=gone" | ||
| 743 | L | ||
| 744 | ) | ||
| 745 | printf '%s\n' "$LEND" | grep -qx 'alive=no' || | ||
| 746 | fail stop "$LAN's daemon $LPID survives mux d stop by 3 s: $LEND" | ||
| 747 | printf '%s\n' "$LEND" | grep -qx 'sock=gone' || | ||
| 748 | fail stop "$LAN_SOCK outlives the daemon that bound it: $LEND" | ||
| 749 | ok stop "both daemons gone within 3 s, each taking its own socket with it" | ||
| 750 | |||
| 751 | # ---- the pin ---------------------------------------------------------- | ||
| 752 | # A literal, test/vm.sh's and test/mac.sh's style: adding a leg means | ||
| 753 | # editing this by hand, so a leg that stops running cannot pass by not being | ||
| 754 | # counted. | ||
| 755 | [ "$OK_COUNT" = "10" ] || { | ||
| 756 | echo "xos FAIL: $OK_COUNT legs ran, the pin says 10 —" | ||
| 757 | echo " a leg was added (update the pin) or silently lost" | ||
| 758 | exit 1 | ||
| 759 | } | ||
| 760 | echo "xos OK ($OK_COUNT legs: $MAC ($MAC_ARCH) and $LAN ($LAN_ARCH), each the other's client)" | ||