a73x

8443391f

fix: a warm miss on unchanged coordinates costs one deadline, not two

a73x   2026-08-29 15:55

Commit message
fix: a warm miss on unchanged coordinates costs one deadline, not two

On a route where the announced UDP port cannot be reached — blocked, jump
hosted, a listener holding another key — every attach after the first found
those coordinates in the cache, spent the whole budget on silence, refetched
over ssh, was told the SAME port and key, and spent the budget again before
using the ssh pipe it had held open the whole time. Measured 4264ms against
a spec criterion of "within one deadline", and unreconciled since
2026-08-11.

ssh is the authority and it has just said the coordinates have not moved, so
there is nothing a second dial can learn. `handoff.State.failed_on` remembers
what the warm dial went silent on and the table ends on the pipe.

Port AND key: a restarted daemon takes a fresh ephemeral port, a re-keyed one
keeps its old one, and either is still worth dialling.

Seven walk tests assert the whole step trace of each sequence, so an extra
dial is an extra word in a diff. e2e leg (e) attaches a second time onto the
cache the key-mismatch leg left behind: one fallback line, one ssh, and a
ceiling of 3500ms — 4068ms with the row removed, 2137ms with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/client/handoff.zig
Old New
@@ -410,6 +410,9 @@ pub const State = struct {
410 /// Whether there is a cache file to write. Distinct from `cached`: a 410 /// Whether there is a cache file to write. Distinct from `cached`: a
411 /// path holding nothing usable is a cold attach that still caches. 411 /// path holding nothing usable is a cold attach that still caches.
412 has_cache: bool, 412 has_cache: bool,
413 /// The coordinates a warm dial spent its whole budget on and got
414 /// silence back from. Null until one has.
415 failed_on: ?Endpoint = null,
413 /// The announce being decided on. `write_cache` sits between reading 416 /// The announce being decided on. `write_cache` sits between reading
414 /// one and choosing what to do with it, so it has to outlive that 417 /// one and choosing what to do with it, so it has to outlive that
415 /// step; the phase alone cannot carry it. 418 /// step; the phase alone cannot carry it.
@@ -438,13 +441,8 @@ pub fn next(s: *State, o: ?Outcome) Step {
438 .warm_dial => switch (outcome) { 441 .warm_dial => switch (outcome) {
439 .ok => return .use_quic, 442 .ok => return .use_quic,
440 .user_abort => return .fail, 443 .user_abort => return .fail,
441 // ssh is authoritative, so a dead cache is a question it
442 // answers rather than a failure. What the miss cost depends
443 // on how the coordinates were dead: a resolve failure and a
444 // REFUSED port are both instant, while anything SILENT
445 // (blackholed UDP, a listener holding another key) spent the
446 // whole of `deadline_ms` first.
447 .failed => { 444 .failed => {
445 s.failed_on = s.cached;
448 s.phase = .ssh; 446 s.phase = .ssh;
449 return .spawn_ssh; 447 return .spawn_ssh;
450 }, 448 },
@@ -486,6 +484,9 @@ pub fn next(s: *State, o: ?Outcome) Step {
486 484
487 /// What a fresh announce is worth, once it is safely cached. 485 /// What a fresh announce is worth, once it is safely cached.
488 fn afterAnnounce(s: *State, ep: Endpoint) Step { 486 fn afterAnnounce(s: *State, ep: Endpoint) Step {
487 if (s.failed_on) |dead| {
488 if (std.meta.eql(dead, ep)) return .{ .use_pipe = s.asked };
489 }
489 s.phase = .cold_dial; 490 s.phase = .cold_dial;
490 return .{ .dial_quic = ep }; 491 return .{ .dial_quic = ep };
491 } 492 }
@@ -866,9 +867,15 @@ test "handoff step: the abort key inside the warm dial asked to stop, not to try
866 try expectStep("fail", next(&s, .user_abort)); 867 try expectStep("fail", next(&s, .user_abort));
867 } 868 }
868 869
869 test "handoff step: a warm dial that failed hands the question to ssh, which is authoritative" { 870 test "handoff step: a warm dial that failed hands the question to ssh, and remembers what went silent" {
870 var s: State = .{ .cached = epA(), .asked = false, .has_cache = true, .phase = .warm_dial }; 871 var s: State = .{ .cached = epA(), .asked = false, .has_cache = true, .phase = .warm_dial };
871 try expectStep("spawn_ssh", next(&s, .failed)); 872 try expectStep("spawn_ssh", next(&s, .failed));
873 // Remembered here or nowhere: by the time the announce comes back, the
874 // cache holds whatever ssh just said and cannot say what was tried.
875 // `!= null` before the compare, and not a `.?`: an unwrap of null
876 // PANICS, which takes the whole test binary down and every later test
877 // with it — including the walk that would have named the same defect.
878 try std.testing.expect(s.failed_on != null and std.meta.eql(s.failed_on.?, epA()));
872 } 879 }
873 880
874 test "handoff step: an ssh that could not start ends the handoff" { 881 test "handoff step: an ssh that could not start ends the handoff" {
@@ -911,6 +918,40 @@ test "handoff step: the announce that was just cached is the one dialled" {
911 try expectStep("dial_quic 5000/cd", next(&s, .done)); 918 try expectStep("dial_quic 5000/cd", next(&s, .done));
912 } 919 }
913 920
921 test "handoff step: coordinates a warm dial already proved silent are not dialled twice" {
922 for ([_]bool{ false, true }) |asked| {
923 var s: State = .{
924 .cached = epA(),
925 .asked = asked,
926 .has_cache = true,
927 .phase = .cache,
928 .announced = epA(),
929 .failed_on = epA(),
930 };
931 try expectStep(if (asked) "use_pipe(line)" else "use_pipe(silent)", next(&s, .done));
932 }
933 }
934
935 test "handoff step: an announce that moved either half of the endpoint is dialled again" {
936 const moved = [_]Endpoint{
937 epB(),
938 .{ .port = 4433, .key = [_]u8{0xcd} ** key_len },
939 .{ .port = 5000, .key = [_]u8{0xab} ** key_len },
940 };
941 const want = [_][]const u8{ "dial_quic 5000/cd", "dial_quic 4433/cd", "dial_quic 5000/ab" };
942 for (moved, want) |ep, w| {
943 var s: State = .{
944 .cached = epA(),
945 .asked = true,
946 .has_cache = true,
947 .phase = .cache,
948 .announced = ep,
949 .failed_on = epA(),
950 };
951 try expectStep(w, next(&s, .done));
952 }
953 }
954
914 test "handoff step: a cold dial that connected IS the session" { 955 test "handoff step: a cold dial that connected IS the session" {
915 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cold_dial }; 956 var s: State = .{ .cached = null, .asked = true, .has_cache = true, .phase = .cold_dial };
916 try expectStep("use_quic", next(&s, .ok)); 957 try expectStep("use_quic", next(&s, .ok));
@@ -931,6 +972,116 @@ test "handoff step: a cold dial that failed is the ssh pipe, and only the user w
931 } 972 }
932 } 973 }
933 974
975 /// Drives `next` the way `client.Transport.openHandoff` does, answering each
976 /// effect from `answers` in order, and renders the whole trace. The trace is
977 /// the assertion: a policy that fires one dial too many shows up as an extra
978 /// word, not as a count somebody remembered to write down.
979 fn walk(s: *State, answers: []const Outcome, buf: []u8) ![]const u8 {
980 var w: usize = 0;
981 var ai: usize = 0;
982 var step = next(s, null);
983 while (true) {
984 if (w > 0) {
985 @memcpy(buf[w..][0..2], ", ");
986 w += 2;
987 }
988 w += (try stepStr(buf[w..], step)).len;
989 switch (step) {
990 .use_quic, .use_pipe, .fail => return buf[0..w],
991 else => {},
992 }
993 // Rendered rather than indexed past the end: a machine that asks
994 // for more effects than the walk predicted is the answer, and it
995 // belongs in the diff instead of in a bounds panic.
996 if (ai >= answers.len) {
997 @memcpy(buf[w..][0..8], ", asked?");
998 return buf[0 .. w + 8];
999 }
1000 step = next(s, answers[ai]);
1001 ai += 1;
1002 }
1003 }
1004
1005 fn expectWalk(want: []const u8, s: *State, answers: []const Outcome) !void {
1006 var buf: [512]u8 = undefined;
1007 try std.testing.expectEqualStrings(want, try walk(s, answers, &buf));
1008 }
1009
1010 test "handoff walk: a warm cache that answers is one dial and nothing else" {
1011 // No ssh at all: the whole point of caching the announce.
1012 var s: State = .{ .cached = epA(), .asked = true, .has_cache = true };
1013 try expectWalk("dial_quic 4433/ab, use_quic", &s, &.{.ok});
1014 }
1015
1016 test "handoff walk: a warm miss whose refetch names the SAME endpoint pays one deadline, not two" {
1017 // The case this table was built for. On a network where the announced
1018 // UDP port cannot be reached — blocked, jump-hosted, a listener holding
1019 // another key — the warm dial spends the budget, ssh answers with the
1020 // very coordinates that just went silent, and a client that dialled
1021 // them again spent it twice (measured 4264ms against a spec of "within
1022 // one deadline"). EXACTLY ONE `dial_quic` in this trace.
1023 var s: State = .{ .cached = epA(), .asked = true, .has_cache = true };
1024 try expectWalk(
1025 "dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 4433/ab, use_pipe(line)",
1026 &s,
1027 &.{ .failed, .ok, .{ .announced = epA() }, .done },
1028 );
1029 }
1030
1031 test "handoff walk: a warm miss whose refetch moved EITHER half of the endpoint dials again" {
1032 // A daemon that restarted took a fresh ephemeral port; one that was
1033 // re-keyed kept its port. Both are a live host the client can still
1034 // reach, and neither is the endpoint that went silent — so a compare
1035 // that looked at ports alone would strand every re-keyed daemon on ssh.
1036 const moved = [_]Endpoint{
1037 epB(),
1038 .{ .port = 4433, .key = [_]u8{0xcd} ** key_len },
1039 .{ .port = 5000, .key = [_]u8{0xab} ** key_len },
1040 };
1041 const traces = [_][]const u8{
1042 "dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 5000/cd, dial_quic 5000/cd, use_quic",
1043 "dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 4433/cd, dial_quic 4433/cd, use_quic",
1044 "dial_quic 4433/ab, spawn_ssh, read_announce, write_cache 5000/ab, dial_quic 5000/ab, use_quic",
1045 };
1046 for (moved, traces) |ep, want| {
1047 var s: State = .{ .cached = epA(), .asked = true, .has_cache = true };
1048 try expectWalk(want, &s, &.{ .failed, .ok, .{ .announced = ep }, .done, .ok });
1049 }
1050 }
1051
1052 test "handoff walk: a cold attach fetches, caches, then dials what it fetched" {
1053 var s: State = .{ .cached = null, .asked = true, .has_cache = true };
1054 try expectWalk(
1055 "spawn_ssh, read_announce, write_cache 4433/ab, dial_quic 4433/ab, use_quic",
1056 &s,
1057 &.{ .ok, .{ .announced = epA() }, .done, .ok },
1058 );
1059 }
1060
1061 test "handoff walk: a cold attach whose dial cannot get through ends on the pipe it already holds" {
1062 for ([_]bool{ false, true }) |asked| {
1063 var s: State = .{ .cached = null, .asked = asked, .has_cache = true };
1064 try expectWalk(
1065 if (asked)
1066 "spawn_ssh, read_announce, write_cache 4433/ab, dial_quic 4433/ab, use_pipe(line)"
1067 else
1068 "spawn_ssh, read_announce, write_cache 4433/ab, dial_quic 4433/ab, use_pipe(silent)",
1069 &s,
1070 &.{ .ok, .{ .announced = epA() }, .done, .failed },
1071 );
1072 }
1073 }
1074
1075 test "handoff walk: `endpoint none` ends at the pipe without dialling anything" {
1076 var s: State = .{ .cached = null, .asked = true, .has_cache = true };
1077 try expectWalk("spawn_ssh, read_announce, use_pipe(silent)", &s, &.{ .ok, .none });
1078 }
1079
1080 test "handoff walk: an ssh that announces nothing at all ends the handoff" {
1081 var s: State = .{ .cached = null, .asked = true, .has_cache = true };
1082 try expectWalk("spawn_ssh, read_announce, fail", &s, &.{ .ok, .announce_failed });
1083 }
1084
934 // Forces semantic analysis of every pub decl under `zig build test`, so an 1085 // Forces semantic analysis of every pub decl under `zig build test`, so an
935 // unreferenced decl must at least compile (the silent-module-loss hazard, 1086 // unreferenced decl must at least compile (the silent-module-loss hazard,
936 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 1087 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test/e2e.sh
Old New
@@ -170,13 +170,13 @@ done
170 # one of those and adds a convergence point would be pinning a fact every 170 # one of those and adds a convergence point would be pinning a fact every
171 # leg above already establishes. 171 # leg above already establishes.
172 172
173 [ "$OK_COUNT" = "90" ] || { 173 [ "$OK_COUNT" = "91" ] || {
174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 90 —" 174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 91 —"
175 echo " a scenario was added (update the pin) or silently lost" 175 echo " a scenario was added (update the pin) or silently lost"
176 exit 1 176 exit 1
177 } 177 }
178 [ "$CONV_COUNT" = "37" ] || { 178 [ "$CONV_COUNT" = "38" ] || {
179 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 37" 179 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 38"
180 exit 1 180 exit 1
181 } 181 }
182 echo "e2e OK ($OK_COUNT scenarios, $CONV_COUNT convergence points)" 182 echo "e2e OK ($OK_COUNT scenarios, $CONV_COUNT convergence points)"
test/e2e_04_handoff.sh
Old New
@@ -54,8 +54,9 @@ HDEADPORT=$(( 16000 + ($$ % 4000) ))
54 # 54 #
55 # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and 55 # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and
56 # attaches over pure QUIC thereafter — falling back to that same ssh, one 56 # attaches over pure QUIC thereafter — falling back to that same ssh, one
57 # deadline later, when QUIC cannot get through. Five scenarios: cold, warm, 57 # deadline later, when QUIC cannot get through. Six scenarios: cold, warm,
58 # a poisoned cache, a key mismatch, and a remote that can offer nothing. 58 # a poisoned cache, a key mismatch, a warm miss on the coordinates that
59 # mismatch left cached, and a remote that can offer nothing.
59 # 60 #
60 # The shim IS ssh as far as the client can tell: `ssh HOST CMD...` drops 61 # The shim IS ssh as far as the client can tell: `ssh HOST CMD...` drops
61 # HOST and execs CMD here, so `ssh whatever mux d endpoint` runs the real 62 # HOST and execs CMD here, so `ssh whatever mux d endpoint` runs the real
@@ -100,11 +101,15 @@ HPATH="$SSHIM_DIR:$(dirname "$MUX"):$PATH"
100 # `user@` on the host is not decoration. The QUIC dial strips it 101 # `user@` on the host is not decoration. The QUIC dial strips it
101 # (handoff.dialHost) and gets a loopback literal, which is what makes the 102 # (handoff.dialHost) and gets a loopback literal, which is what makes the
102 # dial reach the daemon at all; the CACHE keys on the whole word, which is 103 # dial reach the daemon at all; the CACHE keys on the whole word, which is
103 # what keeps these three scenarios' cache file distinct from scenario (d)'s 104 # what keeps these three scenarios' cache file distinct from the bare
104 # bare `127.0.0.1`. A fake name would resolve to nothing and every QUIC 105 # `127.0.0.1` that (d) and (e) deliberately SHARE — (e)'s whole premise is
105 # attempt below would fail at DNS without ever dialling. 106 # that it attaches onto the coordinates (d) left cached. A fake name would
107 # resolve to nothing and every QUIC attempt below would fail at DNS without
108 # ever dialling.
106 HHOST="mux-e2e@127.0.0.1" 109 HHOST="mux-e2e@127.0.0.1"
107 HCACHE="$XDG_CACHE_HOME/mux/hosts/$HHOST" 110 HCACHE="$XDG_CACHE_HOME/mux/hosts/$HHOST"
111 # The bare word's cache, which (d) writes and (e) then attaches onto.
112 HCACHE_D="$XDG_CACHE_HOME/mux/hosts/127.0.0.1"
108 # (a) COLD: no daemon, no cache. The attach has to produce the daemon — 113 # (a) COLD: no daemon, no cache. The attach has to produce the daemon —
109 # the REMOTE does it, because an asked dial spells `mux d endpoint --start` 114 # the REMOTE does it, because an asked dial spells `mux d endpoint --start`
110 # and that verb ensures a daemon before it announces — fetch coordinates 115 # and that verb ensures a daemon before it announces — fetch coordinates
@@ -405,9 +410,97 @@ HSHIMS_D2=$(wc -l < "$SSHIM_PIDLOG")
405 echo "e2e FAIL: the fallback attach ran $((HSHIMS_D2 - HSHIMS_D)) ssh invocations, want 1" 410 echo "e2e FAIL: the fallback attach ran $((HSHIMS_D2 - HSHIMS_D)) ssh invocations, want 1"
406 exit 1; } 411 exit 1; }
407 assert_converged "$OUT.h4" "$SOCK17" "handoff fallback to ssh" 412 assert_converged "$OUT.h4" "$SOCK17" "handoff fallback to ssh"
413 # This leg's postcondition IS (e)'s precondition: the announce it fetched is
414 # on disk under the bare host word, and (e) attaches onto that file.
415 [ -s "$HCACHE_D" ] || {
416 echo "e2e FAIL: the key-mismatch attach cached nothing at $HCACHE_D"; exit 1; }
408 ok "a key mismatch falls back to the ssh pipe: one deadline (${HMS}ms), one line" 417 ok "a key mismatch falls back to the ssh pipe: one deadline (${HMS}ms), one line"
409 418
410 # (e) ANNOUNCE-NONE: a remote that cannot produce coordinates at all. The 419 # (e) WARM MISS on coordinates the remote still means. The same daemon,
420 # the same runtime dir and the same host word as (d), so the cache file (d)
421 # wrote holds the very endpoint that just went silent — which is the state
422 # a genuinely unreachable QUIC route is in on EVERY attach after the first,
423 # and therefore the common case rather than the exotic one.
424 #
425 # The warm dial spends the budget on silence, the refetch names the same
426 # port and the same key, and the client has to use the ssh pipe it is
427 # already holding. Dialling them again buys nothing: ssh is authoritative
428 # and has just said the coordinates have not moved.
429 # The premise, CHECKED rather than inherited from (d). Every assertion this
430 # leg makes also holds for a COLD run through the table — one shim, one
431 # fallback line, one budget — so a (d) whose cache write had regressed would
432 # leave this leg green and pinning nothing at all. Three facts make the warm
433 # dial certain: the file is there, `handoff.readCache` will ACCEPT it (it
434 # refuses anything looser than 0600 and the attach then runs cold), and it
435 # names the port that just went silent rather than some other daemon's.
436 [ -f "$HCACHE_D" ] || {
437 echo "e2e FAIL: no cache at $HCACHE_D — (d) wrote none, so this leg would"
438 echo " attach COLD and prove nothing about a warm miss"
439 exit 1; }
440 HWPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE_D")
441 [ "$HWPORT" = "$HQPORT" ] || {
442 echo "e2e FAIL: the cache at $HCACHE_D names port '$HWPORT', want the silent $HQPORT"
443 cat -v "$HCACHE_D"; exit 1; }
444 HWMODE=$(stat -c %a "$HCACHE_D")
445 [ "$HWMODE" = "600" ] || {
446 echo "e2e FAIL: the cache at $HCACHE_D is mode $HWMODE; readCache refuses"
447 echo " anything looser, so this leg would attach COLD"
448 exit 1; }
449 HSHIMS_W=$(wc -l < "$SSHIM_PIDLOG")
450 HT4=$(date +%s%N)
451 pipe_mux "$OUT.h6" "$OUT.h6.err" env SHELL=/bin/sh XDG_RUNTIME_DIR="$HRUN2" PATH="$HPATH" timeout 40 \
452 "$MUX" "127.0.0.1"
453 pipe_send 'printf "warmmiss-%%s\\n" ok\n'
454 # Timed to the LINE, as (d) is, so no session sleeps are folded in.
455 wait_for "$OUT.h6.err" "unreachable, attaching over ssh" 15 || {
456 echo "e2e FAIL: the warm miss printed no fallback line in 15s; its stderr was:"
457 cat "$OUT.h6.err" 2>/dev/null; exit 1; }
458 HT5=$(date +%s%N)
459 HMS_W=$(( (HT5 - HT4) / 1000000 ))
460 # ONCE, and naming the port the daemon really holds. Twice would be two
461 # dials wearing one message.
462 HFB_W=$(grep -c "^mux: quic://127.0.0.1:$HQPORT unreachable, attaching over ssh$" "$OUT.h6.err" || true)
463 [ "$HFB_W" -eq 1 ] || {
464 echo "e2e FAIL: the warm miss printed the fallback line $HFB_W times, want 1 (port $HQPORT):"
465 cat "$OUT.h6.err"; exit 1; }
466 # The floor is (d)'s, and for (d)'s reason: below it the warm dial cannot
467 # have happened, and this leg would be pinning nothing.
468 #
469 # The CEILING is what this leg exists for, and it is 3500 = two budgets
470 # minus 500ms of slack rather than (d)'s 10000. One budget is 2000 and the
471 # spread measured there is 2026-2139ms; a client that dialled the refetched
472 # coordinates a second time pays two of them and lands past 4000 — measured
473 # at 4264ms on 2026-08-11, which is the reading that stood against a spec
474 # criterion of "within one deadline" until the step table. So the gap
475 # between a pass and that regression is ~1900ms, and 500ms of slack sits
476 # inside it with room to spare. This bound is not load-sensitive in the way
477 # a tighter one would be: the budget is spent WAITING for a silent peer, not
478 # computing, so the Debug build's ~600x penalty on output does not touch it.
479 [ "$HMS_W" -ge 1500 ] || {
480 echo "e2e FAIL: the warm miss fell back after ${HMS_W}ms, too fast to have spent"
481 echo " the 2000ms QUIC budget — the warm dial cannot have happened"
482 exit 1; }
483 [ "$HMS_W" -lt 3500 ] || {
484 echo "e2e FAIL: the warm miss took ${HMS_W}ms. One QUIC budget is 2000ms; at or"
485 echo " above ~4000 the client dialled the refetched coordinates a SECOND"
486 echo " time, which is the defect this leg pins (4264ms measured before"
487 echo " the fix). Between 3500 and 4000 it is one budget plus an unusually"
488 echo " slow ssh: this attach ran $(( $(wc -l < "$SSHIM_PIDLOG") - HSHIMS_W ))"
489 echo " ssh invocations, and one is the correct number."
490 exit 1; }
491 # ...and the session it fell back to is a real one, over that same pipe.
492 await_out "$OUT.h6" "warmmiss-ok" "warm miss printed the line but served no session"
493 pipe_detach "warm-miss client"
494 # ONE ssh: the refetch. The warm dial runs none, and there is no second
495 # fetch to pay a connect timeout or a password prompt for.
496 HSHIMS_W2=$(wc -l < "$SSHIM_PIDLOG")
497 [ "$((HSHIMS_W2 - HSHIMS_W))" -eq 1 ] || {
498 echo "e2e FAIL: the warm miss ran $((HSHIMS_W2 - HSHIMS_W)) ssh invocations, want 1"
499 exit 1; }
500 assert_converged "$OUT.h6" "$SOCK17" "warm miss falls back to ssh"
501 ok "a warm miss on silent coordinates costs ONE deadline (${HMS_W}ms), not two"
502
503 # (f) ANNOUNCE-NONE: a remote that cannot produce coordinates at all. The
411 # lever is a config home that is a FILE, so the default key can be neither 504 # lever is a config home that is a FILE, so the default key can be neither
412 # created nor found — `mux d endpoint` says so on stderr (ssh carries it to 505 # created nor found — `mux d endpoint` says so on stderr (ssh carries it to
413 # the user) and announces `endpoint none`. 506 # the user) and announces `endpoint none`.
@@ -485,5 +578,6 @@ HAPID=""
485 assert_stopped "$SOCK17" "$HDPID" "key-mismatch daemon" "$OUT.stop" 578 assert_stopped "$SOCK17" "$HDPID" "key-mismatch daemon" "$OUT.stop"
486 HDPID="" 579 HDPID=""
487 rm_swept "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \ 580 rm_swept "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err" \
488 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD" 581 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.h6" "$OUT.h6.err" \
582 "$OUT.stop" "$HKEY" "$HCFGBAD"
489 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" 583 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2"