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

๐Ÿ“ฐ News

๐Ÿ‘ค 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
13implementations
767cells 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 Version Negotiation, Retry, stateless reset, key update, flow control, ECN, PMTU, loss, migration, 0-RTT 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
tierquic, http3 or tls: decides how a close is read (published as layer)
classwhat a pass is worth, below
requirementMUST, SHOULD or MAY, taken from the clause (published with requirement_label)
expectationwhat a conforming client does, written before the run
implementedwhether the anomaly is emitted yet; an unbuilt one is never scored
port_offsetpinned by a test; renumbering is silent breakage (published as port)

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 handshake: in the ServerHello, a group the client never offered, a hybrid share with its ML-KEM half corrupted, or a share whose length contradicts the group named with it; in EncryptedExtensions, a GREASE codepoint in supported_groups, the one place RFC 8446 lets a server send one.

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 staged 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. Between them sits the record the verdict is derived from, which is kept.

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.

And a silence is only read once the client has had its chance to break it. Each test declares a reaction opportunity, which is an event rather than an interval: the exchange completing, the anomaly’s own stream being demonstrably read, early data arriving and being refused in the handshake, or — for the tests whose anomaly is itself an absence — the server still being there a set interval later. A verdict may not depend on an observation that became impossible before the client had the means to make it, so a silence-based failure requires the client to have still been running when its opportunity arrived.

The record a verdict is derived from

A verdict is computed, not written down. What is written down is the evidence: the observation, the error code the clause names, whether the read proof answered, whether early data arrived and how much, and the four instants — when the anomaly was established, when the exchange completed, when the observation ended, when the client process exited. The oracle is a function of that record and reads nothing else.

The record survives the run. Every cell of the published matrix carries the evidence its verdict came from, the version of the scoring rules that produced it, and, where a rule moved the verdict rather than the observation deciding it, the reason code that says which. A live session expires in minutes; the matrix is the copy that is kept, so that is where the evidence has to live.

the run what happened evidence stored, and kept the oracle reads only that verdict The arrow runs one way. Nothing downstream of the evidence can change what the evidence says. Which is what makes it possible to ask whether a verdict moved because the run did, or because the rules did.
The oracle is a function of the stored record, so the same record scored twice gives the same verdict twice.

That one-way arrow is the point. When a verdict differs between two runs, the two records can be compared field by field, and the answer is either that the oracle was given different inputs or that it reached a different conclusion from the same ones. The first is the client or the harness behaving differently on the day; the second is a scoring change, which has to be justified against the clause rather than against the result. Without the record, both look identical — and a suite that cannot tell them apart can quietly fit its rules to its outcomes.

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 probe stops at the first grant rather than filling the client’s buffer, so its cost does not scale with the window — but a peer that never nears its own window still never grants.
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 13 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
767cells compared
100.00%last measured

Every one of those runs measured the build that produced the published matrix, which is how they are selected: a repeat against a different binary is not a repeat. That build has since been replaced on the server, and the figures stay with the matrix they describe until a run of the new build is promoted. 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 one, so it is measured and dropped.

The fleet

13 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 publishes only through the gate

The weekly job cannot write the live matrix. It measures the running build four times — a warm-up that is thrown away, then three scored repeats — and hands the last run to a separate program that publishes it only if every check passes. A run that fails one is not published: the live matrix keeps the last state that passed, and the refusal raises an alert on the monitor.

weekly run repeats, staged gate eight checks all pass? if not, an alert live provenance sound · a stable difference from the sealed baseline is confirmed by name · well formed rollups agree with their own cells · nothing scores off a withdrawn mechanism · no implausible cell no read claimed without proof · no failure on an unread stream’s silence · never a build’s first run A blocked run is not published; a person reads the alert, and the live matrix keeps the last state that passed.
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 never arrives

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

Flow control is the one signal in QUIC that separates delivered from read, and a receiver grants credit only as it approaches its own window. A client advertising a gigabyte says nothing until it has been sent close to a gigabyte, so the question goes unanswered for exactly the clients with the largest buffers. QPACK’s decoder instructions would answer it at any window size, and cannot: a decoder acknowledges only entries it was told to insert, and the clients left unresolved here are exactly the ones advertising SETTINGS_QPACK_MAX_TABLE_CAPACITY of zero — which forbids this endpoint’s encoder from inserting anything at all. The signal is unavailable for the population that needs it, by those clients’ own declaration. The cell reports the read as not observable rather than guessing.

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, and from a socket that is indistinguishable from one reading quietly. The driver holds the process, so it posts the moment its client stopped, and each test names the event that gave the client its chance to answer. A silence-based failure requires the client to have still been running when that event happened; one that left first is inconclusive, carrying the timestamps, a reason code and the evidence the verdict was derived from, so the change can be audited. Leaving afterwards erases nothing — the evidence was already collected.

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: 13 implementations driven by us, on a schedule, published only when every check passes.

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.