Crafter.Network/AGENTS.consumer.md
Jorijn van der Graaf 79a2f12629
docs(quic): what ~ClientQUIC lets finish before it closes
The README and the consumer notes say that a reply sent on a stream let
go just before deleting the connection still arrives, and the agent
notes add the gotcha with the test that guards it, and count 39 tests.

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

46 KiB

Crafter.Network

The Crafter engine's networking library: a C++ module, import Crafter.Network;, with TCP, TLS, HTTP/1.1, QUIC, HTTP/3 and WebTransport as clients and listeners on native Linux, and the client half of HTTP and WebTransport in the browser, where the same code compiles for wasm32-wasip1 and runs on fetch() and the WebTransport API. Everything is in namespace Crafter; the two wire-format codecs are Crafter::HTTP1 and Crafter::HTTP3. Callbacks and *Async calls run on Crafter.Thread's pool (import Crafter.Thread;, Crafter::ThreadPool), which comes with the dependency. Source, examples and issues: https://forgejo.catcrafts.net/Catcrafts/Crafter.Network (LGPL-3.0-only; examples/SimpleClient/ is MIT and meant to be copied). The README there is the manual; these notes are its AGENTS.consumer.md: a fix to them goes there, not into the copy crafter-build writes.

Building against it

  • GitProject({ .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git" } }), or a LocalProject of a sibling checkout, into cfg.dependencies. Its link flags come with it: -lssl -lcrypto from the system OpenSSL, and libmsquic.so.2 from crafter-build's external cache (~/.cache/crafter.build/external/msquic-<hash>/, under ~/.var/app/net.catcrafts.CrafterBuild/cache/ for the Flatpak), which the executable finds through an absolute runpath into that cache. Like every host binary the Flatpak builds, it runs only inside the Flatpak (crafter-build -r, the test runner, flatpak run --command=<path> net.catcrafts.CrafterBuild) and only on the machine that built it; the cache directory is named after msquic's spec hash, and crafter-build clean never touches the cache.
  • A native build clones msquic at a pinned release, v2.6.3, with its submodules, and builds it with CMake on the first build (several minutes, then shared by every project with the same spec) against the OpenSSL the build runs with, 3.5 or newer. crafter-build's Flatpak brings its own git, cmake and OpenSSL 3.5, so nothing is installed for it. A crafter-build built from source uses the host's: git, cmake, the OpenSSL 3 development package (openssl on Arch, libssl-dev on Debian/Ubuntu) and, where present, libnuma (numactl), which msquic links when it finds it. Where that OpenSSL is older than 3.5 (Debian 12, Ubuntu 24.04), build with --no-quic.
  • Project args it reads, from the args your project.cpp forwards (all of them, by default): --no-quic leaves out QUIC, HTTP/3, WebTransport and msquic and keeps TCP, TLS and HTTP/1.1; --local takes Crafter.Thread from the checkout next to Crafter.Network's own instead of forgejo. It strips --no-quic from the args it hands Crafter.Thread; a consumer whose other dependencies also reach Crafter.Thread has to strip it for them the same way, or the build stops with two loads of Crafter.Thread that disagree on their args.
  • The target picks the build: any target whose triple contains wasm is the browser library, everything else the native one. The three surfaces:
native native --no-quic wasm32-*
HTTPRequest, HTTPResponse, CreateRequestHTTP, CreateResponseHTTP, PathWithoutQueryHTTP yes yes yes
ClientTCP, ListenerTCP yes yes no
ByteStream, PlainStream, TLSContext, TLSStream, HTTP1::*, URL, ClientHTTP1, ListenerHTTP1 yes yes no
ClientQUIC, QUICStream yes no *Async only, over WebTransport
ClientHTTP yes no SendAsync only, over fetch()
ListenerQUIC, ListenerHTTP, WebTransportSession, HTTP3::* yes no no
  • Defines do not cross a dependency. The library defines CRAFTER_NETWORK_BROWSER (wasm) and CRAFTER_NETWORK_NO_QUIC (--no-quic) for its own units, and the PCMs carry the surface above either way, but your own #ifdefs see only what your project.cpp defines: a wasm consumer pushes {"CRAFTER_NETWORK_BROWSER", ""} into cfg.defines, as examples/SimpleClient/project.cpp does, and a --no-quic consumer with QUIC-conditional code does the same with CRAFTER_NETWORK_NO_QUIC.
  • Browser build: cfg.target = "wasm32-wasip1", that define, and EnableWasiBrowserRuntime(cfg) after cfg.dependencies is set: it walks the dependencies at call time for the .js files the page must load, and called earlier it misses network-env.js, so the wasm fails to instantiate on its missing env imports. The library ships additional/network-env.js through cfg.files; crafter-build copies it next to your .wasm and the generated page merges it into the env imports, nothing to declare. Its WasmAlloc/WasmFree exports are weak, so it coexists with Crafter.Graphics's DOM backend in one executable.

The thread pool

ThreadPool::Enqueue before ThreadPool::Start() dereferences a null pointer, and most of this library enqueues: start the pool in main before the first listener or *Async call. Start() takes hardware_concurrency() threads, one of them the calling thread's own slot, which only runs tasks inside JoinPool(); Start(n) sets the count, and Start(1) has no worker at all. What goes through the pool:

  • ClientTCP::Recieve*Async, QUICStream::Recieve*Async, ClientHTTP::SendAsync, ClientHTTP1::SendAsync; QUICStream::SendAsync only to report a send that failed before msquic took it.
  • ClientHTTP as such, Send included: its constructor installs an OnStream drain, and every stream the server opens (its control and QPACK streams, right after the handshake) is delivered as a pool task.
  • ClientQUIC::OnStream and OnDatagram callbacks (each delivery is a pool task, whatever the interface comment says about the msquic worker) and WebTransportSession::OnStream.
  • ListenerTCP::ListenSyncAsync, ListenAsyncSync, ListenAsyncAsync; ListenerQUIC::ListenSyncAsync and ListenAsyncAsync for connectCallback (ListenAsync* run the accept loop on a thread of their own), and every stream a peer opens reaches OnStream through the pool, so a QUIC server needs it regardless.
  • ListenerHTTP and ListenerAsyncHTTP entirely: the per-connection setup (the server's SETTINGS), every route handler and every WebTransport handler are pool tasks, and handlers for one connection run concurrently. A saturated pool stalls the server.

What does not: the *Sync calls of ClientTCP, QUICStream and ClientQUIC, ClientHTTP1::Send, ListenerHTTP1 (a thread per connection, so an idle keep-alive connection pins no worker), ListenerTCP::ListenSyncSync, ListenerQUIC::ListenSyncSync, the codecs and the TLS streams. A task that blocks for the life of a connection (a reader parked in RecieveSync) holds a worker that long: give such readers a std::thread, as the library's tests do.

Not the pool but msquic's own worker threads: ClientQUIC::OnClosed (except when set after the connection closed, then a pool task), the native QUICStream::SendAsync callback, and QUICStream::OnRecieve's callbacks (except the bytes waiting when it is called, which it delivers on the calling thread). Code there must not block either; see "QUIC". These are the way to serve a stream without holding a thread.

HTTP, shared by both versions

HTTPRequest { method, scheme = "https", authority, path = "/", headers, body } and HTTPResponse { status = "200", headers, body }, with headers an std::unordered_map<std::string, std::string> keyed lowercase (HTTP/3 forbids uppercase names; the HTTP/1.1 side lowercases on parse and on send, the HTTP/3 client on send) and status the three digits as a string: response.status == "200". The four pseudo-headers are fields, never map entries. CreateRequestHTTP(method, path, authority[, headers][, body]) and CreateResponseHTTP(status[, headers][, body]) fill one in; PathWithoutQueryHTTP(target) is everything before ?.

A route map is std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>>. ListenerHTTP1 and ListenerHTTP take the same map and the same optional fallback, and dispatch alike: the exact path, then the query-stripped path (/thing?x=1 reaches /thing, with the full target still in request.path), then fallback, then a synthetic 404. A handler or fallback that throws becomes a 500 with what() in the body; ListenerHTTP catches only std::exception, so anything else escapes the pool task and ends the program, and its try also covers request parsing, so a malformed HTTP/3 request is a 500 where HTTP/1.1 answers 400. fallback is a plain member to assign before Listen(); the ListenerAsync* wrappers start accepting in their constructor, so there it is the constructor argument after routes (before the TLS credentials on ListenerAsyncHTTP1, last on ListenerAsyncHTTP). A handler moves between the protocols by changing the class name; tests/ShouldFallbackUnknownRoutes replays one table over both.

HTTP/1.1

ClientHTTP1(host, port) is plaintext, ClientHTTP1(host, port, TLSClientCredentials) is https://. Send(request) returns the HTTPResponse on the calling thread, no pool; SendAsync(request, onSuccess, onError) runs it as a pool task with the error as a string. The connection is persistent: the first Send dials, later ones reuse the socket until the peer asks to close; a pooled connection the peer closed meanwhile is redialled once and the request replayed, never once a response byte has arrived and never on a fresh connection, so a broken server is an exception, not a loop. An empty authority means host:port, with the scheme's default port left off (443 under TLS, 80 without); scheme is ignored (origin-form targets; the constructor decided the transport). Set limits (HTTP1::MessageLimits: start line 8 KiB, header section 64 KiB, body 512 MiB), connectTimeout (15 s, per address the host resolves to), handshakeTimeout (15 s) and timeout (30 s for the next piece of a response) before the first Send. Connected(), Secure(), Protocol() (the ALPN the last connection agreed, "http/1.1" under TLS), Disconnect(). One instance serves one caller at a time; instances are independent. Throws HTTP1::HTTP1ProtocolError for a response it refuses to parse, TLSException for a certificate it rejects, SocketErrorException for a socket that cannot resolve or connect or breaks under the request, SocketTimeoutException when the connect runs out, and std::runtime_error when a response stops arriving or the connection closes before one is complete.

ClientHTTP1::Fetch(url, options = {}) is one request to an http:// or https:// URL with redirects followed by RFC 9110 §15.4 (301, 302, 303, 307, 308 for every method; a 303 makes the request a GET without its body and content headers, a 301 or 302 does so for a POST alone, a 307 or 308 keeps method and body), the Location resolved against the sending URL, a hop between the schemes allowed, Authorization, Proxy-Authorization and Cookie dropped on a change of origin, and a client per hop. FetchOptions { method, headers, body, connectTimeout, handshakeTimeout, timeout, limits, credentials, maxRedirects = 10 }; FetchResult { response, url, redirects, stop }, where url is the Crafter::URL that answered and stop (FetchStop::None, NoLocation, OtherScheme, Loop, Limit) says why a redirect came back unfollowed. Throws std::invalid_argument for a URL that is not http(s) or that URL::Parse refuses, else what Send throws. URL::Parse(text), url.Resolve(reference), url.ToString(), Authority(), Target(), PortOrDefault(), Secure(), SameOrigin(other) are the URL type behind it (scheme, userinfo, host, port, path, query, fragment; scheme and host lowercased, an IPv6 host without its brackets).

ListenerHTTP1(port, routes[, fallback][, TLSServerCredentials]) is a ListenerTCP underneath (every IPv4 address, SO_REUSEADDR) and throws std::runtime_error when the port cannot be bound, or TLSException when the credentials yield no usable certificate. Listen() runs the accept loop on the calling thread until Stop(), which stops accepting, closes every live connection and joins their threads. ListenerAsyncHTTP1, same constructors, runs that loop on its thread and exposes the listener; Stop() joins and the destructor stops. Each accepted connection gets its own thread, served one request at a time until the peer closes, sends connection: close, or a timeout passes: keepAliveTimeout 15 s between requests, requestTimeout 30 s between pieces of one request (each read, not the whole request), handshakeTimeout 15 s for TLS; limits as on the client. ConnectionCount(), AcceptedCount(), HandshakeFailureCount() (connections dropped in the TLS handshake: a scanner, a client with no protocol in common, an untrusted client certificate; ordinary on a public port), Secure().

  • Implemented: keep-alive and pipelining, content-length and chunked request bodies with trailers (merged into headers), Expect: 100-continue, absolute-form targets, obs-fold, HTTP/1.0 peers (reuse only when they ask), HEAD (the route's handler runs with method HEAD and its body is dropped, content-length kept), an automatic date unless the handler set one, a handler ending the connection with a connection: close header. Requests reach handlers with scheme "http" or "https" as the transport decided. Not implemented: CONNECT, Upgrade, chunked responses (a handler returns the whole body; responses are content-length framed, and 1xx/204/304 carry none).
  • Refused with a 400 rather than guessed at (request smuggling, RFC 9112 §11.2): Content-Length with Transfer-Encoding, disagreeing duplicate Content-Length, whitespace before a colon, HTTP/0.9, a version other than 1.0 or 1.1, stacked or non-chunked transfer codings. A CR or LF in a header value you ask it to send throws HTTP1ProtocolError instead of splitting the message.
  • The framing headers host, content-length, transfer-encoding and connection belong to the serialiser: copies in your headers map are dropped, so set authority, body and the options, not those names. Pseudo-header names (:method and the like) in the map are skipped.

HTTP1::MessageParser(kind, limits) with SerializeRequest, SerializeResponse and SerializeContinue is the codec on its own, for HTTP/1.1 over a byte stream the library does not own: Feed(bytes) takes arbitrary chunks, Finish() says the peer closed its send side (completes a read-to-EOF body, errors anything else mid-message), then Complete(), TakeRequest() / TakeResponse(), and Reset() to rearm keeping pipelined bytes. SetRequestMethod("HEAD") before feeding a response, since a HEAD response has no body whatever its length says; SetDefaultScheme("https") once per connection; HeadersComplete(), ExpectsContinue() / ContinueSent(), KeepAlive(), AtMessageBoundary(), Method(), Version(), Status(). Interim 1xx responses are dropped. RequestOptions{ keepAlive }, ResponseOptions{ keepAlive, explicitKeepAlive, omitBody }. Helpers: HasToken(fieldValue, token), SplitCommaList, ReasonPhrase(status), FormatHTTPDate(time_point).

TLS

:TLS is libssl (OpenSSL 3) over any connected descriptor, HTTP or not, and no OpenSSL type crosses the interface. TLS 1.2 is the floor; the platform's cipher defaults apply.

  • TLSServerCredentials: certPath/keyPath (PEM files; the certificate file may be a chain, leaf first), else certPem/keyPem (the same inline), else selfSigned; clientCaPath with requireClientCertificate for mutual TLS (a peer with no certificate, or one not chaining to that CA, is dropped in the handshake; an empty clientCaPath means the system store); alpnProtocols = { "http/1.1" } in preference order. A client that offers ALPN and nothing on the list is refused with no_application_protocol; a client offering no ALPN is served.
  • TLSClientCredentials: the default verifies the chain against the system store and the hostname, both. caPath / caPem add a trust anchor (a self-signed listener's own certificate) without replacing the store; serverName overrides SNI and the name checked (a tunnel, a pinned IP); certPath/keyPath present a client certificate; alpnProtocols = { "http/1.1" }, empty sends none; insecureNoServerValidation skips both checks and accepts anyone, development only. An IP literal as host sends no SNI and is checked against the certificate's iPAddress SANs.
  • TLSContext::Server(creds) / Client(creds) return a std::shared_ptr<TLSContext> shared by every stream it produces (one per listener or client: the trust store and certificate are parsed once, and a stream keeps its context alive). TLSStream::Connect(fd, context, hostName, timeout) and Accept(fd, context, timeout) complete the handshake before returning a std::unique_ptr<TLSStream>, or throw TLSException with the certificate error spelled out. The descriptor stays owned by whoever opened it (a ClientTCP's socketid) and is switched to non-blocking. Version() ("TLSv1.3"), PeerCertificateSubject() (empty without a client certificate; with requireClientCertificate it is the authenticated identity), Protocol().
  • ByteStream is what TLSStream and PlainStream(fd) implement and the HTTP/1.1 endpoints are written against: ReadSome(buffer, size, timeout, read&) returns StreamStatus::Data, Closed or TimedOut and throws on a transport error; Write(buffer, size, timeout) writes everything or throws (PlainStream a std::runtime_error such as timed out writing to the peer, TLSStream a TLSException); Shutdown() never throws; Descriptor(), Protocol(), Secure(). Every call carries its own timeout. SetNonBlocking(fd) and PollDescriptor(fd, events, deadline) are exported for code of the same shape.
  • GetSelfSignedCertificatePem() is the process-wide development certificate selfSigned uses, as TLSCertificatePem { certificate, privateKey }: ECDSA P-256, CN=localhost, SANs localhost, 127.0.0.1, ::1, 10 days, generated in-process on first use and new on every process start. Hand .certificate to a client's caPem to test real verification; tests/ShouldSendRecieveHTTPS1 does. It is not the certificate the QUIC listener's selfSigned uses, see "QUIC".

TCP

ClientTCP(host, port, connectTimeout = 0ms) resolves with getaddrinfo for IPv4 and IPv6 and tries every address in the order it returned them, so localhost reaches a server on ::1 alone; zero waits out the kernel's retries per address (about two minutes to a host that never answers), a timeout throws SocketTimeoutException after that long per address, and when no address connects what the last one failed with is thrown. A failed resolve, socket(), connect, send or receive throws SocketErrorException, a std::system_error: code() is the errno under std::generic_category() or the getaddrinfo result under Crafter::ResolverCategory() (whose message() is gai_strerror's), and phase is the step (SocketPhase::Resolve, Open, Connect, Send, Receive); what() is the message ClientTCP always threw with the code's text after it, so a handler for std::runtime_error still catches it, and [Errno {}] {} is code().value() and code().message(). ClientTCP(int socket) adopts a descriptor. Move-only; the destructor shuts down and closes socketid, a public member. SetNoDelay(true) for small frames that must leave at once; SetReceiveTimeout(ms) makes a receive that waits longer throw SocketTimeoutException, losing what a whole-buffer receive had collected; Stop() closes; Connect(timeout) after it opens a new socket to the host and port the client was built from (not on one that adopted a descriptor, which recorded no address).

  • Send(buffer, size) writes it all. The receives return what arrived, at least one byte: RecieveSync() one chunk, RecieveSync(n) up to n, RecieveUntilFullSync(n) exactly n, RecieveUntilCloseSync() everything to the peer's close, RecieveSync(n, buffer) into your memory, returning the count. Asked for zero bytes they return at once. They throw SocketClosedException once the peer has closed, SocketTimeoutException on a receive timeout, SocketErrorException otherwise, and wait on through a signal.
  • The *Async receives (RecieveAsync, RecieveUntilCloseAsync, RecieveAsync(n, ...), RecieveUntilFullAsync(n, ...), and RecieveAsync(n, callback(count), buffer, ...) into memory that must outlive the call) run the same receive as a pool task and take an onError(std::exception_ptr): what the receive throws goes there, and an empty onError drops it, since an exception leaving a pool task ends the program. The task uses the ClientTCP, which must outlive it; a parked receive ends through Stop() from another thread or a receive timeout, both of which arrive at onError.

ListenerTCP(port, connectCallback, concurrentClientLimit, totalClientLimit) binds every IPv4 address with SO_REUSEADDR, backlog 128, and throws std::runtime_error when it cannot bind or listen; ListenerTCP(address, port, ...) binds that one address, IPv4 or IPv6, and port 0 lets the kernel pick, reported by Port(). The callback gets a heap-allocated ClientTCP* and owns it: delete it when the connection is done. ListenSyncSync() accepts on the calling thread and calls the callback there; ListenSyncAsync() calls it as a pool task; ListenAsyncSync() and ListenAsyncAsync() run the accept loop itself as a pool task, the second with the callback as another. Stop() closes the socket, which ends the loop. totalClientLimit ends it after that many accepts; concurrentClientLimit is stored and not enforced.

QUIC

ClientQUIC(host, port, alpn, QUICClientCredentials creds = {}) connects over IPv4 or IPv6 and blocks until the handshake is done, or throws QUICException once settings.handshakeIdleTimeoutMs (10 s) passes. alpn is any token both sides agree on ("h3", HTTP3::kAlpn, for HTTP/3). Move-only.

  • QUICClientCredentials: insecureNoServerValidation (accepts any certificate; development only), serverCertificateHash (32 bytes; unused natively, forwarded to serverCertificateHashes in the browser, where all zeros means the browser's trust store), settings. QUICServerCredentials: certPath/keyPath or selfSigned, and settings.
  • QUICSettings, applied to the msquic configuration: idleTimeoutMs 120 000, handshakeIdleTimeoutMs 10 000, keepAliveIntervalMs 10 000 (client only; keep it below the idle timeout), peerUnidiStreamCount 16 and peerBidiStreamCount 16 (how many streams the peer may have open toward you at once; an HTTP/3 server needs 3 unidirectional ones; each costs flow-control state, and a peer past the cap stalls, as tests/ShouldHonourQUICSettings shows), datagramReceiveEnabled true.
  • OpenStream(unidirectional = false) returns a QUICStream whose start msquic has queued (it starts asynchronously, whatever the interface comment says), or throws QUICException when it cannot be opened; GetStreamId() throws until the stream has started, so call it once the stream has carried data. SendDatagram(buffer, size) is best effort within the path MTU (about 1200 bytes on the open internet) and throws QUICException when msquic refuses it. OnStream(callback(QUICStream)) receives the streams the peer opens; streams that arrived before it was set are queued and delivered when it is, so setting it after the constructor returns is fine. OnDatagram(callback(std::vector<char>)); without one, datagrams queue for RecieveDatagramSync(), which blocks and throws QUICClosedException when the connection closes first. A datagram that arrives before OnDatagram is set goes to that queue, never to the callback: a server that sets OnDatagram in connectCallback can miss one sent right after the handshake (tests/ShouldSendRecieveQUICDatagram sleeps 200 ms for it). Both callbacks run as pool tasks. Stop() initiates a clean shutdown and returns. GetHandle() is the msquic HQUIC.
  • OnClosed(callback()) fires once when the connection has shut down for any reason but its own destruction: the peer closed it, the idle timeout or a transport error ended it, or Stop(). It runs on msquic's worker thread, after the handle is closed, and deleting this ClientQUIC from inside it is safe, which is what it is for; if the connection had already closed when OnClosed was called (the peer went before connectCallback ran), it runs as a pool task instead, never inside OnClosed. Inside it: nothing that blocks, no *Sync call, no deleting a different ClientQUIC (its destructor waits on msquic workers, this one perhaps among them), no throwing. std::stop_callback rules: once ~ClientQUIC has begun it never starts, and the destructor waits for a run already under way on another thread, so never destroy a ClientQUIC while holding a lock the callback takes, or the two deadlock. Calling it again replaces the callback, an empty one unregisters, and once it has fired later calls do nothing. Two owners deleting one ClientQUIC (the callback and a shutdown path) is still a double delete: keep peers in a map under a mutex, and let whoever removes one delete it, outside the lock.
  • QUICStream is move-only, with connection, canSend, canReceive (a unidirectional stream has one of them false) and the msquic handle. SendSync(buffer, size, finish = false) blocks until msquic has taken the buffer, throws QUICClosedException when the stream or connection was closed before that send completed and QUICException when msquic refused it outright; finish closes the send side after it (the peer's RecieveUntilCloseSync returns). RecieveSync() waits for at least one byte and throws QUICClosedException once the peer closed its side and the buffer is drained; RecieveUntilCloseSync(); RecieveUntilFullSync(n) takes exactly n or throws and leaves what arrived for the next receive. SendAsync(buffer, size, finish, onSent(bool)) holds no thread natively: it copies the buffer, hands the copy to msquic and returns, and msquic's SEND_COMPLETE fires onSent exactly once on the msquic worker, true once msquic took the whole buffer (buffered or acknowledged, not a receipt from the peer's application), false when the stream or connection closed first; sends queue in call order, from any thread and from inside callbacks, never waiting for one another; a send msquic never took (closed stream, moved-from wrapper) reports false from a pool task, never inside SendAsync; onSent must not block (no *Sync call: it would wait for the worker it runs on) or throw, and still fires after the wrapper is destroyed, whose Stop() lets the sends already made go out before the FIN. BytesInFlight() and SendsInFlight() count what SendAsync/SendSync handed msquic that has not completed: how far behind the peer is, so a server can drop a follower past a cap. OnRecieve(onData(std::span<const char>), onEnd(bool clean)) receives without a thread: onData gets each run of bytes as it arrives (the span is valid for the call only), onEnd fires once after the last of them, true for the peer's FIN, false for a reset by either side or a connection that went first. Bytes that arrived before the call are delivered by it, on the calling thread, before it returns (with the end, if that came too); afterwards the callbacks run on the msquic worker. No two callbacks of one stream, onSent included, ever run at once. Inside them: SendAsync, Abort, the in-flight counters, destroying or moving the wrapper; never a *Sync call (it waits for the worker it runs on), ~ClientQUIC, anything that blocks, or a throw. Destroying the wrapper or Stop() unregisters both, std::stop_callback-style: neither runs once it returns, it waits for one running on another thread (so never destroy a stream under a lock its callbacks take), and an onEnd not yet delivered never is. To reach the stream from its own callbacks, hold it in a shared_ptr and capture a weak_ptr. Once per stream (a second call throws QUICException), not mixed with the Recieve* calls, which then get nothing; a stream that cannot receive, or a closed one, gets onEnd(false) at once. Abort(errorCode = 0) resets both directions with a 62-bit application error code, from any thread or from inside onData (a byte cap): sends msquic has not taken yet report false (those already in its send buffer reported true and are discarded by the reset), no onData follows, onEnd(false) does; RecieveAsync, RecieveUntilCloseAsync and RecieveUntilFullAsync(n, ...) give a closed stream an empty chunk and an early close what arrived, never an exception. A default-constructed QUICStream behaves as closed. PrependReceived(bytes) pushes bytes back for the next receive (a demuxer that peeked a prefix); GetStreamId() throws QUICException before the stream has started; Stop() shuts both directions down (the send side with a FIN, the receive side abortively, else a peer holding its side open keeps the handle alive), completes asynchronously, and is what the destructor calls.
  • Lifetimes: ~ClientQUIC first destroys peer streams nobody claimed, then lets the streams already stopped finish (up to 1 s, a round trip when the peer answers), so a reply sent with SendAsync(..., finish = true) on a stream let go just before still arrives, then shuts the connection down, which aborts any stream still open, and closes it, waiting up to 5 s for every stream on it to finish closing and then up to 1 s (a round trip, when the peer answers) for the shutdown to complete, so the peer is sent the close and hears of it at once, not at its idle timeout. A connection whose peer is already gone, or one still in its handshake, can take that whole second. Streams may outlive their connection, dead; destroy or Stop() them first when you can. msquic has two process-wide registrations here, one for clients and one for listeners and the peers they accept, each closed by a static destructor at exit, and each close blocks until every connection and stream handle opened under it is closed: an accepted peer you never deleted holds the listener's until the idle timeout, two minutes by default. Delete or Stop() what you own before main returns, or end with std::_Exit after flushing, as the library's own tests do.

ListenerQUIC(port, alpn, QUICServerCredentials, connectCallback(ClientQUIC*), concurrentClientLimit, totalClientLimit) listens on every address of both families and throws QUICException when it cannot. ListenerQUIC(bindAddress, port, alpn, ...), same arguments after the address, listens on one: numeric only (127.0.0.1 or ::1 for this machine alone, 0.0.0.0 or :: for every address of one family; localhost throws QUICException). msquic still binds the UDP port on every address and drops, at its own layer, a connection sent to another one: the client fails within milliseconds with QUIC_STATUS_ALPN_NEG_FAILURE (0x5c) and connectCallback never hears of it. A listener on 127.0.0.1 is not reachable through ::1, so its clients dial 127.0.0.1, not localhost. Port() is the UDP port it bound: pass port 0 to let msquic pick a free one and read it back (still valid after Stop(), 0 on a moved-from listener). The callback's ClientQUIC* is heap-allocated and yours: set OnStream / OnDatagram on it, keep it as long as the connection should live, Stop() or delete it to shut the connection down, and delete it in any case, since only that frees it (a connection the peer or the idle timeout ended has its msquic handle closed for it, but the object stays): OnClosed says when, and peer->OnClosed([peer] { delete peer; }) in the callback is the whole of it for a server that keeps no other reference. ListenSyncSync() runs the accept loop on the calling thread and the callback there; ListenSyncAsync() runs the callback as a pool task; ListenAsyncSync() and ListenAsyncAsync() run the loop on a thread of their own, the second with the callback on the pool. Stop() stops accepting; the destructor stops, joins, and deletes peers accepted but not yet handed out. totalClientLimit ends the hand-off loop after that many connections (later ones queue until the listener is destroyed); concurrentClientLimit is stored and not enforced.

  • selfSigned serves one development pair per user, made in-process (nothing to install) and kept at $XDG_CACHE_HOME/crafter-network/quic-cert/{cert,key}.pem (else ~/.cache/..., else $XDG_RUNTIME_DIR/...; inside crafter-build's Flatpak XDG_CACHE_HOME is ~/.var/app/net.catcrafts.CrafterBuild/cache). The directory is made 0700 and refused, with QUICException, if another user owns it; with none of those variables set the constructor throws too. The pair is reused across restarts until it has less than a day of its 10 left, then the next process that needs it makes a new one, so a browser's pinned hash survives restarts but not that rollover; a process keeps the pair it loaded for all its listeners until then. Processes starting together share one pair (a lock covers check and replacement; the listener loads it from memory), and a pair that does not parse or match is replaced. ECDSA P-256, CN=localhost, SANs localhost, 127.0.0.1, ::1, with the extensions Chromium's serverCertificateHashes demands. GetSelfSignedCertificatePath() is that file, and ComputeCertificateHashSHA256(certPath) is the 32-byte SHA-256 of a PEM file's certificate, computed in-process (QUICException for a file it cannot read or parse), the digest a browser pins, see "Browser build". A different certificate from the TLS partition's: a client of both needs both.

HTTP/3 and WebTransport

ClientHTTP(host, port, QUICClientCredentials creds = {}) opens the QUIC connection in the constructor (ALPN h3, the mandatory control stream and SETTINGS, the server's control and QPACK streams drained) and throws QUICException when it cannot. Send(request) opens a request stream, sends HEADERS (an empty authority means host:port; names lowercased; scheme defaults to https) and a DATA frame for a non-empty body, FINs, reads to the server's FIN, and returns the status, the headers and the DATA frames concatenated; trailers are decoded and dropped. Throws QUICClosedException when the connection is gone before the request is sent, and HTTP3::HTTP3ProtocolError for a malformed response, which is also what a connection lost mid-response looks like (RecieveUntilCloseSync returns what arrived). SendAsync(request, onSuccess, onError) is Send as a pool task with the error text. One caller at a time per instance; instances are independent connections. It interoperates with public h3 servers through the system trust store (tests/ShouldSend fetches https://cloudflare-quic.com/): QPACK static table and Huffman decoding, no dynamic table.

ListenerHTTP(port, QUICServerCredentials, routes[, wtRoutes][, fallback]) is a ListenerQUIC with ALPN h3 that parses one request per bidirectional stream, dispatches as under "HTTP", and answers on the same stream. Listen() blocks the calling thread (connection setup, route handlers and WT handlers are pool tasks, and handlers for one connection run concurrently); Stop(). ListenerAsyncHTTP, same constructors, runs Listen() on its thread; Stop() joins and the destructor stops. A non-empty wtRoutes (std::unordered_map<std::string, std::function<void(WebTransportSession&)>>, keyed by :path) advertises both the draft-02 and the draft-07+ WebTransport SETTINGS, so current Chrome and Firefox connect (per the code's comments; no test drives a browser), and accepts an extended CONNECT (:method=CONNECT, :protocol=webtransport) whose :path matches an entry exactly, query string included, with a 200 and no FIN; a CONNECT matching nothing is a 404, and fallback does not apply to it. Plain routes and WT routes share the port.

WebTransportSession is handed by reference to the WT handler, as a pool task, and owned by the listener: the handler may return at once and the session lives on. Per-connection state is never pruned, so the sessions, streams and ClientQUIC of every connection the listener ever accepted live until the listener is destroyed (Stop() only stops accepting; the destructor then runs ~ClientQUIC per peer, each waiting up to 5 s for its streams). sessionId (the CONNECT stream's id), path; OpenStream(unidirectional = false) writes the WebTransport stream prefix first, so your first send is the first payload byte (unidirectional = true writes the unidirectional prefix; the interface comment saying it throws is stale); OnStream(callback(QUICStream)) for streams the browser opens, with the 0x41 prefix and the session id already stripped, so what you read is payload, and streams that arrived before the handler delivered when it is set; Close() FINs the CONNECT stream, which browsers take as the session's end, and so does the destructor. Not there yet: OnDatagram and SendDatagram are stubs (the second drops silently), streams the browser opens unidirectionally are drained by the listener and never reach a session, and there is no capsule protocol.

There is no WebTransport client class natively: a client is a ClientQUIC with ALPN h3 plus the HTTP3 helpers, as tests/ShouldEchoWebTransport builds one (the control-stream prelude from BuildWebTransportControlStreamPrelude(maxSessions), a CONNECT through EncodeFieldSection and WriteFrame, BuildWtBidiPrefix(sessionId) first on each data stream). The helpers: EncodeVarint/DecodeVarint, EncodeFieldSection(fields)/DecodeFieldSection(ptr, len), DecodeHuffman, WriteFrame(out, type, payload, len), BuildControlStreamPrelude(), BuildWtUnidiPrefix, the frame, stream-type and setting constants, and HTTP3ProtocolError. The comment at the top of Crafter.Network-HTTP3.cppm predates the export and the Huffman decoder; the code wins. Any ClientQUIC against an HTTP/3 server must drain the server's unidirectional streams (OnStream with a reader loop, as that test does), or they back up and msquic may abort the connection.

Browser build

On a wasm32-* target the same ClientHTTP and ClientQUIC compile against the browser, *Async only (the *Sync methods are not compiled), and everything else in the table above is left out of the build or compiled as an empty partition.

  • ClientHTTP::SendAsync is fetch(): the URL is scheme://authority/path with authority defaulting to host:port, or the bare path when the client was built with an empty host (ClientHTTP("", 0)), which fetches from the page's own origin; the body is sent for any method but GET and HEAD; the response's status is the number as a string, its headers as the browser lowercases them, its body the bytes. QUICClientCredentials is ignored, and CORS applies as to any fetch.
  • ClientQUIC(host, port, alpn, creds) returns at once and opens new WebTransport("https://host:port/alpn", { serverCertificateHashes }) in the background: alpn is the URL path here, so the server is a ListenerHTTP with a wtRoutes entry keyed /<alpn> (the example passes echo and registers /echo; a plain ListenerQUIC is unreachable from a browser), and the QUIC ALPN is the browser's h3. Calls before the connection is ready queue on the JS side. An all-zero serverCertificateHash means the browser's trust store, so a self-signed server needs its hash: the native side writes ComputeCertificateHashSHA256(GetSelfSignedCertificatePath()) somewhere the page can fetch it and the page feeds it in, as examples/SimpleClient does with cert-hash.txt. The *Async stream calls behave as natively: a closed stream gives an empty chunk, an early close what arrived, a failed write false. The rest differs: a stream the server opens before OnStream is set is closed, not queued; OpenStream on a closed or failed connection returns a default-constructed (closed) stream instead of throwing; SendDatagram drops silently instead of throwing; a datagram with no OnDatagram handler is dropped, there is no queue and no RecieveDatagramSync; and the constructor cannot report a failed connection, which shows up as those sentinels.
  • Callbacks run on the JS event loop after main has returned, so the objects they need must outlive main (static, or heap-allocated and left alone); a ClientQUIC or QUICStream destroyed at the end of main closes its connection or stream first. std::cout << std::unitbuf; so prints from callbacks reach the console. exit and _Exit end the program for good, but network-env.js does not notice and keeps calling into the instance when a fetch or a stream completes, after the static destructors have run: don't exit with a request or a stream pending.
  • The page needs a secure context (HTTPS) for WebTransport and a browser with WebAssembly exception handling (Chrome 137, Firefox 131, Safari 18.4 or newer; the generated page names what an older one lacks). The example's workflow: crafter-build --target=x86_64-pc-linux-gnu builds the native echo server, which writes cert-hash.txt into its working directory at startup; plain crafter-build builds the wasm, and cfg.files copies the tracked examples/SimpleClient/cert-hash.txt next to it, which is only a placeholder; the page fetches /cert-hash.txt from its own origin, so serve the wasm output directory over HTTPS and run the server with that directory as its working directory (crafter-build -r would run it from its own bin/ directory instead), then open the page.

Errors

Call On failure
ClientTCP constructor, Connect, Send, Recieve*Sync SocketErrorException (a std::system_error: a failed resolve, socket(), connect, send or receive, with phase and code()); SocketTimeoutException (connect or receive timeout); SocketClosedException (the peer closed)
ClientTCP::Recieve*Async the same, as an std::exception_ptr to onError; dropped without one
ListenerTCP, ListenerHTTP1, ListenerAsyncHTTP1 constructors std::runtime_error when the port cannot be bound; TLSException for unusable credentials
ListenerHTTP, ListenerAsyncHTTP constructors QUICException (they build a ListenerQUIC)
ClientHTTP1(host, port, TLSClientCredentials) constructor TLSException for an unreadable certificate or trust anchor, at construction rather than at the first Send
TLSContext::Server/Client, TLSStream::Connect/Accept TLSException, naming the certificate error
ByteStream::ReadSome / Write std::runtime_error; a read that times out is StreamStatus::TimedOut, a write that does throws
ClientHTTP1::Send HTTP1::HTTP1ProtocolError, TLSException, SocketErrorException (the socket's), SocketTimeoutException (the connect), std::runtime_error (timed out waiting for the response, connection closed before a complete response arrived)
ClientHTTP1::Fetch, URL::Parse, URL::Resolve std::invalid_argument for a URL that is not http(s), has no host or a bad port; Fetch otherwise what Send throws
ClientHTTP1::SendAsync, ClientHTTP::SendAsync what() as a string to onError
HTTP1::MessageParser, SerializeRequest/SerializeResponse HTTP1::HTTP1ProtocolError
ClientQUIC constructor, OpenStream, SendDatagram, GetStreamId, ListenerQUIC constructor QUICException (native; the browser build's ClientQUIC never throws, see "Browser build")
QUICStream::SendSync, Recieve*Sync, RecieveDatagramSync, WebTransportSession::OpenStream QUICClosedException (SendSync also QUICException for a send msquic refuses)
QUICStream::*Async, BytesInFlight, SendsInFlight never throw: an empty chunk, what arrived, or sent == false
ClientHTTP::Send QUICException, QUICClosedException, HTTP3::HTTP3ProtocolError
Route handlers and fallbacks an exception becomes a 500; a WT handler's exception is swallowed

SocketClosedException, SocketTimeoutException and QUICClosedException derive from std::exception, not std::runtime_error; SocketErrorException (through std::system_error), QUICException, TLSException, HTTP1ProtocolError and HTTP3ProtocolError do, so a catch (const std::runtime_error&) misses the first three.

Spellings

Recieve is how every receive is spelled: RecieveSync, RecieveUntilCloseSync, RecieveUntilFullSync, RecieveAsync, RecieveUntilCloseAsync, RecieveUntilFullAsync, RecieveDatagramSync, and the tests ShouldRecieveTCP and ShouldSendRecieve*. The listeners' loop variants are ListenSyncSync, ListenSyncAsync, ListenAsyncSync, ListenAsyncAsync: the accept loop first, then the callback. Don't correct them in a consumer; a rename is an upstream change for every consumer at once.

Testing a consumer

  • Start the pool first, and end a QUIC test with std::fflush(stdout); std::_Exit(code);, or delete every accepted peer and Stop() every client before main returns; otherwise the exit waits for the idle timeout.
  • crafter-build runs tests in parallel, so each test binds a port of its own. ListenerTCP and ListenerHTTP1 set SO_REUSEADDR, so a port in TIME_WAIT from the previous run is fine; one held by a stray server from a crashed run is Could not bind port. Simpler is no fixed port at all: ListenerTCP(address, 0, ...) and ListenerQUIC(0, ...) take whatever port is free and report it with Port().
  • QUICServerCredentials{ .selfSigned = true } against QUICClientCredentials{ .insecureNoServerValidation = true } is the local pair; TLSServerCredentials{ .selfSigned = true } against TLSClientCredentials{ .caPem = GetSelfSignedCertificatePem().certificate } exercises real verification. Neither needs anything installed.
  • Give a blocked reader (RecieveSync in a loop) a std::thread, not a pool task, and a test that could hang a watchdog thread that calls std::_Exit(1) sooner than the runner's 60 s timeout, as tests/ShouldNotDropEarlyStreams does at 8 s (the churn test's 90 s watchdog never fires first).