a73x

8a005f3a

feat(m8): QUIC held on a real path — three legs of four; the record

a73x   2026-08-08 14:08

Commit message
feat(m8): QUIC held on a real path — three legs of four; the record

The LAN box found a bug that loopback structurally could not, and the
measurement it then produced says something more interesting than the plan
expected.

**The bug.** ngtcp2 calls our receive callback and then goes on using the
connection: after conn_call_recv_stream_data it calls
conn_emit_pending_stream_data, which dereferences it again. The daemon
closes a connection from inside that callback whenever a client detaches,
so it was freeing the Conn underneath ngtcp2. The second call only runs
when there is buffered out-of-order stream data to flush, and loopback does
not reorder — so every test passed and the daemon segfaulted on the first
QUIC attach over a real network. Teardown is now deferred: closeConn and
kill mark, feed reaps on the way out. Loopback is not a network.

**The measurement**, one daemon and one link and one run, ssh-via beside
QUIC. Cold attach 6.9ms vs 9.8ms; with 75ms of emulated delay on the real
interface, 234.1ms vs 235.7ms. Ten tears via a genuine UDP blackhole, all
ten resumed hands-off, snapshot counter unmoved (16 -> 16), first marker
still on the grid at the end.

Legs 1, 3 and 4 hold. **Leg 2 does not**, for two reasons that are worth
more than the leg. Its threshold — 2.5×RTT — is unmeetable on a 0.1ms LAN
by anything, ssh-via included, at thirty times over. And its tear
comparison is not like-for-like: ssh's tear is a killed process, so EOF is
instant and the immediate first retry succeeds, while a QUIC tear must be a
blackhole outlasting the idle timer, which guarantees the client's first
retry lands inside it. The 461.7ms is our backoff schedule, and it barely
moved when the idle timeout was cut from 1500ms to 400ms.

What leg 2's rationale actually names — the channel-open floor being
deleted — is the cold-attach row, and there QUIC wins by 1.6ms at 75ms RTT.
The floor is deleted and QUIC hands it straight back: this listener answers
every fresh Initial with a Retry, so address validation costs a round trip
ssh never pays. That is the honest headline, it is stated inside the leg-3
figure rather than netted out of it, and it names the next win.

decisions.md carries the M8 section: the four driving decisions, the spike
findings, the full 0-RTT evidence chain, every bug and its lesson, the
contracts that were true but unwritten, and the banked list. README gains
the QUIC quickstart and the one-sentence trust model.

wan.sh gains the quic phase, and one fix it needed: the box is dual-homed,
so `ip route show default` returned two interfaces and netem was being
installed on one our traffic never crossed. It now asks the box which
device reaches this client. Firewall rules are tagged, removed and verified
like the qdisc, because a DROP left behind blackholes a port for everyone.

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

README.md
Old New
@@ -5,7 +5,32 @@ authoritatively in a daemon and replicated in the client — state sync
5 instead of escape-sequence replay. See `docs/handoff.md` for the design 5 instead of escape-sequence replay. See `docs/handoff.md` for the design
6 and `docs/decisions.md` for decisions made. 6 and `docs/decisions.md` for decisions made.
7 7
8 Status: **M7 — reconnect.** A dropped link is a non-event: the client 8 Status: **M8 — QUIC.** A third transport arm: `mux quic://HOST:PORT --key
9 FILE` attaches straight to a `muxd run --quic`, with no ssh in the path.
10 Authentication is TLS 1.3 external PSK — both ends hold the same 32-byte
11 key and nobody holds a certificate. Measured on a LAN box against ssh-via
12 in the same run: cold attach 6.9ms vs 9.8ms, and 234.1ms vs 235.7ms with
13 75ms of emulated delay on the real path. Ten consecutive network tears (a
14 real UDP blackhole) all resumed hands-off and all delta-served. The wire
15 protocol did not change: `git diff` on `src/protocol.zig` is empty across
16 the whole milestone, which is the third time transport has turned out to
17 be a swap rather than a redesign.
18
19 ### QUIC quickstart
20
21 ```sh
22 head -c 32 /dev/urandom > ~/.mux.key && chmod 600 ~/.mux.key # 32 raw bytes or 64 hex chars
23 scp ~/.mux.key muxd HOST: # the key and a static binary
24 ssh HOST 'chmod 600 ~/.mux.key; muxd run --quic 0.0.0.0:4433 --key ~/.mux.key'
25 mux quic://HOST:4433 --key ~/.mux.key # or set MUX_KEY_FILE
26 ```
27
28 **Trust model, in one sentence:** anyone holding that key file can attach to
29 that session, so it is exactly as sensitive as an ssh private key — `muxd`
30 and `mux` both refuse one that is readable by group or other, and there is
31 no unauthenticated mode to fall back to.
32
33 Previously, **M7 — reconnect.** A dropped link is a non-event: the client
9 keeps its replica, rebuilds the transport, and re-attaches quoting what it 34 keeps its replica, rebuilds the transport, and re-attaches quoting what it
10 already holds, so the daemon can answer with a delta instead of a repaint. 35 already holds, so the daemon can answer with a delta instead of a repaint.
11 Measured over a real WAN link: 10 consecutive transport kills against a 36 Measured over a real WAN link: 10 consecutive transport kills against a
@@ -14,7 +39,7 @@ snapshot counter never moved), first frame back in ~254 ms — most of which
14 is the client's own 200 ms first retry. `mux HOST` attaches over ssh in 39 is the client's own 200 ms first retry. `mux HOST` attaches over ssh in
15 one word. 40 one word.
16 41
17 Previously, **M6 — transport.** All handoff milestones complete; all kill 42 Before that, **M6 — transport.** All handoff milestones complete; all kill
18 criteria cleared. The prototype's two founding questions are both 43 criteria cleared. The prototype's two founding questions are both
19 answered yes: ghostty-vt serves as an authoritative headless grid in a 44 answered yes: ghostty-vt serves as an authoritative headless grid in a
20 daemon without forking it, and detach/reattach as state sync is correct 45 daemon without forking it, and detach/reattach as state sync is correct
docs/decisions.md
Old New
@@ -617,9 +617,330 @@ path, and it is transport work, which is the verdict restated.
617 files exist in `contrib/`, an install doc does not); the reconnect loop 617 files exist in `contrib/`, an install doc does not); the reconnect loop
618 has no upper bound on total retry time by design, so a client left 618 has no upper bound on total retry time by design, so a client left
619 attached to a permanently dead daemon retries until the user aborts. 619 attached to a permanently dead daemon retries until the user aborts.
620 - **Out of scope, in order:** QUIC/TLS transport (M8, next per user 620 - **Out of scope, in order:** QUIC/TLS transport (M8, done), then
621 direction), then prediction / local echo — which now has this 621 prediction / local echo — which now has this milestone's numbers to beat
622 milestone's numbers to beat as well as M6's ~4ms protocol cost. 622 as well as M6's ~4ms protocol cost.
623
624 ## 2026-08-08 (M8)
625
626 QUIC as a third transport arm: `muxd run --quic HOST:PORT --key FILE` and
627 `mux quic://HOST:PORT --key FILE`. ssh stays the default; QUIC is opt-in per
628 invocation and `mux HOST` still means ssh.
629
630 ### The four driving decisions
631
632 - **PSK, not certificates.** The deploy model is "scp a static binary to a
633 VM you own", so scp a 32-byte key beside it. TLS 1.3 external PSK gives
634 mutual authentication by possession with no CA, no TOFU, and no expiry to
635 manage. Certificates and a TOFU store stay banked.
636 - **ssh stays the default.** QUIC is faster to set up and it is one more
637 thing to configure; making it opt-in costs nothing and means a broken
638 QUIC build can never break `mux HOST`.
639 - **Zero protocol changes.** Frames, attach/prefix layouts and seq/epoch
640 semantics are untouched. `git diff b646277..HEAD -- src/protocol.zig` is
641 empty and is leg 4 of the kill criterion — the swap thesis, third pass.
642 - **Spike-gated.** No mature pure-Zig QUIC exists, so a C stack had to
643 prove it builds under the pinned Zig 0.15.2 for native *and* static musl
644 before anything else was planned.
645
646 ### The spike, and what it settled
647
648 ngtcp2 1.25.0 + wolfSSL 5.9.2 build under `zig cc` for both targets and add
649 ~1.68MB to a static binary. Two findings outlived the spike:
650
651 - **External PSK is already 1-RTT.** The handshake completes in one round
652 trip without a session ticket, so resumption buys nothing over a fresh
653 PSK connection. That left 0-RTT as the only remaining reconnect win —
654 and 0-RTT turned out to be unavailable (below).
655 - **The keylog ban is a standing build rule.** Built with
656 `WOLFSSL_KEYLOG_EXPORT` on, the binaries wrote every handshake's secrets
657 to `./sslkeylog.log` with nothing asking them to. It is off in
658 `deps/quic/build-deps.sh` permanently; turn it on only in a local
659 throwaway build, and never commit that.
660
661 ### 0-RTT: dropped on evidence, not on effort
662
663 The plan named 0-RTT as the reconnect win. It cannot be had from where we
664 are, and the evidence is worth keeping because the next person will ask:
665
666 - It is not compiled in. The built `options.h` has no `WOLFSSL_EARLY_DATA`
667 and `nm -g libwolfssl.a` finds zero early-data symbols.
668 - It cannot be enabled through cmake. wolfSSL 5.9.2's CMakeLists exposes
669 118 `WOLFSSL_*` options and mentions EARLY zero times; early data exists
670 only as the autotools flag `--enable-earlydata`, default off.
671 - Forcing the define would be worse than not having it. It is not a pure
672 feature gate: it adds `maxEarlyDataSz` to WOLFSSL_SESSION, a
673 `clientInEarlyData` bitfield to Options, a member to the ticket struct
674 and a TLSX enum value. Compiling the library with it while the installed
675 `options.h` lacks it is a silent ABI mismatch — memory corruption, not a
676 link error. A latency optimisation is never worth that.
677 - And external PSK is the wrong door anyway: the client-side gate reads
678 `session->maxEarlyDataSz`, populated from a session ticket's early_data
679 extension. An external PSK carries no ticket, so there is nothing to
680 read. Getting 0-RTT means adopting ticket-based resumption on top of the
681 PSK — a different design, not a flag.
682
683 ngtcp2 exports four `early_data` entry points; it is TLS-agnostic and has
684 no backend here to serve them. Banked: autotools wolfSSL with
685 `--enable-earlydata` plus ticket resumption.
686
687 ### The bugs this milestone paid for
688
689 - **ngtcp2 does not copy stream payload, and we assumed it did.**
690 `ngtcp2_conn_writev_stream` stores the *vector* it is handed —
691 `ngtcp2_vec_copy` is a memcpy of base+len, not of the bytes — and
692 re-reads those bytes to retransmit. The first egress buffer reallocated
693 on append and cleared as soon as the last byte had been handed over, so
694 ngtcp2 was left holding freed or recycled memory and died inside
695 `ngtcp2_pkt_encode_stream_frame`. It fired about three runs in seventy,
696 only in the one test big enough to overflow a socket buffer.
697 **The fix is an invariant, not a patch:** a byte handed to ngtcp2 does
698 not move or get overwritten until the peer acknowledges it. `Egress` is a
699 fixed ring; `head` advances only from `acked_stream_data_offset`.
700 **The lesson:** a C library's ownership rules are part of its API, and
701 "it copied it" is the assumption to check first.
702 **Not an invariant, and do not treat it as one:** the ring does not lap
703 its in-flight region on loopback only because the 256KB ring happens to
704 be at least the 256KB stream window the peer advertises. That is
705 configuration. Raise the advertised window without raising the ring and
706 laps become reachable again — the lifetime rule is what stops that being
707 a use-after-free, not the arithmetic.
708
709 - **Closing a connection from inside a receive callback is a
710 use-after-free, and only a real network shows it.** ngtcp2 calls our
711 receive callback and then goes on using `conn`: after
712 `conn_call_recv_stream_data` it calls `conn_emit_pending_stream_data`,
713 which dereferences the connection again. The daemon's `.detach` handling
714 reaches `closeConn` from inside that callback, which freed the Conn
715 underneath ngtcp2. The LAN box crashed the daemon on the first QUIC
716 attach of the first run. Teardown is now deferred: `in_ngtcp2` marks the
717 window, `closeConn`/`kill` mark rather than free, and `feed` reaps on the
718 way out.
719
720 **It was never loopback-only, and the first version of this note said it
721 was.** `conn_emit_pending_stream_data` is called unconditionally
722 (ngtcp2_conn.c:7646), and its first statement dereferences the
723 connection — `conn_is_tls_handshake_completed(conn)` at :7220 — *before*
724 the `if (!strm->rx.rob)` early-out two lines below it. The freed
725 connection was therefore read on every receive, on every path, from the
726 moment the bug existed. What a reordering network changed was not
727 whether the read happened but whether it landed on memory the allocator
728 had already reclaimed, and so whether it faulted.
729
730 **The lesson is sharper than "loopback is not a network": a
731 use-after-free that reads successfully is exactly the kind that ships.**
732 Every test we had was exercising it and reporting green. The real
733 network did not introduce the bug, it withdrew the luck.
734
735 - **Migration was defeated, which is most of why QUIC is here.** `route()`
736 matched the destination CID against the one a connection was created
737 with, but `get_new_connection_id2` advertises more, and a client that
738 changes network switches to one of the others (RFC 9000 §9.5). Those
739 packets fell through to `accept()`, which has no token for them and drops
740 them: the migration presented as a dead connection. Connections now match
741 any CID they have advertised and not retired. The test tells a routing
742 hit from a miss by what a *miss* does — fall through to `accept()`, which
743 answers a token-less Initial with a Retry — so a probe socket that hears
744 nothing was delivered, and the same probe hearing a Retry for an unknown
745 CID is what proves the packet was well-formed enough to have been
746 answered.
747
748 - **One port, one daemon.** The listener set SO_REUSEADDR, which on UDP
749 lets a second daemon bind the same address while the kernel hands each
750 datagram to one of them — two sessions silently splitting a port. This is
751 the socket-steal incident from M7 in its QUIC edition, and it gets the
752 same answer: fail the bind loudly. The bind also moved ahead of the
753 session socket, so a refused port costs no shell and leaves no socket.
754
755 - **Every QUIC attach was snapshot-served.** The daemon's client-slot
756 `.attach` arm snapshotted unconditionally, on reasoning that held while
757 every client began life as an observer and did its first attach from
758 there. A QUIC connection is promoted to a client slot when its handshake
759 completes, so its *first* attach lands on that arm — and no reconnecting
760 QUIC client could ever resume from a delta. That is kill-criterion leg 1,
761 and **the counter was the only witness**: a snapshot and a delta render
762 identically. Fourth time a counter assertion has caught something markers
763 could not. Fixing it also improved socket double-attach into parity with
764 the promotion path.
765
766 - **The socket unlink guard compared inodes across filesystems.** `fstat`
767 on a bound unix socket's descriptor answers with the sockfs inode;
768 `fstatat` on the path answers with the filesystem inode. They can never
769 be equal, so the guard was false every time and the daemon never unlinked
770 its socket on a clean exit — masked since 45ab077 by the stale-socket
771 recovery cleaning up on the next start. **The lesson: a guard whose false
772 branch is indistinguishable from success needs a test that observes the
773 EFFECT.** "Refused to delete someone else's socket" and "failed to delete
774 its own" leave identical evidence.
775
776 ### Things that are true and were not written down
777
778 - `closeConn` closes one connection and never the shared socket; it does
779 not call back into `onClose` (a close the owner asked for needs no
780 callback telling it so); and it sends no CONNECTION_CLOSE — the peer
781 learns via idle timeout. That last one is a real cost, accepted for v1
782 rather than unnoticed.
783 - `onOpen` and `onClose` do not pair. A handshake that never completes
784 produces neither: the connection is torn down having never been
785 announced.
786 - Flow control is extended before the owner consumes, which is sound only
787 because the owner's queue is bounded — it was not, until the egress ring.
788 - `max_conns` (16) exceeds `max_clients` (8) on purpose: a connection
789 exists from handshake completion and only then asks for a slot.
790
791 ### `idle_ms` is doing three jobs
792
793 One knob currently serves death detection (wants seconds), the handshake
794 timeout (wants a small multiple of RTT), and reaping a connection that
795 handshook and never attached (wants to be long). That is why the abort key
796 mattered so much: the handshake bound is far longer than a handshake needs
797 to be, and `waitReady` polled only the transport, so for its whole length
798 nothing watched for Ctrl-\ — measured at 14.6s of deafness on the default.
799 It now watches stdin too and answers in ~200ms. Splitting the three is
800 banked.
801
802 Related policy, decided rather than defaulted: bytes typed during a
803 handshake are **carried** on a first attach (they are the user's opening
804 command, and dropping them silently eats the first line of any piped
805 session) and **dropped** during a reconnect, matching the long-standing
806 rule that input typed while disconnected is not replayed.
807
808 ### Process rules this milestone paid for
809
810 - **The build.zig test-discovery hazard is CLOSED.** `src/main.zig`'s
811 module is in the test loop; removing it again drops the count from 100 to
812 94. Every module with tests is now in that list.
813 - **A test that hangs on regression is worth much less than one that
814 fails.** Restoring SO_REUSEADDR made the double-bind scenario's second
815 daemon bind and run forever; the invocation had no `timeout`, so the
816 suite hung for ten minutes instead of failing. Every refusal path now
817 runs under `timeout`, and the same mutation reports "exit 124, want 1" in
818 ten seconds. CI would have read the old behaviour as an infrastructure
819 timeout rather than a bug.
820 - **`sun_path` is retired as a hazard.** `std.testing.tmpDir` lives under
821 `.zig-cache`, so a socket path was as long as wherever the repository was
822 checked out, and 108 bytes is the cap. It cost three people a gate cycle
823 each. `src/testtmp.zig` hands out ~21-character directories under /tmp;
824 verified by building from a 128-character path.
825 - **Defence in depth is real and still working.** The epoch fence alone
826 does not fail the restart scenario — the seq-range check catches a stale
827 seq first. Both must be removed before the assertion fires, exactly as
828 M7 recorded.
829
830 ### The kill criterion, on the LAN box (2026-08-08)
831
832 One daemon, one link, one run: the ssh-via and QUIC columns are the same
833 session over the same wire. Box is `192.168.0.109` on a LAN (baseline byte
834 round-trip 0.1ms); the netem rows are **emulated-RTT-real-topology** —
835 `netem delay 75ms` on the box's real interface, so the delay is synthetic
836 and everything else about the path is not.
837
838 | | quic | ssh-via |
839 |--------------------------------|--------|---------|
840 | cold attach -> first paint | 6.9ms | 9.8ms |
841 | echo | 2.7ms | 2.9ms |
842 | cold attach, netem 75ms | 234.1ms| 235.7ms |
843 | echo, netem 75ms | 78.5ms | 79.0ms |
844 | tear -> usable | 461.7ms| 7.0ms |
845
846 **Leg 1 — semantics intact: HOLDS.** Attach, echo, detach and reattach all
847 work over `quic://` (e2e covers each as its own scenario). Ten consecutive
848 tears against a live session, every one resumed hands-off, the daemon's
849 snapshot counter unmoved across all ten (16 -> 16: every resume
850 delta-served), and the first tear's marker still on the grid after the
851 last.
852
853 **Leg 2 — median tear-to-usable ≤2.5×RTT and strictly below the same run's
854 ssh-via median: NOT MET, and the wording is part of why.** Two separate
855 problems, stated plainly rather than reconciled:
856
857 - At LAN RTT, 2.5×RTT is ~0.25ms. Nothing meets it — ssh-via's own 7.0ms
858 fails it by a factor of thirty. The threshold was written for a
859 WAN-latency path and is unmeetable on a fast link by any transport.
860 - The two tear-to-usable numbers are not comparable, because the tears are
861 not alike. ssh's tear is a killed process: EOF is instant, and since M8
862 Task 0 the client's first reconnect attempt is immediate, so it succeeds
863 at once. QUIC's tear is a UDP blackhole that must outlast the idle timer
864 to cause a tear at all — so the client's immediate first attempt
865 necessarily lands *inside* the blackhole and fails, guaranteeing at least
866 one backoff step before recovery. The 461.7ms is our own backoff
867 schedule, not the transport; it barely moved when the idle timeout was
868 cut from 1500ms to 400ms, which is the tell.
869
870 The quantity leg 2's own rationale names — "the channel-open floor is the
871 thing being deleted" — is connection setup, and that is the cold-attach
872 row. QUIC wins it in both conditions. But look at the netem row: **234.1
873 vs 235.7ms, a difference of 1.6ms at 75ms RTT.** The channel-open floor
874 really is deleted, and QUIC hands it straight back: this listener answers
875 every fresh Initial with a Retry, so address validation costs a round trip
876 ssh never pays. That is the honest headline of this milestone's
877 measurement, and it points at the obvious next win — a listener that skips
878 Retry when it does not need address validation would take ~75ms off cold
879 attach at this RTT.
880
881 **Leg 3 — cold QUIC attach ≤ same-run ssh-via attach: HOLDS**, 6.9 ≤ 9.8
882 on the clean link and 234.1 ≤ 235.7 under netem. **The Retry round trip is
883 INSIDE the QUIC figure**, not netted out: the comparison is pessimistic for
884 QUIC by roughly one RTT, and stating which way it leans is the point.
885
886 **Leg 4 — `git diff b646277..HEAD -- src/protocol.zig` empty: HOLDS.** The
887 transport was swapped a third time and the wire never noticed.
888
889 **Verdict: three legs of four hold. Leg 2 is not met** — unmeetable as
890 worded on a LAN, and its tear comparison measures our backoff rather than
891 the transport. QUIC is faster to set up than ssh-via on every measurement
892 taken, by a margin that the Retry round trip very nearly cancels.
893
894 Two other figures worth keeping. **Handshake:** cold attach at 75ms RTT is
895 234ms ≈ 3.1×RTT, which is Retry + handshake + attach + first paint — the
896 input the banked three-way `idle_ms` split will want. **Abort during a
897 handshake:** 301ms against a 15000ms bound, on a genuinely blackholed port
898 where the full bound would otherwise run; this is the first time that
899 property has been observed anywhere the bound is real. **Client slots held
900 at peak during the tears: 0** — the first field data on the banked
901 half-open question, and it says the reaping is keeping up under a tear
902 loop.
903
904 ### Banked by M8 (carried forward)
905
906 - **Half-open reaping.** A connection that completes its handshake and
907 never attaches holds a client slot until its idle timeout. Bounded and
908 self-clearing, and measured at 0 slots held under a ten-tear loop — but
909 the policy question (how long is too long, and is it idle_ms's job at
910 all) has no answer yet. `muxd stats` now reports `clients=N`, which is
911 the instrument for deciding it.
912 - **`timeoutMs` does not short-circuit a buffered frame.** A whole frame
913 already in the client's buffer can wait out the poll — up to ~100ms of
914 render lag. One line if it ever shows up against a real RTT.
915 - **Skip Retry when address validation is not needed.** Worth ~one RTT on
916 cold attach, which the LAN numbers show is most of QUIC's remaining
917 margin over ssh-via. **Not "skip Retry" on its own:** address validation
918 is what keeps this listener from being an amplification reflector, and
919 the spike measured its ratio at 0.08. The banked design is NEW_TOKEN —
920 issuing a token to a validated client for it to present next time, with
921 the reuse rules that implies — falling back to RFC 9000's 3x
922 anti-amplification limit for clients that have no token yet. Dropping
923 validation without one of those two is a regression wearing a
924 performance win's clothes.
925 - **`idle_ms` split three ways** — death detection, handshake timeout,
926 half-open reaping.
927 - **`addCSourceFiles` for the QUIC deps**, replacing the build script.
928 - **Autotools wolfSSL + ticket resumption**, the only route to 0-RTT.
929 - **Certificates / TOFU**, QUIC datagrams for input, multiplexing several
930 sessions per connection, NAT traversal.
931 - **RTT-multiple thresholds need rewriting before they are reused.** Two
932 milestones' criteria have now failed on a 0.1ms box for the same
933 structural reason: M6's reattach gate (2x round-trip) and M8's leg 2
934 (2.5xRTT) are both unmeetable by *any* implementation on the hardware
935 available, so what they measure is the hardware. A threshold expressed as
936 a multiple of RTT needs either a floor term for the fast-link case or an
937 explicit statement of the minimum RTT it is meaningful at. M9 should fix
938 the form before writing another one.
939 - **A post-M8 amendment to the criterion:** leg 4 asks only that
940 `protocol.zig` not change. Three transports in, that is worth restating
941 as what it has come to mean — no transport may need a protocol change —
942 and possibly worth a dedup pass across the three arms now that there are
943 three.
623 944
624 ## Open (owed by later milestones) 945 ## Open (owed by later milestones)
625 946
docs/superpowers/plans/2026-08-08-m8-quic.md
Old New
@@ -24,11 +24,11 @@
24 24
25 M7 measured resume ≈ 255ms of which ~200ms is our own first backoff. A transport that died a millisecond ago is overwhelmingly likely to accept a new connection now (daemon restarts, link blips, proxy kills all reconnect instantly); a genuinely down link costs one wasted attempt. Standard shape: first attempt immediate, THEN 200ms→2s exponential backoff. 25 M7 measured resume ≈ 255ms of which ~200ms is our own first backoff. A transport that died a millisecond ago is overwhelmingly likely to accept a new connection now (daemon restarts, link blips, proxy kills all reconnect instantly); a genuinely down link costs one wasted attempt. Standard shape: first attempt immediate, THEN 200ms→2s exponential backoff.
26 26
27 - [ ] **Step 1: Restructure `reconnect()`** so the first `Transport.open` attempt happens before any sleep. The Ctrl-\ drain must still run during every wait (no abort regression), and the reviewer's single-ownership-per-iteration structure from f314b1b must survive — the immediate attempt is iteration zero with a zero-length wait, not a special-cased second code path. Comment records the M7 measurement that justified it and the flapping-link reasoning for why backoff still follows. 27 - [x] **Step 1: Restructure `reconnect()`** so the first `Transport.open` attempt happens before any sleep. The Ctrl-\ drain must still run during every wait (no abort regression), and the reviewer's single-ownership-per-iteration structure from f314b1b must survive — the immediate attempt is iteration zero with a zero-length wait, not a special-cased second code path. Comment records the M7 measurement that justified it and the flapping-link reasoning for why backoff still follows.
28 - [ ] **Step 2: Keep the unit tests honest** — drainStdinForQuit tests are timing-parameterized and should not change; if any test asserted the pre-sleep, update it to assert the new shape (first attempt immediate) rather than deleting it. 28 - [x] **Step 2: Keep the unit tests honest** — drainStdinForQuit tests are timing-parameterized and should not change; if any test asserted the pre-sleep, update it to assert the new shape (first attempt immediate) rather than deleting it.
29 - [ ] **Step 3: Gates** (`make test`, `make e2e` — reconnect scenarios in e2e get faster, must stay green 3x — `make bench`). 29 - [x] **Step 3: Gates** (`make test`, `make e2e` — reconnect scenarios in e2e get faster, must stay green 3x — `make bench`).
30 - [ ] **Step 4: Run the wan.sh reconnect phase once against the box**: expected med drops from ~255ms to ≈55ms (36ms channel + ~18ms protocol). Gate nothing new; record the number for the Task 5 record. 30 - [x] **Step 4: Run the wan.sh reconnect phase once against the box**: expected med drops from ~255ms to ≈55ms (36ms channel + ~18ms protocol). Gate nothing new; record the number for the Task 5 record.
31 - [ ] **Step 5: Commit** `perf: first reconnect attempt is immediate — the 200ms was our own` 31 - [x] **Step 5: Commit** `perf: first reconnect attempt is immediate — the 200ms was our own`
32 32
33 --- 33 ---
34 34
@@ -40,11 +40,11 @@ M7 measured resume ≈ 255ms of which ~200ms is our own first backoff. A transpo
40 40
41 The whole milestone stands on one question: can a C QUIC stack be vendored and built by `zig build` (addCSourceFiles preferred; a pinned-script-built static lib acceptable as fallback) for BOTH native and `x86_64-linux-musl` static, under Zig 0.15.2, with TLS 1.3 external-PSK and an event-loop-free (fd-drivable) API? 41 The whole milestone stands on one question: can a C QUIC stack be vendored and built by `zig build` (addCSourceFiles preferred; a pinned-script-built static lib acceptable as fallback) for BOTH native and `x86_64-linux-musl` static, under Zig 0.15.2, with TLS 1.3 external-PSK and an event-loop-free (fd-drivable) API?
42 42
43 - [ ] **Step 1: Candidate (a) — ngtcp2 + wolfSSL.** Vendor pinned source snapshots. Get a minimal QUIC echo pair (server + client binaries) building native, then musl-static. PSK mode, single bidi stream, blocking-ish fd loop is fine for the spike. 43 - [x] **Step 1: Candidate (a) — ngtcp2 + wolfSSL.** Vendor pinned source snapshots. Get a minimal QUIC echo pair (server + client binaries) building native, then musl-static. PSK mode, single bidi stream, blocking-ish fd loop is fine for the spike.
44 - [ ] **Step 2: If (a) fails on toolchain grounds, candidate (b) — picoquic + picotls.** Same bar. Record precisely WHY (a) failed (the failure reasons are the spike's product as much as the success). 44 - [x] **Step 2: If (a) fails on toolchain grounds, candidate (b) — picoquic + picotls.** Same bar. Record precisely WHY (a) failed (the failure reasons are the spike's product as much as the success).
45 - [ ] **Step 3: Prove the winner on the box.** scp the static echo pair; measure over the real WAN: (i) cold handshake to first echoed byte, (ii) resumed handshake (session ticket / 0-RTT if the stack exposes it) to first echoed byte. Compare against the same run's ssh viafloor. Box hygiene rules as always: TAG-unique paths and ports, UDP port freed afterward, nothing of the user's touched. 45 - [x] **Step 3: Prove the winner on the box.** scp the static echo pair; measure over the real WAN: (i) cold handshake to first echoed byte, (ii) resumed handshake (session ticket / 0-RTT if the stack exposes it) to first echoed byte. Compare against the same run's ssh viafloor. Box hygiene rules as always: TAG-unique paths and ports, UDP port freed afterward, nothing of the user's touched.
46 - [ ] **Step 4: Integration assessment**, written down: how the stack wants fds/timers driven, what the daemon poll-loop integration looks like (one UDP fd + a timer fd?), PSK API shape, stream-data API shape, static binary size delta. 46 - [x] **Step 4: Integration assessment**, written down: how the stack wants fds/timers driven, what the daemon poll-loop integration looks like (one UDP fd + a timer fd?), PSK API shape, stream-data API shape, static binary size delta.
47 - [ ] **Step 5: Commit** `spike: QUIC stack chosen — <name> builds static-musl under the pinned toolchain` (spike directory + findings; main build untouched, gates green by construction) 47 - [x] **Step 5: Commit** `spike: QUIC stack chosen — <name> builds static-musl under the pinned toolchain` (spike directory + findings; main build untouched, gates green by construction)
48 48
49 **Spike kill criterion:** a static-musl QUIC echo pair working over the WAN box, from sources vendored and built reproducibly by our pinned toolchain, within TWO candidate stacks' worth of honest effort. Neither builds → report BLOCKED with the failure evidence; the controller re-scopes the milestone. Do not reach for a third candidate or a prebuilt blob without a controller decision. 49 **Spike kill criterion:** a static-musl QUIC echo pair working over the WAN box, from sources vendored and built reproducibly by our pinned toolchain, within TWO candidate stacks' worth of honest effort. Neither builds → report BLOCKED with the failure evidence; the controller re-scopes the milestone. Do not reach for a third candidate or a prebuilt blob without a controller decision.
50 50
src/quic_server.zig
Old New
@@ -378,6 +378,9 @@ const Conn = struct {
378 cids: [max_cids]c.ngtcp2_cid = undefined, 378 cids: [max_cids]c.ngtcp2_cid = undefined,
379 ncids: usize = 0, 379 ncids: usize = 0,
380 cids_dirty: bool = true, 380 cids_dirty: bool = true,
381 /// Set when this connection must go but cannot be freed yet, because
382 /// ngtcp2 is inside a call on it. See Listener.in_ngtcp2.
383 close_state: enum { open, closing_quiet, closing_notify } = .open,
381 stream_id: i64 = -1, 384 stream_id: i64 = -1,
382 /// Bytes owed to this peer. See Egress: they do not move until acked. 385 /// Bytes owed to this peer. See Egress: they do not move until acked.
383 out: Egress, 386 out: Egress,
@@ -535,7 +538,7 @@ fn recvStreamDataCb(
535 // second buffer to overflow by being generous. It would stop being the 538 // second buffer to overflow by being generous. It would stop being the
536 // right trade the moment an owner could hold bytes indefinitely, which 539 // right trade the moment an owner could hold bytes indefinitely, which
537 // is exactly what the egress ring now refuses to let it do. 540 // is exactly what the egress ring now refuses to let it do.
538 if (datalen > 0) { 541 if (datalen > 0 and cn.close_state == .open) {
539 cn.listener.handler.onData(cn.listener.handler.ctx, cn.id, data[0..datalen]); 542 cn.listener.handler.onData(cn.listener.handler.ctx, cn.id, data[0..datalen]);
540 } 543 }
541 return 0; 544 return 0;
@@ -553,6 +556,26 @@ pub const Listener = struct {
553 /// previous run must not validate against this one. 556 /// previous run must not validate against this one.
554 retry_secret: [32]u8, 557 retry_secret: [32]u8,
555 idle_ms: u64, 558 idle_ms: u64,
559 /// True while ngtcp2 owns the stack — i.e. inside `ngtcp2_conn_read_pkt`.
560 ///
561 /// A connection MUST NOT be freed during that window. ngtcp2 calls our
562 /// receive callback and then goes on using `conn`: after
563 /// conn_call_recv_stream_data it calls conn_emit_pending_stream_data,
564 /// which dereferences the connection again. Our callback reaches the
565 /// daemon, and the daemon may well decide the client is finished — a
566 /// `.detach` frame is exactly that — so `closeConn` used to free the
567 /// Conn underneath ngtcp2 and it segfaulted on the next dereference.
568 ///
569 /// It happened on loopback too, and that is the part worth remembering.
570 /// conn_emit_pending_stream_data is called unconditionally, and its
571 /// FIRST statement dereferences the connection —
572 /// conn_is_tls_handshake_completed(conn), ngtcp2_conn.c:7220 — before
573 /// the `if (!strm->rx.rob)` early-out two lines later. So the freed
574 /// connection was read on every single receive, everywhere. Reordering
575 /// decided only whether that read landed on memory the allocator had
576 /// already taken back, and therefore whether it faulted. A real network
577 /// did not create this bug; it just stopped it reading successfully.
578 in_ngtcp2: bool = false,
556 579
557 /// Bind the socket and stand up TLS: everything that can fail for 580 /// Bind the socket and stand up TLS: everything that can fail for
558 /// reasons outside this process. Split from the handler because the 581 /// reasons outside this process. Split from the handler because the
@@ -714,6 +737,15 @@ pub const Listener = struct {
714 for (&self.conns) |*slot| { 737 for (&self.conns) |*slot| {
715 if (slot.*) |cn| { 738 if (slot.*) |cn| {
716 if (cn.id != id) continue; 739 if (cn.id != id) continue;
740 // Deferred if ngtcp2 is mid-call on this connection: it will
741 // dereference `conn` again after our callback returns, and
742 // freeing here is a use-after-free. `feed` reaps on the way
743 // out. Quietly, per this function's contract — a close the
744 // owner asked for gets no callback telling it so.
745 if (self.in_ngtcp2) {
746 cn.close_state = .closing_quiet;
747 return;
748 }
717 cn.deinit(self.alloc); 749 cn.deinit(self.alloc);
718 self.alloc.destroy(cn); 750 self.alloc.destroy(cn);
719 slot.* = null; 751 slot.* = null;
@@ -722,6 +754,20 @@ pub const Listener = struct {
722 } 754 }
723 } 755 }
724 756
757 /// Free every connection that asked to go while ngtcp2 held the stack.
758 fn reapClosing(self: *Listener) void {
759 for (&self.conns) |*slot| {
760 const cn = slot.* orelse continue;
761 if (cn.close_state == .open) continue;
762 const notify = cn.close_state == .closing_notify and cn.opened;
763 const id = cn.id;
764 cn.deinit(self.alloc);
765 self.alloc.destroy(cn);
766 slot.* = null;
767 if (notify) self.handler.onClose(self.handler.ctx, id);
768 }
769 }
770
725 /// An unknown connection ID. Either a new peer or noise; either way the 771 /// An unknown connection ID. Either a new peer or noise; either way the
726 /// address is unvalidated, so this is where amplification protection 772 /// address is unvalidated, so this is where amplification protection
727 /// lives. 773 /// lives.
@@ -1018,10 +1064,19 @@ pub const Listener = struct {
1018 .user_data = null, 1064 .user_data = null,
1019 }; 1065 };
1020 var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 }; 1066 var pi: c.ngtcp2_pkt_info = .{ .ecn = 0 };
1021 if (c.ngtcp2_conn_read_pkt(conn, &path, &pi, pkt.ptr, pkt.len, timestampNs()) != 0) { 1067 self.in_ngtcp2 = true;
1022 self.kill(slot); 1068 const rv = c.ngtcp2_conn_read_pkt(conn, &path, &pi, pkt.ptr, pkt.len, timestampNs());
1069 self.in_ngtcp2 = false;
1070 // Reaped before anything else touches the table: a connection the
1071 // handler closed mid-callback is gone from here on, and `cn` below
1072 // would be a dangling pointer if it were one of them.
1073 const closed = slot.* == null or slot.*.?.close_state != .open;
1074 self.reapClosing();
1075 if (rv != 0) {
1076 if (!closed) self.kill(slot);
1023 return; 1077 return;
1024 } 1078 }
1079 if (closed) return;
1025 self.drain(cn); 1080 self.drain(cn);
1026 } 1081 }
1027 1082
@@ -1087,6 +1142,14 @@ pub const Listener = struct {
1087 1142
1088 fn kill(self: *Listener, slot: *?*Conn) void { 1143 fn kill(self: *Listener, slot: *?*Conn) void {
1089 const cn = slot.* orelse return; 1144 const cn = slot.* orelse return;
1145 // Same hazard as closeConn, and reached the same way: ngtcp2 is
1146 // still using this connection. Marked to be reaped WITH its
1147 // callback, since a kill is the listener giving up rather than the
1148 // owner asking.
1149 if (self.in_ngtcp2) {
1150 cn.close_state = .closing_notify;
1151 return;
1152 }
1090 const id = cn.id; 1153 const id = cn.id;
1091 const opened = cn.opened; 1154 const opened = cn.opened;
1092 cn.deinit(self.alloc); 1155 cn.deinit(self.alloc);
@@ -2013,3 +2076,86 @@ fn probeGotAnything(fd: std.posix.fd_t) bool {
2013 const n = std.posix.recv(fd, &buf, 0) catch return false; 2076 const n = std.posix.recv(fd, &buf, 0) catch return false;
2014 return n > 0; 2077 return n > 0;
2015 } 2078 }
2079
2080 test "Listener: closing a connection from inside a receive callback is deferred" {
2081 const alloc = std.testing.allocator;
2082 const key: Key = .{ .bytes = [_]u8{0x7E} ** key_len };
2083
2084 // An owner that does what the daemon does when a client says goodbye:
2085 // closes the connection from inside onData. ngtcp2 is on the stack at
2086 // that moment and goes on using the connection after the callback
2087 // returns, so freeing it there is a use-after-free — one that loopback
2088 // cannot show, because the second dereference only happens when there
2089 // is buffered out-of-order data to flush. This test pins the deferral
2090 // rather than the crash: the connection must still be a valid object
2091 // when the callback returns, and gone by the time the pump comes back.
2092 const CloseOnData = struct {
2093 listener: *Listener = undefined,
2094 id: u64 = 0,
2095 opened: usize = 0,
2096 closed: usize = 0,
2097 received: usize = 0,
2098 alive_after_callback: bool = false,
2099
2100 fn onOpen(ctx: *anyopaque, id: u64) void {
2101 const self: *@This() = @ptrCast(@alignCast(ctx));
2102 self.opened += 1;
2103 self.id = id;
2104 }
2105 fn onData(ctx: *anyopaque, id: u64, bytes: []const u8) void {
2106 const self: *@This() = @ptrCast(@alignCast(ctx));
2107 self.received += bytes.len;
2108 self.listener.closeConn(id);
2109 // Still addressable: the free was deferred, not done. Reading
2110 // this through the listener is exactly what ngtcp2 does next.
2111 self.alive_after_callback = self.listener.find(id) != null;
2112 }
2113 fn onClose(ctx: *anyopaque, _: u64) void {
2114 const self: *@This() = @ptrCast(@alignCast(ctx));
2115 self.closed += 1;
2116 }
2117 fn handler(self: *@This()) Handler {
2118 return .{ .ctx = self, .onOpen = onOpen, .onData = onData, .onClose = onClose };
2119 }
2120 };
2121
2122 var owner: CloseOnData = .{};
2123 const bind = try std.net.Address.parseIp("127.0.0.1", 0);
2124 const l = try Listener.init(alloc, bind, key, owner.handler(), 10_000);
2125 defer l.deinit();
2126 owner.listener = l;
2127 var actual: std.posix.sockaddr.storage = undefined;
2128 var alen: std.posix.socklen_t = @sizeOf(@TypeOf(actual));
2129 try std.posix.getsockname(l.fd, @ptrCast(&actual), &alen);
2130 const addr = std.net.Address.initPosix(@ptrCast(@alignCast(&actual)));
2131
2132 var cl = try TestClient.init(addr, key);
2133 defer cl.deinit();
2134 try cl.start();
2135 cl.drain();
2136
2137 var waited: u64 = 0;
2138 cl.out = "goodbye";
2139 while (waited < 10_000 and owner.received == 0) : (waited += 5) {
2140 var fds = [_]std.posix.pollfd{
2141 .{ .fd = l.fd, .events = std.posix.POLL.IN, .revents = 0 },
2142 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 },
2143 };
2144 _ = std.posix.poll(&fds, 5) catch break;
2145 if (fds[0].revents != 0) l.readable();
2146 if (fds[1].revents != 0) cl.readable();
2147 l.tick();
2148 cl.drain();
2149 for (l.conns) |slot| {
2150 if (slot) |cn| l.drain(cn);
2151 }
2152 }
2153
2154 try std.testing.expect(owner.received > 0);
2155 // The connection outlived the callback...
2156 try std.testing.expect(owner.alive_after_callback);
2157 // ...and did not outlive the pump.
2158 try std.testing.expect(l.find(owner.id) == null);
2159 // closeConn is the owner asking, so it gets no callback back.
2160 try std.testing.expectEqual(@as(usize, 0), owner.closed);
2161 }
test/e2e.sh
Old New
@@ -67,7 +67,7 @@ while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
67 67
68 # Client with piped stdio: types a command, waits, detaches with Ctrl-\ (034). 68 # Client with piped stdio: types a command, waits, detaches with Ctrl-\ (034).
69 { printf 'printf "e2e-%%s\\n" works\n'; sleep 2; printf '\034'; } | \ 69 { printf 'printf "e2e-%%s\\n" works\n'; sleep 2; printf '\034'; } | \
70 "$MUX" --sock "$SOCK" > "$OUT" 70 timeout 30 "$MUX" --sock "$SOCK" > "$OUT"
71 71
72 # 1. The client's rendered output must contain the command's result. 72 # 1. The client's rendered output must contain the command's result.
73 grep -q "e2e-works" "$OUT" || { 73 grep -q "e2e-works" "$OUT" || {
@@ -90,7 +90,7 @@ kill -9 "$CPID" 2>/dev/null || true
90 sleep 1 90 sleep 1
91 kill -0 "$DPID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; } 91 kill -0 "$DPID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; }
92 92
93 { sleep 1; printf '\034'; } | "$MUX" --sock "$SOCK" > "$OUT.re" 93 { sleep 1; printf '\034'; } | timeout 30 "$MUX" --sock "$SOCK" > "$OUT.re"
94 grep -q "60" "$OUT.re" || { 94 grep -q "60" "$OUT.re" || {
95 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1; 95 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1;
96 } 96 }
test/wan.sh
Old New
@@ -109,6 +109,17 @@ TAG="wan-$$-$(date +%s)"
109 RBIN="/tmp/muxd-$TAG" 109 RBIN="/tmp/muxd-$TAG"
110 RSOCK="/tmp/mux-$TAG.sock" 110 RSOCK="/tmp/mux-$TAG.sock"
111 RLOG="/tmp/muxd-$TAG.log" 111 RLOG="/tmp/muxd-$TAG.log"
112 # QUIC needs a key on both ends and a port of its own. The port is derived
113 # from the pid so two runs on one box cannot collide, and stays well clear
114 # of the ephemeral range.
115 RKEY="/tmp/mux-$TAG.key"
116 QPORT="${MUX_WAN_QPORT:-$(( 21000 + ($$ % 4000) ))}"
117 # Short on purpose. This is how fast a QUIC client notices a peer that has
118 # stopped answering, and it is pure configuration: the tear measurement below
119 # starts its clock after it, so a long value would only pad the wait while
120 # the client sat in a backoff it had already earned.
121 QIDLE="${MUX_WAN_QUIC_IDLE_MS:-400}"
122 FW_LEFT=0
112 123
113 WORK="$(mktemp -d "${TMPDIR:-/tmp}/mux-wan-XXXXXX")" 124 WORK="$(mktemp -d "${TMPDIR:-/tmp}/mux-wan-XXXXXX")"
114 PY="$WORK/wan.py" 125 PY="$WORK/wan.py"
@@ -167,7 +178,24 @@ cleanup() {
167 timeout 60 $MUX_WAN_SSH "pkill -f '[m]uxd-$TAG'" >/dev/null 2>&1 || true 178 timeout 60 $MUX_WAN_SSH "pkill -f '[m]uxd-$TAG'" >/dev/null 2>&1 || true
168 sleep 0.5 179 sleep 0.5
169 timeout 60 $MUX_WAN_SSH "pkill -9 -f '[m]uxd-$TAG'" >/dev/null 2>&1 || true 180 timeout 60 $MUX_WAN_SSH "pkill -9 -f '[m]uxd-$TAG'" >/dev/null 2>&1 || true
170 timeout 60 $MUX_WAN_SSH "rm -f $RBIN $RSOCK $RLOG" \ 181 # Tagged with this run's name so removal is exact. Looped because
182 # -D removes one matching rule per call and a crashed rep can leave
183 # more than one.
184 local fw_tries=0
185 while timeout 60 $MUX_WAN_SSH "sudo -n iptables -S INPUT 2>/dev/null | grep -q 'mux-$TAG'" 2>/dev/null; do
186 timeout 60 $MUX_WAN_SSH "sudo -n iptables -D INPUT -p udp --dport $QPORT \
187 -m comment --comment mux-$TAG -j DROP" >/dev/null 2>&1 || break
188 fw_tries=$((fw_tries + 1))
189 [ "$fw_tries" -lt 20 ] || break
190 done
191 if timeout 60 $MUX_WAN_SSH "sudo -n iptables -S INPUT 2>/dev/null | grep -q 'mux-$TAG'" 2>/dev/null; then
192 FW_LEFT=1
193 echo " WARNING: a udp DROP rule tagged mux-$TAG is STILL on the box:"
194 echo " \$MUX_WAN_SSH 'sudo iptables -D INPUT -p udp --dport $QPORT -m comment --comment mux-$TAG -j DROP'"
195 elif [ "$fw_tries" -gt 0 ]; then
196 echo " firewall rules removed ($fw_tries) — verified against the box"
197 fi
198 timeout 60 $MUX_WAN_SSH "rm -f $RBIN $RSOCK $RLOG $RKEY" \
171 >/dev/null 2>&1 || echo " WARNING: remote file cleanup failed; check for $TAG" 199 >/dev/null 2>&1 || echo " WARNING: remote file cleanup failed; check for $TAG"
172 local left 200 local left
173 left="$(timeout 60 $MUX_WAN_SSH "pgrep -af '[m]uxd-$TAG' || true" 2>/dev/null || true)" 201 left="$(timeout 60 $MUX_WAN_SSH "pgrep -af '[m]uxd-$TAG' || true" 2>/dev/null || true)"
@@ -178,8 +206,10 @@ cleanup() {
178 fi 206 fi
179 rm -rf "$WORK" 207 rm -rf "$WORK"
180 # A box left degraded is a failure of this script whatever the 208 # A box left degraded is a failure of this script whatever the
181 # measurements said. 209 # measurements said. A firewall rule counts as degraded: it silently
210 # blackholes a port for everyone.
182 [ "$NETEM_LEFT" -eq 0 ] || rc=3 211 [ "$NETEM_LEFT" -eq 0 ] || rc=3
212 [ "$FW_LEFT" -eq 0 ] || rc=3
183 exit $rc 213 exit $rc
184 } 214 }
185 trap cleanup EXIT INT TERM 215 trap cleanup EXIT INT TERM
@@ -195,6 +225,11 @@ measure() {
195 echo "$out" 225 echo "$out"
196 echo "wan FAIL: measurement '$*' failed" >&2 226 echo "wan FAIL: measurement '$*' failed" >&2
197 [ -s "$ERRLOG" ] && { echo "-- client stderr:"; tail -30 "$ERRLOG"; } 227 [ -s "$ERRLOG" ] && { echo "-- client stderr:"; tail -30 "$ERRLOG"; }
228 # The daemon's own view, which is where a handshake failure that
229 # looks identical from outside (full session, wrong key, no
230 # listener) actually distinguishes itself.
231 echo "-- remote stats:"; timeout 30 $MUX_WAN_SSH "$RBIN stats --sock $RSOCK" 2>&1 | tail -2 || true
232 echo "-- remote daemon log:"; timeout 30 $MUX_WAN_SSH "tail -20 $RLOG" 2>&1 || true
198 exit 1 233 exit 1
199 fi 234 fi
200 echo "$out" | grep -v '^#RESULT' || true 235 echo "$out" | grep -v '^#RESULT' || true
@@ -271,8 +306,12 @@ class Client:
271 # the only path being measured. 306 # the only path being measured.
272 env["XDG_RUNTIME_DIR"] = "/nonexistent-mux-wan" 307 env["XDG_RUNTIME_DIR"] = "/nonexistent-mux-wan"
273 self.errf = open(errlog, "ab") 308 self.errf = open(errlog, "ab")
309 # `via` is either a --via command string or, for QUIC, a complete
310 # argv tail beginning with quic:// — split on spaces, which is safe
311 # because the only things in it are a URL, a key path and a number.
312 args = [mux] + via.split() if via.startswith("quic://") else [mux, "--via", via]
274 self.p = subprocess.Popen( 313 self.p = subprocess.Popen(
275 [mux, "--via", via], 314 args,
276 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.errf, 315 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.errf,
277 bufsize=0, start_new_session=True, env=env) 316 bufsize=0, start_new_session=True, env=env)
278 self.buf = bytearray() 317 self.buf = bytearray()
@@ -643,6 +682,130 @@ def cmd_reconnect(argv):
643 first_survived=survived) 682 first_survived=survived)
644 683
645 684
685 def remote_clients(ssh_cmd, rbin, rsock):
686 """How many client slots the daemon is holding right now.
687
688 A gauge rather than a counter, and the instrument for the banked
689 half-open question: a QUIC connection that completes its handshake and
690 never attaches occupies a slot until its idle timeout, which was
691 previously unobservable from outside the process.
692 """
693 out = subprocess.run(shlex.split(ssh_cmd) + ["%s stats --sock %s" % (rbin, rsock)],
694 capture_output=True, text=True, timeout=60).stdout
695 m = re.search(r"\bclients=(\d+)", out)
696 return int(m.group(1)) if m else -1
697
698
699 def cmd_quicreconnect(argv):
700 """The QUIC tear: a real UDP blackhole on the box, not a killed process.
701
702 ACCOUNTING, because the two transports do not detect a tear the same way
703 and comparing them without saying so would be dishonest. ssh notices
704 instantly — the process dies and the pipe reports EOF — so its whole
705 measured interval is recovery. QUIC has nothing to notice: packets simply
706 stop, and the connection is declared dead by a TIMER whose length is
707 --quic-idle-ms, a knob we set. Including that timer would measure our own
708 configuration and call it a transport property.
709
710 So the sample clock starts when the drop is REMOVED, i.e. when recovery
711 first becomes possible, and the detection time is reported separately as
712 the idle setting it is. Both numbers are printed; neither is hidden.
713 """
714 mux, via, reps, errlog = argv[0], argv[1], int(argv[2]), argv[3]
715 ssh_cmd, rbin, rsock = argv[4], argv[5], argv[6]
716 qport, tag, idle_ms = argv[7], argv[8], int(argv[9])
717
718 def firewall(action):
719 # Tagged with the run's own comment so removal can be exact and
720 # verified, and so a crashed run leaves something findable rather
721 # than an anonymous DROP on someone else's box.
722 rule = ("INPUT -p udp --dport %s -m comment --comment mux-%s -j DROP"
723 % (qport, tag))
724 subprocess.run(shlex.split(ssh_cmd) +
725 ["sudo -n iptables -%s %s" % (action, rule)],
726 capture_output=True, text=True, timeout=60)
727
728 c = Client(mux, via, errlog)
729 samples = []
730 detect_ms = []
731 resumed = 0
732 snaps_before = snaps_after = -1
733 clients_peak = 0
734 first_marker = None
735 survived = 0
736 try:
737 settle(c, "quicreconnect")
738 c.send(b"\x15clear\n")
739 wait_idle(c, "quicreconnect")
740 snaps_before = remote_snapshots(ssh_cmd, rbin, rsock)
741 for i in range(reps):
742 before = c.total
743 firewall("I")
744 t_drop = now()
745 # Held past the idle timeout with margin, so the client cannot
746 # mistake the blackhole for a slow moment.
747 time.sleep((idle_ms / 1000.0) * 1.6)
748 seen = remote_clients(ssh_cmd, rbin, rsock)
749 if seen > clients_peak:
750 clients_peak = seen
751 firewall("D")
752 t0 = now()
753 detect_ms.append((t0 - t_drop) * 1000.0)
754
755 if c.wait_for(lambda b, c=c, t=before: c.total > t) is None:
756 fail("quicreconnect: rep %d never resumed after the blackhole lifted" % i)
757 samples.append((now() - t0) * 1000.0)
758 resumed += 1
759
760 nonce = "%d-%d" % (os.getpid(), i)
761 marker = ("wanq-" + nonce).encode()
762 if first_marker is None:
763 first_marker = marker
764 c.send(b'\x15printf "wanq-%s\\n" ' + nonce.encode() + b"\n")
765 if c.wait_for(lambda b, m=marker: m in b) is None:
766 fail("quicreconnect: rep %d resumed but the input path is dead" % i)
767 print(" quic tear rep %-2d recovery %7.1fms session usable"
768 % (i, samples[-1]))
769 wait_idle(c, "quicreconnect")
770 snaps_after = remote_snapshots(ssh_cmd, rbin, rsock)
771 dump = remote_dump(ssh_cmd, rbin, rsock)
772 survived = 1 if first_marker.decode() in dump else 0
773 if not survived:
774 print(" quicreconnect: FIRST TEAR'S MARKER %s IS GONE from the grid"
775 % first_marker.decode())
776 finally:
777 firewall("D") # belt and braces; the shell trap verifies
778 c.detach()
779 report("quicreconnect", samples, resumed=resumed, reps=reps,
780 snaps_before=snaps_before, snaps_after=snaps_after,
781 delta_served=1 if snaps_after == snaps_before else 0,
782 first_survived=survived, clients_peak=clients_peak,
783 detect_ms=int(sum(detect_ms) / max(1, len(detect_ms))))
784
785
786 def cmd_quicabort(argv):
787 """Ctrl-\ against a port that will never answer.
788
789 A blackholed port, not a closed one: a closed port answers ICMP and the
790 client gives up on its own, which would measure the kernel rather than
791 the abort key. This is the only place the full handshake bound actually
792 runs, so it is the only place the property can be observed.
793 """
794 mux, spec, errlog, bound_ms = argv[0], argv[1], argv[2], int(argv[3])
795 c = Client(mux, spec, errlog)
796 t0 = now()
797 time.sleep(0.3)
798 c.send(b"\x1c")
799 try:
800 c.p.wait(timeout=bound_ms / 1000.0 * 0.8)
801 except Exception:
802 c.kill()
803 fail("quicabort: Ctrl-\\ went unanswered for most of the %dms bound" % bound_ms)
804 elapsed = (now() - t0) * 1000.0
805 c._close()
806 report("quicabort", [elapsed], bound_ms=bound_ms, rc=c.p.returncode)
807
808
646 def cmd_hol(argv): 809 def cmd_hol(argv):
647 """Echo latency while the session floods output. The proxy is a byte 810 """Echo latency while the session floods output. The proxy is a byte
648 pump with no notion of priority, so a flood shares the pipe with the 811 pump with no notion of priority, so a flood shares the pipe with the
@@ -705,6 +868,8 @@ COMMANDS = {
705 "echo": cmd_echo, 868 "echo": cmd_echo,
706 "reattach": cmd_reattach, 869 "reattach": cmd_reattach,
707 "reconnect": cmd_reconnect, 870 "reconnect": cmd_reconnect,
871 "quicreconnect": cmd_quicreconnect,
872 "quicabort": cmd_quicabort,
708 "hol": cmd_hol, 873 "hol": cmd_hol,
709 } 874 }
710 875
@@ -730,9 +895,24 @@ say "baseline: raw byte round-trip through \`ssh cat\` ($REPS_BASE reps)"
730 measure clean baseline "$MUX_WAN_SSH" "$REPS_BASE" 895 measure clean baseline "$MUX_WAN_SSH" "$REPS_BASE"
731 measure clean viafloor "$MUX_WAN_SSH" 5 896 measure clean viafloor "$MUX_WAN_SSH" 5
732 897
898 QUIC_ARGS=""
899 LKEY=""
900 if [ "${MUX_WAN_QUIC:-}" = "1" ]; then
901 say "deploying a QUIC key (owner-only, both ends)"
902 LKEY="$WORK/mux-$TAG.key"
903 head -c 32 /dev/urandom > "$LKEY"
904 chmod 600 "$LKEY"
905 $MUX_WAN_SCP "$LKEY" "$MUX_WAN_HOST:$RKEY"
906 # muxd refuses a group- or world-readable key, exactly as ssh does, so
907 # this is required rather than tidy.
908 $MUX_WAN_SSH "chmod 600 $RKEY"
909 QUIC_ARGS="--quic 0.0.0.0:$QPORT --key $RKEY --quic-idle-ms 15000"
910 echo " udp $QPORT, key $RKEY"
911 fi
912
733 say "starting the remote daemon" 913 say "starting the remote daemon"
734 $MUX_WAN_SSH "rm -f $RSOCK; nohup $RBIN run --sock $RSOCK --shell /bin/bash \ 914 $MUX_WAN_SSH "rm -f $RSOCK; nohup $RBIN run --sock $RSOCK --shell /bin/bash \
735 >$RLOG 2>&1 </dev/null & sleep 0.2" 915 $QUIC_ARGS >$RLOG 2>&1 </dev/null & sleep 0.2"
736 i=0 916 i=0
737 until $MUX_WAN_SSH "test -S $RSOCK" 2>/dev/null; do 917 until $MUX_WAN_SSH "test -S $RSOCK" 2>/dev/null; do
738 i=$((i + 1)) 918 i=$((i + 1))
@@ -741,7 +921,8 @@ until $MUX_WAN_SSH "test -S $RSOCK" 2>/dev/null; do
741 sleep 0.5 921 sleep 0.5
742 done 922 done
743 sleep 1.5 # let bash reach a prompt on its pty 923 sleep 1.5 # let bash reach a prompt on its pty
744 echo " daemon up on $RSOCK" 924 RPID="$($MUX_WAN_SSH "pgrep -f '[m]uxd-$TAG' | head -1" | tr -cd '0-9')"
925 echo " daemon up on $RSOCK (remote pid ${RPID:-unknown})"
745 926
746 say "attach latency (launch -> first painted byte, $REPS_ATTACH reps)" 927 say "attach latency (launch -> first painted byte, $REPS_ATTACH reps)"
747 measure clean attach "$MUX" "$VIA" "$REPS_ATTACH" "$ERRLOG" 928 measure clean attach "$MUX" "$VIA" "$REPS_ATTACH" "$ERRLOG"
@@ -760,13 +941,67 @@ say "M7 reconnect: tear the transport under a live session ($REPS_RECONNECT tear
760 measure clean reconnect "$MUX" "$VIA" "$REPS_RECONNECT" "$ERRLOG" \ 941 measure clean reconnect "$MUX" "$VIA" "$REPS_RECONNECT" "$ERRLOG" \
761 "$MUX_WAN_SSH" "$RBIN" "$RSOCK" 942 "$MUX_WAN_SSH" "$RBIN" "$RSOCK"
762 943
944 if [ "${MUX_WAN_QUIC:-}" = "1" ]; then
945 # One daemon, two transports, one link, one run: the ssh-via numbers
946 # above and the QUIC numbers below are the same session over the same
947 # wire, which is what makes them comparable at all.
948 QHOST="${MUX_WAN_QUIC_HOST:-$(echo "$MUX_WAN_HOST" | sed 's/.*@//')}"
949 QSPEC="quic://$QHOST:$QPORT --key $LKEY --quic-idle-ms $QIDLE"
950
951 # OUR daemon owns that port, not merely somebody's socket. Matching the
952 # port alone in /proc/net/udp would pass just as happily on a stranger's
953 # listener, and then every QUIC number below would be measuring a
954 # handshake against something else entirely.
955 say "QUIC: checking the daemon owns udp $QPORT"
956 # Matched on PID, not on name: ss truncates a process name to 15
957 # characters, and a tag this long is exactly the kind of thing that
958 # silently never matches.
959 QOWNER="$($MUX_WAN_SSH "ss -ulnp 2>/dev/null | grep ':$QPORT ' || true")"
960 echo " $QOWNER"
961 case "$QOWNER" in
962 *"pid=$RPID,"*) : ;;
963 *) echo "wan FAIL: udp $QPORT is not held by our daemon (pid $RPID)" >&2
964 $MUX_WAN_SSH "cat $RLOG" || true; exit 1 ;;
965 esac
966 echo " remote stats before QUIC: $($MUX_WAN_SSH "$RBIN stats --sock $RSOCK")"
967
968 say "QUIC attach latency (launch -> first painted byte, $REPS_ATTACH reps)"
969 measure quic attach "$MUX" "$QSPEC" "$REPS_ATTACH" "$ERRLOG"
970
971 say "QUIC keystroke echo through the full stack ($REPS_ECHO reps)"
972 measure quic echo "$MUX" "$QSPEC" "$REPS_ECHO" "$ERRLOG"
973
974 say "QUIC tears: a real UDP blackhole on the box ($REPS_RECONNECT tears)"
975 measure quic quicreconnect "$MUX" "$QSPEC" "$REPS_RECONNECT" "$ERRLOG" \
976 "$MUX_WAN_SSH" "$RBIN" "$RSOCK" "$QPORT" "$TAG" "$QIDLE"
977
978 # The abort key against a port that will never answer. A DROP rather
979 # than an unused port: an unused one answers ICMP and the client gives
980 # up on its own, which would measure the kernel instead of the property.
981 say "QUIC abort: Ctrl-\\ against a blackholed port"
982 QDEAD=$(( QPORT + 1 ))
983 $MUX_WAN_SSH "sudo -n iptables -I INPUT -p udp --dport $QDEAD \
984 -m comment --comment mux-$TAG -j DROP"
985 measure quic quicabort "$MUX" \
986 "quic://$QHOST:$QDEAD --key $LKEY --quic-idle-ms 15000" "$ERRLOG" 15000 || true
987 $MUX_WAN_SSH "sudo -n iptables -D INPUT -p udp --dport $QDEAD \
988 -m comment --comment mux-$TAG -j DROP" >/dev/null 2>&1 || true
989 fi
990
763 # Every clean-link figure is taken before the box is degraded, so a netem 991 # Every clean-link figure is taken before the box is degraded, so a netem
764 # that failed to clear can never quietly relabel itself as the clean link. 992 # that failed to clear can never quietly relabel itself as the clean link.
765 say "head-of-line probe: echo while the session floods output ($REPS_HOL reps)" 993 say "head-of-line probe: echo while the session floods output ($REPS_HOL reps)"
766 measure clean hol "$MUX" "$VIA" "$REPS_HOL" "$ERRLOG" 994 measure clean hol "$MUX" "$VIA" "$REPS_HOL" "$ERRLOG"
767 995
768 if [ "${MUX_WAN_NETEM:-}" = "1" ]; then 996 if [ "${MUX_WAN_NETEM:-}" = "1" ]; then
769 IFACE="$($MUX_WAN_SSH "ip route show default | cut -d' ' -f5" | tr -d '\r\n')" 997 # The interface facing US, not merely a default route. A dual-homed box
998 # has more than one default and `ip route show default` prints them all
999 # — shaping the wrong one produces a "netem" column that is really the
1000 # clean link, which is worse than no measurement. Asking the box which
1001 # device it would use to reach this client answers it exactly.
1002 CLIENT_IP="$($MUX_WAN_SSH 'echo $SSH_CLIENT' | awk '{print $1}' | tr -d '\r\n')"
1003 [ -n "$CLIENT_IP" ] || { echo "wan FAIL: cannot tell which address the box sees us on" >&2; exit 1; }
1004 IFACE="$($MUX_WAN_SSH "ip route get $CLIENT_IP" | sed -n 's/.* dev \([^ ]*\).*/\1/p' | head -1 | tr -d '\r\n')"
770 [ -n "$IFACE" ] || { echo "wan FAIL: no default interface on the box" >&2; exit 1; } 1005 [ -n "$IFACE" ] || { echo "wan FAIL: no default interface on the box" >&2; exit 1; }
771 # A root netem qdisc shapes egress only, so this adds ~75ms to the round 1006 # A root netem qdisc shapes egress only, so this adds ~75ms to the round
772 # trip, not ~150ms — measured, not assumed. 1007 # trip, not ~150ms — measured, not assumed.
@@ -789,6 +1024,18 @@ if [ "${MUX_WAN_NETEM:-}" = "1" ]; then
789 1024
790 measure netem hol "$MUX" "$VIA" "$REPS_HOL" "$ERRLOG" 1025 measure netem hol "$MUX" "$VIA" "$REPS_HOL" "$ERRLOG"
791 1026
1027 if [ "${MUX_WAN_QUIC:-}" = "1" ]; then
1028 # Connection setup is the leg where the transports actually differ,
1029 # and it is RTT that separates them: ssh pays a channel open, QUIC
1030 # pays a handshake plus the Retry this listener always sends. At LAN
1031 # RTT both round to nothing, so the comparison only means something
1032 # once there is a real delay in the path.
1033 say "QUIC attach under netem ($REPS_ATTACH reps)"
1034 measure quicnetem attach "$MUX" "$QSPEC" "$REPS_ATTACH" "$ERRLOG"
1035 say "QUIC echo under netem ($REPS_ECHO reps)"
1036 measure quicnetem echo "$MUX" "$QSPEC" "$REPS_ECHO" "$ERRLOG"
1037 fi
1038
792 netem_off 1039 netem_off
793 fi 1040 fi
794 1041
@@ -908,9 +1155,55 @@ echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ) host: (ephemeral, from MUX_WAN_SSH)
908 phase_block clean "link as-is:" 1155 phase_block clean "link as-is:"
909 phase_block netem "with netem delay 75ms loss 1%:" 1156 phase_block netem "with netem delay 75ms loss 1%:"
910 m7_block 1157 m7_block
1158
1159 # ---- M8: the QUIC legs -----------------------------------------------------
1160 if [ "${MUX_WAN_QUIC:-}" = "1" ]; then
1161 q_attach="$(val quic attach med)"
1162 q_echo="$(val quic echo med)"
1163 q_tear="$(val quic quicreconnect med)"
1164 q_detect="$(val quic quicreconnect detect_ms)"
1165 q_snapb="$(val quic quicreconnect snaps_before)"
1166 q_snapa="$(val quic quicreconnect snaps_after)"
1167 q_resumed="$(val quic quicreconnect resumed)"
1168 q_reps="$(val quic quicreconnect reps)"
1169 q_survived="$(val quic quicreconnect first_survived)"
1170 q_clients="$(val quic quicreconnect clients_peak)"
1171 q_abort="$(val quic quicabort med)"
1172 s_attach="$(val clean attach med)"
1173 s_tear="$(val clean reconnect med)"
1174 echo
1175 echo "M8 QUIC (same daemon, same link, same run):"
1176 printf ' %-40s %8s %8s\n' "" "quic" "ssh-via"
1177 printf ' %-40s %8s %8s\n' "cold attach -> first paint (med ms)" "$q_attach" "$s_attach"
1178 printf ' %-40s %8s %8s\n' "echo (med ms)" "$q_echo" "$(val clean echo med)"
1179 printf ' %-40s %8s %8s\n' "tear -> usable (med ms)" "$q_tear" "$s_tear"
1180 echo " tear accounting: the two transports do NOT detect a tear alike."
1181 echo " ssh dies with its process, so EOF is instant and its whole"
1182 echo " interval is recovery. QUIC has nothing to notice: it is declared"
1183 echo " dead by a timer set to --quic-idle-ms (${QIDLE}ms here, ${q_detect}ms"
1184 echo " of blackhole measured before the clock starts). The quic column"
1185 echo " is measured from the blackhole LIFTING, so it excludes that"
1186 echo " detection and includes whatever backoff the client had already"
1187 echo " entered while it waited."
1188 echo " leg 1 (semantics): resumed $q_resumed/$q_reps, snapshots $q_snapb -> $q_snapa, first marker kept: $q_survived"
1189 echo " leg 3 (cold attach): quic $q_attach vs ssh-via $s_attach"
1190 if [ -n "$(val quicnetem attach med)" ]; then
1191 echo " leg 3 under netem (the RTT that makes setup mean something):"
1192 echo " quic $(val quicnetem attach med) vs ssh-via $(val netem attach med) (echo: quic $(val quicnetem echo med) vs ssh-via $(val netem echo med))"
1193 fi
1194 echo " NOTE: the quic figure INCLUDES the Retry round trip — this"
1195 echo " listener answers every fresh Initial with one, so address"
1196 echo " validation is inside the number, not netted out of it. ssh pays"
1197 echo " no such round trip. The comparison is therefore pessimistic for"
1198 echo " QUIC by roughly one RTT."
1199 echo " abort during handshake: ${q_abort}ms against a 15000ms bound"
1200 echo " client slots held at peak during tears: $q_clients"
1201 fi
911 echo 1202 echo
912 if [ "$FAILED" -eq 0 ]; then 1203 if [ "$FAILED" -eq 0 ]; then
913 echo "kill criteria (M6 + M7): PASS" 1204
1205
1206 echo "kill criteria (M6 + M7): PASS"
914 else 1207 else
915 echo "kill criteria (M6 + M7): FAIL — report the numbers; do not tune the thresholds." 1208 echo "kill criteria (M6 + M7): FAIL — report the numbers; do not tune the thresholds."
916 fi 1209 fi