a73x

7bf74d2b

fix: refused QUIC dial fails in ~1 RTT; quic:// attach budget is deadline_ms, not idle_ms

a73x   2026-08-12 15:28

Commit message
fix: refused QUIC dial fails in ~1 RTT; quic:// attach budget is deadline_ms, not idle_ms

Two behaviour changes, the only ones in M15.

An ICMP unreachable on a connected UDP socket is delivered to whichever
syscall runs next, and on a quiet dial that is drain's send, whose `catch
return` dropped it — so the ConnectionRefused arm in readable was
unreachable and a refused port ran the whole budget out. Both paths now
route through one verdict, sendRecvFailed.

And `mux quic://` waited on idle_ms (~15s) where the handoff path already
had its own deadline_ms; QuicTarget gains the same field, so a dead host
is a 2s no instead of a 15s one.

Measured locally, 3 runs each, medians (hermetic XDG, tracked pids):

  failure class                      pre       post
  refused port (loopback, unbound)   15001ms      1ms
  blackholed UDP (192.0.2.1)         15001ms   2002ms
  wrong PSK (live listener)          15009ms   2012ms

Silence is what the 2000ms budget is for and it still sets the number.
handoff.deadline_ms's prose and the M14 entry in decisions.md carry the
re-measurement.

Re-pinned rather than deleted: client.zig's dead-coordinates test flips
its floor to a ceiling (a refused dial is fast now, 2ms measured), and
e2e's stale-cache scenario does the same (204ms, was ~2172ms). The
key-mismatch and blackhole floors survive untouched — those mechanisms
are silence, not refusal.

Two e2e scenarios were leaning on the swallowed refusal without saying
so: the abort-during-handshake case needed a handshake it could interrupt
and now dials a live listener with the wrong key, and the restart-rebind
check was reading /proc/net/udp once, matching the reconnecting client's
rem_address as often as the daemon's bind. It now polls, and on the local
address only.

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

docs/decisions.md
Old New
@@ -2381,6 +2381,14 @@ inside `Client.drain`, whose `catch return` discards it, so the
2381 runs the budget out, and the deadline is the only thing that ends any of 2381 runs the budget out, and the deadline is the only thing that ends any of
2382 them. 2382 them.
2383 2383
2384 **Superseded in M15 (Task 4) for the refusal case only.** Both socket
2385 paths now route ECONNREFUSED through one verdict, so an unbound loopback
2386 port fails in **1ms** instead of 2051. Silence still runs the budget out
2387 and still sets the number: re-measured 2026-08-12 at 2012ms for a wrong
2388 PSK and 2002ms for a blackholed port, so 2000 stands. The same task gave
2389 `quic://` its own `deadline_ms`, so a direct dial to a dead host now
2390 gives up at 2000ms rather than at the connection's 15000ms idle timeout.
2391
2384 That fixes the shape of the trade, and 2000 stands. Below it: a real 2392 That fixes the shape of the trade, and 2000 stands. Below it: a real
2385 handshake is **4ms** local, **6.9ms** cold attach on the M8 LAN, and 2393 handshake is **4ms** local, **6.9ms** cold attach on the M8 LAN, and
2386 **234ms** at 75ms RTT — 3.1× RTT, because every fresh Initial costs a 2394 **234ms** at 75ms RTT — 3.1× RTT, because every fresh Initial costs a
src/client.zig
Old New
@@ -84,6 +84,11 @@ pub const QuicTarget = struct {
84 host_port: []const u8, 84 host_port: []const u8,
85 key_path: []const u8, 85 key_path: []const u8,
86 idle_ms: u32 = quic_idle_ms_default, 86 idle_ms: u32 = quic_idle_ms_default,
87 /// The attach budget, split off idle_ms the way HandoffTarget
88 /// already does: the time we give a handshake is not the time we
89 /// give a quiet session. See handoff.deadline_ms for the number's
90 /// derivation.
91 deadline_ms: u32 = handoff.deadline_ms,
87 }; 92 };
88 93
89 /// The bare-HOST recipe: everything a (re)connect needs to run the 94 /// The bare-HOST recipe: everything a (re)connect needs to run the
@@ -172,7 +177,7 @@ const Transport = struct {
172 // a connection that never completes, and do it again on the next 177 // a connection that never completes, and do it again on the next
173 // pass. Against a daemon that was merely paused, that left a 178 // pass. Against a daemon that was merely paused, that left a
174 // trail of half-open connections and duplicate attaches. 179 // trail of half-open connections and duplicate attaches.
175 try waitReady(cl, q.idle_ms, alloc, carry); 180 try waitReady(cl, q.deadline_ms, alloc, carry);
176 return .{ 181 return .{
177 .conn = .{ .r = cl.pollFd(), .w = -1 }, 182 .conn = .{ .r = cl.pollFd(), .w = -1 },
178 .quic = cl, 183 .quic = cl,
@@ -441,18 +446,21 @@ const Transport = struct {
441 446
442 /// Drive a fresh connection until it can carry bytes, or give up. 447 /// Drive a fresh connection until it can carry bytes, or give up.
443 /// 448 ///
444 /// Bounded by the same idle timeout the connection itself uses: a peer that 449 /// Bounded by the attach budget, not by the connection's idle timeout: the
445 /// will not answer a handshake is the same peer that will not answer 450 /// time we give a handshake is not the time we give a quiet session, and
446 /// anything, and one knob for both is one fewer thing to explain. An 451 /// every caller passes `handoff.deadline_ms` for exactly that reason.
447 /// unreachable UDP port usually produces no error at all — no ICMP, no 452 ///
448 /// refusal — so this bound is the only thing that ends the wait. 453 /// A refused port now ends this wait early — quic_client surfaces the ICMP
454 /// refusal from whichever syscall the kernel hands it to. A blackholed one
455 /// still produces no error at all, and there this bound is the only thing
456 /// that ends the wait.
449 fn waitReady( 457 fn waitReady(
450 cl: *quic_client.Client, 458 cl: *quic_client.Client,
451 idle_ms: u32, 459 budget_ms: u32,
452 alloc: std.mem.Allocator, 460 alloc: std.mem.Allocator,
453 carry: ?*std.ArrayList(u8), 461 carry: ?*std.ArrayList(u8),
454 ) !void { 462 ) !void {
455 const deadline = std.time.milliTimestamp() + idle_ms; 463 const deadline = std.time.milliTimestamp() + budget_ms;
456 // A closed stdin stays readable forever, so once it reports EOF it has to 464 // A closed stdin stays readable forever, so once it reports EOF it has to
457 // stop being polled or this loop spins hot for the rest of the bound 465 // stop being polled or this loop spins hot for the rest of the bound
458 // instead of waiting on the socket. Same hazard drainStdinForQuit 466 // instead of waiting on the socket. Same hazard drainStdinForQuit
@@ -1991,17 +1999,18 @@ test "handoff: endpoint-none rides the open pipe with no deadline paid" {
1991 try std.testing.expect(std.time.milliTimestamp() - t0 < 1000); 1999 try std.testing.expect(std.time.milliTimestamp() - t0 < 1000);
1992 } 2000 }
1993 2001
1994 test "handoff: dead coordinates fall back to the pipe, having spent the budget" { 2002 test "handoff: dead coordinates are a fast no, and the pipe is the fallback" {
1995 // A well-formed announce naming 127.0.0.1:1, where nothing listens. 2003 // A well-formed announce naming 127.0.0.1:1, where nothing listens.
1996 // Measured (M14 Task 4): the ICMP refusal lands on a sendto inside 2004 // The refusal is REAL — an ICMP unreachable comes back — and since M15
1997 // Client.drain and is discarded, so even loopback refusal runs the full 2005 // Task 4 both socket paths in quic_client act on it, so this dial dies
1998 // budget — hence the small deadline_ms here, and the lower bound below. 2006 // in about one loopback round trip instead of running deadline_ms out.
1999 // That lower bound therefore rests on the quirk: if the drain is ever 2007 // The upper bound below is what proves that: it sits far under the
2000 // taught to surface the refusal, a dead loopback port starts failing 2008 // budget, so an implementation that swallowed the refusal again (the
2001 // fast and THIS assertion is what fails. Re-pin it to the new behavior 2009 // pre-M15 bug: drain's `catch return` discarding ECONNREFUSED) would
2002 // then — do not delete it, or nothing checks that the dial happened. 2010 // spend the whole 300ms and fail here. No lower bound — this measures
2011 // 2ms, so any floor would be pinning scheduler noise.
2003 // The stderr line itself is pinned in e2e, where stderr is capturable; 2012 // The stderr line itself is pinned in e2e, where stderr is capturable;
2004 // here the pin is the fallback DECISION. 2013 // here the pin is the fallback DECISION plus the speed of the refusal.
2005 const alloc = std.testing.allocator; 2014 const alloc = std.testing.allocator;
2006 var carry: std.ArrayList(u8) = .empty; 2015 var carry: std.ArrayList(u8) = .empty;
2007 defer carry.deinit(alloc); 2016 defer carry.deinit(alloc);
@@ -2023,12 +2032,14 @@ test "handoff: dead coordinates fall back to the pipe, having spent the budget"
2023 const elapsed = std.time.milliTimestamp() - t0; 2032 const elapsed = std.time.milliTimestamp() - t0;
2024 try std.testing.expect(t.quic == null); 2033 try std.testing.expect(t.quic == null);
2025 try std.testing.expect(t.child != null); 2034 try std.testing.expect(t.child != null);
2026 // The budget was actually spent: an implementation that gave up on the 2035 // The refusal was SEEN, not waited out. 60ms is 30x the measured cost
2027 // dial early — or never dialled — would also produce a child transport, 2036 // and a fifth of the budget: loose enough for a loaded machine, tight
2028 // and this assertion is the only thing that tells the two apart. 2037 // enough that spending the budget cannot slip past it.
2029 try std.testing.expect(elapsed >= 300); 2038 if (elapsed >= 60) std.debug.print(
2030 // And it was bounded by deadline_ms, not by the connection's idle_ms. 2039 "refused dial took {d}ms of a 300ms budget: the ICMP refusal was swallowed, not acted on\n",
2031 try std.testing.expect(elapsed < 2000); 2040 .{elapsed},
2041 );
2042 try std.testing.expect(elapsed < 60);
2032 } 2043 }
2033 2044
2034 /// Put `bytes` on fd 0 for the duration of a test and give back a restorer. 2045 /// Put `bytes` on fd 0 for the duration of a test and give back a restorer.
src/handoff.zig
Old New
@@ -9,18 +9,23 @@ const std = @import("std");
9 const xdg = @import("xdg"); 9 const xdg = @import("xdg");
10 10
11 /// The QUIC attach budget for one attempt, warm path and cold path 11 /// The QUIC attach budget for one attempt, warm path and cold path
12 /// alike. Pinned from measurement on 2026-08-11 (decisions.md, M14). 12 /// alike — and, since M15, for a direct `quic://` dial too. Pinned from
13 /// measurement on 2026-08-11 (decisions.md, M14), re-measured 2026-08-12
14 /// (M15 Task 4) after the one fast-failure case was taught to work.
13 /// 15 ///
14 /// Measured: there is no fast-failure case. A wrong PSK against a live 16 /// Measured: silence is what this bound is for, and silence is the
15 /// listener ran 2037ms of a 2000ms budget, 3049 of 3000, 8031 of 8000 — 17 /// common case. A wrong PSK against a live listener ran 2037ms of a
16 /// mutual auth means the listener does not answer a peer it cannot 18 /// 2000ms budget, 3049 of 3000, 8031 of 8000 — mutual auth means the
17 /// authenticate, so silence is all the client ever gets. A blackholed 19 /// listener does not answer a peer it cannot authenticate — and the
18 /// UDP port did the same (4001 of 4000, 8001 of 8000). So did an 20 /// re-measurement agrees at 2012ms. A blackholed UDP port does the same
19 /// unbound port on LOOPBACK, 2051 of 2000: the ICMP refusal is real but 21 /// (4001 of 4000, 8001 of 8000; 2002ms on re-measurement). Neither ends
20 /// lands on the sendto in Client.drain, which discards it, so the 22 /// any other way, so this bound is the only thing that ends them.
21 /// ConnectionRefused branch in Client.readable never sees it. Every 23 ///
22 /// failure runs the budget out; this bound is the only thing that ends 24 /// The exception is a REFUSED port, and it used to be silent only by
23 /// any of them. 25 /// accident: the ICMP unreachable arrived, but it was delivered to the
26 /// send in Client.drain, whose `catch return` dropped it. Both socket
27 /// paths act on it now, so an unbound loopback port fails in 1ms where
28 /// it once spent 2051 of 2000.
24 /// 29 ///
25 /// That fixes the shape of the trade. Below: a real handshake is 4ms 30 /// That fixes the shape of the trade. Below: a real handshake is 4ms
26 /// local, 6.9ms cold attach on the M8 LAN, and 234ms at 75ms RTT — 3.1x 31 /// local, 6.9ms cold attach on the M8 LAN, and 234ms at 75ms RTT — 3.1x
src/quic_client.zig
Old New
@@ -352,19 +352,28 @@ pub const Client = struct {
352 self.drain(); 352 self.drain();
353 } 353 }
354 354
355 /// The one verdict both socket paths share: ECONNREFUSED on a
356 /// connected UDP socket is an ICMP unreachable — a dead transport,
357 /// not a blip. recv sees it after a failed flight; send sees it
358 /// when the queued error is delivered on the NEXT syscall, which
359 /// on a quiet connection is drain's send. Both must agree or the
360 /// refusal is only noticed on whichever path runs second.
361 fn sendRecvFailed(self: *Client, err: anyerror) void {
362 switch (err) {
363 error.ConnectionRefused => self.dead = true,
364 else => {},
365 }
366 }
367
355 fn readable(self: *Client) void { 368 fn readable(self: *Client) void {
356 var buf: [65536]u8 = undefined; 369 var buf: [65536]u8 = undefined;
357 while (true) { 370 while (true) {
358 const n = std.posix.recv(self.fd, &buf, 0) catch |err| switch (err) { 371 const n = std.posix.recv(self.fd, &buf, 0) catch |err| switch (err) {
359 error.WouldBlock => return, 372 error.WouldBlock => return,
360 // ECONNREFUSED on a connected UDP socket means the port is 373 else => {
361 // not there: an ICMP unreachable came back. That is a dead 374 self.sendRecvFailed(err);
362 // transport, not a blip to keep polling.
363 error.ConnectionRefused => {
364 self.dead = true;
365 return; 375 return;
366 }, 376 },
367 else => return,
368 }; 377 };
369 if (n == 0) continue; 378 if (n == 0) continue;
370 const conn = self.conn orelse return; 379 const conn = self.conn orelse return;
@@ -447,7 +456,10 @@ pub const Client = struct {
447 .brk => return, 456 .brk => return,
448 .cont => {}, 457 .cont => {},
449 } 458 }
450 _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch return; 459 _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch |err| {
460 self.sendRecvFailed(err);
461 return;
462 };
451 } 463 }
452 } 464 }
453 465
src/quic_server.zig
Old New
@@ -1243,6 +1243,10 @@ pub const Listener = struct {
1243 .cont => {}, 1243 .cont => {},
1244 } 1244 }
1245 1245
1246 // No refusal check here, unlike quic_client's send: this socket
1247 // is UNCONNECTED, so the kernel has no peer to attribute an ICMP
1248 // unreachable to and never delivers one. The asymmetry with the
1249 // client is the sockets', not drift.
1246 _ = std.posix.sendto( 1250 _ = std.posix.sendto(
1247 self.fd, 1251 self.fd,
1248 buf[0..@intCast(n)], 1252 buf[0..@intCast(n)],
test/e2e.sh
Old New
@@ -1004,10 +1004,21 @@ grep -q "did not answer" "$OUT.qc" || {
1004 # runs inside Transport.open, after drainStdinForQuit has returned, so 1004 # runs inside Transport.open, after drainStdinForQuit has returned, so
1005 # watching only the socket left nothing looking for Ctrl-\ for as long as 1005 # watching only the socket left nothing looking for Ctrl-\ for as long as
1006 # the handshake bound allows — and during a reconnect the terminal is in 1006 # the handshake bound allows — and during a reconnect the terminal is in
1007 # raw mode, where Ctrl-\ is the only way out. A deliberately unreachable 1007 # raw mode, where Ctrl-\ is the only way out.
1008 # port makes the handshake run its full length if nothing interrupts it. 1008 #
1009 # The target is the LIVE daemon dialled with the wrong key, and it has
1010 # to be something silent like that: an unreachable port used to hold the
1011 # handshake open for the whole bound, but since M15 a refused port is
1012 # answered by an ICMP unreachable that kills the dial in ~2ms — far too
1013 # fast to fit an abort inside, and this scenario would then be timing a
1014 # failure rather than an abort. A listener that cannot authenticate us
1015 # never answers (mutual PSK, same mechanism as case 2 above), so the
1016 # dial runs its full budget and the window is real.
1017 #
1018 # No --quic-idle-ms: since M15 the dial is bounded by the client's
1019 # 2000ms attach budget, which no flag on this command line moves, and
1020 # spelling an idle timeout here would suggest otherwise.
1009 # No convergence: the client aborts before it ever attaches, so it paints nothing. 1021 # No convergence: the client aborts before it ever attaches, so it paints nothing.
1010 QDEAD=$(( QPORT + 1 ))
1011 # A fifo rather than a pipeline, so what is timed is the CLIENT's exit and 1022 # A fifo rather than a pipeline, so what is timed is the CLIENT's exit and
1012 # not how long the writer happened to hang around afterwards. 1023 # not how long the writer happened to hang around afterwards.
1013 QFIFO="${TMPDIR:-/tmp}/mux-e2e-abort-fifo-$$" 1024 QFIFO="${TMPDIR:-/tmp}/mux-e2e-abort-fifo-$$"
@@ -1016,8 +1027,8 @@ mkfifo "$QFIFO"
1016 QWPID=$! 1027 QWPID=$!
1017 QT0=$(date +%s%N) 1028 QT0=$(date +%s%N)
1018 set +e 1029 set +e
1019 timeout 30 "$MUX" "quic://127.0.0.1:$QDEAD" --key "$QKEY" \ 1030 timeout 30 "$MUX" "quic://127.0.0.1:$QPORT" --key "$QKEY.wrong" \
1020 --quic-idle-ms 15000 < "$QFIFO" > "$OUT.qa" 2>&1 1031 < "$QFIFO" > "$OUT.qa" 2>&1
1021 RC=$? 1032 RC=$?
1022 set -e 1033 set -e
1023 QT1=$(date +%s%N) 1034 QT1=$(date +%s%N)
@@ -1028,11 +1039,13 @@ QMS=$(( (QT1 - QT0) / 1000000 ))
1028 echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)" 1039 echo "e2e FAIL: aborted quic handshake exited $RC (want 0; 124 means Ctrl-\ went unheard)"
1029 cat "$OUT.qa"; exit 1; 1040 cat "$OUT.qa"; exit 1;
1030 } 1041 }
1031 # Generous against the 15000ms bound but far below it: the point is that the 1042 # The abort lands 300ms in and the budget ends at 2000ms, so this bound is
1032 # abort is answered on the user's schedule, not the handshake's. Measured at 1043 # what tells "answered the user" from "ran the budget out and reported a
1033 # ~200ms; anything under 5s proves stdin was being watched. 1044 # failure that happened to exit late" — the exit code catches the second
1034 [ "$QMS" -lt 5000 ] || { 1045 # too, but only this catches an abort that was merely slow. Measured at
1035 echo "e2e FAIL: abort during handshake took ${QMS}ms (want well under the 15000ms bound)" 1046 # ~350ms; 1500 leaves room for a loaded machine and still clears 2000.
1047 [ "$QMS" -lt 1500 ] || {
1048 echo "e2e FAIL: abort during handshake took ${QMS}ms (want well inside the 2000ms budget)"
1036 exit 1; 1049 exit 1;
1037 } 1050 }
1038 1051
@@ -1124,8 +1137,26 @@ while [ ! -S "$SOCK4" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
1124 } 1137 }
1125 # The UDP port really came back, and to THIS daemon. Without SO_REUSEADDR a 1138 # The UDP port really came back, and to THIS daemon. Without SO_REUSEADDR a
1126 # bind that collided would have failed loudly instead. 1139 # bind that collided would have failed loudly instead.
1127 grep -qi " $QHEX " /proc/net/udp || { 1140 #
1128 echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT" 1141 # Polled rather than sampled once, and matched on the LOCAL address only
1142 # ($2), because both halves of the old one-shot `grep " $QHEX "` were
1143 # accidents. The socket wait above cannot gate it: kill -9 leaves the
1144 # socket FILE behind, so that loop exits before the new daemon has done
1145 # anything. And a bare grep also matched the reconnecting CLIENT's socket,
1146 # whose rem_address is this port — which is what made an unbound instant
1147 # read look bound. That client no longer holds a socket at this moment:
1148 # since M15 a refused dial dies in ~1 RTT and the socket goes with it, so
1149 # the gap between reconnect attempts is now genuinely empty. Both fixed
1150 # here rather than one, since either alone still passes on an accident.
1151 i=0
1152 while ! awk -v h="$QHEX" '$2==h{f=1} END{exit !f}' /proc/net/udp && [ "$i" -lt 50 ]; do
1153 sleep 0.1; i=$((i+1))
1154 done
1155 awk -v h="$QHEX" '$2==h{f=1} END{exit !f}' /proc/net/udp || {
1156 echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT ($QHEX) in 5s"
1157 echo " daemon pid $D4PID: $(ps -o stat=,comm= -p "$D4PID" 2>/dev/null || echo gone)"
1158 echo " /proc/net/udp lines mentioning the port:"
1159 grep -i " $QHEX " /proc/net/udp || echo " (none)"
1129 cat "$OUT.q"; exit 1; 1160 cat "$OUT.q"; exit 1;
1130 } 1161 }
1131 kill -0 "$D4PID" || { echo "e2e FAIL: restarted --quic daemon died"; cat "$OUT.q"; exit 1; } 1162 kill -0 "$D4PID" || { echo "e2e FAIL: restarted --quic daemon died"; cat "$OUT.q"; exit 1; }
@@ -2465,7 +2496,7 @@ assert_converged "$OUT.h2" "$SOCK16" "warm handoff"
2465 ok "warm handoff: the cache dials QUIC, ssh never runs (pidlog still $HSHIMS_B2)" 2496 ok "warm handoff: the cache dials QUIC, ssh never runs (pidlog still $HSHIMS_B2)"
2466 2497
2467 # (c) STALE CACHE: a well-formed announce naming a port nothing holds. The 2498 # (c) STALE CACHE: a well-formed announce naming a port nothing holds. The
2468 # invariant under test is that a stale cache costs TIME and never 2499 # invariant under test is that a stale cache costs a ROUND TRIP and never
2469 # correctness: the dial fails, the cold path refetches, QUIC succeeds on 2500 # correctness: the dial fails, the cold path refetches, QUIC succeeds on
2470 # the fresh coordinates, and the user is told nothing — a fallback line 2501 # the fresh coordinates, and the user is told nothing — a fallback line
2471 # here would be reporting a failure that did not happen. 2502 # here would be reporting a failure that did not happen.
@@ -2475,18 +2506,19 @@ ok "warm handoff: the cache dials QUIC, ssh never runs (pidlog still $HSHIMS_B2)
2475 # real cache above and only the port is swapped, so the line differs from a 2506 # real cache above and only the port is swapped, so the line differs from a
2476 # genuine one in exactly the way the scenario is about. 2507 # genuine one in exactly the way the scenario is about.
2477 # 2508 #
2478 # It costs a full deadline (~2s): a refused loopback port is not fast — 2509 # Until M15 this cost a full deadline (~2s), because the ICMP refusal from
2479 # the ICMP lands on a sendto inside the QUIC drain and is discarded there 2510 # the dead port landed on a sendto inside the QUIC drain and was discarded
2480 # (measured, M14 Task 4) — so every wait below is budgeted for it. 2511 # there. Both socket paths act on it now, so the dial dies in about one
2512 # loopback round trip and the whole heal measures ~200ms. The bound below
2513 # was a floor on that spent budget and is now a ceiling on its absence.
2481 # 2514 #
2482 # Which is also why the detach byte is six seconds out rather than the 2515 # The detach byte stays six seconds out even so. The stdin script runs on
2483 # suite's usual two and a half. The stdin script runs on its own clock, 2516 # its own clock, in PARALLEL with the attach, and `waitReady` answers
2484 # in PARALLEL with an attach that spends a deadline before the session 2517 # Ctrl-\ DURING a handshake (M8 pins exactly that). A detach landing
2485 # even exists, and `waitReady` answers Ctrl-\ DURING a handshake (M8 pins 2518 # inside the dial window aborts the attach: no session, no error, exit 0 —
2486 # exactly that). A detach landing inside the dial window aborts the 2519 # a scenario that passes its exit check having tested nothing. The window
2487 # attach: no session, no error, exit 0 — a scenario that passes its exit 2520 # it has to clear shrank from 2172ms to ~200ms; six seconds is now margin
2488 # check having tested nothing. Measured here at 2172ms to the marker, so 2521 # over the ssh refetch and the session that follows it.
2489 # six seconds is margin, not superstition.
2490 HHEXKEY=$(sed -n 's/^endpoint [0-9][0-9]* \([0-9a-f]*\)$/\1/p' "$HCACHE") 2522 HHEXKEY=$(sed -n 's/^endpoint [0-9][0-9]* \([0-9a-f]*\)$/\1/p' "$HCACHE")
2491 [ -n "$HHEXKEY" ] || { echo "e2e FAIL: could not read the cached key"; exit 1; } 2523 [ -n "$HHEXKEY" ] || { echo "e2e FAIL: could not read the cached key"; exit 1; }
2492 printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE" 2524 printf 'endpoint %s %s\n' "$HDEADPORT" "$HHEXKEY" > "$HCACHE"
@@ -2527,21 +2559,27 @@ HSHIMS_C2=$(wc -l < "$SSHIM_PIDLOG")
2527 [ "$((HSHIMS_C2 - HSHIMS_C))" -eq 1 ] || { 2559 [ "$((HSHIMS_C2 - HSHIMS_C))" -eq 1 ] || {
2528 echo "e2e FAIL: the healing attach ran $((HSHIMS_C2 - HSHIMS_C)) ssh invocations, want 1" 2560 echo "e2e FAIL: the healing attach ran $((HSHIMS_C2 - HSHIMS_C)) ssh invocations, want 1"
2529 exit 1; } 2561 exit 1; }
2530 # The invariant this scenario is named for is "a stale cache costs TIME, 2562 # The other half of the invariant, and it changed direction in M15. A
2531 # never correctness", and every check above measures only the correctness 2563 # floor used to live here: while a refused port ran the whole budget out,
2532 # half. The time is asserted here or the claim is unbacked: a build that 2564 # spending it was proof the poisoned coordinates had been dialled, which
2533 # read no cache at all would take the cold path, heal, print nothing, and 2565 # no other check in this scenario makes. That proof is gone — the heal now
2534 # spawn exactly one ssh — passing all six checks above while never paying 2566 # costs ~200ms, which is indistinguishable from a build that read no cache
2535 # the cost the sentence is about. (Scenario (b) is what actually catches 2567 # and simply went cold. Scenario (b) is what catches THAT build (a warm
2536 # that build; this floor is what keeps THIS scenario standing alone, since 2568 # attach there must spawn no ssh at all), and this scenario now leans on
2537 # the spec names it as the invariant's pin.) 2569 # it rather than pretending to stand alone.
2538 [ "$HMS_C" -ge 1500 ] || { 2570 #
2539 echo "e2e FAIL: the stale-cache attach converged in ${HMS_C}ms, too fast to have" 2571 # What replaces the floor is its mirror: the refusal must be SEEN, not
2540 echo " dialled the poisoned port at all — the cache was not read, so" 2572 # waited out. A build that swallowed the ICMP again — the pre-M15 bug —
2541 echo " nothing here witnessed a stale cache costing time" 2573 # would push this back over 2s, and the ceiling is what says so. 1500
2574 # clears the measured ~200ms by a wide margin and still sits under the
2575 # 2000ms budget, so nothing between "fast" and "spent the budget" hides.
2576 [ "$HMS_C" -lt 1500 ] || {
2577 echo "e2e FAIL: the stale-cache attach took ${HMS_C}ms; a refused port is meant to"
2578 echo " fail in about one round trip, so this dial spent the 2000ms QUIC"
2579 echo " budget instead of acting on the ICMP refusal"
2542 exit 1; } 2580 exit 1; }
2543 assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal" 2581 assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal"
2544 ok "a stale cache self-heals: one refetch (${HMS_C}ms), no fallback line, real port cached" 2582 ok "a stale cache self-heals: one refetch (${HMS_C}ms, no budget spent), no fallback line, real port cached"
2545 2583
2546 # (d) FALLBACK LINE, by key mismatch. A second daemon holding a key nothing 2584 # (d) FALLBACK LINE, by key mismatch. A second daemon holding a key nothing
2547 # else on this box has; `muxd endpoint` announces the DEFAULT key (that is 2585 # else on this box has; `muxd endpoint` announces the DEFAULT key (that is