ca46fe0e
fix: Task 4 follow-through — the third re-timed site, the inverted mechanism comment, narrowed error set
a73x 2026-08-12 15:59
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -2389,7 +2389,7 @@ 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 | 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. | 2390 | gives up at 2000ms rather than at the connection's 15000ms idle timeout. |
| 2391 | 2391 | ||
| 2392 | That fixes the shape of the trade, and 2000 stands. Below it: a real | 2392 | Silence spending the budget fixes the shape of the trade. Below it: a real |
| 2393 | 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 |
| 2394 | **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 |
| 2395 | Retry round trip — so 2000 covers RTT to ~645ms, past any terrestrial | 2395 | Retry round trip — so 2000 covers RTT to ~645ms, past any terrestrial |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -222,11 +222,13 @@ const Transport = struct { | |||
| 222 | // asked to stop, not to try the next thing. | 222 | // asked to stop, not to try the next thing. |
| 223 | error.UserAbort => return err, | 223 | error.UserAbort => return err, |
| 224 | // Every other cached-path failure falls through to ssh, | 224 | // Every other cached-path failure falls through to ssh, |
| 225 | // which is authoritative. Only a resolve failure is | 225 | // which is authoritative. What that costs depends on |
| 226 | // instant; anything dialable spends the whole deadline | 226 | // how the coordinates are dead: a resolve failure and |
| 227 | // first — even a loopback refusal, whose ICMP is | 227 | // a REFUSED port are both instant — the ICMP comes |
| 228 | // swallowed inside the QUIC drain (measured, M14 Task | 228 | // back and quic_client acts on it — while anything |
| 229 | // 4; see handoff.deadline_ms). | 229 | // SILENT (blackholed UDP, a listener holding another |
| 230 | // key) spends the whole deadline first. See | ||
| 231 | // handoff.deadline_ms for both numbers. | ||
| 230 | else => {}, | 232 | else => {}, |
| 231 | } | 233 | } |
| 232 | } else |_| {} // no cache yet, or one we cannot use: cold path | 234 | } else |_| {} // no cache yet, or one we cannot use: cold path |
| @@ -447,8 +449,9 @@ const Transport = struct { | |||
| 447 | /// Drive a fresh connection until it can carry bytes, or give up. | 449 | /// Drive a fresh connection until it can carry bytes, or give up. |
| 448 | /// | 450 | /// |
| 449 | /// Bounded by the attach budget, not by the connection's idle timeout: the | 451 | /// Bounded by the attach budget, not by the connection's idle timeout: the |
| 450 | /// time we give a handshake is not the time we give a quiet session, and | 452 | /// time we give a handshake is not the time we give a quiet session. Every |
| 451 | /// every caller passes `handoff.deadline_ms` for exactly that reason. | 453 | /// caller passes the target's `deadline_ms`, which defaults to |
| 454 | /// `handoff.deadline_ms`; tests shrink it. | ||
| 452 | /// | 455 | /// |
| 453 | /// A refused port now ends this wait early — quic_client surfaces the ICMP | 456 | /// 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 | 457 | /// refusal from whichever syscall the kernel hands it to. A blackholed one |
| @@ -2009,8 +2012,14 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { | |||
| 2009 | // pre-M15 bug: drain's `catch return` discarding ECONNREFUSED) would | 2012 | // pre-M15 bug: drain's `catch return` discarding ECONNREFUSED) would |
| 2010 | // spend the whole 300ms and fail here. No lower bound — this measures | 2013 | // spend the whole 300ms and fail here. No lower bound — this measures |
| 2011 | // 2ms, so any floor would be pinning scheduler noise. | 2014 | // 2ms, so any floor would be pinning scheduler noise. |
| 2012 | // The stderr line itself is pinned in e2e, where stderr is capturable; | 2015 | // |
| 2013 | // here the pin is the fallback DECISION plus the speed of the refusal. | 2016 | // Which means this test no longer witnesses that a dial happened AT |
| 2017 | // ALL: a build that skipped the cache entirely would also land on the | ||
| 2018 | // child transport, quickly. That property lives only in e2e now — | ||
| 2019 | // scenario (b), where a warm attach must spawn no ssh, and (c)'s | ||
| 2020 | // ceiling. What is pinned HERE is the fallback DECISION and the speed | ||
| 2021 | // of the refusal. The stderr line is e2e's too, where stderr is | ||
| 2022 | // capturable. | ||
| 2014 | const alloc = std.testing.allocator; | 2023 | const alloc = std.testing.allocator; |
| 2015 | var carry: std.ArrayList(u8) = .empty; | 2024 | var carry: std.ArrayList(u8) = .empty; |
| 2016 | defer carry.deinit(alloc); | 2025 | defer carry.deinit(alloc); |
| @@ -2032,14 +2041,18 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { | |||
| 2032 | const elapsed = std.time.milliTimestamp() - t0; | 2041 | const elapsed = std.time.milliTimestamp() - t0; |
| 2033 | try std.testing.expect(t.quic == null); | 2042 | try std.testing.expect(t.quic == null); |
| 2034 | try std.testing.expect(t.child != null); | 2043 | try std.testing.expect(t.child != null); |
| 2035 | // The refusal was SEEN, not waited out. 60ms is 30x the measured cost | 2044 | // The refusal was SEEN, not waited out. 150ms is derived from both |
| 2036 | // and a fifth of the budget: loose enough for a loaded machine, tight | 2045 | // ends: the failure it catches spends the full 300ms, so half the |
| 2037 | // enough that spending the budget cannot slip past it. | 2046 | // budget still separates the two by 2x, while the 2ms this measures |
| 2038 | if (elapsed >= 60) std.debug.print( | 2047 | // leaves 75x of headroom. It needs that headroom — `elapsed` also |
| 2048 | // covers spawning /bin/sh and reading the announce out of it, and the | ||
| 2049 | // sibling endpoint-none test budgets 1000ms for that same spawn under | ||
| 2050 | // load. | ||
| 2051 | if (elapsed >= 150) std.debug.print( | ||
| 2039 | "refused dial took {d}ms of a 300ms budget: the ICMP refusal was swallowed, not acted on\n", | 2052 | "refused dial took {d}ms of a 300ms budget: the ICMP refusal was swallowed, not acted on\n", |
| 2040 | .{elapsed}, | 2053 | .{elapsed}, |
| 2041 | ); | 2054 | ); |
| 2042 | try std.testing.expect(elapsed < 60); | 2055 | try std.testing.expect(elapsed < 150); |
| 2043 | } | 2056 | } |
| 2044 | 2057 | ||
| 2045 | /// Put `bytes` on fd 0 for the duration of a test and give back a restorer. | 2058 | /// Put `bytes` on fd 0 for the duration of a test and give back a restorer. |
src/handoff.zig
| Old | New | ||
|---|---|---|---|
| @@ -27,15 +27,16 @@ const xdg = @import("xdg"); | |||
| 27 | /// paths act on it now, so an unbound loopback port fails in 1ms where | 27 | /// paths act on it now, so an unbound loopback port fails in 1ms where |
| 28 | /// it once spent 2051 of 2000. | 28 | /// it once spent 2051 of 2000. |
| 29 | /// | 29 | /// |
| 30 | /// That fixes the shape of the trade. Below: a real handshake is 4ms | 30 | /// Silence spending the budget fixes the shape of the trade, and 2000 |
| 31 | /// local, 6.9ms cold attach on the M8 LAN, and 234ms at 75ms RTT — 3.1x | 31 | /// stands. Below: a real handshake is 4ms local, 6.9ms cold attach on |
| 32 | /// RTT, since every fresh Initial costs a Retry round trip — so 2000 | 32 | /// the M8 LAN, and 234ms at 75ms RTT — 3.1x RTT, since every fresh |
| 33 | /// covers RTT to ~645ms, past any terrestrial link. Above: with no | 33 | /// Initial costs a Retry round trip — so 2000 covers RTT to ~645ms, |
| 34 | /// negative caching of "UDP blocked" by design, every fallback attach on | 34 | /// past any terrestrial link. Above: with no negative caching of "UDP |
| 35 | /// such a network pays this in full, so it wants to be as low as the | 35 | /// blocked" by design, every fallback attach on such a network pays this |
| 36 | /// floor allows. 1000 would halve the tax and also halve the RTT ceiling | 36 | /// in full, so it wants to be as low as the floor allows. 1000 would |
| 37 | /// to ~320ms, buying latency on a degraded network at the price of | 37 | /// halve the tax and also halve the RTT ceiling to ~320ms, buying |
| 38 | /// abandoning QUIC paths that would have worked. Wrong way round. | 38 | /// latency on a degraded network at the price of abandoning QUIC paths |
| 39 | /// that would have worked. Wrong way round. | ||
| 39 | pub const deadline_ms: u32 = 2000; | 40 | pub const deadline_ms: u32 = 2000; |
| 40 | 41 | ||
| 41 | /// The PSK's length in bytes. The same 32 as `quic.Key`, spelled again | 42 | /// The PSK's length in bytes. The same 32 as `quic.Key`, spelled again |
src/quic_client.zig
| Old | New | ||
|---|---|---|---|
| @@ -356,9 +356,18 @@ pub const Client = struct { | |||
| 356 | /// connected UDP socket is an ICMP unreachable — a dead transport, | 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 | 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 | 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 | 359 | /// on a quiet connection is drain's send. Both must agree, because |
| 360 | /// refusal is only noticed on whichever path runs second. | 360 | /// the error goes to whichever syscall runs first after it is |
| 361 | fn sendRecvFailed(self: *Client, err: anyerror) void { | 361 | /// queued and is cleared by it — if that path does not act, nothing |
| 362 | /// else ever sees it. | ||
| 363 | /// | ||
| 364 | /// The parameter is the union of the two call sites' error sets | ||
| 365 | /// rather than `anyerror`, so a misspelled prong below is a compile | ||
| 366 | /// error instead of an arm that silently never matches. | ||
| 367 | fn sendRecvFailed( | ||
| 368 | self: *Client, | ||
| 369 | err: (std.posix.RecvFromError || std.posix.SendError), | ||
| 370 | ) void { | ||
| 362 | switch (err) { | 371 | switch (err) { |
| 363 | error.ConnectionRefused => self.dead = true, | 372 | error.ConnectionRefused => self.dead = true, |
| 364 | else => {}, | 373 | else => {}, |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -1131,6 +1131,12 @@ sleep 0.5 | |||
| 1131 | D4PID=$! | 1131 | D4PID=$! |
| 1132 | i=0 | 1132 | i=0 |
| 1133 | while [ ! -S "$SOCK4" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | 1133 | while [ ! -S "$SOCK4" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done |
| 1134 | # Nearly vacuous, and kept only because a MISSING file would still be worth | ||
| 1135 | # saying out loud: kill -9 leaves the old socket file behind, so this | ||
| 1136 | # passes on the dead daemon's leavings. What proves the unix socket serves | ||
| 1137 | # again is further down, where `muxd dump` and `muxd stats` answer on it; | ||
| 1138 | # what proves the daemon serves SESSIONS again is the post-restart marker | ||
| 1139 | # the resumed client gets back. | ||
| 1134 | [ -S "$SOCK4" ] || { | 1140 | [ -S "$SOCK4" ] || { |
| 1135 | echo "e2e FAIL: restarted --quic daemon never rebound its session socket" | 1141 | echo "e2e FAIL: restarted --quic daemon never rebound its session socket" |
| 1136 | cat "$OUT.q"; exit 1; | 1142 | cat "$OUT.q"; exit 1; |
| @@ -1148,11 +1154,16 @@ while [ ! -S "$SOCK4" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | |||
| 1148 | # since M15 a refused dial dies in ~1 RTT and the socket goes with it, so | 1154 | # 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 | 1155 | # the gap between reconnect attempts is now genuinely empty. Both fixed |
| 1150 | # here rather than one, since either alone still passes on an accident. | 1156 | # here rather than one, since either alone still passes on an accident. |
| 1157 | # | ||
| 1158 | # One expression, used by the wait and the verdict: two spellings of the | ||
| 1159 | # same match are two matches waiting to drift apart. | ||
| 1160 | udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; } | ||
| 1151 | i=0 | 1161 | i=0 |
| 1152 | while ! awk -v h="$QHEX" '$2==h{f=1} END{exit !f}' /proc/net/udp && [ "$i" -lt 50 ]; do | 1162 | # 50 x 0.1s, the same 5s every daemon wait in this file allows. |
| 1163 | while ! udp_local_bound "$QHEX" && [ "$i" -lt 50 ]; do | ||
| 1153 | sleep 0.1; i=$((i+1)) | 1164 | sleep 0.1; i=$((i+1)) |
| 1154 | done | 1165 | done |
| 1155 | awk -v h="$QHEX" '$2==h{f=1} END{exit !f}' /proc/net/udp || { | 1166 | udp_local_bound "$QHEX" || { |
| 1156 | echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT ($QHEX) in 5s" | 1167 | 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)" | 1168 | echo " daemon pid $D4PID: $(ps -o stat=,comm= -p "$D4PID" 2>/dev/null || echo gone)" |
| 1158 | echo " /proc/net/udp lines mentioning the port:" | 1169 | echo " /proc/net/udp lines mentioning the port:" |
| @@ -2574,9 +2585,12 @@ HSHIMS_C2=$(wc -l < "$SSHIM_PIDLOG") | |||
| 2574 | # clears the measured ~200ms by a wide margin and still sits under the | 2585 | # 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. | 2586 | # 2000ms budget, so nothing between "fast" and "spent the budget" hides. |
| 2576 | [ "$HMS_C" -lt 1500 ] || { | 2587 | [ "$HMS_C" -lt 1500 ] || { |
| 2577 | echo "e2e FAIL: the stale-cache attach took ${HMS_C}ms; a refused port is meant to" | 2588 | echo "e2e FAIL: the stale-cache attach took ${HMS_C}ms, where a refused port is" |
| 2578 | echo " fail in about one round trip, so this dial spent the 2000ms QUIC" | 2589 | echo " meant to fail in about one round trip (~200ms end to end). Either" |
| 2579 | echo " budget instead of acting on the ICMP refusal" | 2590 | echo " the ICMP refusal is being swallowed again and this dial spent the" |
| 2591 | echo " 2000ms QUIC budget, or this box is loaded enough to stretch a" | ||
| 2592 | echo " 200ms attach past 1500 — the daemon log and the load average" | ||
| 2593 | echo " tell those apart" | ||
| 2580 | exit 1; } | 2594 | exit 1; } |
| 2581 | assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal" | 2595 | assert_converged "$OUT.h3" "$SOCK16" "stale-cache self-heal" |
| 2582 | ok "a stale cache self-heals: one refetch (${HMS_C}ms, no budget spent), no fallback line, real port cached" | 2596 | ok "a stale cache self-heals: one refetch (${HMS_C}ms, no budget spent), no fallback line, real port cached" |
test/wan.sh
| Old | New | ||
|---|---|---|---|
| @@ -790,10 +790,14 @@ def cmd_quicreconnect(argv): | |||
| 790 | def cmd_quicabort(argv): | 790 | def cmd_quicabort(argv): |
| 791 | """Ctrl-\ against a port that will never answer. | 791 | """Ctrl-\ against a port that will never answer. |
| 792 | 792 | ||
| 793 | A blackholed port, not a closed one: a closed port answers ICMP and the | 793 | A blackholed port, not a closed one: a closed port answers ICMP, which |
| 794 | client gives up on its own, which would measure the kernel rather than | 794 | since M15 the client acts on — the dial dies in ~1 RTT and this would |
| 795 | the abort key. This is the only place the full handshake bound actually | 795 | measure the kernel rather than the abort key. Silence is what still |
| 796 | runs, so it is the only place the property can be observed. | 796 | runs the attach budget out, so a blackhole is the only way to hold a |
| 797 | handshake open long enough for the property to be observed. | ||
| 798 | |||
| 799 | `bound_ms` is that budget (client.QuicTarget.deadline_ms, 2000), not | ||
| 800 | the connection's idle timeout, which no longer ends a dial. | ||
| 797 | """ | 801 | """ |
| 798 | mux, spec, errlog, bound_ms = argv[0], argv[1], argv[2], int(argv[3]) | 802 | mux, spec, errlog, bound_ms = argv[0], argv[1], argv[2], int(argv[3]) |
| 799 | c = Client(mux, spec, errlog) | 803 | c = Client(mux, spec, errlog) |
| @@ -807,6 +811,12 @@ def cmd_quicabort(argv): | |||
| 807 | fail("quicabort: Ctrl-\\ went unanswered for most of the %dms bound" % bound_ms) | 811 | fail("quicabort: Ctrl-\\ went unanswered for most of the %dms bound" % bound_ms) |
| 808 | elapsed = (now() - t0) * 1000.0 | 812 | elapsed = (now() - t0) * 1000.0 |
| 809 | c._close() | 813 | c._close() |
| 814 | # Gated, not merely reported: an abort that is answered by the dial | ||
| 815 | # failing on its own exits 1, and without this the scenario would pass | ||
| 816 | # on the very outcome it exists to rule out. | ||
| 817 | if c.p.returncode != 0: | ||
| 818 | fail("quicabort: client exited %s (want 0; an abort is not a failure)" | ||
| 819 | % c.p.returncode) | ||
| 810 | report("quicabort", [elapsed], bound_ms=bound_ms, rc=c.p.returncode) | 820 | report("quicabort", [elapsed], bound_ms=bound_ms, rc=c.p.returncode) |
| 811 | 821 | ||
| 812 | 822 | ||
| @@ -1184,13 +1194,17 @@ if [ "${MUX_WAN_QUIC:-}" = "1" ]; then | |||
| 1184 | 1194 | ||
| 1185 | # The abort key against a port that will never answer. A DROP rather | 1195 | # The abort key against a port that will never answer. A DROP rather |
| 1186 | # than an unused port: an unused one answers ICMP and the client gives | 1196 | # than an unused port: an unused one answers ICMP and the client gives |
| 1187 | # up on its own, which would measure the kernel instead of the property. | 1197 | # up on its own — in ~1 RTT since M15 — which would measure the kernel |
| 1198 | # instead of the property. The bound is the client's 2000ms attach | ||
| 1199 | # budget, which is what ends this dial; no --quic-idle-ms, since the | ||
| 1200 | # idle timeout does not bound a handshake and naming one here would | ||
| 1201 | # suggest it does. | ||
| 1188 | say "QUIC abort: Ctrl-\\ against a blackholed port" | 1202 | say "QUIC abort: Ctrl-\\ against a blackholed port" |
| 1189 | QDEAD=$(( QPORT + 1 )) | 1203 | QDEAD=$(( QPORT + 1 )) |
| 1190 | $MUX_WAN_SSH "sudo -n iptables -I INPUT -p udp --dport $QDEAD \ | 1204 | $MUX_WAN_SSH "sudo -n iptables -I INPUT -p udp --dport $QDEAD \ |
| 1191 | -m comment --comment mux-$TAG -j DROP" | 1205 | -m comment --comment mux-$TAG -j DROP" |
| 1192 | measure quic quicabort "$MUX" \ | 1206 | measure quic quicabort "$MUX" \ |
| 1193 | "quic://$QHOST:$QDEAD --key $LKEY --quic-idle-ms 15000" "$ERRLOG" 15000 || true | 1207 | "quic://$QHOST:$QDEAD --key $LKEY" "$ERRLOG" 2000 || true |
| 1194 | $MUX_WAN_SSH "sudo -n iptables -D INPUT -p udp --dport $QDEAD \ | 1208 | $MUX_WAN_SSH "sudo -n iptables -D INPUT -p udp --dport $QDEAD \ |
| 1195 | -m comment --comment mux-$TAG -j DROP" >/dev/null 2>&1 || true | 1209 | -m comment --comment mux-$TAG -j DROP" >/dev/null 2>&1 || true |
| 1196 | fi | 1210 | fi |