a73x

d92023ef

test: 04_handoff moves to a file of its own

a73x   2026-08-26 18:54

Commit message
test: 04_handoff moves to a file of its own

the ssh->QUIC handoff, cold, warm, stale and refused — 5 scenarios, 442 lines. The legs move unedited; the runner
sources the file where they used to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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"