d92023ef
test: 04_handoff moves to a file of its own
a73x 2026-08-26 18:54
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -102,7 +102,7 @@ done | |||
| 102 | E2E_DIR=$(dirname "$0") | 102 | E2E_DIR=$(dirname "$0") |
| 103 | . "$E2E_DIR/e2e_lib.sh" | 103 | . "$E2E_DIR/e2e_lib.sh" |
| 104 | 104 | ||
| 105 | E2E_GROUPS='01_boot 02_predict 03_side' | 105 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff' |
| 106 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses | 106 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses |
| 107 | # these by name rather than running them: a group whose fixtures are | 107 | # these by name rather than running them: a group whose fixtures are |
| 108 | # missing does not fail, it passes having tested something else — and a | 108 | # missing does not fail, it passes having tested something else — and a |
| @@ -145,445 +145,6 @@ for _g in $E2E_GROUPS; do | |||
| 145 | . "$E2E_DIR/e2e_$_g.sh" | 145 | . "$E2E_DIR/e2e_$_g.sh" |
| 146 | done | 146 | done |
| 147 | 147 | ||
| 148 | # M14 ssh→QUIC handoff. The shim that stands in for ssh (built where the | ||
| 149 | # scenarios run), and a RUNTIME DIR | ||
| 150 | # per daemon: the handoff dials the DEFAULT socket on the far side, and the | ||
| 151 | # far side is this box, so each scenario's default socket has to be one of | ||
| 152 | # ours rather than the operator's. Two daemons, no more — the one a cold | ||
| 153 | # attach auto-starts, and the key-mismatch one that cannot be shared | ||
| 154 | # because it holds a key nothing else on this box has. | ||
| 155 | SSHIM_DIR="${TMPDIR:-/tmp}/muxd-e2e-sshim-$$" | ||
| 156 | defer_rm "$SSHIM_DIR" | ||
| 157 | SSHIM_PIDLOG="$SSHIM_DIR/pids" | ||
| 158 | export SSHIM_PIDLOG | ||
| 159 | HRUN="${TMPDIR:-/tmp}/mux-e2e-hrun-$$" | ||
| 160 | defer_rm "$HRUN" | ||
| 161 | HRUN2="${TMPDIR:-/tmp}/mux-e2e-hrun2-$$" | ||
| 162 | defer_rm "$HRUN2" | ||
| 163 | SOCK16="$HRUN/muxd.sock" | ||
| 164 | SOCK17="$HRUN2/muxd.sock" | ||
| 165 | defer_sock "$SOCK16" "$SOCK17" | ||
| 166 | HKEY="${TMPDIR:-/tmp}/mux-e2e-hkey-$$" | ||
| 167 | defer_rm "$HKEY" | ||
| 168 | # A config home that is a FILE: `muxd endpoint` cannot create a key under | ||
| 169 | # it and cannot find one, which is the announce-none scenario's lever. | ||
| 170 | HCFGBAD="${TMPDIR:-/tmp}/mux-e2e-hnokey-$$" | ||
| 171 | defer_rm "$HCFGBAD" | ||
| 172 | # Bands of their own, like every other port here, so a concurrent suite | ||
| 173 | # cannot collide — and BELOW the ephemeral range (32768–60999 here), which | ||
| 174 | # for HDEADPORT is not housekeeping but the scenario's correctness. It is | ||
| 175 | # the port the poisoned cache points at, and the daemon it is poisoning | ||
| 176 | # against holds a KERNEL-ASSIGNED ephemeral port: land on that one and the | ||
| 177 | # "dead" port is live, the dial succeeds, and the self-heal scenario | ||
| 178 | # quietly asserts a heal that never had to happen. | ||
| 179 | HQPORT=$(( 11000 + ($$ % 4000) )) | ||
| 180 | HDEADPORT=$(( 16000 + ($$ % 4000) )) | ||
| 181 | |||
| 182 | # --- M14: the ssh→QUIC handoff ----------------------------------------- | ||
| 183 | # | ||
| 184 | # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and | ||
| 185 | # attaches over pure QUIC thereafter — falling back to that same ssh, one | ||
| 186 | # deadline later, when QUIC cannot get through. Five scenarios: cold, warm, | ||
| 187 | # a poisoned cache, a key mismatch, and a remote that can offer nothing. | ||
| 188 | # | ||
| 189 | # The shim IS ssh as far as the client can tell: `ssh HOST CMD...` drops | ||
| 190 | # HOST and execs CMD here, so `ssh whatever muxd endpoint` runs the real | ||
| 191 | # binary against a socket of this suite's own making and every line of the | ||
| 192 | # client's handoff code runs for real, with no network anywhere. | ||
| 193 | # | ||
| 194 | # Every invocation appends its pid to SSHIM_PIDLOG BEFORE the exec, and | ||
| 195 | # that log is how these scenarios know whether ssh ran. Never `ps | grep | ||
| 196 | # ssh`: this box belongs to someone who is probably ssh'd into something, | ||
| 197 | # and a warm attach's entire claim is that no ssh existed. | ||
| 198 | # | ||
| 199 | # The contract it depends on — real ssh's own: everything after HOST is | ||
| 200 | # joined with spaces and handed to the REMOTE user's shell. The shim | ||
| 201 | # models that with `sh -c "$*"`, which is what lets the client's quoted | ||
| 202 | # remote command (the PATH-suffix form recipeFor builds) run here the | ||
| 203 | # same way sshd would run it. A bare `exec "$@"` was enough while the | ||
| 204 | # command was plain argv; it execs a program literally named | ||
| 205 | # `PATH=... muxd endpoint` now, and dies at 127 without an announce. | ||
| 206 | # | ||
| 207 | # The arity guard covers the case that would NOT be loud: `ssh HOST` with | ||
| 208 | # no command at all. There, `shift` empties "$@" and a bare `exec` is a | ||
| 209 | # no-op, so the shim would exit 0 having run nothing — silence that looks | ||
| 210 | # exactly like success. Note what the guard's 97 does and does not buy: | ||
| 211 | # nothing prints it, and the client experiences it only as an ssh that | ||
| 212 | # closed without an announce. It converts "quietly wrong" into "visibly | ||
| 213 | # no announce", which is a diagnosis this suite can act on. | ||
| 214 | mkdir -p "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||
| 215 | : > "$SSHIM_PIDLOG" | ||
| 216 | cat > "$SSHIM_DIR/ssh" <<'SHIM' | ||
| 217 | #!/bin/sh | ||
| 218 | [ $# -ge 2 ] || exit 97 | ||
| 219 | echo $$ >> "${SSHIM_PIDLOG:?}" | ||
| 220 | shift | ||
| 221 | exec /bin/sh -c "$*" | ||
| 222 | SHIM | ||
| 223 | chmod +x "$SSHIM_DIR/ssh" | ||
| 224 | # The PATH every scenario below runs the client under: the shim shadows any | ||
| 225 | # real ssh, and `muxd` resolves to the binary under test rather than to | ||
| 226 | # whatever is installed. | ||
| 227 | HPATH="$SSHIM_DIR:$(dirname "$MUXD"):$PATH" | ||
| 228 | # `user@` on the host is not decoration. The QUIC dial strips it | ||
| 229 | # (handoff.dialHost) and gets a loopback literal, which is what makes the | ||
| 230 | # dial reach the daemon at all; the CACHE keys on the whole word, which is | ||
| 231 | # what keeps these three scenarios' cache file distinct from scenario (d)'s | ||
| 232 | # bare `127.0.0.1`. A fake name would resolve to nothing and every QUIC | ||
| 233 | # attempt below would fail at DNS without ever dialling. | ||
| 234 | HHOST="mux-e2e@127.0.0.1" | ||
| 235 | HCACHE="$XDG_CACHE_HOME/mux/hosts/$HHOST" | ||
| 236 | # (a) COLD: no daemon, no cache. The attach has to produce the daemon (via | ||
| 237 | # `muxd endpoint`'s own auto-start), fetch coordinates over the shim, and | ||
| 238 | # END UP ON QUIC — and that last part is proven by observation rather than | ||
| 239 | # inference: the ssh the announce arrived on is killed by the client on | ||
| 240 | # QUIC success, so once its pid is OBSERVED gone, a second marker still | ||
| 241 | # making the round trip can only be riding QUIC. The pipe's owner is dead. | ||
| 242 | pipe_mux "$OUT.h1" "$OUT.h1.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 60 \ | ||
| 243 | "$MUX" "$HHOST" | ||
| 244 | pipe_send 'printf "cold-%%s\\n" one\n' | ||
| 245 | # The bounded wait, with a message of its own, because of what it is FOR: | ||
| 246 | # a client blocked on an announce that is never coming has no other | ||
| 247 | # symptom. It cannot be left to the suite's timeout or the trap — those | ||
| 248 | # report a dead process, not which line stopped arriving. | ||
| 249 | wait_for "$OUT.h1" "cold-one" 25 || { | ||
| 250 | echo "e2e FAIL: cold handoff: no marker in 25s — the attach never converged." | ||
| 251 | echo " A client that blocks here read no announce line off the ssh pipe." | ||
| 252 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 253 | |||
| 254 | # Exactly one ssh, and its pid, from the log rather than from ps. | ||
| 255 | HSHIMS=$(wc -l < "$SSHIM_PIDLOG") | ||
| 256 | [ "$HSHIMS" -eq 1 ] || { | ||
| 257 | echo "e2e FAIL: cold handoff ran $HSHIMS ssh invocations, want exactly 1" | ||
| 258 | cat "$SSHIM_PIDLOG"; exit 1; } | ||
| 259 | HSHIMPID=$(head -1 "$SSHIM_PIDLOG") | ||
| 260 | wait_pid_gone "$HSHIMPID" "cold handoff: QUIC took over, so ssh must be gone" | ||
| 261 | # The second marker is typed only now, after the death was OBSERVED, so its | ||
| 262 | # round trip is traffic that moved after the pipe's owner died — by | ||
| 263 | # construction, not by a clock. What guarantees the ordering on the product | ||
| 264 | # side: openHandoff kills the ssh child the moment QUIC is ready, before | ||
| 265 | # Transport.open returns, so before the session can emit a single byte. | ||
| 266 | # Anyone who moves that kill (to after the first frame, into a deferred | ||
| 267 | # teardown) takes this scenario's premise with them. | ||
| 268 | pipe_send 'printf "cold-%%s\\n" two\n' | ||
| 269 | wait_for "$OUT.h1" "cold-two" 20 || { | ||
| 270 | echo "e2e FAIL: cold handoff: the session stopped converging once ssh was gone," | ||
| 271 | echo " so the bytes were riding the pipe rather than QUIC" | ||
| 272 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 273 | pipe_detach "cold handoff client" | ||
| 274 | |||
| 275 | # The daemon `muxd endpoint` started, by the pid its own up-line reported — | ||
| 276 | # the only handle this suite has on a process that is nobody's child. Same | ||
| 277 | # accepted gap as the M13 blocks: a failure above leaves it untracked, and | ||
| 278 | # the trap's stop-by-socket is what covers that. | ||
| 279 | grep -q '^muxd endpoint: starting' "$OUT.h1.err" || { | ||
| 280 | echo "e2e FAIL: cold handoff printed no endpoint starting line" | ||
| 281 | cat "$OUT.h1.err"; exit 1; } | ||
| 282 | HAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.h1.err" | head -1) | ||
| 283 | defer_kill "$HAPID" | ||
| 284 | [ -n "$HAPID" ] || { | ||
| 285 | echo "e2e FAIL: endpoint up-line carries no pid"; cat "$OUT.h1.err"; exit 1; } | ||
| 286 | kill -0 "$HAPID" 2>/dev/null || { | ||
| 287 | echo "e2e FAIL: the handoff daemon (pid $HAPID) is not alive"; exit 1; } | ||
| 288 | |||
| 289 | # The cache: present, private (it holds the key), and naming a port that is | ||
| 290 | # really being listened on. The last one is the assertion that matters — | ||
| 291 | # a remembered number proves nothing, a remembered number that maps to the | ||
| 292 | # daemon's actual UDP socket proves the announce was true. /proc/net/udp | ||
| 293 | # for the same reasons the M8 block uses it, and 00000000 because the lazy | ||
| 294 | # bind takes 0.0.0.0. | ||
| 295 | [ -f "$HCACHE" ] || { | ||
| 296 | echo "e2e FAIL: cold handoff left no cache at $HCACHE" | ||
| 297 | ls -la "$XDG_CACHE_HOME/mux/hosts" 2>/dev/null; exit 1; } | ||
| 298 | HCPERM=$(stat -c %a "$HCACHE") | ||
| 299 | [ "$HCPERM" = "600" ] || { | ||
| 300 | echo "e2e FAIL: handoff cache perms $HCPERM, want 600 (the file holds a key)"; exit 1; } | ||
| 301 | HCDPERM=$(stat -c %a "$XDG_CACHE_HOME/mux/hosts") | ||
| 302 | [ "$HCDPERM" = "700" ] || { | ||
| 303 | echo "e2e FAIL: handoff cache dir perms $HCDPERM, want 700"; exit 1; } | ||
| 304 | HPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | ||
| 305 | [ -n "$HPORT" ] || { | ||
| 306 | echo "e2e FAIL: the cache is not an announce line:"; cat -v "$HCACHE"; exit 1; } | ||
| 307 | HHEXUDP=$(printf '00000000:%04X' "$HPORT") | ||
| 308 | grep -qi " $HHEXUDP " /proc/net/udp || { | ||
| 309 | echo "e2e FAIL: the cached port $HPORT names no UDP listener ($HHEXUDP);" | ||
| 310 | echo " the announce the client believed was not the daemon's" | ||
| 311 | exit 1; } | ||
| 312 | assert_converged "$OUT.h1" "$SOCK16" "cold handoff" | ||
| 313 | ok "cold handoff: ssh fetches the coordinates, QUIC carries the session" | ||
| 314 | |||
| 315 | # (b) WARM: the same daemon, the cache from (a). No ssh may run at all — | ||
| 316 | # the whole point of remembering coordinates — so the pidlog count is | ||
| 317 | # snapshotted and must not move. The transport is also silent: the M13 | ||
| 318 | # contract that a warm attach prints no spawn progress, extended to the | ||
| 319 | # handoff's own line, which has nothing to report when nothing fell back. | ||
| 320 | HSHIMS_B=$(wc -l < "$SSHIM_PIDLOG") | ||
| 321 | pipe_mux "$OUT.h2" "$OUT.h2.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 "$MUX" "$HHOST" | ||
| 322 | pipe_send 'printf "warm-%%s\\n" ok\n' | ||
| 323 | await_out "$OUT.h2" "warm-ok" "warm-ok never reached the client" | ||
| 324 | pipe_detach "warm handoff client" | ||
| 325 | grep -q "warm-ok" "$OUT.h2" || { | ||
| 326 | echo "e2e FAIL: warm handoff served no session" | ||
| 327 | cat "$OUT.h2" "$OUT.h2.err" 2>/dev/null; exit 1; } | ||
| 328 | HSHIMS_B2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 329 | # This also carries M13's warm-attach silence contract, which is why there | ||
| 330 | # is no separate grep for spawn progress here: `muxd endpoint` is the only | ||
| 331 | # thing that prints a starting line, it only runs under ssh, and ssh | ||
| 332 | # provably did not run. A progress grep could therefore never fail on its | ||
| 333 | # own — it would be a check the line above has already made. | ||
| 334 | [ "$HSHIMS_B2" -eq "$HSHIMS_B" ] || { | ||
| 335 | echo "e2e FAIL: warm handoff spawned ssh: pidlog went $HSHIMS_B -> $HSHIMS_B2" | ||
| 336 | echo " (want unchanged — a cache hit dials QUIC and never shells out)" | ||
| 337 | exit 1; } | ||
| 338 | grep -q 'unreachable, attaching over ssh' "$OUT.h2.err" && { | ||
| 339 | echo "e2e FAIL: warm handoff printed the fallback line"; cat "$OUT.h2.err"; exit 1; } | ||
| 340 | assert_converged "$OUT.h2" "$SOCK16" "warm handoff" | ||
| 341 | ok "warm handoff: the cache dials QUIC, ssh never runs (pidlog still $HSHIMS_B2)" | ||
| 342 | |||
| 343 | # (c) STALE CACHE: a well-formed announce naming a port nothing holds. The | ||
| 344 | # invariant under test is that a stale cache costs a ROUND TRIP and never | ||
| 345 | # correctness: the dial fails, the cold path refetches, QUIC succeeds on | ||
| 346 | # the fresh coordinates, and the user is told nothing — a fallback line | ||
| 347 | # here would be reporting a failure that did not happen. | ||
| 348 | # | ||
| 349 | # The poison is written by hand because shell cannot import handoff.zig. | ||
| 350 | # The grammar is `endpoint <port> <64 hex>\n`; the key is STOLEN from the | ||
| 351 | # real cache above and only the port is swapped, so the line differs from a | ||
| 352 | # genuine one in exactly the way the scenario is about. | ||
| 353 | # | ||
| 354 | # Until M15 this cost a full deadline (~2s), because the ICMP refusal from | ||
| 355 | # the dead port landed on a sendto inside the QUIC drain and was discarded | ||
| 356 | # there. Both socket paths act on it now, so the dial dies in about one | ||
| 357 | # loopback round trip and the whole heal measures ~200ms. The bound below | ||
| 358 | # was a floor on that spent budget and is now a ceiling on its absence. | ||
| 359 | # | ||
| 360 | # The detach is sent only after the marker has rendered, and that ordering | ||
| 361 | # is load-bearing: `waitReady` answers Ctrl-\ DURING a handshake (M8 pins | ||
| 362 | # exactly that), so a detach landing inside the dial window aborts the | ||
| 363 | # attach — no session, no error, exit 0 — and a scenario that paced the | ||
| 364 | # detach on a clock could pass its exit check having tested nothing. | ||
| 365 | HHEXKEY=$(sed -n 's/^endpoint [0-9][0-9]* \([0-9a-f]*\)$/\1/p' "$HCACHE") | ||
| 366 | [ -n "$HHEXKEY" ] || { echo "e2e FAIL: could not read the cached key"; exit 1; } | ||
| 367 | printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE" | ||
| 368 | chmod 600 "$HCACHE" | ||
| 369 | HSHIMS_C=$(wc -l < "$SSHIM_PIDLOG") | ||
| 370 | HT4=$(date +%s%N) | ||
| 371 | pipe_mux "$OUT.h3" "$OUT.h3.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 \ | ||
| 372 | "$MUX" "$HHOST" | ||
| 373 | pipe_send 'printf "heal-%%s\\n" ok\n' | ||
| 374 | wait_for "$OUT.h3" "heal-ok" 30 || { | ||
| 375 | echo "e2e FAIL: stale-cache handoff never served a session" | ||
| 376 | cat "$OUT.h3" "$OUT.h3.err" 2>/dev/null; exit 1; } | ||
| 377 | HT5=$(date +%s%N) | ||
| 378 | HMS_C=$(( (HT5 - HT4) / 1000000 )) | ||
| 379 | pipe_detach "stale-cache handoff client" | ||
| 380 | # The absent-grep, and the control that proves it can fire is scenario (d) | ||
| 381 | # below: the same pattern, asserted PRESENT, against a build where the | ||
| 382 | # fallback really happened. | ||
| 383 | grep -q 'unreachable, attaching over ssh' "$OUT.h3.err" && { | ||
| 384 | echo "e2e FAIL: a stale cache printed the fallback line; it costs time, not correctness" | ||
| 385 | cat "$OUT.h3.err"; exit 1; } | ||
| 386 | HPORT_C=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | ||
| 387 | [ "$HPORT_C" = "$HPORT" ] || { | ||
| 388 | echo "e2e FAIL: the cache did not heal: holds $HPORT_C, want the live $HPORT" | ||
| 389 | cat -v "$HCACHE"; exit 1; } | ||
| 390 | HSHIMS_C2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 391 | [ "$((HSHIMS_C2 - HSHIMS_C))" -eq 1 ] || { | ||
| 392 | echo "e2e FAIL: the healing attach ran $((HSHIMS_C2 - HSHIMS_C)) ssh invocations, want 1" | ||
| 393 | exit 1; } | ||
| 394 | # The other half of the invariant, and it changed direction in M15. A | ||
| 395 | # floor used to live here: while a refused port ran the whole budget out, | ||
| 396 | # spending it was proof the poisoned coordinates had been dialled, which | ||
| 397 | # no other check in this scenario makes. That proof is gone — the heal now | ||
| 398 | # costs ~200ms, which is indistinguishable from a build that read no cache | ||
| 399 | # and simply went cold. Scenario (b) is what catches THAT build (a warm | ||
| 400 | # attach there must spawn no ssh at all), and this scenario now leans on | ||
| 401 | # it rather than pretending to stand alone. | ||
| 402 | # | ||
| 403 | # What replaces the floor is its mirror: the refusal must be SEEN, not | ||
| 404 | # waited out. A build that swallowed the ICMP again — the pre-M15 bug — | ||
| 405 | # would push this back over 2s, and the ceiling is what says so. 1500 | ||
| 406 | # clears the measured ~200ms by a wide margin and still sits under the | ||
| 407 | # 2000ms budget, so nothing between "fast" and "spent the budget" hides. | ||
| 408 | [ "$HMS_C" -lt 1500 ] || { | ||
| 409 | echo "e2e FAIL: the stale-cache attach took ${HMS_C}ms, where a refused port is" | ||
| 410 | echo " meant to fail in about one round trip (~200ms end to end). Either" | ||
| 411 | echo " the ICMP refusal is being swallowed again and this dial spent the" | ||
| 412 | echo " 2000ms QUIC budget, or this box is loaded enough to stretch a" | ||
| 413 | echo " 200ms attach past 1500 — the daemon log and the load average" | ||
| 414 | echo " tell those apart" | ||
| 415 | exit 1; } | ||
| 416 | assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal" | ||
| 417 | ok "a stale cache self-heals: one refetch (${HMS_C}ms, no budget spent), no fallback line, real port cached" | ||
| 418 | |||
| 419 | # (d) FALLBACK LINE, by key mismatch. A second daemon holding a key nothing | ||
| 420 | # else on this box has; `muxd endpoint` announces the DEFAULT key (that is | ||
| 421 | # what the endpoint process resolves) together with this daemon's real | ||
| 422 | # port, so the coordinates are perfectly well-formed and the handshake can | ||
| 423 | # never complete. PSK auth is mutual and a listener does not answer a peer | ||
| 424 | # it cannot authenticate, so the client gets silence and spends the whole | ||
| 425 | # deadline — which is exactly the case the fallback line exists for. | ||
| 426 | # | ||
| 427 | # The host is the literal `127.0.0.1`, and it has to be: a fake name would | ||
| 428 | # fail at DNS instantly, skip the deadline, and leave this scenario pinning | ||
| 429 | # nothing. Its cache file is `127.0.0.1`, distinct from (a)'s. | ||
| 430 | # | ||
| 431 | # This scenario costs ~2s of wall time by design. That is the price of | ||
| 432 | # pinning the budget, and it is the only place the suite pays it. | ||
| 433 | # | ||
| 434 | # Six seconds to the detach byte, for the reason spelled out in (c): the | ||
| 435 | # stdin script's clock runs in parallel with a deadline-long attach, and a | ||
| 436 | # Ctrl-\ that lands inside the dial is answered by `waitReady` as an abort | ||
| 437 | # — which would leave this scenario asserting a fallback line that a | ||
| 438 | # correct build never got as far as printing. | ||
| 439 | head -c 32 /dev/urandom > "$HKEY" | ||
| 440 | chmod 600 "$HKEY" | ||
| 441 | # No --quic-idle-ms: the daemon's default is already 15000, and spelling | ||
| 442 | # it here would suggest the ceiling's reasoning below depends on this | ||
| 443 | # flag. It does not — the 15000 that bounds an overrunning dial is the | ||
| 444 | # CLIENT's `quic_idle_ms_default`, which this daemon cannot influence. | ||
| 445 | start_daemon "$SOCK17" "$OUT.d17.d" "key-mismatch daemon never bound" --shell /bin/sh \ | ||
| 446 | --quic "127.0.0.1:$HQPORT" --key "$HKEY" | ||
| 447 | HDPID=$DPID | ||
| 448 | HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") | ||
| 449 | HT0=$(date +%s%N) | ||
| 450 | pipe_mux "$OUT.h4" "$OUT.h4.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ | ||
| 451 | "$MUX" "127.0.0.1" | ||
| 452 | pipe_send 'printf "fallback-%%s\\n" ok\n' | ||
| 453 | # Timed to the LINE rather than to the client's exit, so no session sleeps | ||
| 454 | # are folded in. The elapsed time is the dial's budget plus launch overhead | ||
| 455 | # (measured 26-129ms), which only inflates it — the floor is safe. | ||
| 456 | # Only the stderr is printed on failure, deliberately: the complaint is | ||
| 457 | # about a line that is missing from it, and the capture beside it is a | ||
| 458 | # screenful of escape sequences that would bury the answer. | ||
| 459 | wait_for "$OUT.h4.err" "unreachable, attaching over ssh" 15 || { | ||
| 460 | echo "e2e FAIL: key mismatch printed no fallback line in 15s; its stderr was:" | ||
| 461 | cat "$OUT.h4.err" 2>/dev/null; exit 1; } | ||
| 462 | HT1=$(date +%s%N) | ||
| 463 | HMS=$(( (HT1 - HT0) / 1000000 )) | ||
| 464 | # The exact line, with the port the daemon really holds: a fallback that | ||
| 465 | # named the wrong coordinates would be a different bug wearing this one's | ||
| 466 | # message. | ||
| 467 | grep -q "^mux: quic://127.0.0.1:$HQPORT unreachable, attaching over ssh$" "$OUT.h4.err" || { | ||
| 468 | echo "e2e FAIL: the fallback line is not the pinned one (want port $HQPORT):" | ||
| 469 | cat "$OUT.h4.err"; exit 1; } | ||
| 470 | # Bounded on BOTH sides. The floor is what proves the dial actually | ||
| 471 | # happened — an implementation that never tried would fall back instantly — | ||
| 472 | # and it sits below the 2000ms budget only by the slack the measurement | ||
| 473 | # itself needs. The ceiling is what proves the budget bounds anything at | ||
| 474 | # all: without it a hang reads as a pass that took a while. | ||
| 475 | # | ||
| 476 | # That ceiling is 10000 and not the plan's deadline+3s, and the difference | ||
| 477 | # is a decision rather than an oversight. It separates "bounded" from | ||
| 478 | # "hung" and is not asked to do anything finer: the observed spread is | ||
| 479 | # 2026-2129ms across 13 runs, and a regression that overruns the budget | ||
| 480 | # does not overrun it by a few hundred milliseconds — it overruns to the | ||
| 481 | # connection's idle timeout, 15000ms, because that is the only other bound | ||
| 482 | # in the dial. A tighter ceiling would therefore catch nothing this one | ||
| 483 | # misses, while making the single scenario that already pays 2s by design | ||
| 484 | # the most load-sensitive check in the suite. | ||
| 485 | # | ||
| 486 | # The wait_for above is the backstop beyond it: an overrun past 15s is | ||
| 487 | # reported there, with its own message, rather than here. | ||
| 488 | [ "$HMS" -ge 1500 ] || { | ||
| 489 | echo "e2e FAIL: the fallback came after ${HMS}ms, too fast to have spent the" | ||
| 490 | echo " 2000ms QUIC budget — the dial cannot have happened" | ||
| 491 | exit 1; } | ||
| 492 | [ "$HMS" -lt 10000 ] || { | ||
| 493 | echo "e2e FAIL: the fallback took ${HMS}ms; the deadline is meant to bound it" | ||
| 494 | exit 1; } | ||
| 495 | # ...and the session it fell back to is a real one, over the pipe that | ||
| 496 | # carried the announce. | ||
| 497 | await_out "$OUT.h4" "fallback-ok" "key-mismatch attach printed the line but served no session" | ||
| 498 | pipe_detach "key-mismatch client" | ||
| 499 | HSHIMS_D2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 500 | [ "$((HSHIMS_D2 - HSHIMS_D))" -eq 1 ] || { | ||
| 501 | echo "e2e FAIL: the fallback attach ran $((HSHIMS_D2 - HSHIMS_D)) ssh invocations, want 1" | ||
| 502 | exit 1; } | ||
| 503 | assert_converged "$OUT.h4" "$SOCK17" "handoff fallback to ssh" | ||
| 504 | ok "a key mismatch falls back to the ssh pipe: one deadline (${HMS}ms), one line" | ||
| 505 | |||
| 506 | # (e) ANNOUNCE-NONE: a remote that cannot produce coordinates at all. The | ||
| 507 | # lever is a config home that is a FILE, so the default key can be neither | ||
| 508 | # created nor found — `muxd endpoint` says so on stderr (ssh carries it to | ||
| 509 | # the user) and announces `endpoint none`. | ||
| 510 | # | ||
| 511 | # Against that, no deadline may be paid: no coordinates were ever in play, | ||
| 512 | # so there is nothing to dial and nothing to report as unreachable. Its own | ||
| 513 | # host, uncached by construction — reusing (a)'s would hit the cache and | ||
| 514 | # never reach the announce at all. | ||
| 515 | : > "$HCFGBAD" | ||
| 516 | HSHIMS_E=$(wc -l < "$SSHIM_PIDLOG") | ||
| 517 | HT2=$(date +%s%N) | ||
| 518 | pipe_mux "$OUT.h5" "$OUT.h5.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" XDG_CONFIG_HOME="$HCFGBAD" \ | ||
| 519 | PATH="$HPATH" timeout 40 \ | ||
| 520 | "$MUX" "mux-e2e-none@127.0.0.1" | ||
| 521 | pipe_send 'printf "none-%%s\\n" ok\n' | ||
| 522 | wait_for "$OUT.h5" "none-ok" 20 || { | ||
| 523 | echo "e2e FAIL: announce-none handoff never served a session" | ||
| 524 | cat "$OUT.h5" "$OUT.h5.err" 2>/dev/null; exit 1; } | ||
| 525 | HT3=$(date +%s%N) | ||
| 526 | HMS_E=$(( (HT3 - HT2) / 1000000 )) | ||
| 527 | pipe_detach "announce-none client" | ||
| 528 | # The remote said why, in one line, on the stderr ssh already carries. | ||
| 529 | grep -q '^muxd endpoint: .*staying on ssh' "$OUT.h5.err" || { | ||
| 530 | echo "e2e FAIL: announce-none said nothing about why it stayed on ssh" | ||
| 531 | cat "$OUT.h5.err"; exit 1; } | ||
| 532 | # ...and said nothing about a fallback, because nothing fell back: the | ||
| 533 | # client never held coordinates to fail with. | ||
| 534 | # | ||
| 535 | # EVIDENCE NOTE, and it is written down precisely because a comment that | ||
| 536 | # implied otherwise would be worse than the gap. This scenario makes seven | ||
| 537 | # assertions in all; three of them are specific to what it claims — the | ||
| 538 | # staying-on-ssh line above, this absent fallback line, and the budget | ||
| 539 | # bound below — and only the FIRST has been shown able to fail (deleting | ||
| 540 | # announceKey's create_failed message kills it, at 19 checkpoints). The | ||
| 541 | # other four are the shared shape every scenario here uses: exit status, | ||
| 542 | # marker, pidlog delta, convergence, each exercised by mutations | ||
| 543 | # elsewhere. | ||
| 544 | # | ||
| 545 | # So these two have never been exercised by any mutation — the suite stops | ||
| 546 | # at its first failure and every mutation tried so far dies in an earlier | ||
| 547 | # scenario. Neither is unfalsifiable: this grep is the same shape as (c)'s, | ||
| 548 | # which the deleted-fallback-line mutation did kill, and the bound below | ||
| 549 | # fires on any number at or above it. They are untested, which is a | ||
| 550 | # different thing, and Task 8's regrade can take one if it comes cheap. | ||
| 551 | grep -q 'unreachable, attaching over ssh' "$OUT.h5.err" && { | ||
| 552 | echo "e2e FAIL: announce-none printed the fallback line; no dial was ever possible" | ||
| 553 | cat "$OUT.h5.err"; exit 1; } | ||
| 554 | HSHIMS_E2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 555 | [ "$((HSHIMS_E2 - HSHIMS_E))" -eq 1 ] || { | ||
| 556 | echo "e2e FAIL: announce-none ran $((HSHIMS_E2 - HSHIMS_E)) ssh invocations, want 1" | ||
| 557 | exit 1; } | ||
| 558 | # No budget spent, measured to the first marker rather than to exit. The | ||
| 559 | # bound sits at 1500 rather than at the 2000ms budget itself so that a | ||
| 560 | # slow-but-honest attach still passes; what it cannot do is name a cause | ||
| 561 | # it has not proved. A dial that ran its full budget lands at 2000+ | ||
| 562 | # (measured 2026-2129 in scenario (d)); anything between 1500 and 2000 is | ||
| 563 | # an attach that was merely slow, or one that started a dial it should | ||
| 564 | # never have begun. The message therefore reports the number and says | ||
| 565 | # which readings are which, rather than asserting a spent budget on | ||
| 566 | # evidence that cannot distinguish one. | ||
| 567 | [ "$HMS_E" -lt 1500 ] || { | ||
| 568 | echo "e2e FAIL: announce-none took ${HMS_E}ms to converge, over the 1500ms bound." | ||
| 569 | echo " At or above ~2000ms a full QUIC budget was spent, on coordinates" | ||
| 570 | echo " the remote said it did not have. Below that it is either load or" | ||
| 571 | echo " a dial that began and was cut short — check which before blaming" | ||
| 572 | echo " the machine." | ||
| 573 | exit 1; } | ||
| 574 | assert_converged "$OUT.h5" "$SOCK16" "announce-none stays on ssh" | ||
| 575 | ok "announce-none stays on ssh: no deadline paid (${HMS_E}ms), no fallback line" | ||
| 576 | |||
| 577 | # Both daemons down by the sanctioned verb, and OBSERVED dead by pid — the | ||
| 578 | # M13 teardown shape, all four assertions of it (see assert_stopped). | ||
| 579 | assert_stopped "$SOCK16" "$HAPID" "handoff daemon" "$OUT.stop" | ||
| 580 | HAPID="" | ||
| 581 | assert_stopped "$SOCK17" "$HDPID" "key-mismatch daemon" "$OUT.stop" | ||
| 582 | HDPID="" | ||
| 583 | rm_swept "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ | ||
| 584 | "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD" | ||
| 585 | rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||
| 586 | |||
| 587 | # M18. Two daemons — the CLI multi-session block's and the wall's — and one | 148 | # M18. Two daemons — the CLI multi-session block's and the wall's — and one |
| 588 | # more hub on the next 5000-spaced port. Only two sockets for four-and-some | 149 | # more hub on the next 5000-spaced port. Only two sockets for four-and-some |
| 589 | # sessions, which is the whole point of the milestone: sessions are named on | 150 | # sessions, which is the whole point of the milestone: sessions are named on |
test/e2e_04_handoff.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,442 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # e2e_04_handoff.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in | ||
| 3 | # the order they stand in; see the lib's header for what this file may | ||
| 4 | # assume and what it must register. | ||
| 5 | # M14 ssh→QUIC handoff. The shim that stands in for ssh (built where the | ||
| 6 | # scenarios run), and a RUNTIME DIR | ||
| 7 | # per daemon: the handoff dials the DEFAULT socket on the far side, and the | ||
| 8 | # far side is this box, so each scenario's default socket has to be one of | ||
| 9 | # ours rather than the operator's. Two daemons, no more — the one a cold | ||
| 10 | # attach auto-starts, and the key-mismatch one that cannot be shared | ||
| 11 | # because it holds a key nothing else on this box has. | ||
| 12 | SSHIM_DIR="${TMPDIR:-/tmp}/muxd-e2e-sshim-$$" | ||
| 13 | defer_rm "$SSHIM_DIR" | ||
| 14 | SSHIM_PIDLOG="$SSHIM_DIR/pids" | ||
| 15 | export SSHIM_PIDLOG | ||
| 16 | HRUN="${TMPDIR:-/tmp}/mux-e2e-hrun-$$" | ||
| 17 | defer_rm "$HRUN" | ||
| 18 | HRUN2="${TMPDIR:-/tmp}/mux-e2e-hrun2-$$" | ||
| 19 | defer_rm "$HRUN2" | ||
| 20 | SOCK16="$HRUN/muxd.sock" | ||
| 21 | SOCK17="$HRUN2/muxd.sock" | ||
| 22 | defer_sock "$SOCK16" "$SOCK17" | ||
| 23 | HKEY="${TMPDIR:-/tmp}/mux-e2e-hkey-$$" | ||
| 24 | defer_rm "$HKEY" | ||
| 25 | # A config home that is a FILE: `muxd endpoint` cannot create a key under | ||
| 26 | # it and cannot find one, which is the announce-none scenario's lever. | ||
| 27 | HCFGBAD="${TMPDIR:-/tmp}/mux-e2e-hnokey-$$" | ||
| 28 | defer_rm "$HCFGBAD" | ||
| 29 | # Bands of their own, like every other port here, so a concurrent suite | ||
| 30 | # cannot collide — and BELOW the ephemeral range (32768–60999 here), which | ||
| 31 | # for HDEADPORT is not housekeeping but the scenario's correctness. It is | ||
| 32 | # the port the poisoned cache points at, and the daemon it is poisoning | ||
| 33 | # against holds a KERNEL-ASSIGNED ephemeral port: land on that one and the | ||
| 34 | # "dead" port is live, the dial succeeds, and the self-heal scenario | ||
| 35 | # quietly asserts a heal that never had to happen. | ||
| 36 | HQPORT=$(( 11000 + ($$ % 4000) )) | ||
| 37 | HDEADPORT=$(( 16000 + ($$ % 4000) )) | ||
| 38 | |||
| 39 | # --- M14: the ssh→QUIC handoff ----------------------------------------- | ||
| 40 | # | ||
| 41 | # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and | ||
| 42 | # attaches over pure QUIC thereafter — falling back to that same ssh, one | ||
| 43 | # deadline later, when QUIC cannot get through. Five scenarios: cold, warm, | ||
| 44 | # a poisoned cache, a key mismatch, and a remote that can offer nothing. | ||
| 45 | # | ||
| 46 | # The shim IS ssh as far as the client can tell: `ssh HOST CMD...` drops | ||
| 47 | # HOST and execs CMD here, so `ssh whatever muxd endpoint` runs the real | ||
| 48 | # binary against a socket of this suite's own making and every line of the | ||
| 49 | # client's handoff code runs for real, with no network anywhere. | ||
| 50 | # | ||
| 51 | # Every invocation appends its pid to SSHIM_PIDLOG BEFORE the exec, and | ||
| 52 | # that log is how these scenarios know whether ssh ran. Never `ps | grep | ||
| 53 | # ssh`: this box belongs to someone who is probably ssh'd into something, | ||
| 54 | # and a warm attach's entire claim is that no ssh existed. | ||
| 55 | # | ||
| 56 | # The contract it depends on — real ssh's own: everything after HOST is | ||
| 57 | # joined with spaces and handed to the REMOTE user's shell. The shim | ||
| 58 | # models that with `sh -c "$*"`, which is what lets the client's quoted | ||
| 59 | # remote command (the PATH-suffix form recipeFor builds) run here the | ||
| 60 | # same way sshd would run it. A bare `exec "$@"` was enough while the | ||
| 61 | # command was plain argv; it execs a program literally named | ||
| 62 | # `PATH=... muxd endpoint` now, and dies at 127 without an announce. | ||
| 63 | # | ||
| 64 | # The arity guard covers the case that would NOT be loud: `ssh HOST` with | ||
| 65 | # no command at all. There, `shift` empties "$@" and a bare `exec` is a | ||
| 66 | # no-op, so the shim would exit 0 having run nothing — silence that looks | ||
| 67 | # exactly like success. Note what the guard's 97 does and does not buy: | ||
| 68 | # nothing prints it, and the client experiences it only as an ssh that | ||
| 69 | # closed without an announce. It converts "quietly wrong" into "visibly | ||
| 70 | # no announce", which is a diagnosis this suite can act on. | ||
| 71 | mkdir -p "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||
| 72 | : > "$SSHIM_PIDLOG" | ||
| 73 | cat > "$SSHIM_DIR/ssh" <<'SHIM' | ||
| 74 | #!/bin/sh | ||
| 75 | [ $# -ge 2 ] || exit 97 | ||
| 76 | echo $$ >> "${SSHIM_PIDLOG:?}" | ||
| 77 | shift | ||
| 78 | exec /bin/sh -c "$*" | ||
| 79 | SHIM | ||
| 80 | chmod +x "$SSHIM_DIR/ssh" | ||
| 81 | # The PATH every scenario below runs the client under: the shim shadows any | ||
| 82 | # real ssh, and `muxd` resolves to the binary under test rather than to | ||
| 83 | # whatever is installed. | ||
| 84 | HPATH="$SSHIM_DIR:$(dirname "$MUXD"):$PATH" | ||
| 85 | # `user@` on the host is not decoration. The QUIC dial strips it | ||
| 86 | # (handoff.dialHost) and gets a loopback literal, which is what makes the | ||
| 87 | # dial reach the daemon at all; the CACHE keys on the whole word, which is | ||
| 88 | # what keeps these three scenarios' cache file distinct from scenario (d)'s | ||
| 89 | # bare `127.0.0.1`. A fake name would resolve to nothing and every QUIC | ||
| 90 | # attempt below would fail at DNS without ever dialling. | ||
| 91 | HHOST="mux-e2e@127.0.0.1" | ||
| 92 | HCACHE="$XDG_CACHE_HOME/mux/hosts/$HHOST" | ||
| 93 | # (a) COLD: no daemon, no cache. The attach has to produce the daemon (via | ||
| 94 | # `muxd endpoint`'s own auto-start), fetch coordinates over the shim, and | ||
| 95 | # END UP ON QUIC — and that last part is proven by observation rather than | ||
| 96 | # inference: the ssh the announce arrived on is killed by the client on | ||
| 97 | # QUIC success, so once its pid is OBSERVED gone, a second marker still | ||
| 98 | # making the round trip can only be riding QUIC. The pipe's owner is dead. | ||
| 99 | pipe_mux "$OUT.h1" "$OUT.h1.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 60 \ | ||
| 100 | "$MUX" "$HHOST" | ||
| 101 | pipe_send 'printf "cold-%%s\\n" one\n' | ||
| 102 | # The bounded wait, with a message of its own, because of what it is FOR: | ||
| 103 | # a client blocked on an announce that is never coming has no other | ||
| 104 | # symptom. It cannot be left to the suite's timeout or the trap — those | ||
| 105 | # report a dead process, not which line stopped arriving. | ||
| 106 | wait_for "$OUT.h1" "cold-one" 25 || { | ||
| 107 | echo "e2e FAIL: cold handoff: no marker in 25s — the attach never converged." | ||
| 108 | echo " A client that blocks here read no announce line off the ssh pipe." | ||
| 109 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 110 | |||
| 111 | # Exactly one ssh, and its pid, from the log rather than from ps. | ||
| 112 | HSHIMS=$(wc -l < "$SSHIM_PIDLOG") | ||
| 113 | [ "$HSHIMS" -eq 1 ] || { | ||
| 114 | echo "e2e FAIL: cold handoff ran $HSHIMS ssh invocations, want exactly 1" | ||
| 115 | cat "$SSHIM_PIDLOG"; exit 1; } | ||
| 116 | HSHIMPID=$(head -1 "$SSHIM_PIDLOG") | ||
| 117 | wait_pid_gone "$HSHIMPID" "cold handoff: QUIC took over, so ssh must be gone" | ||
| 118 | # The second marker is typed only now, after the death was OBSERVED, so its | ||
| 119 | # round trip is traffic that moved after the pipe's owner died — by | ||
| 120 | # construction, not by a clock. What guarantees the ordering on the product | ||
| 121 | # side: openHandoff kills the ssh child the moment QUIC is ready, before | ||
| 122 | # Transport.open returns, so before the session can emit a single byte. | ||
| 123 | # Anyone who moves that kill (to after the first frame, into a deferred | ||
| 124 | # teardown) takes this scenario's premise with them. | ||
| 125 | pipe_send 'printf "cold-%%s\\n" two\n' | ||
| 126 | wait_for "$OUT.h1" "cold-two" 20 || { | ||
| 127 | echo "e2e FAIL: cold handoff: the session stopped converging once ssh was gone," | ||
| 128 | echo " so the bytes were riding the pipe rather than QUIC" | ||
| 129 | cat "$OUT.h1" "$OUT.h1.err" 2>/dev/null; exit 1; } | ||
| 130 | pipe_detach "cold handoff client" | ||
| 131 | |||
| 132 | # The daemon `muxd endpoint` started, by the pid its own up-line reported — | ||
| 133 | # the only handle this suite has on a process that is nobody's child. Same | ||
| 134 | # accepted gap as the M13 blocks: a failure above leaves it untracked, and | ||
| 135 | # the trap's stop-by-socket is what covers that. | ||
| 136 | grep -q '^muxd endpoint: starting' "$OUT.h1.err" || { | ||
| 137 | echo "e2e FAIL: cold handoff printed no endpoint starting line" | ||
| 138 | cat "$OUT.h1.err"; exit 1; } | ||
| 139 | HAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.h1.err" | head -1) | ||
| 140 | defer_kill "$HAPID" | ||
| 141 | [ -n "$HAPID" ] || { | ||
| 142 | echo "e2e FAIL: endpoint up-line carries no pid"; cat "$OUT.h1.err"; exit 1; } | ||
| 143 | kill -0 "$HAPID" 2>/dev/null || { | ||
| 144 | echo "e2e FAIL: the handoff daemon (pid $HAPID) is not alive"; exit 1; } | ||
| 145 | |||
| 146 | # The cache: present, private (it holds the key), and naming a port that is | ||
| 147 | # really being listened on. The last one is the assertion that matters — | ||
| 148 | # a remembered number proves nothing, a remembered number that maps to the | ||
| 149 | # daemon's actual UDP socket proves the announce was true. /proc/net/udp | ||
| 150 | # for the same reasons the M8 block uses it, and 00000000 because the lazy | ||
| 151 | # bind takes 0.0.0.0. | ||
| 152 | [ -f "$HCACHE" ] || { | ||
| 153 | echo "e2e FAIL: cold handoff left no cache at $HCACHE" | ||
| 154 | ls -la "$XDG_CACHE_HOME/mux/hosts" 2>/dev/null; exit 1; } | ||
| 155 | HCPERM=$(stat -c %a "$HCACHE") | ||
| 156 | [ "$HCPERM" = "600" ] || { | ||
| 157 | echo "e2e FAIL: handoff cache perms $HCPERM, want 600 (the file holds a key)"; exit 1; } | ||
| 158 | HCDPERM=$(stat -c %a "$XDG_CACHE_HOME/mux/hosts") | ||
| 159 | [ "$HCDPERM" = "700" ] || { | ||
| 160 | echo "e2e FAIL: handoff cache dir perms $HCDPERM, want 700"; exit 1; } | ||
| 161 | HPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | ||
| 162 | [ -n "$HPORT" ] || { | ||
| 163 | echo "e2e FAIL: the cache is not an announce line:"; cat -v "$HCACHE"; exit 1; } | ||
| 164 | HHEXUDP=$(printf '00000000:%04X' "$HPORT") | ||
| 165 | grep -qi " $HHEXUDP " /proc/net/udp || { | ||
| 166 | echo "e2e FAIL: the cached port $HPORT names no UDP listener ($HHEXUDP);" | ||
| 167 | echo " the announce the client believed was not the daemon's" | ||
| 168 | exit 1; } | ||
| 169 | assert_converged "$OUT.h1" "$SOCK16" "cold handoff" | ||
| 170 | ok "cold handoff: ssh fetches the coordinates, QUIC carries the session" | ||
| 171 | |||
| 172 | # (b) WARM: the same daemon, the cache from (a). No ssh may run at all — | ||
| 173 | # the whole point of remembering coordinates — so the pidlog count is | ||
| 174 | # snapshotted and must not move. The transport is also silent: the M13 | ||
| 175 | # contract that a warm attach prints no spawn progress, extended to the | ||
| 176 | # handoff's own line, which has nothing to report when nothing fell back. | ||
| 177 | HSHIMS_B=$(wc -l < "$SSHIM_PIDLOG") | ||
| 178 | pipe_mux "$OUT.h2" "$OUT.h2.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 "$MUX" "$HHOST" | ||
| 179 | pipe_send 'printf "warm-%%s\\n" ok\n' | ||
| 180 | await_out "$OUT.h2" "warm-ok" "warm-ok never reached the client" | ||
| 181 | pipe_detach "warm handoff client" | ||
| 182 | grep -q "warm-ok" "$OUT.h2" || { | ||
| 183 | echo "e2e FAIL: warm handoff served no session" | ||
| 184 | cat "$OUT.h2" "$OUT.h2.err" 2>/dev/null; exit 1; } | ||
| 185 | HSHIMS_B2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 186 | # This also carries M13's warm-attach silence contract, which is why there | ||
| 187 | # is no separate grep for spawn progress here: `muxd endpoint` is the only | ||
| 188 | # thing that prints a starting line, it only runs under ssh, and ssh | ||
| 189 | # provably did not run. A progress grep could therefore never fail on its | ||
| 190 | # own — it would be a check the line above has already made. | ||
| 191 | [ "$HSHIMS_B2" -eq "$HSHIMS_B" ] || { | ||
| 192 | echo "e2e FAIL: warm handoff spawned ssh: pidlog went $HSHIMS_B -> $HSHIMS_B2" | ||
| 193 | echo " (want unchanged — a cache hit dials QUIC and never shells out)" | ||
| 194 | exit 1; } | ||
| 195 | grep -q 'unreachable, attaching over ssh' "$OUT.h2.err" && { | ||
| 196 | echo "e2e FAIL: warm handoff printed the fallback line"; cat "$OUT.h2.err"; exit 1; } | ||
| 197 | assert_converged "$OUT.h2" "$SOCK16" "warm handoff" | ||
| 198 | ok "warm handoff: the cache dials QUIC, ssh never runs (pidlog still $HSHIMS_B2)" | ||
| 199 | |||
| 200 | # (c) STALE CACHE: a well-formed announce naming a port nothing holds. The | ||
| 201 | # invariant under test is that a stale cache costs a ROUND TRIP and never | ||
| 202 | # correctness: the dial fails, the cold path refetches, QUIC succeeds on | ||
| 203 | # the fresh coordinates, and the user is told nothing — a fallback line | ||
| 204 | # here would be reporting a failure that did not happen. | ||
| 205 | # | ||
| 206 | # The poison is written by hand because shell cannot import handoff.zig. | ||
| 207 | # The grammar is `endpoint <port> <64 hex>\n`; the key is STOLEN from the | ||
| 208 | # real cache above and only the port is swapped, so the line differs from a | ||
| 209 | # genuine one in exactly the way the scenario is about. | ||
| 210 | # | ||
| 211 | # Until M15 this cost a full deadline (~2s), because the ICMP refusal from | ||
| 212 | # the dead port landed on a sendto inside the QUIC drain and was discarded | ||
| 213 | # there. Both socket paths act on it now, so the dial dies in about one | ||
| 214 | # loopback round trip and the whole heal measures ~200ms. The bound below | ||
| 215 | # was a floor on that spent budget and is now a ceiling on its absence. | ||
| 216 | # | ||
| 217 | # The detach is sent only after the marker has rendered, and that ordering | ||
| 218 | # is load-bearing: `waitReady` answers Ctrl-\ DURING a handshake (M8 pins | ||
| 219 | # exactly that), so a detach landing inside the dial window aborts the | ||
| 220 | # attach — no session, no error, exit 0 — and a scenario that paced the | ||
| 221 | # detach on a clock could pass its exit check having tested nothing. | ||
| 222 | HHEXKEY=$(sed -n 's/^endpoint [0-9][0-9]* \([0-9a-f]*\)$/\1/p' "$HCACHE") | ||
| 223 | [ -n "$HHEXKEY" ] || { echo "e2e FAIL: could not read the cached key"; exit 1; } | ||
| 224 | printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE" | ||
| 225 | chmod 600 "$HCACHE" | ||
| 226 | HSHIMS_C=$(wc -l < "$SSHIM_PIDLOG") | ||
| 227 | HT4=$(date +%s%N) | ||
| 228 | pipe_mux "$OUT.h3" "$OUT.h3.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" PATH="$HPATH" timeout 40 \ | ||
| 229 | "$MUX" "$HHOST" | ||
| 230 | pipe_send 'printf "heal-%%s\\n" ok\n' | ||
| 231 | wait_for "$OUT.h3" "heal-ok" 30 || { | ||
| 232 | echo "e2e FAIL: stale-cache handoff never served a session" | ||
| 233 | cat "$OUT.h3" "$OUT.h3.err" 2>/dev/null; exit 1; } | ||
| 234 | HT5=$(date +%s%N) | ||
| 235 | HMS_C=$(( (HT5 - HT4) / 1000000 )) | ||
| 236 | pipe_detach "stale-cache handoff client" | ||
| 237 | # The absent-grep, and the control that proves it can fire is scenario (d) | ||
| 238 | # below: the same pattern, asserted PRESENT, against a build where the | ||
| 239 | # fallback really happened. | ||
| 240 | grep -q 'unreachable, attaching over ssh' "$OUT.h3.err" && { | ||
| 241 | echo "e2e FAIL: a stale cache printed the fallback line; it costs time, not correctness" | ||
| 242 | cat "$OUT.h3.err"; exit 1; } | ||
| 243 | HPORT_C=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | ||
| 244 | [ "$HPORT_C" = "$HPORT" ] || { | ||
| 245 | echo "e2e FAIL: the cache did not heal: holds $HPORT_C, want the live $HPORT" | ||
| 246 | cat -v "$HCACHE"; exit 1; } | ||
| 247 | HSHIMS_C2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 248 | [ "$((HSHIMS_C2 - HSHIMS_C))" -eq 1 ] || { | ||
| 249 | echo "e2e FAIL: the healing attach ran $((HSHIMS_C2 - HSHIMS_C)) ssh invocations, want 1" | ||
| 250 | exit 1; } | ||
| 251 | # The other half of the invariant, and it changed direction in M15. A | ||
| 252 | # floor used to live here: while a refused port ran the whole budget out, | ||
| 253 | # spending it was proof the poisoned coordinates had been dialled, which | ||
| 254 | # no other check in this scenario makes. That proof is gone — the heal now | ||
| 255 | # costs ~200ms, which is indistinguishable from a build that read no cache | ||
| 256 | # and simply went cold. Scenario (b) is what catches THAT build (a warm | ||
| 257 | # attach there must spawn no ssh at all), and this scenario now leans on | ||
| 258 | # it rather than pretending to stand alone. | ||
| 259 | # | ||
| 260 | # What replaces the floor is its mirror: the refusal must be SEEN, not | ||
| 261 | # waited out. A build that swallowed the ICMP again — the pre-M15 bug — | ||
| 262 | # would push this back over 2s, and the ceiling is what says so. 1500 | ||
| 263 | # clears the measured ~200ms by a wide margin and still sits under the | ||
| 264 | # 2000ms budget, so nothing between "fast" and "spent the budget" hides. | ||
| 265 | [ "$HMS_C" -lt 1500 ] || { | ||
| 266 | echo "e2e FAIL: the stale-cache attach took ${HMS_C}ms, where a refused port is" | ||
| 267 | echo " meant to fail in about one round trip (~200ms end to end). Either" | ||
| 268 | echo " the ICMP refusal is being swallowed again and this dial spent the" | ||
| 269 | echo " 2000ms QUIC budget, or this box is loaded enough to stretch a" | ||
| 270 | echo " 200ms attach past 1500 — the daemon log and the load average" | ||
| 271 | echo " tell those apart" | ||
| 272 | exit 1; } | ||
| 273 | assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal" | ||
| 274 | ok "a stale cache self-heals: one refetch (${HMS_C}ms, no budget spent), no fallback line, real port cached" | ||
| 275 | |||
| 276 | # (d) FALLBACK LINE, by key mismatch. A second daemon holding a key nothing | ||
| 277 | # else on this box has; `muxd endpoint` announces the DEFAULT key (that is | ||
| 278 | # what the endpoint process resolves) together with this daemon's real | ||
| 279 | # port, so the coordinates are perfectly well-formed and the handshake can | ||
| 280 | # never complete. PSK auth is mutual and a listener does not answer a peer | ||
| 281 | # it cannot authenticate, so the client gets silence and spends the whole | ||
| 282 | # deadline — which is exactly the case the fallback line exists for. | ||
| 283 | # | ||
| 284 | # The host is the literal `127.0.0.1`, and it has to be: a fake name would | ||
| 285 | # fail at DNS instantly, skip the deadline, and leave this scenario pinning | ||
| 286 | # nothing. Its cache file is `127.0.0.1`, distinct from (a)'s. | ||
| 287 | # | ||
| 288 | # This scenario costs ~2s of wall time by design. That is the price of | ||
| 289 | # pinning the budget, and it is the only place the suite pays it. | ||
| 290 | # | ||
| 291 | # Six seconds to the detach byte, for the reason spelled out in (c): the | ||
| 292 | # stdin script's clock runs in parallel with a deadline-long attach, and a | ||
| 293 | # Ctrl-\ that lands inside the dial is answered by `waitReady` as an abort | ||
| 294 | # — which would leave this scenario asserting a fallback line that a | ||
| 295 | # correct build never got as far as printing. | ||
| 296 | head -c 32 /dev/urandom > "$HKEY" | ||
| 297 | chmod 600 "$HKEY" | ||
| 298 | # No --quic-idle-ms: the daemon's default is already 15000, and spelling | ||
| 299 | # it here would suggest the ceiling's reasoning below depends on this | ||
| 300 | # flag. It does not — the 15000 that bounds an overrunning dial is the | ||
| 301 | # CLIENT's `quic_idle_ms_default`, which this daemon cannot influence. | ||
| 302 | start_daemon "$SOCK17" "$OUT.d17.d" "key-mismatch daemon never bound" --shell /bin/sh \ | ||
| 303 | --quic "127.0.0.1:$HQPORT" --key "$HKEY" | ||
| 304 | HDPID=$DPID | ||
| 305 | HSHIMS_D=$(wc -l < "$SSHIM_PIDLOG") | ||
| 306 | HT0=$(date +%s%N) | ||
| 307 | pipe_mux "$OUT.h4" "$OUT.h4.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \ | ||
| 308 | "$MUX" "127.0.0.1" | ||
| 309 | pipe_send 'printf "fallback-%%s\\n" ok\n' | ||
| 310 | # Timed to the LINE rather than to the client's exit, so no session sleeps | ||
| 311 | # are folded in. The elapsed time is the dial's budget plus launch overhead | ||
| 312 | # (measured 26-129ms), which only inflates it — the floor is safe. | ||
| 313 | # Only the stderr is printed on failure, deliberately: the complaint is | ||
| 314 | # about a line that is missing from it, and the capture beside it is a | ||
| 315 | # screenful of escape sequences that would bury the answer. | ||
| 316 | wait_for "$OUT.h4.err" "unreachable, attaching over ssh" 15 || { | ||
| 317 | echo "e2e FAIL: key mismatch printed no fallback line in 15s; its stderr was:" | ||
| 318 | cat "$OUT.h4.err" 2>/dev/null; exit 1; } | ||
| 319 | HT1=$(date +%s%N) | ||
| 320 | HMS=$(( (HT1 - HT0) / 1000000 )) | ||
| 321 | # The exact line, with the port the daemon really holds: a fallback that | ||
| 322 | # named the wrong coordinates would be a different bug wearing this one's | ||
| 323 | # message. | ||
| 324 | grep -q "^mux: quic://127.0.0.1:$HQPORT unreachable, attaching over ssh$" "$OUT.h4.err" || { | ||
| 325 | echo "e2e FAIL: the fallback line is not the pinned one (want port $HQPORT):" | ||
| 326 | cat "$OUT.h4.err"; exit 1; } | ||
| 327 | # Bounded on BOTH sides. The floor is what proves the dial actually | ||
| 328 | # happened — an implementation that never tried would fall back instantly — | ||
| 329 | # and it sits below the 2000ms budget only by the slack the measurement | ||
| 330 | # itself needs. The ceiling is what proves the budget bounds anything at | ||
| 331 | # all: without it a hang reads as a pass that took a while. | ||
| 332 | # | ||
| 333 | # That ceiling is 10000 and not the plan's deadline+3s, and the difference | ||
| 334 | # is a decision rather than an oversight. It separates "bounded" from | ||
| 335 | # "hung" and is not asked to do anything finer: the observed spread is | ||
| 336 | # 2026-2129ms across 13 runs, and a regression that overruns the budget | ||
| 337 | # does not overrun it by a few hundred milliseconds — it overruns to the | ||
| 338 | # connection's idle timeout, 15000ms, because that is the only other bound | ||
| 339 | # in the dial. A tighter ceiling would therefore catch nothing this one | ||
| 340 | # misses, while making the single scenario that already pays 2s by design | ||
| 341 | # the most load-sensitive check in the suite. | ||
| 342 | # | ||
| 343 | # The wait_for above is the backstop beyond it: an overrun past 15s is | ||
| 344 | # reported there, with its own message, rather than here. | ||
| 345 | [ "$HMS" -ge 1500 ] || { | ||
| 346 | echo "e2e FAIL: the fallback came after ${HMS}ms, too fast to have spent the" | ||
| 347 | echo " 2000ms QUIC budget — the dial cannot have happened" | ||
| 348 | exit 1; } | ||
| 349 | [ "$HMS" -lt 10000 ] || { | ||
| 350 | echo "e2e FAIL: the fallback took ${HMS}ms; the deadline is meant to bound it" | ||
| 351 | exit 1; } | ||
| 352 | # ...and the session it fell back to is a real one, over the pipe that | ||
| 353 | # carried the announce. | ||
| 354 | await_out "$OUT.h4" "fallback-ok" "key-mismatch attach printed the line but served no session" | ||
| 355 | pipe_detach "key-mismatch client" | ||
| 356 | HSHIMS_D2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 357 | [ "$((HSHIMS_D2 - HSHIMS_D))" -eq 1 ] || { | ||
| 358 | echo "e2e FAIL: the fallback attach ran $((HSHIMS_D2 - HSHIMS_D)) ssh invocations, want 1" | ||
| 359 | exit 1; } | ||
| 360 | assert_converged "$OUT.h4" "$SOCK17" "handoff fallback to ssh" | ||
| 361 | ok "a key mismatch falls back to the ssh pipe: one deadline (${HMS}ms), one line" | ||
| 362 | |||
| 363 | # (e) ANNOUNCE-NONE: a remote that cannot produce coordinates at all. The | ||
| 364 | # lever is a config home that is a FILE, so the default key can be neither | ||
| 365 | # created nor found — `muxd endpoint` says so on stderr (ssh carries it to | ||
| 366 | # the user) and announces `endpoint none`. | ||
| 367 | # | ||
| 368 | # Against that, no deadline may be paid: no coordinates were ever in play, | ||
| 369 | # so there is nothing to dial and nothing to report as unreachable. Its own | ||
| 370 | # host, uncached by construction — reusing (a)'s would hit the cache and | ||
| 371 | # never reach the announce at all. | ||
| 372 | : > "$HCFGBAD" | ||
| 373 | HSHIMS_E=$(wc -l < "$SSHIM_PIDLOG") | ||
| 374 | HT2=$(date +%s%N) | ||
| 375 | pipe_mux "$OUT.h5" "$OUT.h5.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN" XDG_CONFIG_HOME="$HCFGBAD" \ | ||
| 376 | PATH="$HPATH" timeout 40 \ | ||
| 377 | "$MUX" "mux-e2e-none@127.0.0.1" | ||
| 378 | pipe_send 'printf "none-%%s\\n" ok\n' | ||
| 379 | wait_for "$OUT.h5" "none-ok" 20 || { | ||
| 380 | echo "e2e FAIL: announce-none handoff never served a session" | ||
| 381 | cat "$OUT.h5" "$OUT.h5.err" 2>/dev/null; exit 1; } | ||
| 382 | HT3=$(date +%s%N) | ||
| 383 | HMS_E=$(( (HT3 - HT2) / 1000000 )) | ||
| 384 | pipe_detach "announce-none client" | ||
| 385 | # The remote said why, in one line, on the stderr ssh already carries. | ||
| 386 | grep -q '^muxd endpoint: .*staying on ssh' "$OUT.h5.err" || { | ||
| 387 | echo "e2e FAIL: announce-none said nothing about why it stayed on ssh" | ||
| 388 | cat "$OUT.h5.err"; exit 1; } | ||
| 389 | # ...and said nothing about a fallback, because nothing fell back: the | ||
| 390 | # client never held coordinates to fail with. | ||
| 391 | # | ||
| 392 | # EVIDENCE NOTE, and it is written down precisely because a comment that | ||
| 393 | # implied otherwise would be worse than the gap. This scenario makes seven | ||
| 394 | # assertions in all; three of them are specific to what it claims — the | ||
| 395 | # staying-on-ssh line above, this absent fallback line, and the budget | ||
| 396 | # bound below — and only the FIRST has been shown able to fail (deleting | ||
| 397 | # announceKey's create_failed message kills it, at 19 checkpoints). The | ||
| 398 | # other four are the shared shape every scenario here uses: exit status, | ||
| 399 | # marker, pidlog delta, convergence, each exercised by mutations | ||
| 400 | # elsewhere. | ||
| 401 | # | ||
| 402 | # So these two have never been exercised by any mutation — the suite stops | ||
| 403 | # at its first failure and every mutation tried so far dies in an earlier | ||
| 404 | # scenario. Neither is unfalsifiable: this grep is the same shape as (c)'s, | ||
| 405 | # which the deleted-fallback-line mutation did kill, and the bound below | ||
| 406 | # fires on any number at or above it. They are untested, which is a | ||
| 407 | # different thing, and Task 8's regrade can take one if it comes cheap. | ||
| 408 | grep -q 'unreachable, attaching over ssh' "$OUT.h5.err" && { | ||
| 409 | echo "e2e FAIL: announce-none printed the fallback line; no dial was ever possible" | ||
| 410 | cat "$OUT.h5.err"; exit 1; } | ||
| 411 | HSHIMS_E2=$(wc -l < "$SSHIM_PIDLOG") | ||
| 412 | [ "$((HSHIMS_E2 - HSHIMS_E))" -eq 1 ] || { | ||
| 413 | echo "e2e FAIL: announce-none ran $((HSHIMS_E2 - HSHIMS_E)) ssh invocations, want 1" | ||
| 414 | exit 1; } | ||
| 415 | # No budget spent, measured to the first marker rather than to exit. The | ||
| 416 | # bound sits at 1500 rather than at the 2000ms budget itself so that a | ||
| 417 | # slow-but-honest attach still passes; what it cannot do is name a cause | ||
| 418 | # it has not proved. A dial that ran its full budget lands at 2000+ | ||
| 419 | # (measured 2026-2129 in scenario (d)); anything between 1500 and 2000 is | ||
| 420 | # an attach that was merely slow, or one that started a dial it should | ||
| 421 | # never have begun. The message therefore reports the number and says | ||
| 422 | # which readings are which, rather than asserting a spent budget on | ||
| 423 | # evidence that cannot distinguish one. | ||
| 424 | [ "$HMS_E" -lt 1500 ] || { | ||
| 425 | echo "e2e FAIL: announce-none took ${HMS_E}ms to converge, over the 1500ms bound." | ||
| 426 | echo " At or above ~2000ms a full QUIC budget was spent, on coordinates" | ||
| 427 | echo " the remote said it did not have. Below that it is either load or" | ||
| 428 | echo " a dial that began and was cut short — check which before blaming" | ||
| 429 | echo " the machine." | ||
| 430 | exit 1; } | ||
| 431 | assert_converged "$OUT.h5" "$SOCK16" "announce-none stays on ssh" | ||
| 432 | ok "announce-none stays on ssh: no deadline paid (${HMS_E}ms), no fallback line" | ||
| 433 | |||
| 434 | # Both daemons down by the sanctioned verb, and OBSERVED dead by pid — the | ||
| 435 | # M13 teardown shape, all four assertions of it (see assert_stopped). | ||
| 436 | assert_stopped "$SOCK16" "$HAPID" "handoff daemon" "$OUT.stop" | ||
| 437 | HAPID="" | ||
| 438 | assert_stopped "$SOCK17" "$HDPID" "key-mismatch daemon" "$OUT.stop" | ||
| 439 | HDPID="" | ||
| 440 | rm_swept "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ | ||
| 441 | "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD" | ||
| 442 | rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" | ||