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
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 |