The README's QUIC section shows sending without a thread: what onSent's true and false mean, the thread it runs on and what that forbids, and BytesInFlight as the way to drop a follower that cannot keep up. The consumer notes give the full contract, take SendAsync out of the list of what goes through the pool (but for a send msquic never took) and into what runs on msquic's workers; the agent notes count the new test and add onSent to the callbacks that really do run on the worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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(astd::system_errorwith theSocketPhasethat failed;ResolverCategory()forgetaddrinfo's codes), the receive semantics (what arrived, never a padded buffer) and theonErrorof the async receives. The listener hands its callback anew ClientTCPthe callback owns.:Stream:ByteStream(ReadSomeandWritewith a timeout per call,Shutdown,Descriptor,Protocol,Secure) andPlainStream, so one HTTP/1.1 implementation serveshttp://andhttps://.SetNonBlocking,PollDescriptor.:TLS:TLSContext,TLSStream, both credential structs,GetSelfSignedCertificatePem. OpenSSL stays behind theImplpointers; no OpenSSL header reaches an importer.:HTTP:HTTPRequest,HTTPResponse, theCreate*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; whatClientHTTP1::Fetchfollows 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 aByteStream&. The listener is aListenerTCPwith a thread per accepted connection; the client keeps one pooled connection with a replay-once rule, andClientHTTP1::Fetchis the URL fetch that follows redirects by RFC 9110 §15.4 with a client per hop (FetchOptions,FetchResult,FetchStop).:ClientQUIC:ClientQUICandQUICStreamfor both ends of a connection,QUICSettings, both QUIC credential structs,QUICException,QUICClosedException. The native unit holds the process-wideMsQuicRuntime(API table and registration, closed by a static destructor at exit), the stream registry with its refcounted handles, aSendRequestper send carrying its own outcome, theCloseNoticebehindOnClosed, and the drain in~ClientQUIC(streams, then the connection's ownSHUTDOWN_COMPLETE).:ListenerQUIC: the msquic listener, its fourListen*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:ClientQUICand:ListenerQUIC. The listener demuxes every bidirectional stream into an HTTP/3 request or a WebTransport data stream and accepts extended CONNECT whenwtRoutesis 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: 37 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>; theSendRecieve*andInterop*families end inHTTP1orHTTPS1on the HTTP/1.1 side andHTTPorQUICon the QUIC side, andShouldSend,ShouldNotDropEarlyStreams,ShouldFallbackUnknownRoutesandShouldSurviveConnectionChurncarry no suffix. Openmain.cppwith a comment on the behaviour under test and the failure it guards against;tests/ShouldRecieveTCP/main.cppandtests/ShouldSurviveConnectionChurn/main.cppshow the shape, and a regression test names the bug. - A
Check(ok, what)helper in an anonymous namespace counts failures and prints them (PASS/FAILlines in the newer tests), andmainreturns 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), andShouldBuildWithoutQUICandShouldSendbind none. A new test binds port 0 and readsPort()(ListenerTCPandListenerQUICboth have it); a fixed port is for when it cannot. ThreadPool::Start()first, for anything that enqueues: every*Asynccall,ClientHTTP(its constructor installs anOnStreamdrain that runs as pool tasks),ListenerHTTP,ListenerQUIC, and the*Async-dispatchListen*variants ofListenerTCP;ListenerHTTP1and the sync TCP listeners need none, which is why ten tests never call it.Start()runshardware_concurrency() - 1workers andStart(1)none at all (tasks then wait for aJoinPool()nobody calls), so don't useStart(1)to make callbacks deterministic.- A QUIC test ends in
std::_Exit(code), afterstd::fflush(stdout)since_Exitflushes nothing (ShouldReportClosedQUICStreamdoes; 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 frommainwaits for the idle timeout.ShouldSurviveConnectionChurnandShouldNotifyClosedConnectionQUICare the exceptions on purpose: that wait is their assertion, so they delete everything and return, the first under a watchdog thread that callsstd::_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 inShouldNotDropEarlyStreamsand the 45 s one inShouldNotifyClosedConnectionQUICdo. A test that forks (ShouldNotifyClosedConnectionQUIC,ShouldShareSelfSignedCertificateQUIC) does so beforeThreadPool::Start()or anything QUIC, and a parent that must stay fork-safe usesalarm()as its watchdog. Any test that can hang gets one. - Credentials:
QUICServerCredentials{ .selfSigned = true }againstQUICClientCredentials{ .insecureNoServerValidation = true }; for TLS,TLSServerCredentials{ .selfSigned = true }against a client givenGetSelfSignedCertificatePem().certificateascaPem, so verification is exercised rather than skipped. - A
ClientTCPdrives the wire whereClientHTTP1would hide the thing under test (pipelining inShouldSendRecieveHTTP1, garbage and a stalled peer inShouldSurviveAbuseHTTP1), and raw POSIX sockets (headers before the imports) whereClientTCPitself is under test (ShouldRecieveTCP,ShouldConfigureTCP,ShouldBuildWithoutQUIC). - The outside world:
curl,python3 -m http.serverand theopensslCLI are probed withcommand -v, and a missing one skips that half with a line saying so.ShouldSendis 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-onlyand//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.cpphas stray text after its copyright line; fix it when you touch the file.) - Four-space indentation in new code.
Crafter.Network-ClientTCP.cppm,-ListenerTCP.cppmand parts of-ClientTCP.cppand-ListenerTCP.cppare tab-indented from the first version; match the block you are in and do not reindent files, it buries the real diff.Crafter.Network.cppmandCrafter.Network-ListenerTCP.cppmare 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 aftermodule;, and in an interface only under#ifndef CRAFTER_NETWORK_BROWSER, since the wasm sysroot has none of them. Tests#includePOSIX headers before their imports the same way.PascalCasefor types and functions,camelCasefor variables and members,k-prefixed constants in the codecs (HTTP3::kFrameHeaders). Exceptions are<Thing>Exceptionor<Codec>ProtocolError.std::printlnfor output in new code; some tests still usestd::cout.- Spellings that are API:
Recievein every receive method and test name, and theListenSyncSyncfamily. 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,MakeBidiHandlerin-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
*Asynccall reports through its callback (onError(std::exception_ptr)on TCP,bool sentand the empty chunk on QUIC streams, an error string on the HTTP clients), because an exception escaping aThreadPooltask 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,QUICSettingson QUIC, andstd::chrono::millisecondseverywhere. A new blocking path gets a bound.
Design rules
Decided deliberately; change them only with the maintainer's say-so.
- One source, three builds. Native,
--no-quicand the browser are the same files, with#ifndef CRAFTER_NETWORK_BROWSERaround whole partitions,#ifndef CRAFTER_NETWORK_NO_QUICin the umbrella, and two-Browserimplementation units. No per-platform directory and no host-side#ifdefbeyond those two. - The browser is a client, and async. No listener, no raw TCP and no
*Syncmethod there; the JS bridge drivesfetch()andWebTransportand calls back through wasm exports. An API that cannot be made async does not join the shared surface. - 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, andShouldFallbackUnknownRoutesreplays one table over both to keep it so. fallbackis a hook, not a pattern syntax. Consumers bring their router; the library never grows a second route table that can disagree with it.- 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.
https://is a constructor argument, not a class. The HTTP/1.1 code holds aByteStream&and never learns whether it is aPlainStreamor aTLSStream;:TLSis protocol-agnostic and keeps OpenSSL out of the interface.- 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.cnfbroke inside the Flatpak;:TLSlinks the samelibssl. Where that OpenSSL is too old the answer is--no-quic, not a second TLS stack. - msquic is pinned, Crafter.Thread is not. Externals from outside Catcrafts get a
commit; sibling Catcrafts repositories track their default branch on purpose. - 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.
- 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.
- msquic handles are refcounted, and streams drain before their connection closes.
QUICStream::Stopinitiates, the asynchronousSHUTDOWN_COMPLETEcloses,~ClientQUICwaits (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 hangsRegistrationCloseat exit. - This project's flags stay here.
--no-quicis 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>orfix/<topic>otherwise) and open the PR againstmasteron the project's Forgejo atforgejo.catcrafts.net/Catcrafts/Crafter.Network.originis 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, withfeat,fix,docsortestand 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 logforfix(quic)shows the voice), and aBreaking:paragraph when a signature changes. Add aCo-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.mdwhen a signature, a default or a rule consumers lean on changes; consumers pick the notes up on their next build. crafter-build testandcrafter-build test --no-quicgreen before pushing, and the wasm build when the browser surface was touched. There is no CI.- Never commit
bin/,build/,AGENTS.mdor the example's build outputs.examples/SimpleClient/cert-hash.txtis tracked becausecfg.filesneeds it to exist; the native server overwrites it at runtime, and a changed hash in a diff is noise.
Gotchas
ThreadPool::EnqueuebeforeThreadPool::Start()is a null dereference, and most of the library enqueues: every*Async, every QUICOnStreamandOnDatagramdelivery,ClientHTTPandListenerHTTPentirely. The crash has no message; in a test it shows as 💥.- Two self-signed certificates, both made in-process by
:TLS.QUICServerCredentials::selfSignedis 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 onlockthere; the listener hands msquic the pair as an in-memory PKCS#12, not the paths.TLSServerCredentials::selfSignedis new every start and carrieskeyEnciphermentthe QUIC one lacks.GetSelfSignedCertificatePath()is the first,GetSelfSignedCertificatePem()the second. To start the QUIC one cold, delete that directory. :TLSdeclares module-internal functions (MakeWebTransportCertificatePem,CertificatePairExpiry,EncodePkcs12,CertificateSha256) withoutexport::ListenerQUICimports:TLSfor 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
mainwith 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)ingit log, with theSHUTDOWN_ON_FAILstory). - Callbacks the interfaces say run "on the msquic worker" run as pool tasks:
ClientQUIC::OnStreamandOnDatagramboth enqueue. What arrives before a handler is set is queued: streams are delivered onOnStream; datagrams go toRecieveDatagramSync's queue and never to a laterOnDatagram.OnClosedand the nativeQUICStream::SendAsynccallback are the exceptions: they do run on the worker,OnClosedinsideSHUTDOWN_COMPLETEafter the handle is closed (a pool task only when set after the close),onSentinside the send'sSEND_COMPLETE(a pool task only for a send msquic never took), so that neither depends on a pool the host shares. ~ClientQUICwaits for the connection'sSHUTDOWN_COMPLETE, up to 1 s, beforeConnectionClose. msquic sendsCONNECTION_CLOSEfrom aFLUSH_SENDit queues while processingConnectionShutdownand ignores onceShutdownCompleteis set; a close queued right behind the shutdown completed it silently, and the peer only timed the connection out (fix(quic)ingit log,ShouldTellPeerOfCloseQUIC). So a destructor on an msquic worker thread (inside a stream callback) blocks that second and then some; onlyOnClosed's, which has nothing left to close, is free.ListenerQUIC::ListenAsync*run the accept loop on astd::thread, not the pool as their comments say, andListenerHTTP::ListenisListenSyncAsyncunderneath, so aListenerHTTP'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
ClientQUICagainst an HTTP/3 server must drain the server's unidirectional streams (control, QPACK encoder and decoder) with anOnStreamreader, or they back up and msquic may abort the connection.ClientHTTPdoes it; a hand-rolled WebTransport client (ShouldEchoWebTransport) does it itself. QUICSettings::peerUnidiStreamCountis the first thing to raise when a peer stalls opening streams; it was a hardcoded 16 untilShouldHonourQUICSettings.- The comments about
-fno-exceptionsinproject.cpp, the umbrella and the browser unit are history. crafter-build 4.0.0 compiles wasm with exceptions on and refuses-fno-exceptions;:HTTP3stays out of the browser build and the browser units still report through callbacks because the JS side has no exception channel, not becausethrowwould not compile. - The header comment of
Crafter.Network-HTTP3.cppmis stale: the partition is exported from the umbrella natively and has a Huffman decoder. The README is right. - More comments the code outgrew:
ListenerHTTP.cppmsaysListen()dispatches on the calling thread (it isListenSyncAsync); the link-flag comment inproject.cppstill has msquic bundling quictls (it links the systemlibssl). 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::concurrentClientLimitis stored and never read; onlytotalClientLimitends 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.ClientTCPresolves for both families and tries every address; QUIC connects on either family (QUIC_ADDRESS_FAMILY_UNSPEC), andListenerQUIC(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 inQuicBindingGetListener: a connection to another local address is closed withno_application_protocol(the client sees0x5cin 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 useon the HTTP/1.1 side and aListenerStart failedQUICExceptionon 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.