a73x

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
fix: Task 4 follow-through — the third re-timed site, the inverted mechanism comment, narrowed error set

Three sites the first pass left asserting the old behaviour, plus the
nits ruled in on review.

openHandoff's warm-cache arm still said every dialable failure spends the
whole deadline "even a loopback refusal, whose ICMP is swallowed". It
now splits the two: a resolve failure and a refused port are both
instant, silence still costs the deadline.

wan.sh's quicabort was timed against 15000 and gated on nothing. The
bound is now the 2000ms attach budget that actually ends the dial (the
wait derives from it, 0.8x, which still lands under the budget the way
e2e case 3's 1500 does), the --quic-idle-ms that no longer bounds
anything is gone, and rc != 0 now fails the scenario instead of being
reported beside it — an abort answered by the dial dying on its own
exits 1, which is the outcome the scenario exists to rule out. The
docstring no longer claims to be where "the full handshake bound" runs.
REVIEWED, NOT RUN: wan.sh needs MUX_WAN_SSH and a sudo-capable remote
box, so this edit is checked by reading and by bash -n / py-compile only.

sendRecvFailed's comment had the mechanism backwards — it read as if a
missed refusal would be caught by the other path later. Corrected: the
error goes to whichever syscall runs first after it is queued and is
cleared by it.

Nits: the helper's parameter is now the union of its two call sites'
error sets, so a misspelled prong is a compile error (it needs
parentheses in a parameter position — `err: A || B` does not parse on
0.15.2); the dead-coordinates ceiling goes 60 -> 150ms with its
derivation written down, and says outright that the dial-happened
property now lives only in e2e; waitReady's doc names the target's
deadline_ms rather than the constant; e2e's stale-cache failure text
names load as the other cause; the restart-rebind check shares one awk
expression between its wait and its verdict; and decisions.md says "2000
stands" once.

make test rc=0. make e2e: 20 scenarios, 33 convergence points, with the
stale-cache heal at 202ms and the key-mismatch deadline at 2029ms.
Mutation re-checked against the raised ceiling: reverting drain's arm
still prints "refused dial took 302ms of a 300ms budget".

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

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