The conformance and interoperability test drivers for the from-scratch HTTP/3 stack that lives
in the Vanaheimr Hermod library — QUIC + TLS 1.3 + HTTP/3 in pure C# on .NET 10, straight on UDP
sockets, no large dependencies beyond the BCL. Hermod is pulled in here as a git submodule under
libs/; this repository is what drives it, and what has to answer the only question that matters
about a hand-written protocol stack: does it actually interoperate with implementations nobody
here wrote?
Everything below is repeatable from a clean checkout with the command next to it.
| Driver | What it establishes | Result |
|---|---|---|
tests/run-tests.sh |
the gate: two harnesses against the live demo host over real UDP | 42/42 checks |
├ tests/h3semantics |
RFC 9114 semantics, driven by msquic through .NET's HttpClient — a foreign stack on the client side |
29/29 checks |
└ tests/h3attack |
hand-built hostile datagrams: noise, undersized Initials, version negotiation, stateless reset, amplification, a 128-source flood | 13/13 checks |
dotnet run --project tests/h3interop |
our client against 8 public QUIC stacks — quiche, nginx, Google, mvfst, lsquic, msquic, quic-go, Akamai — full chain + hostname validation, no -k |
8/8 reachable |
pwsh tools/browser-interop.ps1 -Browser chrome |
Chrome 150 / Edge 150 headless, incl. WebTransport and the post-quantum hybrid | 8/8 checks |
curl --http3-only -k https://127.0.0.1:4433/ |
ngtcp2/LibreSSL and OpenSSL-QUIC against our server — GET, POST, 300 KB, 103 + trailers | see INTEROP.md |
dotnet test libs/Hermod/HermodTests --filter FullyQualifiedName~Hermod.Tests.HTTP3 |
the in-process suite that ships with the stack: RFC vectors, "evil" raw-QUIC peers, a seeded lossy link | 340 tests (419 more for QUIC) |
dotnet run --project tests/h3bench |
throughput, latency percentiles, concurrency scaling | numbers, no verdict |
On the other side of those rows sits code nobody here wrote: eight public QUIC stacks, two
independent curl builds, Chromium's QUICHE in two browsers, and msquic driving our own server.
That is the point of the table. The 340 in-process tests are the more thorough half of the coverage
and they gate every push — but they cannot disagree with us, because both ends of every one of them
are our own code, sharing one reading of the RFCs and one set of bugs.
Six findings, all from driving the demo host from outside. None showed in the in-process tests, which work in single digits of streams and have our own code at both ends:
- A connection stalled after exactly 100 requests. ✅ fixed —
MAX_STREAMSwas parsed and logged but never sent, soinitial_max_streams_bidiwas the lifetime budget of every connection rather than an opening grant (RFC 9000 §4.6). A browser tab reaches that on a single page. Found by driving a running server with msquic until it stalled; fixed in Hermod#20, and theh3semanticscheck that found it stays in place as the regression guard. - Large uploads were slow, and sometimes fatal. ✅ fixed — two separate defects behind one
symptom. First, 300 000 bytes up took ~130 ms against ~11 ms down, degrading to ~830 ms: the
server delayed acknowledgments the peer had never asked it to delay (RFC 9000 §13.2.2); Hermod
87a561d5 made them prompt. Second, about one fresh connection in five lost its first large upload
to the idle timeout. When msquic retransmitted lost bytes in shorter frames, a shorter frame
replaced the longer one held at the same offset and dropped its acknowledged tail, so the
request sat complete on the wire and unread on the server. That is
Hermod#129, fixed in
Hermod#159. Its first write-up blamed a
regression in 22768a4a..d2d608d2. It was not one: the defect is as old as Hermod's QUIC, and the
"clean" earlier pin rested on three lucky trials.
h3semanticsnow opens sixteen fresh connections, each uploading first. - Throughput fell as concurrency rose. 🔶 mostly fixed —
GET /bigover loopback went from ~35 MiB/s with one request in flight to ~10 MiB/s with 32. Every ACK cost the server work in proportion to all packets in flight, not to what the ACK acknowledged, so with thousands in flight the single-threaded server fell behind its own ACKs, and the client's flow-control credit waited in the same queue. Fixed in Hermod#165, for ~45 → ~28 MiB/s. Profiling the server then took a third off its CPU per byte: batched sends on Windows, ACK frames that cost their ranges rather than every packet held, and no key import per packet (Hermod#174–#176). The ladder now runs ~63 → ~54 MiB/s. What remains is a gap rather than a defect: Kestrel, serving the same body to the same msquic client, reaches 140–190 MiB/s at 32 in flight. - Memory grew with every response on a connection. ✅ fixed on the server — 400
GET /bigon one connection took the demo host from 11 MB to 323 MB, and nothing came back. Every stream kept its 512 KB send buffer, and the connection keeps its streams; Hermod#177 lets the buffer go once the FIN is out. Hermod's HTTP/3 client still does the same on the receive side. - The demo host's timers ran only on silence. ✅ fixed — it ran its connections' timers
(probe timeout, loss detection, idle timeout) only after a full second without a datagram from
any client. When the path to one client went dark mid-response, a quiet server sent its first
probe after ~1.1 s instead of one PTO. With a second client keeping it busy, it sent none at all.
The timers now run every 5 ms whether datagrams arrive or not, and the server probes after
50–140 ms.
h3semanticscuts the path through a relay to check both cases. - About 1 in 1 000 fresh connections never answered. ✅ fixed — the timer fix waited for
datagrams with a 5 ms receive timeout. On Windows, a blocking receive that times out can lose the
datagram arriving at that moment. On a busy machine, about 1 in 110 new connections lost its
first Initial, and 1 in 360 its first three, which outlasts the client's 5 s timeout. qlogs
showed the Initials never arriving, and a relay showed the client sending them. The loop waits
with
Pollnow, at 6–8 % more server CPU.
Details in tests/README.md. The interop evidence and how to repeat it is in
INTEROP.md; the implementation history — phases, milestones, crypto roadmap — is in
PLAN.md, and the stack's own reference lives next to the code in
libs/Hermod/Hermod/QUIC/ and libs/Hermod/Hermod/HTTP3/.
What the stack implements, and therefore what there is to conform to. The stack itself now lives in Hermod — this table is the map from RFC to evidence, not a build log; the chronology is in PLAN.md.
| Ref | What is covered | State |
|---|---|---|
| 0 | Setup, VarInt, buffer reader/writer, test scaffolding | ✅ done |
| 1 | Initial crypto (HKDF, packet/header protection, Retry) — RFC 9001 App. A byte-exact | ✅ done |
| 1b | QUIC packet formats (long/short header, connection ID, packet numbers) — RFC 9000 §17 | ✅ done |
| 1c | QUIC frames (PADDING, PING, ACK, CRYPTO, STREAM, CONNECTION_CLOSE, …) — RFC 9000 §19 | ✅ done |
| 2 | TLS 1.3 handshake: ClientHello, ServerHello, ECDHE (P-256), transport parameters | ✅ done |
| 2b | TLS key schedule (verified against RFC 8448) → handshake packets decrypted live | ✅ done |
| 3a | Sending our own Finished, ACKs, 1-RTT keys — handshake fully completed | ✅ done |
| 3b | Reusable QuicClientConnection + TlsClientHandshake engine |
✅ done |
| M1 | Full QUIC/TLS 1.3 handshake against cloudflare-quic.com — HANDSHAKE_DONE received | ✅ reached |
| 4 | Streams & flow control — server HTTP/3 streams reassembled | ✅ done |
| 6 | QPACK (static table + Huffman + literals) — RFC vectors verified | ✅ done |
| 7 | HTTP/3 (control/QPACK streams, SETTINGS, HEADERS/DATA) | ✅ done |
| M2 | Real GET https://cloudflare-quic.com/ — status 200 + 126 KB HTML |
✅ reached |
| 4-FC | Receive-side flow-control updates (MAX_STREAM_DATA/MAX_DATA) |
✅ done |
| 5 | Loss recovery (RFC 9002): RTT, loss detection, PTO, NewReno, retransmission | ✅ done |
| 8-S | HTTP/3 server (TLS server handshake, self-signed cert, QUIC/HTTP-3 server) | ✅ done |
| M3 | Our own server: the H3Get client fetches status 200 + HTML over real localhost UDP |
✅ reached |
| 8 | Robustness: VN, Retry, close/draining, idle/keep-alive, key lifecycle & key update, 0-RTT + resumption, CID rotation, stateless reset, migration, RESET_STREAM/STOP_SENDING, PQ hybrid, ML-DSA certificates | ✅ core complete |
| 7+ | RFC 9114 feature audit: request bodies, cancellation, frame state machine + H3 error codes, GOAWAY, trailers/1xx, MAX_FIELD_SECTION_SIZE, malformed validation | ✅ complete |
| 9218 | Priorities: priority header + PRIORITY_UPDATE, §10 scheduler (urgency/incremental) | ✅ done |
| 9220 | WebSockets over HTTP/3: Extended CONNECT (RFC 8441) + tunnel + RFC 6455 framing (reused from Hermod) | ✅ done |
| 9297/9221 | HTTP datagrams over QUIC DATAGRAM frames | ✅ done |
| webtrans | WebTransport over HTTP/3 (draft-13): sessions, uni/bidi streams, datagrams, flow-control capsules, protocol negotiation (WT-Available-Protocols/WT-Protocol) |
✅ done |
| reset-at | RESET_STREAM_AT (draft-ietf-quic-reliable-stream-reset): stream reset with guaranteed partial delivery (reset_stream_at TP + frame 0x24) |
✅ done |
| async | async API: Http3Client/Http3Server — Task facades with their own UDP socket + background pump (ConnectAsync/GetAsync/SendAsync, Http3RequestException, CID demux incl. migration) |
✅ done |
| curl | curl --http3 interop (server side): ngtcp2/LibreSSL (Windows curl 8.21) and OpenSSL-QUIC (WSL/Debian curl 8.14) — GET/POST/300 KB/103+trailers |
✅ done |
| 8+ | Transport-error matrix complete: connection-level FLOW_CONTROL_ERROR, TRANSPORT_PARAMETER_ERROR (§7.3 authentication + §7.4/§18.2 value ranges), §10.2.3 close delivery, parser fuzzer | ✅ done |
| 9 | Performance: zero-alloc hot paths (ByteQueue, 300 KB download 51→7 MiB), UDP batching (GSO via UdpBatchSender), window auto-tuning (ReceiveWindowTuner, BDP) |
✅ done |
| interop | Client interop against 8 foreign QUIC stacks: quiche, nginx, Google, mvfst, lsquic, msquic, quic-go, Akamai — each 2xx/3xx with full cert validation | ✅ done |
| browser | Browser interop (server side): Chrome 150 / Edge 150 headless, 8/8 checks incl. WebTransport and the PQ hybrid — tools/browser-interop.ps1 |
✅ done |
| harness | Out-of-process harnesses (tests/): h3semantics drives our server with msquic — the first foreign client to do so besides curl and the browsers — and h3attack with hand-built hostile datagrams; tests/run-tests.sh gives one verdict, and CI runs it |
✅ 42/42 |
| bench | h3bench: throughput, latency percentiles and concurrency scaling against the demo host — the first reproducible performance numbers this repository has |
✅ done |
The sections below record what each area implements and which live check exercised it — the "Live:" lines are the reproducible part. They describe code that now lives in Hermod; for the API and the internals, read the READMEs next to it.
X25519KeyExchange(BouncyCastle, encapsulated behindIKeyExchange— the only external crypto dep); the client offers X25519 + P-256, the server picks from the key shares- HelloRetryRequest (RFC 8446 §4.1.4) on the client and server side, including the synthetic
message_hashtranscript handling — tested in-process (client offers only P-256, server demands X25519 → HRR → completion with X25519) - Live: against
cloudflare-quic.com, X25519 is negotiated (Handshake completed (group X25519)) - Client interop matrix (live, full cert validation without
-k): the client fetches status 2xx/3xx from 8 independent QUIC stacks — quiche (Cloudflare), nginx, Google, mvfst (Meta), lsquic (LiteSpeed), msquic (Microsoft,outlook.office.com— P-256 + AES-256 + RSA), quic-go (Caddy) and Akamai (AES-256). Covers both KEX groups (X25519 + P-256), both suites (AES-128/256-GCM) and both cert types (ECDSA + RSA-PSS) live. Full table + one-command repeat: INTEROP.md (dotnet run --project tests/h3interop). - X448 (RFC 7748, Curve448, named group 0x001e): key-exchange primitive from BouncyCastle
(
X448KeyExchange, encapsulated like X25519), 56-byte key/secret. RFC 7748 §5.2 byte-exact; the named groups are threaded through the whole API (keyExchangeGroups/preferredGroups). Live over UDP:H3Server --x448+H3Get --x448 -k→ group X448, status 200 - X25519MLKEM768 (post-quantum hybrid, named group 0x11EC, draft-ietf-tls-ecdhe-mlkem): combines
ML-KEM-768 from the BCL (
MLKem, .NET 10 native) with X25519 (BouncyCastle). A KEM is asymmetric, soIKeyExchangehas anEncapsulatemethod (server encapsulates, client decapsulates). Shares/secret: ek(1184)‖x25519 / ct(1088)‖x25519 / ss_mlkem‖ss_x25519 — ML-KEM first. Byte-exact interop live against cloudflare-quic.com (H3Get --mlkem→ group X25519MlKem768, full chain, status 200, 126 KB) - Ed25519 (RFC 8032,
SignatureScheme0x0807): signature primitive from BouncyCastle (Ed25519Signature, encapsulated like X25519) — the client verifies the CertificateVerify signature (PureEdDSA, no pre-hash),ServerCertificategenerates a self-signed Ed25519 certificate on demand. RFC 8032 §7.1 byte-exact; live over UDP:H3Server --ed25519+H3Get -k→ signature verified, status 200 - Ed448 (RFC 8032,
SignatureScheme0x0808, edwards448/SHAKE256): analogously from BouncyCastle (Ed448Signature), PureEdDSA with empty context, 57-byte key / 114-byte signature;ServerCertificategenerates an Ed448 certificate on demand. RFC 8032 §7.4 byte-exact; live over UDP:H3Server --ed448+H3Get -k→ signature verified, status 200 - ML-DSA (FIPS 204, draft-ietf-tls-mldsa,
SignatureSchememldsa44/65/87 = 0x0904–0x0906): post-quantum signatures, completely BCL-native (.NET 10MLDsa+CertificateRequest) — pure signature without pre-hash, empty FIPS 204 context; the client additionally checks that the parameter strength of the certificate key (id-ML-DSA-44/65/87) matches the scheme.ServerCertificate.CreateSelfSignedMLDsa(). Live over UDP:H3Server --mldsa+H3Get -k→ status 200 — and fully post-quantum with--mldsa --mlkem(X25519MLKEM768 KEX + ML-DSA-65 signature)
ServerCertificate— self-signed ECDSA P-256 viaCertificateRequest(forcurl -k/ tests)TlsServerHandshake— ServerHello/EncryptedExtensions/Certificate/CertificateVerify (signed)/ Finished, verifies the client Finished. Verified in-process against the client handshake (matching secrets)QuicServerConnection— server role (Initial keys from the client DCID, HANDSHAKE_DONE),Http3ServerConnection— accepts requests, invokes a handler, sends HEADERS + DATA- Client and server share the transport logic via the
QuicEndpointbase class; the two connection classes are thin subclasses with role hooks (key direction/stream perspective viaIsServer) - M3 live:
dotnet run --project samples/H3Server+dotnet run --project samples/H3Get -- localhost / --port=4433 -k→ status 200 + our own HTML page over real UDP (both ends from scratch) curl --http3interop: the server side passes against two independent foreign HTTP/3 stacks — the official Windows curl 8.21 (ngtcp2 + nghttp3 + LibreSSL) and the Debian curl 8.14 under WSL (OpenSSL-3.5-QUIC + nghttp3, across the WSL2 NAT boundary): handshake,GET /(200),POST /echo(byte-exact echo),GET /big(300 000 B),GET /hints(103 Early Hints + final 200 + trailers), clean closes. Example:curl --http3-only -k https://127.0.0.1:4433/- Browser interop:
GET /browserserves a page whose JS runs the whole battery and posts its verdict to/report, which the server prints — so the server log is the record and a headless run needs no DOM scraping. Chrome 150 and Edge 150 both report 8/8, including WebTransport (session viaserverCertificateHashes, datagram + bidi + uni echo) and, with--mlkem, the PQ hybridX25519MLKEM768. One command:pwsh tools/browser-interop.ps1 -Browser chrome. Details and the two required browser flags: INTEROP.md
- CertificateVerify signature (RFC 8446 §4.4.3) over the transcript hash with the public key of the leaf certificate — always verified (ECDSA P-256/P-384, RSA-PSS). This is the actual cryptographic MITM defence: it binds the presented certificate to exactly this handshake.
- Trust policy separate from that, via
CertificateValidationOptions: chain building up to a trusted root (X509Chain), hostname (X509Certificate2.MatchesHostname) and validity period.Default= full validation against the system roots;Insecure= likecurl -k(signature only);CustomTrustRoots= trust a specific test certificate. - Live: GET against
cloudflare-quic.compasses with full chain validation (CN=cloudflare-quic.com, chain up to a Windows system root, hostname match). The local self-signed H3Server is correctly rejected without-k, accepted with-k.
- Flow control (receive):
QuicClientConnectiontracks the windows and sendsMAX_STREAM_DATA/MAX_DATAas soon as the credit falls below half the window — with--smallthe sample loads the 126 KB page even through a 48 KB connection window. - Loss recovery (RFC 9002):
RttEstimator,NewRenoCongestionControl,LossRecovery(sent-packet tracking, packet/time threshold, PTO). Lost CRYPTO/STREAM frames are resent.dotnet run --project samples/H3Get -- --loss=10survives the loss: handshake + GET get through despite ~17 dropped datagrams. - ECN (RFC 9000 §13.4 / RFC 9002 §7.3): the receiver counts the ECN codepoints (ECT0/ECT1/CE) per
packet-number space and reports them in the ACK frame (type 0x03); the sender treats an increased
CE counter like a loss and halves the congestion window (once per recovery period). The codepoint
comes in via
ProcessDatagram(dg, ecn). Verified in-process (counting/reporting, CE reaction, end-to-end cwnd decrease). The actual IP marking (setting ECT/reading CE) is beyond BCL UDP sockets (especially on Windows) — pure transport layer, the protocol logic is complete. - cwnd enforcement & pacing (RFC 9002 §7/§7.7): new stream data is limited by
min(cwnd − bytes_in_flight, pacing budget); thePacer(token bucket, rate1.25·cwnd/smoothed_rtt, burst cap ≈ initial window) spreads it over time; pure ACKs and PTO probes remain exempt. The 1-RTT send path emits multiple MTU-sized packets per call. A 150 KB in-process transfer proves byte-exact, MTU-conformant transmission over the paced path. - Persistent congestion (RFC 9002 §7.6): during a blackout period (two ack-eliciting packets
lost, nothing acknowledged in between, spacing > PC duration
≈ PTO·3) the window collapses tokMinimumWindowand slow start restarts — only takes effect after the first RTT sample. - Idle timeout (RFC 9000 §10.1):
IdleTimeoutnegotiatesmin(local, peer)(0 = disabled), effectively raises it to at least3·PTOand restarts the timer on successful receive or when sending an ack-eliciting packet. When it expires, the connection is closed silently. TheH3Serverreaps inactive connections —dotnet run --project samples/H3Server -- 4433 --idle=3000closes a connection ~3 s after the last packet (log: "closed after idle timeout"). - Keep-alive via PING (RFC 9000 §10.1.2): if
KeepAliveIntervalis set, the connection schedules an ack-eliciting PING after the corresponding inactivity, resetting the idle timeout on both sides. Live:H3Get … --hold=6againstH3Server … --idle=3000— the connection stays open idle for 6 s where it would be reaped after 3 s without keep-alive.
-
Version negotiation: the server answers an unsupported version with a
VersionNegotiationPacket(version field 0, listed versions, DCID/SCID swapped). Anti-amplification (§6.1/§14.1): no VN for datagrams < 1200 B. As GREASE (§6.3) a reserved version matching the pattern0x?a?a?a?ais included, probing client robustness and preventing ossification. The client detects VN, applies the §6.2 discard rules, ignores the reserved version and otherwise reportsVersionNegotiationReceived+OfferedVersions. -
Retry / address validation: with
--retrytheH3Serverresponds to the first Initial with aRetryPacketcarrying a token and 16-byte integrity tag (RFC 9001 §5.8). The client verifies the tag against its original DCID, re-derives the Initial keys from the Retry SCID (RFC 9001 §5.2), echoes the token in the next Initial and completes the handshake. Live:dotnet run --project samples/H3Server -- 4433 --retry+H3Get … -k→ "Handshake completed … after Retry (address validation)" + status 200. -
Stateless Retry (RFC 9000 §8.1.2/§8.1.4) — the form that actually helps under load.
--retrycreates the connection object first and only then sends the Retry, so every spoofed Initial still costs state. With--stateless-retry(orHttp3Server(..., addressValidation: new RetryTokenGenerator())) the decision is made from the cleartext header alone: no token ⇒ Retry, valid token ⇒ connection, bad token ⇒ INVALID_TOKEN in an Initial. The token — AES-256-GCM overkind ‖ issued ‖ ODCID ‖ Retry-SCID, with the client address and port as associated data — is the state, so nothing is remembered between the two round trips. Same flood of 128 distinct sources: dozens of connections without it, zero with it. Live:curl --http3completes through the Retry. -
Client certificates / mutual TLS (RFC 8446 §4.3.2): the server asks with a CertificateRequest after EncryptedExtensions (never on a PSK handshake — §4.3.2 MUST NOT); the client answers at the end of its flight with Certificate + CertificateVerify signed under the client context string, and its Finished covers both.
ClientCertificateMode.Requirerejects an anonymous client withcertificate_required;Requestlets it in unauthenticated and hands the application the verdict viaClientAuthentication. Live:dotnet run --project samples/H3Server -- 4433 --mtls=ca.pem+curl --http3 --cert client.pem --key client.key -k https://localhost:4433/→ 200, while the same curl without a certificate is refused and the server keeps serving everyone else.
Close(TransportError, reason)sends a CONNECTION_CLOSE and puts the connection into the closing state (only CONNECTION_CLOSE from then on, re-sent for every incoming packet). Receiving a CONNECTION_CLOSE leads to the draining state (nothing is sent anymore,PeerCloseFramerecorded); after3·PTOfollows closed. States visible viaIsClosing/IsDraining/IsClosed.- Live:
H3Getcloses down properly after the GET ("CONNECTION_CLOSE, NO_ERROR"); theH3Serverlogs "peer … closed the connection" and removes the draining connection.
- Discarding after the handshake: Initial keys are discarded as soon as the client sends a
Handshake packet or the server processes one (§4.9.1); Handshake keys once the handshake is
confirmed (§4.9.2).
DiscardKeysclears keys, pending CRYPTO/retransmits and the loss-recovery space (RFC 9002 §6.4). Fixes a bug where a post-handshake PTO wrongly probed the Initial space and resent the ClientHello as a 1200-byte Initial. - Handshake confirmation (§4.1.2): the server confirms at completion; the client at HANDSHAKE_DONE or — additionally (RFC-legitimate) — as soon as one of its 1-RTT packets is acknowledged. This way the Handshake keys are discarded (earlier) even when HANDSHAKE_DONE is lost — verified in-process (confirmation solely via 1-RTT ACK with HANDSHAKE_DONE suppressed).
- No reordering window for Handshake keys (§4.9.2): the Handshake keys are discarded immediately on confirmation — unlike 0-RTT keys, for which §4.9.3 explicitly allows the server a short retention (~3×PTO) against reordering. This asymmetry is intentional: after mutual confirmation, a late reordered Handshake packet would only carry information already known, and keeping keys longer would only widen the attack window. (The "keep briefly" of previous read keys applies exclusively to the 1-RTT key update per §6.)
- Client 0-RTT keys (§4.9.3): the client discards its 0-RTT key set as soon as the 1-RTT keys are installed — it sends no 0-RTT packets after that and never receives any itself. There is no reordering window for this (unlike on the server): the client has no 0-RTT read path, and lost 0-RTT data is retransmitted over 1-RTT — the keys have "no use after that moment".
- Server 0-RTT read keys (§4.9.3): the server keeps them briefly after the first received
1-RTT packet (so reordered 0-RTT packets remain decryptable without 1-RTT retransmission) and then
discards them "within a short time" — RECOMMENDED 3×PTO, purely time-driven (even without
further traffic). Earlier, as soon as the packet numbers from 0 are gap-free: then provably all
0-RTT packets have arrived ("keeping track of missing packet numbers") and the keys are discarded
immediately instead of waiting out the deadline. If the connection ends earlier,
Dispose()releases the 0-RTT read keys (along with all other keys).
TrafficKeys.Nextderives the next generation (secret_<n+1> = HKDF-Expand-Label(secret_<n>, "quic ku", …)→ new key/IV, HP key unchanged).PacketProtection.RemoveHeaderProtectionseparates header from packet protection so the key-phase bit can be read before key selection.InitiateKeyUpdate()rotates the send keys and flips the phase; a received flipped bit rotates read (and possibly send) keys; previous read keys are kept briefly for reordered packets.- Live:
dotnet run --project samples/H3Get -- --key-updatefetches the page, rotates the keys and repeats the GET under the new keys — againstcloudflare-quic.comstatus 200 both times.
- After the handshake the server issues a NewSessionTicket (stateful
ServerResumptionCache); the client derives the resumption PSK from it (resumption_master_secret→HKDF-Expand-Label(…, "resumption", nonce)) and stores it as aResumptionTicket. - Resumption sends
pre_shared_key(as the last extension) with a binder —HMAC(finished_key( binder_key), transcript hash(ClientHello up to just before the binder list)), the classic truncation boundary from RFC 8446 §4.2.11.2 — pluspsk_key_exchange_modes(which is always sent, otherwise the server issues no tickets). The server verifies the binder and resumes without Certificate/CertificateVerify. - Live resumption:
H3Get --resume→ first a GET, then a second connection via PSK. Againstcloudflare-quic.com, Cloudflare delivers 2 real tickets, accepts our binder byte-exactly ("PSK accepted, no certificate") and answers the second GET with status 200; likewise againstH3Serverover UDP. - 0-RTT (early data): if the ticket allows 0-RTT, the client sends the
early_dataextension and derives theclient_early_traffic_secret. The HTTP/3 request is sent before handshake completion as a 0-RTT packet (long header 0x01, its own key set in the application PN space); the server confirms early_data in the EncryptedExtensions and processes the request immediately. Live:H3Get --zerorttagainstcloudflare-quic.com→ "0-RTT ACCEPTED", 126 KB, status 200 (no round trip before the request); likewise againstH3Serverover UDP. - 0-RTT rejection → 1-RTT retry (RFC 9001 §4.6.2): if the server rejects early_data, normal loss recovery already applies thanks to the shared application PN space; additionally the client proactively moves the 0-RTT frames into the 1-RTT retransmit queue on detected rejection (without PTO delay, without double-sending). The request thus gets through even on rejection — verified in-process (server rejects ⇒ status 200 over 1-RTT). The Handshake key discard also stays correct on this path: confirmation via 1-RTT ACK (§4.1.2) counts only real 1-RTT packets (never a 0-RTT packet, although both share the same PN space), so a 0-RTT ACK never confirms the handshake too early — verified (0-RTT rejected + HANDSHAKE_DONE suppressed ⇒ confirmation only via the 1-RTT ACK, then Handshake keys gone).
ConnectionIdManagermanages the connection IDs we issued (peer → DCID) and those offered by the peer (us → DCID) along with sequence numbers.IssueConnectionId()sends NEW_CONNECTION_ID (respectingactive_connection_id_limit);RotateDestinationConnectionId()switches the DCID and retires the old one via RETIRE_CONNECTION_ID. "Retire Prior To" and incoming RETIRE are handled; packets to a local CID that was never (or no longer is) issued are dropped.- Live over UDP: the
H3Serveroffers a spare CID after the handshake;H3Get … --rotate-cidswitches its DCID to it and repeats the GET under the new connection ID (status 200).
- Receive: the peer's stateless-reset tokens (from NEW_CONNECTION_ID + the
stateless_reset_tokenTP) are stored. A non-processable short-header datagram whose last 16 bytes match a known token (constant-time) is recognised as a stateless reset and ends the connection (StatelessResetReceived→ draining). - Send: the server derives its tokens from the CID —
StatelessResetTokenGenerator=HMAC-SHA256(secret, CID)[0..16](§10.3.1) — so they remain recomputable after state loss. The demux (H3Server) answers a 1-RTT packet for an unknown DCID withStatelessReset.BuildResponse: compute the token from the DCID, build a reset smaller than the triggering packet (loop avoidance §10.3.3), only for short headers above a minimum size. Tested in-process end-to-end (stateless responder with shared secret → reset which the client recognises). - Persisted secret: the
H3Serverstores the secret in a file (--secret-file=, default next to the exe) and loads it at startup — so it stays identical across restarts (verified: identical bytes, startup message "loaded — survives restarts"), which lets a freshly restarted server send valid, client-recognisable resets for connections established before the restart. The demux only opens new connections for genuine Initial packets; short header to unknown CID ⇒ reset, other long headers ⇒ dropped.
- Protocol violations by the peer are answered with a CONNECTION_CLOSE carrying the appropriate error
code instead of being silently ignored or crashing: FRAME_ENCODING_ERROR (broken/unknown frame),
STREAM_LIMIT_ERROR (stream beyond the granted limit), FLOW_CONTROL_ERROR and
FINAL_SIZE_ERROR (wired up from the
StreamReceiveBuffer). After the first violation no further frames of the packet are processed. - At the same time PATH_CHALLENGE/PATH_RESPONSE (RFC 9000 §19.17/§19.18) are added and answered —
with that the parser covers all v1 frames and "unknown frame = fatal" doesn't break real servers
(confirmed live against
cloudflare-quic.com). Tested end-to-end (among others STREAM_LIMIT_ERROR).
- Path validation:
InitiatePathValidation()sends a PATH_CHALLENGE with 8 random bytes; a matching PATH_RESPONSE setsPathValidated(deadline 3·PTO). PATH_CHALLENGE is answered. ViaOwnsConnectionId, connections can be identified by connection ID (instead of by address). - Live over UDP: the
H3Serverdemultiplexes via the connection ID;H3Get … --migrateswitches the local UDP port and fetches a second GET — the server detects the migration (log: "connection migration: … → …"), validates the new path and answers the GET over the new path (status 200). The connection survives the address change. Tested in-process (client/server-initiated + expiry timeout).
- Before address validation the server sends at most 3× as many bytes as it has received —
protection against amplification via spoofed source addresses. If the budget doesn't suffice, the
CRYPTO stays persistently buffered and goes out once more has been received. The address counts as
validated at the first decrypted Handshake packet (the peer owns our handshake keys) or at a
valid Retry token; the client is unrestricted by construction. Tested via the invariant
sent ≤ 3×received; live, our own server reaches the goal under the limit.
Http3Frames(DATA/HEADERS/SETTINGS, incremental parsing),Http3Request/Http3ResponseHttp3ClientConnectionon top ofQuicClientConnection: opens the control stream (+ SETTINGS) and QPACK encoder/decoder streams, sends requests as a QPACK-encoded HEADERS frame on a bidirectional stream, reassembles the response (HEADERS → QPACK decode, DATA → body)- Request bodies (RFC 9114 §4.1):
Http3Request.Body/Http3Request.Post(...)— the body follows the HEADERS frame as a DATA frame (automaticcontent-length, §4.1.2); the server collects DATA frames and only answers the message once it is complete (FIN). Tested via POST echo + 120 KB upload (SHA-256-verified, the first time the client send path is under load); live:H3Get --post=<text>— echo against our ownH3Server(POST /echo) over UDP and POST againstcloudflare-quic.com(status 200) - Request cancellation (RFC 9114 §4.1.1) + RESET_STREAM/STOP_SENDING (RFC 9000 §19.4/§19.5):
CancelRequestaborts send and read side withH3_REQUEST_CANCELLED; the QUIC layer delivers both frames reliably (loss recovery), validates final size (§4.5) and stream states (STREAM_STATE_ERROR) and automatically answers STOP_SENDING with RESET_STREAM carrying the copied error code (§3.5 MUST); the server detects client aborts (H3_REQUEST_REJECTED/CANCELLED). H3 error codes (§8.1) asHttp3Error. Live:H3Get --cancelagainstcloudflare-quic.com— abort mid-download, Cloudflare resets with0x10c, a second GET over the same connection delivers status 200 - Frame/stream state machine (RFC 9114 §4.1/§6.2/§7.2): protocol violations by the peer are answered with CONNECTION_CLOSE type 0x1d and the correct H3 error code — H3_MISSING_SETTINGS (first control frame ≠ SETTINGS), H3_FRAME_UNEXPECTED (second SETTINGS, DATA before HEADERS, frames after trailers, reserved HTTP/2 types, PUSH_PROMISE from the client, …), H3_STREAM_CREATION_ERROR (duplicate critical streams, client push stream), H3_CLOSED_CRITICAL_STREAM (control/QPACK stream ends), H3_SETTINGS_ERROR (reserved/duplicate setting IDs), H3_FRAME_ERROR (layout violations, truncated final frame), H3_ID_ERROR (push without MAX_PUSH_ID, GOAWAY with wrong ID). Grease frames/settings are tolerated and sent ourselves (§7.2.4.1 SHOULD); 14 tests with deliberately "evil" raw-QUIC peers in both directions, live interop unchanged
- WebSockets over HTTP/3 (RFC 9220 / RFC 8441 / RFC 6455): Extended CONNECT with the
:protocolpseudo-header, gated viaSETTINGS_ENABLE_CONNECT_PROTOCOL(0x08); after the 2xx acceptance the request stream becomes a byte tunnel (Http3Tunnel, bytes in DATA frames per RFC 9114 §4.4; FIN ≙ TCP close, reset ≙H3_REQUEST_CANCELLED). The RFC 6455 framing is reused from Hermod's HTTP/2 WebSocket (byte-identical copies, only the namespace swapped — it is written transport-agnostically against the 2-method tunnel interface, incl. masking, UTF-8 validation, permessage-deflate, close handshake). Unknown:protocol⇒ 501. Live over UDP:H3Get --websocket— CONNECT 200 → text echo → close handshake - WebTransport over HTTP/3 (draft-ietf-webtrans-http3-13, complete draft incl. flow control):
session establishment via Extended CONNECT (
:protocol=webtransport, gated via SETTINGS_WT_MAX_SESSIONS + datagrams; 404 for unknown resources, H3_REQUEST_REJECTED above the session limit); uni streams (type 0x54) and bidi streams (WT_STREAM 0x41), both sides opening/receiving; WebTransport datagrams; flow control via the capsule protocol (WT_MAX_STREAMS/WT_MAX_DATA/WT__BLOCKED); session end via the WT_CLOSE_SESSION capsule (streams aborted with WT_SESSION_GONE); app error-code remapping (§4.3); ALPN-like protocol negotiation (§3.3: the client offers viaWT-Available-Protocols— a structured-fields list of strings, RFC 9651 — the server picks exactly one viaWT-Protocol; invalid value types ⇒ whole field ignored, a pick outside the list discarded on both sides). API:ConnectWebTransport(…, availableProtocols:)/webTransportHandler+webTransportProtocolSelector,WebTransportSession(OpenUni/Bidi, Accept, SendDatagram, Close,NegotiatedProtocol). Live over UDP:H3Get --webtransport— session, datagram echo, uni/bidi stream (echo), clean shutdown, WT protocolecho-v2negotiated out of three offers - RESET_STREAM_AT (draft-ietf-quic-reliable-stream-reset): stream reset with guaranteed partial
delivery — the reason a receiver still sees the critical stream prefix (e.g. the WebTransport
stream header) despite an abort. Transport parameter
reset_stream_at(0x1d, empty value, on by default; non-empty ⇒ TRANSPORT_PARAMETER_ERROR) and frame RESET_STREAM_AT (0x24 = RESET_STREAM + reliable size; reliable > final ⇒ FRAME_ENCODING_ERROR). On the receive side the first reliable-size bytes are still delivered (flow control nevertheless accounts the full final size), later frames may only lower the reliable size (§5.2), a changed error code ⇒ STREAM_STATE_ERROR. API:QuicStream.ResetAt(code, reliableSize)(guarantees bytes already sent; keeps retransmitting STREAM frames below it; degrades to RESET_STREAM without peer support),QuicStream.PeerReliableSize. Live over UDP: Cloudflare accepts TP 0x1d (handshake + 200) - HTTP datagrams (RFC 9297 / RFC 9221): the foundation of MASQUE/WebTransport. QUIC layer:
transport parameter
max_datagram_frame_size(0x20) +DatagramFrame(0x30/0x31, unfragmentable, not retransmitted, congestion-controlled); receiving without announcement/above the limit ⇒ PROTOCOL_VIOLATION. HTTP/3 layer:SETTINGS_H3_DATAGRAM(0x33) on both sides, quarter-stream-ID format, association with the Extended-CONNECT tunnel; unparsable/too-large ID ⇒ H3_DATAGRAM_ERROR (connection error), a datagram for a GET/POST ⇒ request reset (stream error). API:enableDatagrams,Http3Tunnel.TrySendDatagram/TryReceiveDatagram. Live over UDP:H3Get --datagrams— 3/3 datagrams echoed over QUIC DATAGRAM frames - Priorities (RFC 9218):
priorityheader (u0–7,i; fault-tolerant structured-fields parsing) and PRIORITY_UPDATE frame (0xF0700, client control stream only — all §7.2 MUSTs enforced, updates for unopened streams buffered, update overrides the header). The QUIC send path schedules per §10: ascending urgency; equal urgency non-incremental in request order, incremental via round-robin; control/QPACK streams with urgency 0. API:Http3Request.Priority,SendPriorityUpdate(streamId, prio). Live over UDP:H3Get --priorities— theu=0download overtakes the earlier-requested default download, and a PRIORITY_UPDATE (u=7) demotes au=0prefetch afterwards - Malformed detection (RFC 9114 §4.1.2/§4.2/§4.3): strict validation of pseudo-header rules
(mandatory/forbidden/order/context, no pseudo-headers in trailers), field names (lowercase,
token characters) and values (no NUL/CR/LF — smuggling protection), connection-specific fields
(
teonly "trailers") and content-length consistency (exception HEAD/204/304). Malformed requests ⇒ 400 + stream errorH3_MESSAGE_ERRORwithout invoking the handler; malformed responses are discarded (IsResponseMalformed); the client throws locally on its own malformed requests. A valid CONNECT ⇒ 501 - MAX_FIELD_SECTION_SIZE (RFC 9114 §4.2.2): announcable on both sides (
maxFieldSectionSize⇒ SETTINGS 0x06, formula: name + value + 32 per field). Respected on the sender side (the client throws on oversized request headers, the server downgrades oversized response headers to 500); enforced on the receiver side (server: 431 without handler invocation + STOP_SENDINGH3_NO_ERROR; client: discard the response,IsResponseTooLarge). Live: Cloudflare announces 131072 — parsed and respected - Trailers + interim responses (RFC 9114 §4.1):
Http3Request.Trailers/Http3Response.Trailers(final HEADERS frame after the content, both directions, separate from the headers) andHttp3Response.InterimResponses— e.g. 103 Early Hints as separate 1xx section(s) before the final response, cleanly split by:status. Content after an interim ⇒ malformed ⇒ STREAM errorH3_MESSAGE_ERROR(§4.1.2), the connection stays alive. Live over UDP:H3Get localhost /hintsshows103 (interim) → 200 → trailer: checksum: … - GOAWAY / graceful shutdown (RFC 9114 §5.2):
InitiateGracefulShutdown()announces via GOAWAY the first request-stream ID no longer accepted; in-flight work is served to completion, later requests are rejected withH3_REQUEST_REJECTED(safely repeatable), finallyCloseGracefully()with H3_NO_ERROR. The client refuses new requests after GOAWAY (MUST NOT) and marks in-flight requests ≥ the boundary as rejected; increasing GOAWAY IDs ⇒H3_ID_ERROR. Live over UDP:H3Server --goaway+H3Get --goaway— GET 200 → GOAWAY → request refused → CLOSE0x100 - M2 live:
dotnet run --project samples/H3Getfetches the real "QUIC | Cloudflare" page (status 200,content-type: text/html,server: cloudflare, ~126 KB body)
- Encoder/decoder without dynamic table (spec-conformant with
QPACK_MAX_TABLE_CAPACITY=0) - Static table (99 entries) + Huffman (RFC 7541 App. B) + N-bit integer/string codec — the Huffman/static tables are generated from the RFCs by script (no hand transcription)
- Verified: RFC 9204 B.1 (decode), RFC 7541 Huffman vectors, header round-trips; the encoder prefers Huffman when shorter, and indexed/name-reference forms for static-table hits
- Dynamic table (RFC 9204 §3):
QpackDynamicTable(FIFO with byte capacity, eviction, absolute indexing),QpackDynamicEncoder(produces insert instructions for the encoder stream + field section, base = required insert count) andQpackDynamicDecoder(Set Capacity / Insert With Name Reference static+dynamic / Insert With Literal Name / Duplicate; field lines indexed static/dynamic, post-base, literal names; required-insert-count modulo reconstruction §4.5.1). Verified byte-exactly against RFC 9204 appendix B.2, plus encoder↔decoder round-trip, dynamic reuse and eviction. - Wired into HTTP/3 (
Http3Qpack): SETTINGS announce the capacity; the peer's uni streams (control + QPACK encoder) are read, insert instructions processed streamingly, blocked HEADERS buffered and retried. Gated: capacity 0 = purely static (default, Cloudflare-interop-safe);H3Get --qpack-dynamicagainst our own server enables the dynamic table on both sides — live over UDP: a request insert (:authority) flows to the server, response inserts (content-type/server) back to the client, status 200. End-to-end test over the full stack (encoder-stream exchange + blocked retry). - Decoder-stream feedback (RFC 9204 §4.4): after a dynamic section the decoder sends a section acknowledgment; the encoder processes section-ack/stream-cancellation/insert-count- increment and releases the referenced entries. The table never evicts a still-referenced entry (eviction protection, §2.1.1) — the acks keep it usable across many requests.
StreamId(initiator/direction bits),StreamReceiveBuffer(reassembly, FIN/final size, flow control),StreamSendBuffer(frame production within the window),QuicStream- Flow-control frames (
MAX_DATA/MAX_STREAM_DATA/MAX_STREAMS/*_BLOCKED),RESET_STREAM/STOP_SENDING; 1-RTT send path (ShortHeader.Build) QuicClientConnectionroutes incoming STREAM frames, decodes the server transport parameters (from EncryptedExtensions) and honours stream + connection windows when sending- Live: Cloudflare's HTTP/3 control stream (with SETTINGS) and the QPACK streams are correctly reassembled and typed — direct transition to HTTP/3 (phase 7)
TlsClientHandshake(Quic.Tls): drives the TLS 1.3 client handshake behind the established "CRYPTO in / CRYPTO + keys out" interface (ClientHello, ServerHello, key schedule, Finished, app secrets). Verifies the server certificate (CertificateVerify signature + chain/hostname) — see the "Certificate validation" section.QuicClientConnection(Quic): encryption levels,PacketNumberSpaceper space, CRYPTO reassembly, key installation —Start()/GetDatagramsToSend()/ProcessDatagram(). TheH3Getsample is thereby pure UDP I/O (~60 lines).
KeySchedule: early → handshake → master secret,Derive-Secret, handshake/application traffic secrets — checked byte-exactly against the RFC 8448 tracesTranscript(IncrementalHash),HandshakeMessages(splitting the CRYPTO byte stream)CryptoStreamAssembler(QUIC): offset-based, cross-packet CRYPTO reassembly (receive); outbound,AppendLevelPacketsdistributes large CRYPTO (e.g. the PQ-hybrid ClientHello) offset-correctly across multiple ≤MTU Initials/Handshakes — confirmed live against Cloudflare with--mlkem
dotnet run --project samples/H3Get performs the complete QUIC/TLS 1.3 handshake against
cloudflare-quic.com:443 — from scratch, BCL only:
- Client Initial (ClientHello) sent, accepted and ACKed
- ServerHello parsed (
AES-128-GCM-SHA256, TLS 1.3,key_share=secp256r1) - ECDHE + transcript(CH‖SH) → handshake traffic secrets
- 5 Handshake packets decrypted, CRYPTO reassembled: EncryptedExtensions, Certificate, CertificateVerify, Finished — server Finished MAC verified
- Transcript advanced → our own (client) Finished computed and sent in a Handshake packet, plus ACKs for Initial + Handshake
- 1-RTT application keys derived, and the server's HANDSHAKE_DONE received in a decrypted 1-RTT packet → handshake fully completed
(The 1-RTT packets already carry Cloudflare's HTTP/3 control stream — a preview of phase 7.)
ClientHello.Build: complete handshake message with SNI, supported_groups, signature_algorithms, supported_versions (TLS 1.3), key_share (P-256), ALPN (h3) andquic_transport_parameters; verified by structural re-parsingEcdheKeyExchange: P-256/P-384 key exchange (uncompressed point, X coordinate as the secret)TransportParameters(QUIC layer): encode/decode, ignores unknown parameters (grease)TlsWriter: length-prefixed vectors via back-patching;BufferWriter.PatchSpanadded
Framebase +PADDING,PING,HANDSHAKE_DONE,CRYPTO,STREAM,ACK(incl. ECN),CONNECTION_CLOSEAckFramewith an absolute range model (converts gap/length wire encoding)FrameParser: splits payloads, coalesces PADDING, reports FRAME_ENCODING_ERROR/unknown types- Verified: the real decrypted server Initial payload (ACK + CRYPTO) from RFC 9001 A.3 is parsed and re-serialised byte-exactly
TransportErrorcodes (RFC 9000 §20.1)
ConnectionId(0–20 bytes, value equality, usable as a demux key)PacketNumber: encode/decode + length selection per RFC 9000 appendix A (tested against the examples)LongHeader: parse (cleartext fields →PacketNumberOffset) and build for Initial/Handshake; the packets built from fields match the RFC 9001 vectors byte-exactly, including a full send→receive round-trip (build → parse → unprotect → cleartext)ShortHeader: DCID/packet-number localisation based on the known local CID length
- A.1 key schedule:
initial_secret, client/server keys, IVs, HP keys, HkdfLabel encoding - A.2 client Initial (1200 bytes) generated byte-exactly + header-protection mask
- A.3 server Initial generated byte-exactly, header mask and decryption round-trip
- A.4 Retry integrity tag
- A.5 ChaCha20-Poly1305: header-protection mask
aefefe7d03byte-exact (our own ChaCha20 block), plus the RFC 8439 block vector and a packet round-trip; the suite is negotiated and confirmed live against Cloudflare (H3Get … --chacha20)
Building blocks in libs/Hermod/Hermod/QUIC/Crypto/: TlsHkdf, TrafficKeys, InitialSecrets,
PacketProtection (AEAD AES-GCM/ChaCha20-Poly1305 + header protection + packet-number
reconstruction), ChaCha20 (raw block for the HP mask), RetryIntegrity.
The library itself is no longer in this repository. QUIC moved into Hermod on 2026-07-28 and HTTP/3 followed on 2026-07-30; what remains here are the samples, the interop evidence and the plan.
libs/Hermod/ The whole stack, as a submodule:
Hermod/QUIC/ transport (buffers, crypto, packets, frames, streams,
connection, recovery, qlog) + TLS/ the TLS 1.3 handshake
engine — see its README
Hermod/HTTP3/ HTTP/3 + QPack/ + WebSocket/ (RFC 6455) + WebTransport/
(draft-13) + the async facades Http3Client/Http3Server —
see its README
HermodTests/QUIC/ and HermodTests/HTTP3/ (340 tests, incl. RFC vectors,
"evil" raw-QUIC peers and a seeded lossy link)
Namespaces are unchanged: org.GraphDefined.Vanaheimr.Hermod.Quic.* and
org.GraphDefined.Vanaheimr.Hermod.HTTP3.*
tests/ Harnesses that drive H3Server as a separate process over real UDP — see its README
h3semantics/ RFC 9114 semantics, driven by .NET's HttpClient over msquic
(a foreign stack: no ProjectReference to Hermod)
h3attack/ hand-built hostile datagrams: noise, undersized Initials,
version negotiation, stateless reset, amplification, flood
h3bench/ throughput/latency/concurrency baseline (no pass/fail)
h3interop/ the matrix against 8 public HTTP/3 servers — the one harness
pointing outwards; live network, so nightly-only
run-tests.sh builds, starts the demo host, runs the gated harnesses,
one verdict; one script, bash, every platform
tools/ browser-interop.ps1 — headless Chrome/Edge against H3Server, exit code as verdict
samples/H3Get/ HTTP/3 client: GET/POST against cloudflare-quic.com or our own server
(--post, --cancel, --goaway, --priorities, --websocket, --datagrams, --webtransport, --zerortt, --resume, --key-update, --migrate,
--rotate-cid, --qpack-dynamic, --mlkem, --x448, --chacha20, --loss, --hold, -k)
samples/H3Server/ HTTP/3 server: self-signed cert, handler over UDP (routes: /, /big, POST /echo,
/hints with 103 + trailers; CONNECT websocket/datagram-echo, WebTransport /wt;
options incl. --retry/--stateless-retry, --mtls, --idle, --goaway, --ed25519/--ed448/--mldsa)
Namespaces: QUIC lives under org.GraphDefined.Vanaheimr.Hermod.Quic (+ .Tls, .Core, …) — as a
standalone transport next to HTTP/3, not below it; HTTP/3 under
org.GraphDefined.Vanaheimr.Hermod.HTTP3 (+ .Qpack, .WebTransport); the tests under
org.GraphDefined.Vanaheimr.Hermod.Tests.QUIC / .Tests.HTTP3. All using blocks sit in
#region Usings … #endregion (Hermod style).
Prerequisite: .NET 10 SDK. Everything builds from the repository root:
dotnet build HTTP3ConformanceTests.slnx --configuration ReleaseThe gate — the demo host, both harnesses, one verdict. This is what ci.yml runs, on
both legs:
tests/run-tests.shOn Windows this runs under the Git Bash that ships with Git for Windows. The same command, spelled for a shell that needs the interpreter named explicitly:
sh tests/run-tests.shThe stack's own suite lives in the submodule and is the other half of the gate:
dotnet test libs/Hermod/HermodTests/HermodTests.csproj --filter "TestCategory!=LiveDNS"h3interop, h3bench and the browser battery are deliberately outside the gate — one needs the
open internet, one has no verdict to give, and one needs a browser installed. Their commands are in
the table at the top.
Apache License, Version 2.0 — see LICENSE. The same licence the source headers have carried all along; the file itself was simply missing.