d0550135
test: e2e — ssh shim + five handoff scenarios; pins 15->20 / 28->33
a73x 2026-08-11 15:44
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -18,7 +18,11 @@ OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" | |||
| 18 | # never the developer's real ~/.config/mux/key. | 18 | # never the developer's real ~/.config/mux/key. |
| 19 | XDG_CONFIG_HOME="${TMPDIR:-/tmp}/mux-e2e-cfg-$$" | 19 | XDG_CONFIG_HOME="${TMPDIR:-/tmp}/mux-e2e-cfg-$$" |
| 20 | XDG_STATE_HOME="${TMPDIR:-/tmp}/mux-e2e-state-$$" | 20 | XDG_STATE_HOME="${TMPDIR:-/tmp}/mux-e2e-state-$$" |
| 21 | export XDG_CONFIG_HOME XDG_STATE_HOME | 21 | # M14: and the same for the handoff's per-host cache. It holds a KEY, and |
| 22 | # the M14 scenarios both read and poison it — neither of which may ever | ||
| 23 | # touch the developer's real ~/.cache/mux. | ||
| 24 | XDG_CACHE_HOME="${TMPDIR:-/tmp}/mux-e2e-cache-$$" | ||
| 25 | export XDG_CONFIG_HOME XDG_STATE_HOME XDG_CACHE_HOME | ||
| 22 | # Second daemon, used only by the M7 abort scenario; declared here so the | 26 | # Second daemon, used only by the M7 abort scenario; declared here so the |
| 23 | # trap below can reference them under `set -u` before they are ever started. | 27 | # trap below can reference them under `set -u` before they are ever started. |
| 24 | SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" | 28 | SOCK2="${TMPDIR:-/tmp}/muxd-e2e-abort-$$.sock" |
| @@ -85,6 +89,35 @@ SOCK14="${TMPDIR:-/tmp}/muxd-e2e-astart-$$.sock" | |||
| 85 | SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock" | 89 | SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock" |
| 86 | APID="" | 90 | APID="" |
| 87 | PAPID="" | 91 | PAPID="" |
| 92 | # M14 ssh→QUIC handoff. The shim that stands in for ssh (built where the | ||
| 93 | # scenarios run, declared here so the trap can reach it), and a RUNTIME DIR | ||
| 94 | # per daemon: the handoff dials the DEFAULT socket on the far side, and the | ||
| 95 | # far side is this box, so each scenario's default socket has to be one of | ||
| 96 | # ours rather than the operator's. Two daemons, no more — the one a cold | ||
| 97 | # attach auto-starts, and the key-mismatch one that cannot be shared | ||
| 98 | # because it holds a key nothing else on this box has. | ||
| 99 | SSHIM_DIR="${TMPDIR:-/tmp}/muxd-e2e-sshim-$$" | ||
| 100 | SSHIM_PIDLOG="$SSHIM_DIR/pids" | ||
| 101 | export SSHIM_PIDLOG | ||
| 102 | HRUN="${TMPDIR:-/tmp}/mux-e2e-hrun-$$" | ||
| 103 | HRUN2="${TMPDIR:-/tmp}/mux-e2e-hrun2-$$" | ||
| 104 | SOCK16="$HRUN/muxd.sock" | ||
| 105 | SOCK17="$HRUN2/muxd.sock" | ||
| 106 | HKEY="${TMPDIR:-/tmp}/mux-e2e-hkey-$$" | ||
| 107 | # A config home that is a FILE: `muxd endpoint` cannot create a key under | ||
| 108 | # it and cannot find one, which is the announce-none scenario's lever. | ||
| 109 | HCFGBAD="${TMPDIR:-/tmp}/mux-e2e-hnokey-$$" | ||
| 110 | # Bands of their own, like every other port here, so a concurrent suite | ||
| 111 | # cannot collide — and BELOW the ephemeral range (32768–60999 here), which | ||
| 112 | # for HDEADPORT is not housekeeping but the scenario's correctness. It is | ||
| 113 | # the port the poisoned cache points at, and the daemon it is poisoning | ||
| 114 | # against holds a KERNEL-ASSIGNED ephemeral port: land on that one and the | ||
| 115 | # "dead" port is live, the dial succeeds, and the self-heal scenario | ||
| 116 | # quietly asserts a heal that never had to happen. | ||
| 117 | HQPORT=$(( 11000 + ($$ % 4000) )) | ||
| 118 | HDEADPORT=$(( 16000 + ($$ % 4000) )) | ||
| 119 | HAPID="" | ||
| 120 | HDPID="" | ||
| 88 | 121 | ||
| 89 | # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one | 122 | # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one |
| 90 | # such line on exit; every field is a key=value pair, so a rename or reorder | 123 | # such line on exit; every field is a key=value pair, so a rename or reorder |
| @@ -154,11 +187,17 @@ wait_gone() { | |||
| 154 | # being unlinked, and the process can still be a fraction behind that. Only | 187 | # being unlinked, and the process can still be a fraction behind that. Only |
| 155 | # a pid can say the daemon itself ended, which is the assertion the M13 | 188 | # a pid can say the daemon itself ended, which is the assertion the M13 |
| 156 | # teardowns owe — never the stop command's own claim. | 189 | # teardowns owe — never the stop command's own claim. |
| 190 | # | ||
| 191 | # M14 gave it a second kind of caller: the cold-handoff scenario asks the | ||
| 192 | # same question of the ssh the client is supposed to have killed once QUIC | ||
| 193 | # took over. So the message names the pid and leaves the EXPECTATION to | ||
| 194 | # each call site's label — the two are "stop said it stopped" and "QUIC | ||
| 195 | # said it had taken over", and one wording cannot honestly claim both. | ||
| 157 | wait_pid_gone() { | 196 | wait_pid_gone() { |
| 158 | _i=0 | 197 | _i=0 |
| 159 | while kill -0 "$1" 2>/dev/null; do | 198 | while kill -0 "$1" 2>/dev/null; do |
| 160 | _i=$((_i + 1)); [ "$_i" -lt 40 ] || { | 199 | _i=$((_i + 1)); [ "$_i" -lt 40 ] || { |
| 161 | echo "e2e FAIL: $2: stop reported stopped but pid $1 still runs"; exit 1; } | 200 | echo "e2e FAIL: $2: pid $1 is still running 2s later"; exit 1; } |
| 162 | sleep 0.05 | 201 | sleep 0.05 |
| 163 | done | 202 | done |
| 164 | } | 203 | } |
| @@ -290,6 +329,16 @@ cleanup() { | |||
| 290 | # `muxd stop` on a path nobody serves is a no-op that exits 0. | 329 | # `muxd stop` on a path nobody serves is a no-op that exits 0. |
| 291 | [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK14" >/dev/null 2>&1 || true | 330 | [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK14" >/dev/null 2>&1 || true |
| 292 | [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK15" >/dev/null 2>&1 || true | 331 | [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK15" >/dev/null 2>&1 || true |
| 332 | # M14's two, the same way and for the same reason: the cold-handoff | ||
| 333 | # daemon is spawned by `muxd endpoint` and is nobody's child here, so | ||
| 334 | # its pid is read off the up-line and the socket stop backstops the | ||
| 335 | # window before that read. The stops come BEFORE the rm -rf below — | ||
| 336 | # unlinking a runtime dir first would leave a live daemon this trap | ||
| 337 | # could no longer reach by path. | ||
| 338 | [ -n "$HDPID" ] && kill "$HDPID" 2>/dev/null || true | ||
| 339 | [ -n "$HAPID" ] && kill "$HAPID" 2>/dev/null || true | ||
| 340 | [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK16" >/dev/null 2>&1 || true | ||
| 341 | [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK17" >/dev/null 2>&1 || true | ||
| 293 | rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \ | 342 | rm -f "$SOCK8" "$SOCK8T" "$SOCK11" "$OUT.start" "$OUT.start2" "$OUT.s8" \ |
| 294 | "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \ | 343 | "$OUT.ra" "$OUT.rb" "$OUT.goal" "$OUT.g9" "$OUT.dead" \ |
| 295 | "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ | 344 | "$SOCK9" "$SOCK10" "$OUT.env1" "$OUT.env2" \ |
| @@ -328,10 +377,17 @@ cleanup() { | |||
| 328 | "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" \ | 377 | "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" \ |
| 329 | "$OUT.stop" "$OUT.stop2" \ | 378 | "$OUT.stop" "$OUT.stop2" \ |
| 330 | "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" | 379 | "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" |
| 380 | # M14 handoff. The shim, both runtime dirs (each holding its daemon's | ||
| 381 | # socket), the second key, the unusable config home, and the five | ||
| 382 | # captures. | ||
| 383 | rm -f "$HKEY" "$HCFGBAD" \ | ||
| 384 | "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ | ||
| 385 | "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" | ||
| 386 | rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||
| 331 | # The convergence files a FAILING assert_converged leaves behind | 387 | # The convergence files a FAILING assert_converged leaves behind |
| 332 | # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not | 388 | # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not |
| 333 | # chased here: on a failing run they are the evidence. | 389 | # chased here: on a failing run they are the evidence. |
| 334 | rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "${NOKEY_CFG:-}" | 390 | rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" "${NOKEY_CFG:-}" |
| 335 | } | 391 | } |
| 336 | trap cleanup EXIT INT TERM | 392 | trap cleanup EXIT INT TERM |
| 337 | 393 | ||
| @@ -2098,7 +2154,7 @@ grep -q '^muxd: stopped' "$OUT.stop" || { | |||
| 2098 | echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; } | 2154 | echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; } |
| 2099 | [ ! -S "$SOCK14" ] || { | 2155 | [ ! -S "$SOCK14" ] || { |
| 2100 | echo "e2e FAIL: stop left the socket behind"; ls -l "$SOCK14"; exit 1; } | 2156 | echo "e2e FAIL: stop left the socket behind"; ls -l "$SOCK14"; exit 1; } |
| 2101 | wait_pid_gone "$APID" "proxy auto-start" | 2157 | wait_pid_gone "$APID" "proxy auto-start: stop reported stopped" |
| 2102 | APID="" | 2158 | APID="" |
| 2103 | 2159 | ||
| 2104 | # Idempotence control: stop with nothing there is exit 0 and says so. | 2160 | # Idempotence control: stop with nothing there is exit 0 and says so. |
| @@ -2172,22 +2228,396 @@ grep -q '^muxd: stopped' "$OUT.stop" || { | |||
| 2172 | echo "e2e FAIL: pty leg stop did not report stopped"; cat "$OUT.stop"; exit 1; } | 2228 | echo "e2e FAIL: pty leg stop did not report stopped"; cat "$OUT.stop"; exit 1; } |
| 2173 | [ ! -S "$SOCK15" ] || { | 2229 | [ ! -S "$SOCK15" ] || { |
| 2174 | echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; } | 2230 | echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; } |
| 2175 | wait_pid_gone "$PAPID" "pty leg" | 2231 | wait_pid_gone "$PAPID" "pty leg: stop reported stopped" |
| 2176 | PAPID="" | 2232 | PAPID="" |
| 2177 | rm -f "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop" | 2233 | rm -f "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop" |
| 2178 | ok "local mux auto-start under the pty fixture" | 2234 | ok "local mux auto-start under the pty fixture" |
| 2179 | 2235 | ||
| 2236 | # --- M14: the ssh→QUIC handoff ----------------------------------------- | ||
| 2237 | # | ||
| 2238 | # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and | ||
| 2239 | # attaches over pure QUIC thereafter — falling back to that same ssh, one | ||
| 2240 | # deadline later, when QUIC cannot get through. Five scenarios: cold, warm, | ||
| 2241 | # a poisoned cache, a key mismatch, and a remote that can offer nothing. | ||
| 2242 | # | ||
| 2243 | # The shim IS ssh as far as the client can tell: `ssh HOST CMD...` drops | ||
| 2244 | # HOST and execs CMD here, so `ssh whatever muxd endpoint` runs the real | ||
| 2245 | # binary against a socket of this suite's own making and every line of the | ||
| 2246 | # client's handoff code runs for real, with no network anywhere. | ||
| 2247 | # | ||
| 2248 | # Every invocation appends its pid to SSHIM_PIDLOG BEFORE the exec, and | ||
| 2249 | # that log is how these scenarios know whether ssh ran. Never `ps | grep | ||
| 2250 | # ssh`: this box belongs to someone who is probably ssh'd into something, | ||
| 2251 | # and a warm attach's entire claim is that no ssh existed. | ||
| 2252 | mkdir -p "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||
| 2253 | : > "$SSHIM_PIDLOG" | ||
| 2254 | cat > "$SSHIM_DIR/ssh" <<'SHIM' | ||
| 2255 | #!/bin/sh | ||
| 2256 | echo $$ >> "${SSHIM_PIDLOG:?}" | ||
| 2257 | shift | ||
| 2258 | exec "$@" | ||
| 2259 | SHIM | ||
| 2260 | chmod +x "$SSHIM_DIR/ssh" | ||
| 2261 | # The PATH every scenario below runs the client under: the shim shadows any | ||
| 2262 | # real ssh, and `muxd` resolves to the binary under test rather than to | ||
| 2263 | # whatever is installed. | ||
| 2264 | HPATH="$SSHIM_DIR:$(dirname "$MUXD"):$PATH" | ||
| 2265 | # `user@` on the host is not decoration. The QUIC dial strips it | ||
| 2266 | # (handoff.dialHost) and gets a loopback literal, which is what makes the | ||
| 2267 | # dial reach the daemon at all; the CACHE keys on the whole word, which is | ||
| 2268 | # what keeps these three scenarios' cache file distinct from scenario (d)'s | ||
| 2269 | # bare `127.0.0.1`. A fake name would resolve to nothing and every QUIC | ||
| 2270 | # attempt below would fail at DNS without ever dialling. | ||
| 2271 | HHOST="mux-e2e@127.0.0.1" | ||
| 2272 | HCACHE="$XDG_CACHE_HOME/mux/hosts/$HHOST" | ||
| 2273 | # Nothing may be set in the environment that redirects the key resolution: | ||
| 2274 | # `muxd endpoint` reads MUX_KEY_FILE first, and an operator who has one | ||
| 2275 | # exported would silently change which key gets announced. | ||
| 2276 | unset MUX_KEY_FILE | ||
| 2277 | |||
| 2278 | # (a) COLD: no daemon, no cache. The attach has to produce the daemon (via | ||
| 2279 | # `muxd endpoint`'s own auto-start), fetch coordinates over the shim, and | ||
| 2280 | # END UP ON QUIC — and that last part is proven by observation rather than | ||
| 2281 | # inference: the ssh the announce arrived on is killed by the client on | ||
| 2282 | # QUIC success, so once its pid is OBSERVED gone, a second marker still | ||
| 2283 | # making the round trip can only be riding QUIC. The pipe's owner is dead. | ||
| 2284 | { printf 'printf "cold-%%s\\n" one\n'; sleep 6; \ | ||
| 2285 | printf 'printf "cold-%%s\\n" two\n'; sleep 2.5; printf '\034'; } | \ | ||
| 2286 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 60 \ | ||
| 2287 | "$MUX" "$HHOST" > "$OUT.h1" 2> "$OUT.h1.err" & | ||
| 2288 | H1PID=$! | ||
| 2289 | # The bounded wait, with a message of its own, because of what it is FOR: | ||
| 2290 | # a client blocked on an announce that is never coming has no other | ||
| 2291 | # symptom. It cannot be left to the suite's timeout or the trap — those | ||
| 2292 | # report a dead process, not which line stopped arriving. | ||
| 2293 | wait_for "$OUT.h1" "cold-one" 25 || { | ||
| 2294 | echo "e2e FAIL: cold handoff: no marker in 25s — the attach never converged." | ||
| 2295 | echo " A client that blocks here read no announce line off the ssh pipe." | ||
| 2296 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 2297 | |||
| 2298 | # Exactly one ssh, and its pid, from the log rather than from ps. | ||
| 2299 | HSHIMS=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2300 | [ "$HSHIMS" -eq 1 ] || { | ||
| 2301 | echo "e2e FAIL: cold handoff ran $HSHIMS ssh invocations, want exactly 1" | ||
| 2302 | cat "$SSHIM_PIDLOG"; exit 1; } | ||
| 2303 | HSHIMPID=$(head -1 "$SSHIM_PIDLOG") | ||
| 2304 | wait_pid_gone "$HSHIMPID" "cold handoff: QUIC took over, so ssh must be gone" | ||
| 2305 | # ...and the session did not go with it. | ||
| 2306 | wait_for "$OUT.h1" "cold-two" 20 || { | ||
| 2307 | echo "e2e FAIL: cold handoff: the session stopped converging once ssh was gone," | ||
| 2308 | echo " so the bytes were riding the pipe rather than QUIC" | ||
| 2309 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 2310 | set +e | ||
| 2311 | wait "$H1PID" | ||
| 2312 | RC=$? | ||
| 2313 | set -e | ||
| 2314 | [ "$RC" -eq 0 ] || { | ||
| 2315 | echo "e2e FAIL: cold handoff client exited $RC (want 0; 124 means it hung)" | ||
| 2316 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 2317 | |||
| 2318 | # The daemon `muxd endpoint` started, by the pid its own up-line reported — | ||
| 2319 | # the only handle this suite has on a process that is nobody's child. Same | ||
| 2320 | # accepted gap as the M13 blocks: a failure above leaves it untracked, and | ||
| 2321 | # the trap's stop-by-socket is what covers that. | ||
| 2322 | grep -q '^muxd endpoint: starting' "$OUT.h1.err" || { | ||
| 2323 | echo "e2e FAIL: cold handoff printed no endpoint starting line" | ||
| 2324 | cat "$OUT.h1.err"; exit 1; } | ||
| 2325 | HAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.h1.err" | head -1) | ||
| 2326 | [ -n "$HAPID" ] || { | ||
| 2327 | echo "e2e FAIL: endpoint up-line carries no pid"; cat "$OUT.h1.err"; exit 1; } | ||
| 2328 | kill -0 "$HAPID" || { echo "e2e FAIL: the handoff daemon is not alive"; exit 1; } | ||
| 2329 | |||
| 2330 | # The cache: present, private (it holds the key), and naming a port that is | ||
| 2331 | # really being listened on. The last one is the assertion that matters — | ||
| 2332 | # a remembered number proves nothing, a remembered number that maps to the | ||
| 2333 | # daemon's actual UDP socket proves the announce was true. /proc/net/udp | ||
| 2334 | # for the same reasons the M8 block uses it, and 00000000 because the lazy | ||
| 2335 | # bind takes 0.0.0.0. | ||
| 2336 | [ -f "$HCACHE" ] || { | ||
| 2337 | echo "e2e FAIL: cold handoff left no cache at $HCACHE" | ||
| 2338 | ls -la "$XDG_CACHE_HOME/mux/hosts" 2>/dev/null; exit 1; } | ||
| 2339 | HCPERM=$(stat -c %a "$HCACHE") | ||
| 2340 | [ "$HCPERM" = "600" ] || { | ||
| 2341 | echo "e2e FAIL: handoff cache perms $HCPERM, want 600 (the file holds a key)"; exit 1; } | ||
| 2342 | HCDPERM=$(stat -c %a "$XDG_CACHE_HOME/mux/hosts") | ||
| 2343 | [ "$HCDPERM" = "700" ] || { | ||
| 2344 | echo "e2e FAIL: handoff cache dir perms $HCDPERM, want 700"; exit 1; } | ||
| 2345 | HPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | ||
| 2346 | [ -n "$HPORT" ] || { | ||
| 2347 | echo "e2e FAIL: the cache is not an announce line:"; cat -v "$HCACHE"; exit 1; } | ||
| 2348 | HHEXUDP=$(printf '00000000:%04X' "$HPORT") | ||
| 2349 | grep -qi " $HHEXUDP " /proc/net/udp || { | ||
| 2350 | echo "e2e FAIL: the cached port $HPORT names no UDP listener ($HHEXUDP);" | ||
| 2351 | echo " the announce the client believed was not the daemon's" | ||
| 2352 | exit 1; } | ||
| 2353 | assert_converged "$OUT.h1" "$SOCK16" "cold handoff" | ||
| 2354 | ok "cold handoff: ssh fetches the coordinates, QUIC carries the session" | ||
| 2355 | |||
| 2356 | # (b) WARM: the same daemon, the cache from (a). No ssh may run at all — | ||
| 2357 | # the whole point of remembering coordinates — so the pidlog count is | ||
| 2358 | # snapshotted and must not move. The transport is also silent: the M13 | ||
| 2359 | # contract that a warm attach prints no spawn progress, extended to the | ||
| 2360 | # handoff's own line, which has nothing to report when nothing fell back. | ||
| 2361 | HSHIMS_B=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2362 | set +e | ||
| 2363 | { printf 'printf "warm-%%s\\n" ok\n'; sleep 2.5; printf '\034'; } | \ | ||
| 2364 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 \ | ||
| 2365 | "$MUX" "$HHOST" > "$OUT.h2" 2> "$OUT.h2.err" | ||
| 2366 | RC=$? | ||
| 2367 | set -e | ||
| 2368 | [ "$RC" -eq 0 ] || { | ||
| 2369 | echo "e2e FAIL: warm handoff client exited $RC (want 0)" | ||
| 2370 | cat "$OUT.h2" "$OUT.h2.err" 2>/dev/null; exit 1; } | ||
| 2371 | grep -q "warm-ok" "$OUT.h2" || { | ||
| 2372 | echo "e2e FAIL: warm handoff served no session" | ||
| 2373 | cat "$OUT.h2" "$OUT.h2.err" 2>/dev/null; exit 1; } | ||
| 2374 | HSHIMS_B2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2375 | [ "$HSHIMS_B2" -eq "$HSHIMS_B" ] || { | ||
| 2376 | echo "e2e FAIL: warm handoff spawned ssh: pidlog went $HSHIMS_B -> $HSHIMS_B2" | ||
| 2377 | echo " (want unchanged — a cache hit dials QUIC and never shells out)" | ||
| 2378 | exit 1; } | ||
| 2379 | grep -q 'starting' "$OUT.h2.err" && { | ||
| 2380 | echo "e2e FAIL: warm handoff printed spawn progress"; cat "$OUT.h2.err"; exit 1; } | ||
| 2381 | grep -q 'unreachable, attaching over ssh' "$OUT.h2.err" && { | ||
| 2382 | echo "e2e FAIL: warm handoff printed the fallback line"; cat "$OUT.h2.err"; exit 1; } | ||
| 2383 | assert_converged "$OUT.h2" "$SOCK16" "warm handoff" | ||
| 2384 | ok "warm handoff: the cache dials QUIC, ssh never runs (pidlog still $HSHIMS_B2)" | ||
| 2385 | |||
| 2386 | # (c) STALE CACHE: a well-formed announce naming a port nothing holds. The | ||
| 2387 | # invariant under test is that a stale cache costs TIME and never | ||
| 2388 | # correctness: the dial fails, the cold path refetches, QUIC succeeds on | ||
| 2389 | # the fresh coordinates, and the user is told nothing — a fallback line | ||
| 2390 | # here would be reporting a failure that did not happen. | ||
| 2391 | # | ||
| 2392 | # The poison is written by hand because shell cannot import handoff.zig. | ||
| 2393 | # The grammar is `endpoint <port> <64 hex>\n`; the key is STOLEN from the | ||
| 2394 | # real cache above and only the port is swapped, so the line differs from a | ||
| 2395 | # genuine one in exactly the way the scenario is about. | ||
| 2396 | # | ||
| 2397 | # It costs a full deadline (~2s): a refused loopback port is not fast — | ||
| 2398 | # the ICMP lands on a sendto inside the QUIC drain and is discarded there | ||
| 2399 | # (measured, M14 Task 4) — so every wait below is budgeted for it. | ||
| 2400 | # | ||
| 2401 | # Which is also why the detach byte is six seconds out rather than the | ||
| 2402 | # suite's usual two and a half. The stdin script runs on its own clock, | ||
| 2403 | # in PARALLEL with an attach that spends a deadline before the session | ||
| 2404 | # even exists, and `waitReady` answers Ctrl-\ DURING a handshake (M8 pins | ||
| 2405 | # exactly that). A detach landing inside the dial window aborts the | ||
| 2406 | # attach: no session, no error, exit 0 — a scenario that passes its exit | ||
| 2407 | # check having tested nothing. Measured here at 2172ms to the marker, so | ||
| 2408 | # six seconds is margin, not superstition. | ||
| 2409 | HHEXKEY=$(sed -n 's/^endpoint [0-9][0-9]* \([0-9a-f]*\)$/\1/p' "$HCACHE") | ||
| 2410 | [ -n "$HHEXKEY" ] || { echo "e2e FAIL: could not read the cached key"; exit 1; } | ||
| 2411 | printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE" | ||
| 2412 | chmod 600 "$HCACHE" | ||
| 2413 | HSHIMS_C=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2414 | set +e | ||
| 2415 | { printf 'printf "heal-%%s\\n" ok\n'; sleep 6; printf '\034'; } | \ | ||
| 2416 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 \ | ||
| 2417 | "$MUX" "$HHOST" > "$OUT.h3" 2> "$OUT.h3.err" | ||
| 2418 | RC=$? | ||
| 2419 | set -e | ||
| 2420 | [ "$RC" -eq 0 ] || { | ||
| 2421 | echo "e2e FAIL: stale-cache handoff client exited $RC (want 0)" | ||
| 2422 | cat "$OUT.h3" "$OUT.h3.err" 2>/dev/null; exit 1; } | ||
| 2423 | grep -q "heal-ok" "$OUT.h3" || { | ||
| 2424 | echo "e2e FAIL: stale-cache handoff served no session" | ||
| 2425 | cat "$OUT.h3" "$OUT.h3.err" 2>/dev/null; exit 1; } | ||
| 2426 | # The absent-grep, and the control that proves it can fire is scenario (d) | ||
| 2427 | # below: the same pattern, asserted PRESENT, against a build where the | ||
| 2428 | # fallback really happened. | ||
| 2429 | grep -q 'unreachable, attaching over ssh' "$OUT.h3.err" && { | ||
| 2430 | echo "e2e FAIL: a stale cache printed the fallback line; it costs time, not correctness" | ||
| 2431 | cat "$OUT.h3.err"; exit 1; } | ||
| 2432 | HPORT_C=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | ||
| 2433 | [ "$HPORT_C" = "$HPORT" ] || { | ||
| 2434 | echo "e2e FAIL: the cache did not heal: holds $HPORT_C, want the live $HPORT" | ||
| 2435 | cat -v "$HCACHE"; exit 1; } | ||
| 2436 | HSHIMS_C2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2437 | [ "$((HSHIMS_C2 - HSHIMS_C))" -eq 1 ] || { | ||
| 2438 | echo "e2e FAIL: the healing attach ran $((HSHIMS_C2 - HSHIMS_C)) ssh invocations, want 1" | ||
| 2439 | exit 1; } | ||
| 2440 | assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal" | ||
| 2441 | ok "a stale cache self-heals: one refetch, no fallback line, the real port cached" | ||
| 2442 | |||
| 2443 | # (d) FALLBACK LINE, by key mismatch. A second daemon holding a key nothing | ||
| 2444 | # else on this box has; `muxd endpoint` announces the DEFAULT key (that is | ||
| 2445 | # what the endpoint process resolves) together with this daemon's real | ||
| 2446 | # port, so the coordinates are perfectly well-formed and the handshake can | ||
| 2447 | # never complete. PSK auth is mutual and a listener does not answer a peer | ||
| 2448 | # it cannot authenticate, so the client gets silence and spends the whole | ||
| 2449 | # deadline — which is exactly the case the fallback line exists for. | ||
| 2450 | # | ||
| 2451 | # The host is the literal `127.0.0.1`, and it has to be: a fake name would | ||
| 2452 | # fail at DNS instantly, skip the deadline, and leave this scenario pinning | ||
| 2453 | # nothing. Its cache file is `127.0.0.1`, distinct from (a)'s. | ||
| 2454 | # | ||
| 2455 | # This scenario costs ~2s of wall time by design. That is the price of | ||
| 2456 | # pinning the budget, and it is the only place the suite pays it. | ||
| 2457 | # | ||
| 2458 | # Six seconds to the detach byte, for the reason spelled out in (c): the | ||
| 2459 | # stdin script's clock runs in parallel with a deadline-long attach, and a | ||
| 2460 | # Ctrl-\ that lands inside the dial is answered by `waitReady` as an abort | ||
| 2461 | # — which would leave this scenario asserting a fallback line that a | ||
| 2462 | # correct build never got as far as printing. | ||
| 2463 | head -c 32 /dev/urandom > "$HKEY" | ||
| 2464 | chmod 600 "$HKEY" | ||
| 2465 | "$MUXD" run --sock "$SOCK17" --shell /bin/sh \ | ||
| 2466 | --quic "127.0.0.1:$HQPORT" --key "$HKEY" --quic-idle-ms 15000 & | ||
| 2467 | HDPID=$! | ||
| 2468 | i=0 | ||
| 2469 | while [ ! -S "$SOCK17" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | ||
| 2470 | [ -S "$SOCK17" ] || { echo "e2e FAIL: key-mismatch daemon never bound"; exit 1; } | ||
| 2471 | HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2472 | HT0=$(date +%s%N) | ||
| 2473 | set +e | ||
| 2474 | { printf 'printf "fallback-%%s\\n" ok\n'; sleep 6; printf '\034'; } | \ | ||
| 2475 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ | ||
| 2476 | "$MUX" "127.0.0.1" > "$OUT.h4" 2> "$OUT.h4.err" & | ||
| 2477 | H4PID=$! | ||
| 2478 | set -e | ||
| 2479 | # Timed to the LINE rather than to the client's exit: the line is printed | ||
| 2480 | # the moment the budget runs out, so the elapsed time here IS the deadline | ||
| 2481 | # the dial spent, with no session sleeps folded into it. | ||
| 2482 | # Only the stderr is printed on failure, deliberately: the complaint is | ||
| 2483 | # about a line that is missing from it, and the capture beside it is a | ||
| 2484 | # screenful of escape sequences that would bury the answer. | ||
| 2485 | wait_for "$OUT.h4.err" "unreachable, attaching over ssh" 15 || { | ||
| 2486 | echo "e2e FAIL: key mismatch printed no fallback line in 15s; its stderr was:" | ||
| 2487 | cat "$OUT.h4.err" 2>/dev/null; exit 1; } | ||
| 2488 | HT1=$(date +%s%N) | ||
| 2489 | HMS=$(( (HT1 - HT0) / 1000000 )) | ||
| 2490 | # The exact line, with the port the daemon really holds: a fallback that | ||
| 2491 | # named the wrong coordinates would be a different bug wearing this one's | ||
| 2492 | # message. | ||
| 2493 | grep -q "^mux: quic://127.0.0.1:$HQPORT unreachable, attaching over ssh$" "$OUT.h4.err" || { | ||
| 2494 | echo "e2e FAIL: the fallback line is not the pinned one (want port $HQPORT):" | ||
| 2495 | cat "$OUT.h4.err"; exit 1; } | ||
| 2496 | # Bounded on BOTH sides. The floor is what proves the dial actually | ||
| 2497 | # happened — an implementation that never tried would fall back instantly — | ||
| 2498 | # and it sits below the 2000ms budget only by the slack the measurement | ||
| 2499 | # itself needs. The ceiling is what proves the budget bounds anything at | ||
| 2500 | # all: without it a hang reads as a pass that took a while. | ||
| 2501 | [ "$HMS" -ge 1500 ] || { | ||
| 2502 | echo "e2e FAIL: the fallback came after ${HMS}ms, too fast to have spent the" | ||
| 2503 | echo " 2000ms QUIC budget — the dial cannot have happened" | ||
| 2504 | exit 1; } | ||
| 2505 | [ "$HMS" -lt 10000 ] || { | ||
| 2506 | echo "e2e FAIL: the fallback took ${HMS}ms; the deadline is meant to bound it" | ||
| 2507 | exit 1; } | ||
| 2508 | set +e | ||
| 2509 | wait "$H4PID" | ||
| 2510 | RC=$? | ||
| 2511 | set -e | ||
| 2512 | [ "$RC" -eq 0 ] || { | ||
| 2513 | echo "e2e FAIL: key-mismatch client exited $RC (want 0)" | ||
| 2514 | cat "$OUT.h4" "$OUT.h4.err" 2>/dev/null; exit 1; } | ||
| 2515 | # ...and the session it fell back to is a real one, over the pipe that | ||
| 2516 | # carried the announce. | ||
| 2517 | grep -q "fallback-ok" "$OUT.h4" || { | ||
| 2518 | echo "e2e FAIL: key-mismatch attach printed the line but served no session" | ||
| 2519 | cat "$OUT.h4" "$OUT.h4.err" 2>/dev/null; exit 1; } | ||
| 2520 | HSHIMS_D2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2521 | [ "$((HSHIMS_D2 - HSHIMS_D))" -eq 1 ] || { | ||
| 2522 | echo "e2e FAIL: the fallback attach ran $((HSHIMS_D2 - HSHIMS_D)) ssh invocations, want 1" | ||
| 2523 | exit 1; } | ||
| 2524 | assert_converged "$OUT.h4" "$SOCK17" "handoff fallback to ssh" | ||
| 2525 | ok "a key mismatch falls back to the ssh pipe: one deadline (${HMS}ms), one line" | ||
| 2526 | |||
| 2527 | # (e) ANNOUNCE-NONE: a remote that cannot produce coordinates at all. The | ||
| 2528 | # lever is a config home that is a FILE, so the default key can be neither | ||
| 2529 | # created nor found — `muxd endpoint` says so on stderr (ssh carries it to | ||
| 2530 | # the user) and announces `endpoint none`. | ||
| 2531 | # | ||
| 2532 | # Against that, no deadline may be paid: no coordinates were ever in play, | ||
| 2533 | # so there is nothing to dial and nothing to report as unreachable. Its own | ||
| 2534 | # host, uncached by construction — reusing (a)'s would hit the cache and | ||
| 2535 | # never reach the announce at all. | ||
| 2536 | : > "$HCFGBAD" | ||
| 2537 | HSHIMS_E=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2538 | HT2=$(date +%s%N) | ||
| 2539 | set +e | ||
| 2540 | { printf 'printf "none-%%s\\n" ok\n'; sleep 2.5; printf '\034'; } | \ | ||
| 2541 | SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" XDG_CONFIG_HOME="$HCFGBAD" \ | ||
| 2542 | PATH="$HPATH" timeout 40 \ | ||
| 2543 | "$MUX" "mux-e2e-none@127.0.0.1" > "$OUT.h5" 2> "$OUT.h5.err" & | ||
| 2544 | H5PID=$! | ||
| 2545 | set -e | ||
| 2546 | wait_for "$OUT.h5" "none-ok" 20 || { | ||
| 2547 | echo "e2e FAIL: announce-none handoff never served a session" | ||
| 2548 | cat "$OUT.h5" "$OUT.h5.err" 2>/dev/null; exit 1; } | ||
| 2549 | HT3=$(date +%s%N) | ||
| 2550 | HMS_E=$(( (HT3 - HT2) / 1000000 )) | ||
| 2551 | set +e | ||
| 2552 | wait "$H5PID" | ||
| 2553 | RC=$? | ||
| 2554 | set -e | ||
| 2555 | [ "$RC" -eq 0 ] || { | ||
| 2556 | echo "e2e FAIL: announce-none client exited $RC (want 0)" | ||
| 2557 | cat "$OUT.h5" "$OUT.h5.err" 2>/dev/null; exit 1; } | ||
| 2558 | # The remote said why, in one line, on the stderr ssh already carries. | ||
| 2559 | grep -q '^muxd endpoint: .*staying on ssh' "$OUT.h5.err" || { | ||
| 2560 | echo "e2e FAIL: announce-none said nothing about why it stayed on ssh" | ||
| 2561 | cat "$OUT.h5.err"; exit 1; } | ||
| 2562 | # ...and said nothing about a fallback, because nothing fell back: the | ||
| 2563 | # client never held coordinates to fail with. | ||
| 2564 | grep -q 'unreachable, attaching over ssh' "$OUT.h5.err" && { | ||
| 2565 | echo "e2e FAIL: announce-none printed the fallback line; no dial was ever possible" | ||
| 2566 | cat "$OUT.h5.err"; exit 1; } | ||
| 2567 | HSHIMS_E2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 2568 | [ "$((HSHIMS_E2 - HSHIMS_E))" -eq 1 ] || { | ||
| 2569 | echo "e2e FAIL: announce-none ran $((HSHIMS_E2 - HSHIMS_E)) ssh invocations, want 1" | ||
| 2570 | exit 1; } | ||
| 2571 | # No budget spent, measured to the first marker rather than to exit. The | ||
| 2572 | # bound is the 2000ms deadline with room to spare for an attach that has | ||
| 2573 | # to spawn a shell through two processes: anything at or above the budget | ||
| 2574 | # means a dial happened that the announce said was impossible. | ||
| 2575 | [ "$HMS_E" -lt 1500 ] || { | ||
| 2576 | echo "e2e FAIL: announce-none took ${HMS_E}ms to converge; a QUIC budget was" | ||
| 2577 | echo " spent on coordinates the remote said it did not have" | ||
| 2578 | exit 1; } | ||
| 2579 | assert_converged "$OUT.h5" "$SOCK16" "announce-none stays on ssh" | ||
| 2580 | ok "announce-none stays on ssh: no deadline paid (${HMS_E}ms), no fallback line" | ||
| 2581 | |||
| 2582 | # Both daemons down by the sanctioned verb, and OBSERVED dead by pid — the | ||
| 2583 | # M13 teardown shape, including the labelled RC capture: a stop that fails | ||
| 2584 | # exits 1, which under `set -e` would abort with no line of its own and the | ||
| 2585 | # trap would then remove the evidence. | ||
| 2586 | set +e | ||
| 2587 | "$MUXD" stop --sock "$SOCK16" 2> "$OUT.stop" | ||
| 2588 | RC_STOP=$? | ||
| 2589 | set -e | ||
| 2590 | [ "$RC_STOP" = "0" ] || { | ||
| 2591 | echo "e2e FAIL: handoff daemon stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; } | ||
| 2592 | grep -q '^muxd: stopped' "$OUT.stop" || { | ||
| 2593 | echo "e2e FAIL: handoff daemon stop did not report stopped"; cat "$OUT.stop"; exit 1; } | ||
| 2594 | [ ! -S "$SOCK16" ] || { | ||
| 2595 | echo "e2e FAIL: stop left the handoff daemon's socket"; ls -l "$SOCK16"; exit 1; } | ||
| 2596 | wait_pid_gone "$HAPID" "handoff daemon: stop reported stopped" | ||
| 2597 | HAPID="" | ||
| 2598 | set +e | ||
| 2599 | "$MUXD" stop --sock "$SOCK17" 2> "$OUT.stop" | ||
| 2600 | RC_STOP=$? | ||
| 2601 | set -e | ||
| 2602 | [ "$RC_STOP" = "0" ] || { | ||
| 2603 | echo "e2e FAIL: key-mismatch daemon stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; } | ||
| 2604 | wait_pid_gone "$HDPID" "key-mismatch daemon: stop reported stopped" | ||
| 2605 | HDPID="" | ||
| 2606 | rm -f "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ | ||
| 2607 | "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD" | ||
| 2608 | rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||
| 2609 | |||
| 2180 | # The pins. Literals, not variables set from counting something else — | 2610 | # The pins. Literals, not variables set from counting something else — |
| 2181 | # "assert the literal, never the constant the code under test reads" | 2611 | # "assert the literal, never the constant the code under test reads" |
| 2182 | # (decisions.md, M10). 15 scenario checkpoints; 28 convergence points. | 2612 | # (decisions.md, M10). 20 scenario checkpoints; 33 convergence points. |
| 2183 | # Anyone adding a scenario updates these by hand, on purpose. | 2613 | # Anyone adding a scenario updates these by hand, on purpose. |
| 2184 | [ "$OK_COUNT" = "15" ] || { | 2614 | [ "$OK_COUNT" = "20" ] || { |
| 2185 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 15 —" | 2615 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 20 —" |
| 2186 | echo " a scenario was added (update the pin) or silently lost" | 2616 | echo " a scenario was added (update the pin) or silently lost" |
| 2187 | exit 1 | 2617 | exit 1 |
| 2188 | } | 2618 | } |
| 2189 | [ "$CONV_COUNT" = "28" ] || { | 2619 | [ "$CONV_COUNT" = "33" ] || { |
| 2190 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 28" | 2620 | echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 33" |
| 2191 | exit 1 | 2621 | exit 1 |
| 2192 | } | 2622 | } |
| 2193 | echo "e2e OK (15 scenarios, 28 convergence points)" | 2623 | echo "e2e OK (20 scenarios, 33 convergence points)" |