Files
amethyst/quic/interop/plans/2026-05-06-interop-runner.md
T
Vitor Pamplona 1ff4f98fff docs(quic-interop): add quinn to the default peer set
quinn is now treated as a flawless-required interop target alongside
aioquic, picoquic, and quic-go. Most Rust-based Nostr/MoQ relays our
users run their servers on are built on quinn, so an interop regression
there is a user-visible regression.

Validated by a 3-round flakiness sweep (1 full matrix + 2 audio-critical
subsets) — zero result flakiness across 528 test executions across all
four peers, with two environmental docker-compose stall classes documented
separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:44:15 -04:00

334 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# quic-interop-runner endpoint — Phase 0 scaffolding
Date: 2026-05-06
## Why
We want the [`quic-interop-runner`](https://github.com/quic-interop/quic-interop-runner)
matrix as a **bug-finding harness**, not a vanity scoreboard. Each peer impl
(quiche, aioquic, picoquic, ngtcp2, msquic, mvfst, lsquic, neqo, kwik) enforces
different parts of RFC 9000/9001/9002/9114 strictly, so failures triangulate
to specific bugs in `:quic`. The runner's ns-3 sim also exposes loss /
reorder / migration scenarios that are awkward to reproduce in unit tests.
## What's in Phase 0
- `:quic-interop` Gradle module (JVM-only application, registered at
`quic/interop/` via `settings.gradle`).
- `InteropClient.kt` reads the runner's env-var contract (`ROLE`, `TESTCASE`,
`REQUESTS`, …) and dispatches by testcase. Phase 0 implements only
`handshake`; everything else returns `127` (runner-skip).
- `Dockerfile` based on `martenseemann/quic-network-simulator-endpoint` +
OpenJDK 21 runtime, copies the `installDist` output.
- `run_endpoint.sh` sources the base image's `/setup.sh` then execs our JVM
binary.
- `Makefile` wrappers: `make build`, `make clean`. (A `make smoke`
target previously stood up picoquic + our endpoint on a private Docker
bridge to bisect runner failures from impl failures; dropped once
the runner reliably exercised both paths.)
## Local iteration loop
The fast path: `quic/interop/run-matrix.sh` clones the runner alongside
this repo, sets up a venv, merges our `implementations_quic.json` snippet,
builds the endpoint image, and invokes `run.py`. All steps are
idempotent so repeated invocations just iterate.
```
# Single test against the most permissive peer:
quic/interop/run-matrix.sh -s aioquic -t handshake
# A focused triangulation:
quic/interop/run-matrix.sh -s aioquic -t handshake,chacha20
quic/interop/run-matrix.sh -s quic-go -t handshake,chacha20
quic/interop/run-matrix.sh -s picoquic -t handshake,chacha20
# Tight inner loop — skip the image rebuild between test selections:
SKIP_BUILD=1 quic/interop/run-matrix.sh -s aioquic -t transfer
```
Manual flow (if `run-matrix.sh` doesn't fit):
```
make -C quic/interop build
# Then in a sibling clone of quic-interop-runner, merge our snippet:
jq -s '.[0] * .[1]' implementations_quic.json \
../amethyst/quic/interop/quic-interop-runner-snippet.json \
> implementations_quic.json.new && mv implementations_quic.json.new implementations_quic.json
python run.py -d -i amethyst -s aioquic -t handshake,chacha20 --log-dir ./logs
```
Inspect `./logs/<run>/client_qlog/*.qlog` in qvis when something breaks.
### macOS gotcha — upstream `certs.sh` patch (auto-applied)
`run-matrix.sh` step 1a rewrites `LC_CTYPE=C tr``LC_ALL=C tr` in
the upstream `certs.sh`. macOS BSD `tr` chokes on `/dev/urandom` with
`tr: Illegal byte sequence` when only `LC_CTYPE` is set; `LC_ALL` wins
in the locale-precedence chain and silences it. Without this, the
`amplificationlimit` testcase aborts at cert generation, and every
test sequenced after it in `TESTCASES_QUIC`
(`handshakeloss`, `transferloss`, `handshakecorruption`,
`transfercorruption`, `ipv6`, `v2`, `rebind-port`, `rebind-addr`,
`connectionmigration`, plus the goodput/crosstraffic measurements)
silently never runs. The matrix prints the post-mortem summary
showing only the 12 testcases that ran before the abort, which looks
deceptively complete.
The patch is idempotent and is re-applied on every clone of the
runner (the fix is a working-tree edit to `../quic-interop-runner/`,
not a fork). If you ever delete `../quic-interop-runner/` and
re-clone manually, run `quic/interop/run-matrix.sh` once first or
apply the same `sed` by hand. Upstream PR not yet filed.
## Phase ladder (excerpt — full plan in conversation)
| Phase | Goal | Tests | Exit criterion |
|---|---|---|---|
| 0 | Minimum harness | `handshake` | one test reproducible end-to-end ✅ |
| 1 | Triangulate handshake bugs | + `versionnegotiation`, `chacha20` | green vs aioquic + quiche + picoquic |
| 2 | Streams + loss + multiplexing | + `transfer`, `multiplexing`, `*loss`, `http3` | `transfer` / `multiplexing` / `http3` ✅ landed; loss tests pending |
| 3 | Edge cases | `retry`, `resumption`, `zerortt`, `keyupdate`, `rebinding-*`, `blackhole`, `amplificationlimit` | every test green or unsupported-127 with a written reason |
| 4 | CI gate | nightly Phases 12; PR-blocking subset on every push | qlogs uploaded as artifacts on red |
## Phase 2 — landed 2026-05-06
- Minimal `Http3GetClient` (in `:quic-interop`, NOT `:quic` — interop-test
surface, not a production HTTP/3 client). Opens the three required
client uni streams (control + QPACK encoder + QPACK decoder), sends
empty SETTINGS, then per request opens a bidi stream, encodes a HEADERS
frame with the four pseudo-headers using the existing literal-only
`QpackEncoder`, FINs, and reassembles HEADERS+DATA frames from the
response. Out-of-scope: GOAWAY, PUSH_PROMISE, dynamic QPACK table,
trailers, priority.
- `transfer` + `http3` testcases: GET each URL in `REQUESTS` sequentially,
write each body to `$DOWNLOADS/<basename>`. Status != 200 fails.
- `multiplexing` testcase: same as `transfer` but issues each GET in a
parallel `coroutineScope { async { … } }` so the request streams
genuinely overlap on the wire (what tshark verifies).
- Aliased sim-driven testcases — these reuse the same client code paths;
the runner injects the network condition via the ns-3 sim. Failures
here are exactly the bug-finding signal we want, since they exercise
loss recovery / RTT estimator / congestion behaviour against real peers:
- `transferloss` → transfer (random packet loss)
- `transfercorruption` → transfer (random bit-flip; AEAD AUTH FAIL → drop + retransmit)
- `longrtt` → transfer (emulated high-latency link)
- `goodput` → transfer (throughput floor)
- `crosstraffic` → transfer (competing UDP flows on the same link)
- `handshakeloss` → handshake (loss during handshake — tests CRYPTO retransmit)
## Phase 3 — landed 2026-05-07 (post-quic-go interop)
- ALPN per testcase (`:quic-interop`'s `Alpn` enum + per-test switch in
`InteropClient.main`). quic-go enforces strictly with TLS
`no_application_protocol` (CRYPTO_ERROR 0x178); aioquic / picoquic accept
either. Convention: `h3` for `http3`/`multiplexing`, `hq-interop`
everywhere else.
- `HqInteropGetClient` (HTTP/0.9 over QUIC) — open bidi, send `GET /path\r\n`,
FIN, read body until server FINs. No framing, no QPACK. ~30 lines.
- Multi-stream FIN delivery fix in `QuicConnection.closeAllSignals()`: pre-fix
iterated only the connection-wide signal channels, leaving every per-stream
`incomingChannel` open after teardown — coroutines suspended on
`stream.incoming.collect { ... }` hung forever. Fix iterates `streamsList`
on close. Three regression tests in `MultiStreamFinDeliveryTest`.
- `retry` + `ipv6` testcases enabled in dispatch. `retry` rides on agent 3's
RFC 9000 §17.2.5 + RFC 9001 §5.8 implementation. `ipv6` should "just work"
via JDK's `DatagramChannel` v6 support; runner-validated when run.
## Validated against (as of 2026-05-07 evening)
Latest run results before pushing the multi-ALPN fix `acfe815e1`:
| Peer | handshake | chacha20 | transfer | http3 | multiplexing |
|---|---|---|---|---|---|
| aioquic | ✓ | ✓ | ✓ | ✓ | ✕ (channel-saturation, agent C investigating) |
| picoquic | ✕ (alpn=hq-interop unsupported, fixed in `acfe815e1`) | ✕ | ✕ | ✓ | ✕ |
| quic-go | (untested post-fix) | | | | |
After `acfe815e1`'s multi-ALPN offer, predictions:
- picoquic returns to 4/4 (server picks `h3`, we run Http3GetClient).
- quic-go: handshake / chacha20 / transfer / http3 should green (server picks `hq-interop` for non-h3 tests).
- aioquic: still 4/5; multiplexing held back by channel-saturation bug.
## Phase 4 — landed 2026-05-07 (overnight agents)
All three agents merged onto the branch with three rounds of fixup
(each agent's worktree was based on `main`, not the branch HEAD, so
they clobbered each other's changes during merge):
- **Agent A — VN-handling defense in `:quic`**: configurable
`initialVersion` on `QuicConnection`, `applyVersionNegotiation`
state machine, `vnConsumed` latch, downgrade defenses. *NOTE*: the
runner does not have a `versionnegotiation` testcase (it has `v2`,
which tests QUIC v2 — we're v1-only). Code stays as defensive
support for any server that throws a VN at us, but unused by the
matrix.
- **Agent B — qlog observer**: `QlogObserver` interface in `:quic`
with NoOp default + 12 event hooks (packet-sent/received/dropped,
key-updated, conn-started/closed, loss-detected, PTO-fired,
transport-params, ALPN, version). `QlogWriter` (Jackson-backed
JSON-NDJSON) in `:quic-interop`. `InteropClient` reads `$QLOGDIR`
the runner already sets and writes `client.sqlog` per testcase.
Drag straight into qvis.quictools.info to see the trace.
- **Agent C — peer-uni-stream drainer**: `drainPeerInitiatedUniStreamsIntoBlackHole`
helper on `QuicConnection`, wired into `Http3GetClient.init(scope)`.
Fixes the multiplexing channel-saturation symptom (server's uni
streams accumulated bytes in 64-chunk per-stream channels until
parser tore down with INTERNAL_ERROR ~4.5s into a multi-stream run).
## Phase 5 — landed 2026-05-07 (post-overnight bug-hunt)
The full overnight session pulled hard on the bugs the matrix exposed.
qlog turned out to be the unblocking tool — every fix in this phase
came from staring at a `client.sqlog` and matching it against the RFC
pages it referenced.
| Commit | Diagnosis pattern |
|---|---|
| `bd9d717df` | `IOException: Stream closed` from QlogWriter mid-test → observer threw into the send loop, killed connections that had already completed transfer. |
| `99a1a91de` | qlog stops at t=400ms, no inbound packets → per-event `writer.flush()` stalled the connection lock on macOS Docker filesystem virtualization. |
| `c0d7b6031` | aioquic `CONNECTION_CLOSE: Packet contains no CRYPTO frame` after PTO probe → our PTO emitted bare PING, not CRYPTO retransmit. Restored agent 2's wiring lost in the qlog merge. |
| `17b80270d` | runner's retry verdict `Client reset the packet number. Check failed for PN 0``applyRetry` reset Initial PN to 0; RFC 9001 §5.7 says PN namespace continues across Retry. New `LevelState.resetForRetry` helper. |
| `9a74d1d5d` | multiplexing qlog showed STREAM frames still arriving at t=31s when local timeout fired → bumped `TRANSFER_TIMEOUT_SEC` 30→60. Doesn't fix throughput, just lets the test budget match the workload. |
| `32ccbd2b2` | `coroutineScope { urls.map { async }.map { it.await() } }` blocks on slowest hung stream → per-stream `withTimeoutOrNull` so a single bad stream surfaces as status=0 instead of hanging the matrix. |
After all of these, expectation is aioquic / picoquic ≥6/7 (M
might still be flaky). retry green via qlog-driven debugging is
the marquee result.
## Phase 6 — landed 2026-05-10 (matrix expansion + flakiness sweep)
Three-round flakiness sweep across four peers (aioquic, picoquic,
quic-go, quinn). Round 1 = full 24-testcase matrix; rounds 2 and 3 =
11-test audio-critical subset (handshake, transfer, multiplexing,
transferloss, transfercorruption, handshakeloss, handshakecorruption,
longrtt, blackhole, keyupdate, retry).
**quinn added** to the default set this phase — was previously
untested; cohort coverage demanded it because most Rust-based Nostr /
MoQ relays use it on the server side. Validated identical to the
existing three:
| Peer | Pass | Unsupported | Fail | Goodput (kbps) | Crosstraffic (kbps) |
|---|---|---|---|---|---|
| aioquic | 19/22 | E, CM, V2 | — | 9071 ± 53 | 5084 ± 257 |
| picoquic | 20/22 | V2 | CM (expected — no client-initiated migration) | 9234 ± 12 | 7405 ± 140 |
| quic-go | 19/22 | E, CM, V2 | — | 9449 ± 8 | 6276 ± 218 |
| quinn | 20/22 | CM, V2 | — | 9322 ± 6 | 4117 ± 2283 (env-inflated; see below) |
Across **528 test executions** (4 peers × 1 full round + 4 peers × 2
audio-critical rounds × 11 tests, plus 10-iter measurements):
- **Zero result flakiness.** No testcase that passed on one round ever
failed on another, and vice versa.
- **Two stall classes**, both environmental (Docker Desktop on macOS,
not in `:quic`):
- **Type A (compose-init lockup)** — `docker compose up` hangs for
~15-16 min between Python's command-log and compose's first Docker
API call. Hit 4 testcases: picoquic r2 transfer, quic-go r2
handshakecorruption, quinn r1 rebind-port/rebind-addr (both),
quinn r3 handshakeloss. Test always succeeds once compose starts.
qlog confirms the QUIC connection itself completes in 10-30s. The
quinn-r1 crosstraffic std-dev (±2283 kbps) is entirely driven by
2 of 5 iterations being trapped in this stall — the 3 unaffected
iters were 111/138/97 s = consistent with peers.
- **Type B (pyshark XML stall)** — runner's `_get_packets()`
iteration through tshark's XML via pyshark takes 16+ min on
rebind/crosstraffic pcaps. Direct tshark CLI on the same pcap:
0.6 s. pyshark wrapper: 66 s. Multiple passes per testcase
amplify it.
- Mitigations documented in `plans/2026-05-06-interop-runner.md`
Docker Desktop section (separate investigation note).
## Still open
- **`v2`** — server demands QUIC v2 (RFC 9369). We're v1-only.
Implementing v2 is its own project (different Initial-secret
derivation, different transport-parameter encoding, different
long-header type bits — not just a version-number swap).
- **Multiplexing throughput on Mac+Rosetta** — measured at ~23 streams/sec
(aioquic processed 1359 GETs in 58s). The runner's multiplexing test
generates 1999 files; even with a 60s budget we only get 70% of them
open before the test ends, with most coroutines failing to surface a
status=200 in time for the file-write loop. Throughput is hard-bottlenecked
by `:quic`'s single `conn.lock` serializing the send loop, the read
loop, and `openBidiStream` across all 1999 awaiting coroutines. Under
this lock contention pattern, parallelism doesn't help — coroutines
queue on the lock anyway.
Possible fixes (non-trivial):
- Per-level / per-stream lock split (big refactor; touches almost
everything in `:quic/connection`).
- Batch concurrency via a `Semaphore` capping in-flight `client.get()`
calls to e.g. 64 at a time. Doesn't lift the throughput ceiling but
reduces lock thrash and probably lets MORE files complete in 60s.
- QPACK dynamic-table support so aioquic doesn't need to fall back
to literal encoding (would help on the response-decode side).
None are urgent; this is a stress-test scenario, not a normal-traffic
one. Filed for follow-up.
- **Server role** — entire stack is client-only. Implementing the
server side would unlock `handshakeloss` against aioquic
(currently `?` because aioquic-server doesn't support it), and
would make us a self-validating peer.
## Concurrency
`run-matrix.sh` is NOT safe to run in parallel. The runner's
docker-compose.yml hardcodes `container_name: sim/server/client`
Docker enforces those globally. Use a sequential loop:
```
for peer in aioquic picoquic quic-go quinn; do
quic/interop/run-matrix.sh -s $peer -t handshake,chacha20,...
done
```
## Default peer set
The four-peer baseline that every change touching `:quic` should be
checked against is `aioquic`, `picoquic`, `quic-go`, and `quinn`. The
rationale is product-shaped, not protocol-shaped: this is the cohort
that the Nostr / MoQ relay ecosystem our users actually run their
servers on is built on. quinn in particular underwrites most of the
Rust relay stacks, so an interop regression there is a user-visible
regression — it must stay flawless. quinn was added to the default
set 2026-05-10 after running cleanly across the same audio-critical
3-round flakiness sweep used for the other three peers.
## Explicitly unsupported testcases (return 127, runner skips)
| Testcase | Reason |
|---|---|
| `versionnegotiation` | `QuicConnectionWriter` hard-codes `QuicVersion.V1`; needs a configurable initial version + VN-response retry path. **In progress (background agent)**. |
| `resumption` | session ticket parsing + persistence not yet implemented |
| `zerortt` | depends on `resumption` + early-data path |
| `keyupdate` | KEY_PHASE bit handling not yet implemented in 1-RTT |
| `rebinding-port`, `rebinding-addr` | client-side connection migration (re-bind UDP socket, NEW_CONNECTION_ID rotation) not implemented |
| `amplificationlimit` | server-side test, N/A for client role |
| `blackhole` | inverse test (verifies we *fail* on dead network in bounded time); needs special handling |
| `ipv6` | `UdpSocket` IPv6 path not exercised; risky to claim without testing |
## Phase 1a — landed 2026-05-06
- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark
decrypts the sim's pcap captures. Backed by:
- `TlsClient.clientRandom` (new public read-only property, captured in
`start()` before sending ClientHello).
- `QuicConnection.extraSecretsListener` (new optional constructor param,
chained after the connection's own key-installation listener; no-op
default, so production callers are unaffected).
- `chacha20` testcase: forces ChaCha20-Poly1305 only via new
`QuicConnection.cipherSuites` knob (threaded through `TlsClient`
`buildQuicClientHello``TlsClientHello`).
## Phase 1b — open
- `QLOGDIR`: `:quic` has no qlog observer infrastructure yet. Wiring needs
hooks at packet send/recv, frame dispatch, recovery, and TLS state
transitions, plus the qlog JSON-NDJSON schema. Sized as its own design
doc before implementation.
- `versionnegotiation`: `QuicConnectionWriter` hard-codes `QuicVersion.V1`
in two call sites; threading a configurable initial-version through and
wiring the response-handling path is non-trivial. Defer.
- Server role: we are client-first. Reassess after Phase 3.
- WebTransport is **not** part of the standard interop matrix; it needs a
separate harness against `moq-rs` / chrome-headless.