QUIC Interop Testing¶
The QUIC Interop Runner pairs every registered QUIC implementation with every other one inside a network simulator and reports a pass/fail cell per test case. mod_http3 takes part as a server: there is no HTTP/3 client in this project, so mod_http3 is tested against every client in the matrix.
How it runs¶
.github/workflows/interop.yml
is a stage of the CI pipeline, so it runs on every push, on every branch, once
the module image has built. There is no local runner script: the
matrix needs a docker daemon, a tshark new enough to dissect QUIC, IPv6 on
the host and an hour of wall time, none of which belong in a developer loop.
The build job builds interop/Containerfile, checks the image answers 127
for a test case it does not implement, and publishes it as
ghcr.io/machine-moon/mod_http3-interop:<commit sha>.
That image compiles nothing. It takes MODULE_IMAGE= — the module image the
build stage of the pipeline already produced — and copies the built httpd
and mod_http3.so out of it onto the runner's own base, guarding the result
with ldd. The module image is built with ENABLE_NGTCP2=ON, so the endpoint
carries both QUIC engines and picks one from $ENGINE at start-up.
Each test job then pulls that tag back out of the registry and runs the
matrix against it. Nothing is passed between jobs as a file, so the image the
matrix exercises is byte-for-byte the one the registry serves. The client list
comes from the runner's own implementations_quic.json, so a new peer joins
the matrix without a change here.
The matrix is engine × client, so it runs twice over the client list — once
per QUIC engine, against the same image — and each job is named
test (<engine>, <client>). That is what actually exercises the QUIC
abstraction; the two halves should agree.
A release tags that same image :X.Y.Z and :latest, so
mod_http3-interop:latest always points at an endpoint whose matrix is public
and reproducible.
Reading the results¶
Each test job writes its verdict to the workflow summary. A client that does
not implement the http3 case reports a warning rather than a failure —
nothing reached mod_http3 — and a failing job keeps its logs as an artifact for
two weeks, laid out as logs/<server>_<client>/<case>/:
| Path | Contents |
|---|---|
output.txt |
Everything the runner, the endpoint and the client printed |
server/httpd_error.log |
mod_http3's own log at LogLevel http3:debug |
server/keys.log |
TLS secrets, for decrypting the pcaps in Wireshark |
sim/trace_node_*.pcap |
What actually crossed the simulated link |
A configuration error kills httpd before it opens httpd_error.log, so the
first failures of a broken endpoint are only visible on the server | lines
of output.txt.
To reproduce a cell by hand, clone the
runner, add the
published image to its implementations_quic.json and run it — that is all the
test job does:
Test case support¶
interop/run_endpoint.sh exits 127 for any case the endpoint does not claim,
which the runner records as unsupported rather than failed. Today that is
every case except http3.
The runner moves files with HTTP/0.9 over ALPN hq-interop in all but one
test case — its own quic.md puts it as "unless noted otherwise, test cases use
HTTP/0.9 for file transfers" — and mod_http3 only speaks h3. A client running
handshake offers hq-interop alone, so the connection dies in the handshake
with no_application_protocol before any QUIC behaviour is exercised:
[http3:debug] mod_http3: ALPN: client did not offer h3
[http3:error] QUIC handshake did not complete: ... err=0x178
That is a protocol the module does not implement, not a QUIC or HTTP/3 defect.
nginx covers the same ground with a dedicated http3_hq on directive that
serves HTTP/0.9 over its HTTP/3 stack for this harness; an equivalent here would
open the other 21 cases. Until then http3 is the honest claim, and it still
exercises the handshake, QPACK, parallel streams and flow control against every
client in the matrix.
Troubleshooting¶
Every case is unsupported. The runner refuses an implementation that does
not exit 127 for an unknown test case, and it makes that check with no timeout,
so a hung endpoint hangs the run. The build job pre-checks the same thing
with a timeout before any test job starts.
Every case fails in analysis. The runner replays the simulator's pcaps
through tshark; without 4.5.0 or newer, cases fail in analysis rather than on
the wire.
The runner cannot start the endpoint. Its compose file needs docker engine
28.1 or newer for interface_name, which is why the test jobs pin one.
chrome reports "Expected exactly 1 handshake. Got: 2". The browser opens a
second connection and the case demands one. It does the same against nginx, so
treat that cell as a property of the client rather than of the server.