docs: agent notes for this repository and for its consumers

AGENTS.template.md holds the notes for working on Crafter.Network itself.
crafter-build 4.0.0 writes AGENTS.md from it on every build and test run,
with the notes of each dependency that ships some merged in, so AGENTS.md
is build output and gitignored. AGENTS.consumer.md holds the notes for
agents working in a project that depends on this library; crafter-build
merges it into that project's AGENTS.md, as the Crafter.Build README's
"Dependency notes for agents" describes.

Both were written from the interfaces, implementations, tests and
project.cpp rather than from the README. Where a comment in the sources
disagrees with the code (the HTTP3 header, the -fno-exceptions remarks,
"on the msquic worker", OpenStream(true) "throws", the QUIC certificate's
location and validity), the notes follow the code and list the comment
as a gotcha.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 19:22:49 +01:00
commit 3a58564422
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
3 changed files with 277 additions and 1 deletions

4
.gitignore vendored
View file

@ -1,2 +1,4 @@
build/
bin/
bin/
# written by every build and test run from AGENTS.template.md
/AGENTS.md

137
AGENTS.consumer.md Normal file
View file

@ -0,0 +1,137 @@
## 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::*`, `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 `#ifdef`s 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::SendAsync` and `Recieve*Async`, `ClientHTTP::SendAsync`, `ClientHTTP1::SendAsync`.
- `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.
### 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), `timeout` (30 s for the next piece of a response) and `handshakeTimeout` (15 s) 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, `std::runtime_error` for a socket that cannot connect, times out or closes early. There is no connect timeout: the dial is a `ClientTCP` with the default zero, so a host that never answers blocks `Send` for the kernel's two minutes before `handshakeTimeout` and `timeout` even start.
**`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`, IPv4 only, and connects; zero waits out the kernel's retries (about two minutes to a host that never answers), a timeout throws `SocketTimeoutException` after that long, and a refused connect throws `std::runtime_error` at once. `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, `std::runtime_error` 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. 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`.
- **`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` on a closed stream or connection and `QUICException` on a failed send; `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))` copies the buffer and tells the callback whether the stream took it; `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 shuts the connection down and closes it, waiting up to 5 s for every stream on it to finish closing. 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. 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). `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` shells out to the `openssl` CLI (install it, or pass `certPath`/`keyPath`; without it the constructor throws `QUICException`) and caches the pair at `/tmp/crafter-network-quic-cert/{cert,key}.pem`, reused across restarts while valid (inside crafter-build's Flatpak, `/tmp` is a per-app directory discarded once no instance is running, so there the pair rarely survives between runs; pass `certPath`/`keyPath` for a stable certificate): ECDSA P-256, `CN=localhost`, SANs `localhost`, `127.0.0.1`, `::1`, 10 days, with the extensions Chromium's `serverCertificateHashes` demands. `GetSelfSignedCertificatePath()` is that file, and `ComputeCertificateHashSHA256(certPath)` (also through `openssl`) is the 32-byte 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` | `std::runtime_error` (resolve, refused, socket errors); `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`, `std::runtime_error` (`timed out waiting for the response`, `connection closed before a complete response arrived`, the socket's) |
| `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` | 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`; `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`. For an ephemeral port, bind a socket yourself and read it back with `getsockname`, as `tests/ShouldConfigureTCP` does, then hand it to a `ClientTCP`.
- `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 except, for QUIC, the `openssl` CLI (the Flatpak has it).
- 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).

137
AGENTS.template.md Normal file
View file

@ -0,0 +1,137 @@
# AGENTS.md
Guidance for coding agents and contributors working on Crafter.Network itself. The [README](README.md) is the user-facing manual, and [AGENTS.consumer.md](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 fourteen partitions behind its primary interface (fifteen interface files), with TCP, 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 import`s 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` and `HTTP3` 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. `SocketClosedException`, `SocketTimeoutException`, 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.
- `: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.
- `: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, and the drain in `~ClientQUIC`.
- `:ListenerQUIC`: the msquic listener, its four `Listen*` variants, the self-signed certificate (made by the `openssl` CLI and cached under `/tmp/crafter-network-quic-cert/`), `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 pull`ed 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: the `openssl` CLI (the QUIC self-signed certificate and its hash; the TLS partition makes its own certificate in-process), `curl` and `python3` for the interop tests, 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, so the QUIC certificate cache under `/tmp` rarely survives between runs; 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
```bash
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: 23 tests
crafter-build test 'Should*HTTP1' # a name glob
crafter-build test --no-quic # the TCP, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 12
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, and `ShouldBuildWithoutQUIC` and `ShouldSend` bind none. Pick an unused one for a new test, or bind an ephemeral port yourself (`LoopbackListener` in `ShouldConfigureTCP`).
- `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` is the exception on purpose: that wait is its assertion, so it deletes everything and returns, 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` does. 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.** `QUICServerCredentials::selfSigned` is made by the `openssl` CLI and cached at `/tmp/crafter-network-quic-cert/`, reused across restarts while valid so a browser's pinned hash survives a restart, except inside the Flatpak, whose `/tmp` goes away with its last instance (see "Dev environment"); `TLSServerCredentials::selfSigned` is made in-process by `:TLS` and is new every start. `GetSelfSignedCertificatePath()` is the first, `GetSelfSignedCertificatePem()` the second.
- **`ComputeCertificateHashSHA256` and the QUIC certificate shell out to `openssl`** (`std::system`, `popen`) and throw `QUICException` without the CLI. `:TLS` could do both in-process; nobody has moved them yet.
- **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`.
- **`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**: `-ListenerQUIC.cpp` says the certificate pair lives in a `mkdtemp`'d directory for the life of the process (it is the fixed `/tmp/crafter-network-quic-cert/`, reused) and is valid 13 days (`-days 10`); `ClientQUIC.cppm` calls `selfSigned` an in-memory certificate (it is on disk, made by the CLI); `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.
- **TCP and HTTP/1.1 are IPv4 only** (`AF_INET` in `ClientTCP` and `ListenerTCP`); QUIC listens and connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`).
- **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.
- **`claude/tcp-only` carries four unmerged TCP and HTTP/1.1 commits** (IPv4 and IPv6 connects with the reason a connect failed, which retires the IPv4-only gotcha for the client; a bind address and port 0 for `ListenerTCP`; a bounded connect and `FetchHTTP1` following redirects; errno in the TCP errors), and `bin/` and `build/` keep its six test directories until `clean`.