PQ Crypta Logo

Inside the HTTP/3 conformance lab

A server built to be wrong in 59 specific ways, one per UDP port, and the machinery that decides what a client did about it.

PQ CRYPTA PLATFORM

๐Ÿ  Main

๐Ÿงช Interactive Apps

๐Ÿ“ฐ News

๐Ÿ›ก๏ธ PQ Crypta Proxy

๐Ÿ‘ค Account

โŸจ QUANTUM ERROR PORTAL โŸฉ

Navigate the Error Dimensions

Jump to an anomaly59

Every one of these is a page: what the server emits, the clause it is judged against, and how each client answered. The full list carries the verdict tallies too.

Most conformance tooling drives a client and checks what comes back. This does the opposite. It is a server that is deliberately, precisely wrong — once per port, 59 times over — and the measurement is whatever the client does when it walks into one.

That inversion is the whole design. A client cannot be asked whether it rejects a second control stream; it can only be shown one. Every anomaly below exists because a clause in an RFC says a client must do something about it, and the only way to find out is to produce the situation on the wire and watch.

59anomalies
59UDP ports
12implementations
708cells per run
16RFCs cited

One anomaly, one port

The listener runs inside the production proxy and binds 59 UDP sockets at 4460 + offset, ending at 4518. Offsets are pinned by a unit test, because the port number is a public identifier: renumbering would silently point somebody's script at a different anomaly and it would still return results.

client GET / one socket per anomaly 4460 4518 port 4465 q-stateless-reset A client that met two violations on one connection could be attributed neither. So the rule is one anomaly per port, even where two tests share a configuration. t-hybrid-only and t-hybrid-large-hello serve identical TLS. They differ in what is judged, so they differ in port.
Every connection meets exactly one violation, so whatever the client does is attributable to it.

Three tiers, three places to be wrong

Where an anomaly is injected decides what can be concluded from the answer. A TLS anomaly is a configuration the handshake either survives or does not. A QUIC anomaly is mostly transport configuration and the socket underneath it. An HTTP/3 anomaly is bytes written after the handshake, on a stream chosen for the clause being tested.

8 t— TLS 1.3 group selection, ServerHello key_share impairments, ML-DSA-87 chain, RFC 8879 compression 23 q— QUIC transport idle timeout, flow-control windows, ACK frequency, PMTU, migration, Retry, Version Negotiation 28 h— HTTP/3 and QPACK control-stream frames, reserved types, GOAWAY, push, field sections, QPACK encoder instructions
The tier decides what an answer can mean, which is why the verdict rules differ by tier.

What a catalogue entry has to declare

An anomaly is not a function; it is a record, and the record is what makes the verdict checkable. Nothing in the suite may score a cell on a clause the entry did not name in advance.

idthe URL, the port’s identity, and the search term
titlethe sentence a reader sees, never the thing judged
specclause-level citation — section, not just document
layerquic, http3 or tls: decides how a close is read
classwhat a pass is worth, below
port_offsetpinned by a test; renumbering is silent breakage
expectationwhat a conforming client does, written before the run
requirement_labelMUST, SHOULD or MAY, taken from the clause

Five classes, because a pass does not always mean the same thing

correctness

A clause says MUST. A client that does otherwise is wrong, and the cell says so. The only class where fail is an accusation.

interoperability

Both answers are legal; the question is whether the exchange completes. A fail here is a thing that will not work, not a thing that is forbidden.

extensibility

Reserved codepoints and GREASE. Passing means ignoring something unknown without flinching, which is what keeps the protocol able to change.

resilience

Loss, reordering, a black-holed path, a server that vanishes. Recovery is the measurement; the specification rarely mandates how.

discretionary

The specification permits either. Nothing here is ever a failure — the cell records a choice, and exists so the choice is visible.

A test whose clause says MAY can never produce a fail. That is enforced in the reduction, not left to whoever writes the entry.

The socket underneath

Several QUIC clauses are about conditions no configuration can express: a datagram that never arrives, two that arrive out of order, a path that marks congestion. Those live in a wrapper around the UDP socket which implements poll_recv and poll_send and is free to lie about both.

server client dropped 1 in 12 reordered marked CE counted per peer address, never per port initials ยท zero-RTT ยท ECT in ยท CE marked ยท dropped (loss) ยท dropped (oversize) ยท reordered ยท shadowed ยท version negotiations
Counting per peer rather than per port is not tidiness: during an eight-second wait, a second client arriving is otherwise indistinguishable from the first still talking.

Why the stack is forked

Three impairments have no reachable code path in a correct implementation, which is exactly why they are worth testing and exactly why they cannot be configured. They live in vendored forks.

rustls-fixed

A KeyShareImpairment hook on the server's ServerHello: a group the client never offered, a hybrid share with its ML-KEM half corrupted, a share whose length contradicts the group named with it, a GREASE codepoint in supported_groups.

noq

A fork of quinn carrying Connection::abandon(): terminate locally and tell the endpoint the connection is drained while the peer still believes it is live, so its next packet meets an unknown connection ID and draws a Stateless Reset. Every graceful path says goodbye; this one has to not.

h3, h3-noq

Frame and stream emission the HTTP/3 crate has no API for, plus the QPACK decoder state a blocked field section needs, including the dynamic table capacity a correct encoder would never exceed.

From what happened to what it means

The reduction is two-stage on purpose. Thirteen observations describe what the connection did; five verdicts say what that is worth. Keeping them apart is what lets the suite distinguish a client that objected in the wrong place from one that did not object, and both from a run that never put the client in the situation.

observation what the connection did verdict decided on their own Unsupported NotExercised Violated resolved with the test’s class Signalled ClosedWith { code } ObjectedAtTransport SurvivedAndContinued ReadThenSilent NoCloseObserved ClosedSilently TimedOut PeerUnreachable Ambiguous judge() class + clause code Unsupported Inconclusive Fail Pass Not run reached by neither: never attempted
Three observations settle a cell on their own. The other ten mean different things depending on what the test was for, so they are resolved against its class and the error code its clause names — which is why the same close can be a pass on one port and a fail on another.

A fail is never inferred from silence. Where a clause names a specific error code, a client that closes the QUIC connection instead has objected — the report says so — but the code the clause names never reached the wire, and the test asks what the client signalled.

Proving a client read something

Several HTTP/3 clauses are about a stream the client is not obliged to read promptly. If it never reads, it cannot have violated anything, and scoring it would be an accusation the run cannot support. The proof used is flow-control credit on the anomaly’s own stream, because a receiver extends credit only once its application has consumed what is already there.

server writes client reads anomaly on its own stream MAX_STREAM_DATA — the proof The cost is the same at any window size: the probe stops at the first grant rather than filling the client’s buffer, so reach is not decided by how much memory the client has.
Where the credit never arrives, the cell states that the read was not observable rather than guessing.

Attribution, and the ways it goes wrong

A verdict is worth nothing if it belongs to the wrong connection, and on a port driven by twelve clients in sequence that is not automatic. These are the rules the session layer holds to.

Sessions key on SNI plus peer address
A run is a walk through the catalogue, not a single connection. The driver reads the report once, at the end, and that read is the only signal the endpoint gets that a run is over.
A client is not one connection per test
Chromium opens two to four. A late reconnect that learns nothing must never overwrite the connection that established the finding, so no inconclusive verdict can replace a conclusive one — whatever observation produced it.
Counters are per peer, never per port
A port aggregate makes a second client arriving mid-wait look like the first still talking, so every counter is scoped to the address it came from.
An error path must not return the value of “nothing to do”
A socket operation that fails and one that had nothing to do are counted separately and logged separately, so a broken path cannot read as an idle one.

Driving it

The driver is a binary that runs one client command per anomaly, substituting the URL and port. Whatever the command connects with is what gets measured — there is no shim, no library binding, no mock.

h3-conformance \
    --host conformance.pqcrypta.com \
    --client 'curl -s --http3-only --max-time 15 -o /dev/null {url}' \
    --filter q-stateless-reset \
    --json

Exit status is deliberately ignored. A client failing a test frequently should exit non-zero, and treating that as an error would discard the result the run exists to collect. The verdict is read from the server side, which is the only side that knows what was emitted.

Reproducibility is measured, not asserted

The whole matrix is driven repeatedly and compared cell by cell. A cell counts as reproducible only when every repeat returned the same verdict; a cell missing from any repeat counts as a disagreement rather than being dropped, because a client that failed to launch once is precisely the instability being measured.

3scored repeats
1warm-up, discarded
708cells compared
99.86%last measured

The warm-up is discarded on evidence rather than superstition: the first run after a restart returns inconclusive for clients that pass on every later run, so it is measured and dropped.

The fleet

12 implementations, each driven as its own binary against the same ports, version-pinned to a commit where the project has one. Nothing is simulated: whatever the command connects with is what gets measured, and its exit status is ignored, because a client failing a test frequently should exit non-zero.

A run does not publish itself

The weekly job stages; it cannot write the live matrix. Promotion is a separate program, and it asks, because a run finishing is not a decision to publish it.

weekly run stages only gate four preconditions a person presses the key live no implausible cell · rollups agree with their own cells · nothing scores off a withdrawn mechanism read-proof cells carry their limit · and the first run after a restart is never the one promoted The checks cannot be complete, which is why a person is in the loop rather than a threshold.
Reproducibility is measured by repeating the whole matrix and comparing every cell, after a warm-up run that is thrown away.

What this vantage cannot see

Stated rather than worked around, because a limit that is not published reads as a result. Each of these is a place where the honest answer is inconclusive and the tempting one is a verdict.

A close that was lost

RFC 9000 ยง10.2.1 has a closing endpoint re-send CONNECTION_CLOSE only in response to an incoming packet. A close that is sent and lost is indistinguishable from one never sent, so the server keeps a packet flowing to prompt a repeat — and where it still never arrives, the cell says so.

A read by a large-window client

Credit on the anomaly’s stream proves a read. No credit proves nothing: a client with a window bigger than the anomaly has no reason to extend any. Two probes failed identically here before the limit was accepted as a limit.

An objection at the wrong layer

A client that closes the QUIC connection has plainly objected, and the report says so. But the HTTP/3 code the clause names never reached the wire, and the test asks what the client signalled.

A client that simply leaves

A peer that has exited owes nothing and sends nothing. Distinguishing that from a peer ignoring an anomaly needs the wait to outlast the client’s own idle timer, not a round trip.

Every run is addressable

A driven run ends with its own report and badge, keyed by the session the endpoint assigned. Those URLs are noindex, nofollow: the space is unbounded and each one describes a single execution rather than the state of the field.

Full report: https://conformance.pqcrypta.com/report/<session>
Badge:       https://conformance.pqcrypta.com/badge/<session>.svg

The published matrix is the other thing entirely: 12 implementations driven by us, on a schedule, promoted by hand.

Where to go next

The matrix is every client against every anomaly. The anomaly index is what the server emits and the clause it is judged against. Findings is only the failures. Post-quantum certificates is the one result that deserved an argument rather than a row.