Crafter.Network/AGENTS.template.md
Jorijn van der Graaf 3be8642d32
docs(quic): OnClosed, and what ~ClientQUIC and SendSync now promise
The README's QUIC section gains how to own accepted connections: the
OnClosed callback, the thread it runs on, what not to do in it and the
std::stop_callback rule that makes deleting a connection under a lock
its callback takes a deadlock. The consumer notes give the same
contract with the map-under-a-mutex pattern for a server that also
deletes on shutdown, list OnClosed among what runs on msquic's workers
rather than the pool, say that ~ClientQUIC now waits up to 1 s so the
peer hears the close, and that SendSync throws only for its own send.
The agent notes count the three new tests, add OnClosed's thread and
the destructor's new wait to the gotchas, and the forking tests and
their alarm() watchdog to the testing conventions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 02:20:04 +01:00

30 KiB

AGENTS.md

Guidance for coding agents and contributors working on Crafter.Network itself. The README is the user-facing manual, and AGENTS.consumer.md is what agents working in a consumer of this library read: crafter-build copies it into the dependency-notes section of the consumer's AGENTS.md on every build. When any of the three disagrees with the code, the code wins; the interfaces under interfaces/ are the contract.

Project overview

Crafter.Network is the Crafter engine's networking library: one C++ module, Crafter.Network, with fifteen partitions behind its primary interface (sixteen interface files), with TCP, URLs, a byte-stream seam and a libssl TLS transport, HTTP/1.1 (client, listener and a transport-free codec), QUIC over msquic, HTTP/3 (client, listener and a wire-format partition) and WebTransport on the HTTP/3 listener. One source tree builds three libraries: native Linux (everything), native with --no-quic (TCP, TLS and HTTP/1.1, no msquic), and the browser (wasm32-*: the client half of HTTP and WebTransport over fetch() and the WebTransport API, *Async only). The only crafter-build dependency is Crafter.Thread, whose ThreadPool runs every callback and *Async call; msquic is an ExternalDependency built with CMake, and OpenSSL 3 is a system package.

Path What lives there
interfaces/Crafter.Network.cppm The umbrella: export imports every partition, under #ifndef CRAFTER_NETWORK_NO_QUIC and #ifndef CRAFTER_NETWORK_BROWSER for the ones a build leaves out.
interfaces/Crafter.Network-<Partition>.cppm One partition per file. The native-only ones wrap their whole body in #ifndef CRAFTER_NETWORK_BROWSER, so the browser build compiles them as empty modules.
implementations/Crafter.Network-<Partition>.cpp The native implementation units, Crafter.Network:<Partition>_impl. HTTP, HTTP1, HTTP3 and URL have none: they are inline.
implementations/Crafter.Network-ClientHTTP-Browser.cpp, -ClientQUIC-Browser.cpp The browser implementations of those two partitions, over the JS bridge.
additional/network-env.js The JS bridge: fetch() and WebTransport behind the env imports the wasm declares, shipped next to a consumer's .wasm through cfg.files.
project.cpp The build description: one static library, crafter-network, in its three variants, the msquic external, the OpenSSL link flags and the test declarations.
tests/<Name>/main.cpp One test program per directory, declared in project.cpp.
examples/SimpleClient/ A wasm browser client and a native WebTransport echo server in one project.cpp, MIT licensed, meant to be copied.
AGENTS.template.md, AGENTS.consumer.md The source of this file, and the notes for consumers. Every build and test run writes AGENTS.md from the template, with the AGENTS.consumer.md of each dependency that ships one merged in, and only when the text changes (test --list, lint, format, run and clean leave it alone); AGENTS.md is gitignored, so edit the template. Crafter.Thread ships none today, so there is no notes section, and a fresh clone has no AGENTS.md until its first build, when it gets the template under crafter-build's header line.
build/, bin/ Generated and gitignored, one directory per variant and per test; build/test-logs/ keeps the output of failed tests. The example's bin/ and build/ match the same patterns.

Partitions and what they own:

  • :ClientTCP, :ListenerTCP: POSIX sockets, IPv4 and IPv6. SocketClosedException, SocketTimeoutException, SocketErrorException (a std::system_error with the SocketPhase that failed; ResolverCategory() for getaddrinfo's codes), the receive semantics (what arrived, never a padded buffer) and the onError of the async receives. The listener hands its callback a new ClientTCP the callback owns.
  • :Stream: ByteStream (ReadSome and Write with a timeout per call, Shutdown, Descriptor, Protocol, Secure) and PlainStream, so one HTTP/1.1 implementation serves http:// and https://. SetNonBlocking, PollDescriptor.
  • :TLS: TLSContext, TLSStream, both credential structs, GetSelfSignedCertificatePem. OpenSSL stays behind the Impl pointers; no OpenSSL header reaches an importer.
  • :HTTP: HTTPRequest, HTTPResponse, the Create* helpers, PathWithoutQueryHTTP. Shared by both HTTP versions and by every build.
  • :URL: URL, an absolute URL as RFC 3986 reads it (Parse), the resolution of a reference against it (Resolve, RFC 3986 §5.2, dot segments removed), the recomposition, and the pieces a request is built from. Inline, native only; what ClientHTTP1::Fetch follows a Location with.
  • :HTTP1: the RFC 9112 codec, inline: SerializeRequest, SerializeResponse, SerializeContinue, MessageParser, MessageLimits, HTTP1ProtocolError, and the framing it refuses to guess at.
  • :ClientHTTP1, :ListenerHTTP1: HTTP/1.1 over a ByteStream&. The listener is a ListenerTCP with a thread per accepted connection; the client keeps one pooled connection with a replay-once rule, and ClientHTTP1::Fetch is the URL fetch that follows redirects by RFC 9110 §15.4 with a client per hop (FetchOptions, FetchResult, FetchStop).
  • :ClientQUIC: ClientQUIC and QUICStream for both ends of a connection, QUICSettings, both QUIC credential structs, QUICException, QUICClosedException. The native unit holds the process-wide MsQuicRuntime (API table and registration, closed by a static destructor at exit), the stream registry with its refcounted handles, a SendRequest per send carrying its own outcome, the CloseNotice behind OnClosed, and the drain in ~ClientQUIC (streams, then the connection's own SHUTDOWN_COMPLETE).
  • :ListenerQUIC: the msquic listener, its four Listen* variants, the self-signed certificate's on-disk cache (per user under $XDG_CACHE_HOME/crafter-network/quic-cert/, behind a lock; the OpenSSL work is module-internal functions of :TLS), ComputeCertificateHashSHA256, GetSelfSignedCertificatePath.
  • :HTTP3: QUIC varints, HTTP/3 frames, the QPACK static table, Huffman decoding, the control-stream preludes and the WebTransport stream prefixes. Native only: it throws for protocol errors.
  • :ClientHTTP, :ListenerHTTP: HTTP/3 on :ClientQUIC and :ListenerQUIC. The listener demuxes every bidirectional stream into an HTTP/3 request or a WebTransport data stream and accepts extended CONNECT when wtRoutes is non-empty.
  • :WebTransport: WebTransportSession, the per-session handle a WT handler gets. Bidirectional streams and session close today; datagrams and capsules are stubs.

The variants are decided in project.cpp. Any wasm32-* target defines CRAFTER_NETWORK_BROWSER, compiles nine interfaces (the umbrella, ClientTCP, ListenerTCP, ClientHTTP, ListenerHTTP, HTTP, ClientQUIC, ListenerQUIC, WebTransport; all but the umbrella, HTTP, ClientHTTP and ClientQUIC empty) with the two -Browser units, ships network-env.js, and returns before msquic or OpenSSL come up. --no-quic defines CRAFTER_NETWORK_NO_QUIC, drops the six QUIC-side interfaces and their five units, and leaves msquic out, which is most of a fresh build. The flag is this project's own and is stripped from the args passed on to Crafter.Thread, so a consumer and this project load one Crafter.Thread rather than two with different args.

Dev environment

crafter-build is the only tool, from its Flatpak (alias crafter-build='flatpak run net.catcrafts.CrafterBuild'; this machine has its stable and beta branches, and a build installed from a Crafter.Build checkout is branch local, chosen with --branch=). The host is Linux x86_64. A native build needs network access to forgejo.catcrafts.net for Crafter.Thread, which is unpinned and git pulled by every verb that loads project.cpp, test --list, lint and format included (--local takes ../Crafter.Thread instead, and is forwarded so Crafter.Thread resolves the same way), and to github.com once for msquic, pinned to v2.6.3 (commit 16ca4d8d) with its submodules and built by CMake into ~/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build/external/msquic-<hash>/ (a native crafter-build uses ~/.cache/crafter.build/): several minutes the first time, then shared by every project with the same spec. msquic is configured with QUIC_TLS_LIB=openssl against the OpenSSL the sandbox provides, 3.5 or newer (the Flatpak runtime has 3.5.9), as a shared libmsquic.so that the test binaries and consumers find through an absolute runpath into that cache (a directory named after msquic's spec hash, which crafter-build clean never touches). msquic links libnuma when the build host has it: a native crafter-build on Arch does, the Flatpak does not.

What the suite needs beyond that: curl and python3 for the interop tests, the openssl CLI for the mutual-TLS half of ShouldRequireClientCertificateHTTPS1 (both development certificates are made in-process), and outbound UDP/443 to cloudflare-quic.com for ShouldSend. The Flatpak sandbox has openssl, curl, python3 and wasmtime and no node; its /tmp is a per-app directory under $XDG_RUNTIME_DIR/.flatpak/net.catcrafts.CrafterBuild/tmp, shared by instances running at the same time and discarded once none is; its XDG_CACHE_HOME is ~/.var/app/net.catcrafts.CrafterBuild/cache, so the QUIC tests' certificate lives in crafter-network/quic-cert/ there and survives between runs (delete the directory to start cold; ShouldShareSelfSignedCertificateQUIC points XDG_CACHE_HOME at a scratch directory of its own); and it sees the host filesystem but not the host's PATH (/run/host/usr/bin is the host's /usr/bin). The interop tests print skipping the ... half and pass when a tool is missing: a pass that tested less, so read the log.

Build outputs go to bin/crafter-network-<target>-<march>-<mtune>-<hash>/ (the PCMs, std.pcm and libcrafter-network.a; the wasm variant also carries network-env.js) and build/<the same>/. The hash covers the variant, so the default build, --no-quic and the browser build keep separate directories, and bin/ also holds one directory per test. The Flatpak's host triple is x86_64-unknown-linux-gnu; the README's x86_64-pc-linux-gnu default is a native crafter-build's, and --target= with either is the same build in another directory. crafter-build clean removes them all.

Build, test and style commands

crafter-build                                  # native: TCP, TLS, HTTP/1.1, QUIC, HTTP/3, WebTransport
crafter-build --no-quic                        # native without QUIC, HTTP/3, WebTransport and msquic
crafter-build --local                          # Crafter.Thread from ../Crafter.Thread
crafter-build --target=wasm32-wasip1           # the browser library
crafter-build test                             # the suite, native: 36 tests
crafter-build test 'Should*HTTP1'              # a name glob
crafter-build test --no-quic                   # the TCP, URL, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 19
crafter-build test --list
crafter-build test --jobs=1 --timeout=120
crafter-build clean

A --flag crafter-build does not know is a project arg under any verb, so --no-quic and --local go anywhere before a --; under test, a bare word and everything after -- are name globs, so crafter-build test -- --no-quic runs nothing. project.cpp registers no lint rules, so crafter-build lint and format exit 1 with "no rules"; style is checked by eye (see "Code style"). There is no CI. Before a change is done, crafter-build test and crafter-build test --no-quic pass, and crafter-build --target=wasm32-wasip1 still builds when a #ifndef CRAFTER_NETWORK_BROWSER block, a browser unit or network-env.js was touched. Failed, crashed and timed-out tests write build/test-logs/<Name>.log; a later passing run does not delete them, so check the timestamp before trusting one.

Testing instructions

Tests are declared, not discovered: one cfg.AddTest("<Name>").Dependencies({ &cfg }) line in project.cpp, inside the block for Linux targets (the browser build runs no tests), in the if (quic) branch when the test needs msquic, or in its else when it only exists to show msquic stayed out (ShouldBuildWithoutQUIC). The source is tests/<Name>/main.cpp, linked against the local libcrafter-network.a with the variant's defines. A test is an int main(): 0 passes, nonzero fails, 77 skips (unused here; the interop tests skip a half by printing and passing). The runner gives each 60 s in its own process, in parallel.

Conventions the tests follow:

  • Names are Should<Verb><What>; the SendRecieve* and Interop* families end in HTTP1 or HTTPS1 on the HTTP/1.1 side and HTTP or QUIC on the QUIC side, and ShouldSend, ShouldNotDropEarlyStreams, ShouldFallbackUnknownRoutes and ShouldSurviveConnectionChurn carry no suffix. Open main.cpp with a comment on the behaviour under test and the failure it guards against; tests/ShouldRecieveTCP/main.cpp and tests/ShouldSurviveConnectionChurn/main.cpp show the shape, and a regression test names the bug.
  • A Check(ok, what) helper in an anonymous namespace counts failures and prints them (PASS/FAIL lines in the newer tests), and main returns nonzero at the end; the oldest QUIC tests return at the first failure. Do not stop at the first failure unless later steps are meaningless without it.
  • Each test owns fixed ports, distinct across the suite because the runner is parallel: 8090 to 8097 and 8110 to 8114 for HTTP/1.1 listeners, 8082 to 8086 and 8098 for HTTP/3, 9181, 9182, 9187 and 9188 for QUIC, 19200 to 19224 for the churn test; the two TCP tests bind ephemeral ports, as do the QUIC tests written since ListenerQUIC::Port() (port 0, read back), and ShouldBuildWithoutQUIC and ShouldSend bind none. A new test binds port 0 and reads Port() (ListenerTCP and ListenerQUIC both have it); a fixed port is for when it cannot.
  • ThreadPool::Start() first, for anything that enqueues: every *Async call, ClientHTTP (its constructor installs an OnStream drain that runs as pool tasks), ListenerHTTP, ListenerQUIC, and the *Async-dispatch Listen* variants of ListenerTCP; ListenerHTTP1 and the sync TCP listeners need none, which is why ten tests never call it. Start() runs hardware_concurrency() - 1 workers and Start(1) none at all (tasks then wait for a JoinPool() nobody calls), so don't use Start(1) to make callbacks deterministic.
  • A QUIC test ends in std::_Exit(code), after std::fflush(stdout) since _Exit flushes nothing (ShouldReportClosedQUICStream does; the older ones skip the flush and can lose their last line in the log): msquic's two registrations are closed by static destructors that block until every connection is torn down, and the peers a listener accepted are heap-allocated for the callback's owner, so returning from main waits for the idle timeout. ShouldSurviveConnectionChurn and ShouldNotifyClosedConnectionQUIC are the exceptions on purpose: that wait is their assertion, so they delete everything and return, the first under a watchdog thread that calls std::_Exit(1) after 90 s, which the runner's 60 s timeout beats; a watchdog meant to fire goes under 60 s, as the 8 s one in ShouldNotDropEarlyStreams and the 45 s one in ShouldNotifyClosedConnectionQUIC do. A test that forks (ShouldNotifyClosedConnectionQUIC, ShouldShareSelfSignedCertificateQUIC) does so before ThreadPool::Start() or anything QUIC, and a parent that must stay fork-safe uses alarm() as its watchdog. Any test that can hang gets one.
  • Credentials: QUICServerCredentials{ .selfSigned = true } against QUICClientCredentials{ .insecureNoServerValidation = true }; for TLS, TLSServerCredentials{ .selfSigned = true } against a client given GetSelfSignedCertificatePem().certificate as caPem, so verification is exercised rather than skipped.
  • A ClientTCP drives the wire where ClientHTTP1 would hide the thing under test (pipelining in ShouldSendRecieveHTTP1, garbage and a stalled peer in ShouldSurviveAbuseHTTP1), and raw POSIX sockets (headers before the imports) where ClientTCP itself is under test (ShouldRecieveTCP, ShouldConfigureTCP, ShouldBuildWithoutQUIC).
  • The outside world: curl, python3 -m http.server and the openssl CLI are probed with command -v, and a missing one skips that half with a line saying so. ShouldSend is the one test that needs the network.

A bug fix ships with a test that fails before it and passes after, in its own test: commit when it stands on its own; the fix(quic) commits in git log show the pairing.

Code style

Nothing is enforced by tooling; match the file you are in.

  • The first two lines of every source are exactly //SPDX-License-Identifier: LGPL-3.0-only and //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®, no space after //, then a blank line. The example is MIT in the same shape. (Crafter.Network-ClientQUIC-Browser.cpp has stray text after its copyright line; fix it when you touch the file.)
  • Four-space indentation in new code. Crafter.Network-ClientTCP.cppm, -ListenerTCP.cppm and parts of -ClientTCP.cpp and -ListenerTCP.cpp are tab-indented from the first version; match the block you are in and do not reindent files, it buries the real diff. Crafter.Network.cppm and Crafter.Network-ListenerTCP.cppm are CRLF and every other source LF; an editor that normalises line endings rewrites the whole file, the same burying.
  • import std; everywhere. C headers (<sys/socket.h>, <msquic.h>, <openssl/*.h>) go in the global module fragment after module;, and in an interface only under #ifndef CRAFTER_NETWORK_BROWSER, since the wasm sysroot has none of them. Tests #include POSIX headers before their imports the same way.
  • PascalCase for types and functions, camelCase for variables and members, k-prefixed constants in the codecs (HTTP3::kFrameHeaders). Exceptions are <Thing>Exception or <Codec>ProtocolError. std::println for output in new code; some tests still use std::cout.
  • Spellings that are API: Recieve in every receive method and test name, and the ListenSyncSync family. Consumers build against the default branch, so a rename breaks all of them at once: only on purpose, with the consumers updated in the same move, and never for a new name.
  • Comments say why: the RFC section, the failure mode, the race, the rejected alternative. The codebase carries long comments on purpose (the header of :HTTP1, the stream registry in -ClientQUIC.cpp, MakeBidiHandler in -ListenerHTTP.cpp); do not strip them, and write them for new non-obvious code. Commit bodies follow the same rule.
  • Errors are exceptions on the synchronous paths and never leave a pool task: an *Async call reports through its callback (onError(std::exception_ptr) on TCP, bool sent and the empty chunk on QUIC streams, an error string on the HTTP clients), because an exception escaping a ThreadPool task ends the program and the browser bridge has no exception channel from JS.
  • Timeouts are per call on ByteStream, members on the HTTP/1.1 endpoints, QUICSettings on QUIC, and std::chrono::milliseconds everywhere. A new blocking path gets a bound.

Design rules

Decided deliberately; change them only with the maintainer's say-so.

  1. One source, three builds. Native, --no-quic and the browser are the same files, with #ifndef CRAFTER_NETWORK_BROWSER around whole partitions, #ifndef CRAFTER_NETWORK_NO_QUIC in the umbrella, and two -Browser implementation units. No per-platform directory and no host-side #ifdef beyond those two.
  2. The browser is a client, and async. No listener, no raw TCP and no *Sync method there; the JS bridge drives fetch() and WebTransport and calls back through wasm exports. An API that cannot be made async does not join the shared surface.
  3. One request and response type, one route map, one dispatch order across HTTP/1.1 and HTTP/3: exact path, query-stripped path, fallback, 404. A handler moves between the two by changing a class name, and ShouldFallbackUnknownRoutes replays one table over both to keep it so.
  4. fallback is a hook, not a pattern syntax. Consumers bring their router; the library never grows a second route table that can disagree with it.
  5. Ambiguous HTTP/1.1 framing is rejected, not guessed (RFC 9112 §11.2): conflicting lengths, a length with chunked, whitespace before a colon, CR or LF in a value to send. Compatibility never buys a parse that could smuggle.
  6. https:// is a constructor argument, not a class. The HTTP/1.1 code holds a ByteStream& and never learns whether it is a PlainStream or a TLSStream; :TLS is protocol-agnostic and keeps OpenSSL out of the interface.
  7. One OpenSSL in the process. msquic builds against the system OpenSSL (3.5 or newer), not its bundled quictls, whose reading of the system openssl.cnf broke inside the Flatpak; :TLS links the same libssl. Where that OpenSSL is too old the answer is --no-quic, not a second TLS stack.
  8. msquic is pinned, Crafter.Thread is not. Externals from outside Catcrafts get a commit; sibling Catcrafts repositories track their default branch on purpose.
  9. Receives return what arrived. No padded buffers, no truncation, no exception for a signal, and a receive of zero bytes returns at once. The async variants deliver through callbacks and never throw out of the pool.
  10. The HTTP/1.1 listener runs a thread per connection, not pool tasks: keep-alive connections are idle most of their life and would pin the pool.
  11. msquic handles are refcounted, and streams drain before their connection closes. QUICStream::Stop initiates, the asynchronous SHUTDOWN_COMPLETE closes, ~ClientQUIC waits (bounded) for the registry to empty. Do not short-circuit it: closing a connection with streams open is an msquic bugcheck, and a leaked handle hangs RegistrationClose at exit.
  12. This project's flags stay here. --no-quic is stripped before the args go to Crafter.Thread, so every project in a consumer's graph loads Crafter.Thread with the same args.

Commit and PR guidelines

  • Branch off master (claude/issue-<n> for issue work, claude/<topic> or fix/<topic> otherwise) and open the PR against master on the project's Forgejo at forgejo.catcrafts.net/Catcrafts/Crafter.Network. origin is the HTTPS URL for fetch and push on this clone; Crafter.Build's notes say the server refuses HTTPS pushes, so set an SSH push URL first (git remote set-url --push origin forgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Network.git), as the Crafter.Build, Math, Asset and Graphics clones have.
  • Subject: type(scope): imperative summary, with feat, fix, docs or test and the scopes in use: tcp, tls, https, http1, http, quic, listener. Body: what was wrong, why, and why the fix has this shape, wrapped at about 75 columns, with the numbers when a race was measured (git log for fix(quic) shows the voice), and a Breaking: paragraph when a signature changes. Add a Co-Authored-By: trailer when an agent wrote it.
  • Separate commits for the fix, its test and the README change when each stands on its own. Update the README in the same PR when user-visible behaviour changes, and AGENTS.consumer.md when a signature, a default or a rule consumers lean on changes; consumers pick the notes up on their next build.
  • crafter-build test and crafter-build test --no-quic green before pushing, and the wasm build when the browser surface was touched. There is no CI.
  • Never commit bin/, build/, AGENTS.md or the example's build outputs. examples/SimpleClient/cert-hash.txt is tracked because cfg.files needs it to exist; the native server overwrites it at runtime, and a changed hash in a diff is noise.

Gotchas

  • ThreadPool::Enqueue before ThreadPool::Start() is a null dereference, and most of the library enqueues: every *Async, every QUIC OnStream and OnDatagram delivery, ClientHTTP and ListenerHTTP entirely. The crash has no message; in a test it shows as 💥.
  • Two self-signed certificates, both made in-process by :TLS. QUICServerCredentials::selfSigned is kept per user at $XDG_CACHE_HOME/crafter-network/quic-cert/ (else ~/.cache/..., else $XDG_RUNTIME_DIR/...), reused across restarts until a day before it expires so a browser's pinned hash survives a restart, and shared by processes that start together through a record lock on lock there; the listener hands msquic the pair as an in-memory PKCS#12, not the paths. TLSServerCredentials::selfSigned is new every start and carries keyEncipherment the QUIC one lacks. GetSelfSignedCertificatePath() is the first, GetSelfSignedCertificatePem() the second. To start the QUIC one cold, delete that directory.
  • :TLS declares module-internal functions (MakeWebTransportCertificatePem, CertificatePairExpiry, EncodePkcs12, CertificateSha256) without export: :ListenerQUIC imports :TLS for them so libssl stays behind one partition, and a consumer never sees them.
  • msquic's registration is closed by a static destructor that waits for every handle. A test that returns from main with an accepted peer alive waits two minutes, the idle timeout; the tests _Exit. The exception, ShouldSurviveConnectionChurn, exists because a leaked stream handle once hung that close forever (fix(quic) in git log, with the SHUTDOWN_ON_FAIL story).
  • Callbacks the interfaces say run "on the msquic worker" run as pool tasks: ClientQUIC::OnStream and OnDatagram both enqueue. What arrives before a handler is set is queued: streams are delivered on OnStream; datagrams go to RecieveDatagramSync's queue and never to a later OnDatagram. OnClosed is the exception: it does run on the worker, inside SHUTDOWN_COMPLETE after the handle is closed (a pool task only when set after the close), because deleting the connection from there is its purpose and must not depend on the pool.
  • ~ClientQUIC waits for the connection's SHUTDOWN_COMPLETE, up to 1 s, before ConnectionClose. msquic sends CONNECTION_CLOSE from a FLUSH_SEND it queues while processing ConnectionShutdown and ignores once ShutdownComplete is set; a close queued right behind the shutdown completed it silently, and the peer only timed the connection out (fix(quic) in git log, ShouldTellPeerOfCloseQUIC). So a destructor on an msquic worker thread (inside a stream callback) blocks that second and then some; only OnClosed's, which has nothing left to close, is free.
  • ListenerQUIC::ListenAsync* run the accept loop on a std::thread, not the pool as their comments say, and ListenerHTTP::Listen is ListenSyncAsync underneath, so a ListenerHTTP's per-connection setup is a pool task. A saturated pool delays a new connection's SETTINGS; the early streams are buffered meanwhile (ShouldNotDropEarlyStreams).
  • A ClientQUIC against an HTTP/3 server must drain the server's unidirectional streams (control, QPACK encoder and decoder) with an OnStream reader, or they back up and msquic may abort the connection. ClientHTTP does it; a hand-rolled WebTransport client (ShouldEchoWebTransport) does it itself.
  • QUICSettings::peerUnidiStreamCount is the first thing to raise when a peer stalls opening streams; it was a hardcoded 16 until ShouldHonourQUICSettings.
  • The comments about -fno-exceptions in project.cpp, the umbrella and the browser unit are history. crafter-build 4.0.0 compiles wasm with exceptions on and refuses -fno-exceptions; :HTTP3 stays out of the browser build and the browser units still report through callbacks because the JS side has no exception channel, not because throw would not compile.
  • The header comment of Crafter.Network-HTTP3.cppm is stale: the partition is exported from the umbrella natively and has a Huffman decoder. The README is right.
  • More comments the code outgrew: ListenerHTTP.cppm says Listen() dispatches on the calling thread (it is ListenSyncAsync); the link-flag comment in project.cpp still has msquic bundling quictls (it links the system libssl). Fix a comment when you touch its code.
  • WebTransportSession::OpenStream(true) does not throw as its comment says; it writes the unidirectional prefix. Peer-initiated unidirectional WebTransport streams are drained by the listener and never reach a session.
  • ListenerTCP::concurrentClientLimit is stored and never read; only totalClientLimit ends the loop.
  • ListenerTCP(port, ...) listens on every IPv4 address; given an address it binds that one, IPv4 or IPv6, and port 0 asks the kernel for one. ClientTCP resolves for both families and tries every address; QUIC connects on either family (QUIC_ADDRESS_FAMILY_UNSPEC), and ListenerQUIC(port, ...) listens on both. ListenerQUIC(address, port, ...) listens on that address only, but msquic binds the dual-stack wildcard UDP socket for the port anyway and filters in QuicBindingGetListener: a connection to another local address is closed with no_application_protocol (the client sees 0x5c in milliseconds), not ignored, and no other UDP socket can take that port on another address.
  • The Flatpak's host triple is x86_64-unknown-linux-gnu, and the tests register on any Linux triple since the TCP-only commit; before it they never ran inside the Flatpak.
  • A port in use from a crashed run is Could not bind port <n>: Address already in use on the HTTP/1.1 side and a ListenerStart failed QUICException on the QUIC side; find the stray process rather than changing the port.
  • A warning is printed only when its unit actually recompiles; a build with nothing to do is silent and says nothing about warnings.