8443391f
fix: a warm miss on unchanged coordinates costs one deadline, not two
a73x 2026-08-29 15:55
Commit message
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" |