The README's QUIC section gains how to own accepted connections: the OnClosed callback, the thread it runs on, what not to do in it and the std::stop_callback rule that makes deleting a connection under a lock its callback takes a deadlock. The consumer notes give the same contract with the map-under-a-mutex pattern for a server that also deletes on shutdown, list OnClosed among what runs on msquic's workers rather than the pool, say that ~ClientQUIC now waits up to 1 s so the peer hears the close, and that SendSync throws only for its own send. The agent notes count the three new tests, add OnClosed's thread and the destructor's new wait to the gotchas, and the forking tests and their alarm() watchdog to the testing conventions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
138 lines
30 KiB
Markdown
138 lines
30 KiB
Markdown
# 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 fifteen partitions behind its primary interface (sixteen interface files), with TCP, URLs, a byte-stream seam and a libssl TLS transport, HTTP/1.1 (client, listener and a transport-free codec), QUIC over msquic, HTTP/3 (client, listener and a wire-format partition) and WebTransport on the HTTP/3 listener. One source tree builds three libraries: native Linux (everything), native with `--no-quic` (TCP, TLS and HTTP/1.1, no msquic), and the browser (`wasm32-*`: the client half of HTTP and WebTransport over `fetch()` and the `WebTransport` API, `*Async` only). The only crafter-build dependency is Crafter.Thread, whose `ThreadPool` runs every callback and `*Async` call; msquic is an `ExternalDependency` built with CMake, and OpenSSL 3 is a system package.
|
|
|
|
| Path | What lives there |
|
|
|---|---|
|
|
| `interfaces/Crafter.Network.cppm` | The umbrella: `export 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`, `HTTP3` and `URL` have none: they are inline. |
|
|
| `implementations/Crafter.Network-ClientHTTP-Browser.cpp`, `-ClientQUIC-Browser.cpp` | The browser implementations of those two partitions, over the JS bridge. |
|
|
| `additional/network-env.js` | The JS bridge: `fetch()` and `WebTransport` behind the `env` imports the wasm declares, shipped next to a consumer's `.wasm` through `cfg.files`. |
|
|
| `project.cpp` | The build description: one static library, `crafter-network`, in its three variants, the msquic external, the OpenSSL link flags and the test declarations. |
|
|
| `tests/<Name>/main.cpp` | One test program per directory, declared in `project.cpp`. |
|
|
| `examples/SimpleClient/` | A wasm browser client and a native WebTransport echo server in one `project.cpp`, MIT licensed, meant to be copied. |
|
|
| `AGENTS.template.md`, `AGENTS.consumer.md` | The source of this file, and the notes for consumers. Every build and test run writes `AGENTS.md` from the template, with the `AGENTS.consumer.md` of each dependency that ships one merged in, and only when the text changes (`test --list`, `lint`, `format`, `run` and `clean` leave it alone); `AGENTS.md` is gitignored, so edit the template. Crafter.Thread ships none today, so there is no notes section, and a fresh clone has no `AGENTS.md` until its first build, when it gets the template under crafter-build's header line. |
|
|
| `build/`, `bin/` | Generated and gitignored, one directory per variant and per test; `build/test-logs/` keeps the output of failed tests. The example's `bin/` and `build/` match the same patterns. |
|
|
|
|
Partitions and what they own:
|
|
|
|
- `:ClientTCP`, `:ListenerTCP`: POSIX sockets, IPv4 and IPv6. `SocketClosedException`, `SocketTimeoutException`, `SocketErrorException` (a `std::system_error` with the `SocketPhase` that failed; `ResolverCategory()` for `getaddrinfo`'s codes), the receive semantics (what arrived, never a padded buffer) and the `onError` of the async receives. The listener hands its callback a `new ClientTCP` the callback owns.
|
|
- `:Stream`: `ByteStream` (`ReadSome` and `Write` with a timeout per call, `Shutdown`, `Descriptor`, `Protocol`, `Secure`) and `PlainStream`, so one HTTP/1.1 implementation serves `http://` and `https://`. `SetNonBlocking`, `PollDescriptor`.
|
|
- `:TLS`: `TLSContext`, `TLSStream`, both credential structs, `GetSelfSignedCertificatePem`. OpenSSL stays behind the `Impl` pointers; no OpenSSL header reaches an importer.
|
|
- `:HTTP`: `HTTPRequest`, `HTTPResponse`, the `Create*` helpers, `PathWithoutQueryHTTP`. Shared by both HTTP versions and by every build.
|
|
- `:URL`: `URL`, an absolute URL as RFC 3986 reads it (`Parse`), the resolution of a reference against it (`Resolve`, RFC 3986 §5.2, dot segments removed), the recomposition, and the pieces a request is built from. Inline, native only; what `ClientHTTP1::Fetch` follows a Location with.
|
|
- `:HTTP1`: the RFC 9112 codec, inline: `SerializeRequest`, `SerializeResponse`, `SerializeContinue`, `MessageParser`, `MessageLimits`, `HTTP1ProtocolError`, and the framing it refuses to guess at.
|
|
- `:ClientHTTP1`, `:ListenerHTTP1`: HTTP/1.1 over a `ByteStream&`. The listener is a `ListenerTCP` with a thread per accepted connection; the client keeps one pooled connection with a replay-once rule, and `ClientHTTP1::Fetch` is the URL fetch that follows redirects by RFC 9110 §15.4 with a client per hop (`FetchOptions`, `FetchResult`, `FetchStop`).
|
|
- `:ClientQUIC`: `ClientQUIC` and `QUICStream` for both ends of a connection, `QUICSettings`, both QUIC credential structs, `QUICException`, `QUICClosedException`. The native unit holds the process-wide `MsQuicRuntime` (API table and registration, closed by a static destructor at exit), the stream registry with its refcounted handles, a `SendRequest` per send carrying its own outcome, the `CloseNotice` behind `OnClosed`, and the drain in `~ClientQUIC` (streams, then the connection's own `SHUTDOWN_COMPLETE`).
|
|
- `:ListenerQUIC`: the msquic listener, its four `Listen*` variants, the self-signed certificate's on-disk cache (per user under `$XDG_CACHE_HOME/crafter-network/quic-cert/`, behind a lock; the OpenSSL work is module-internal functions of `:TLS`), `ComputeCertificateHashSHA256`, `GetSelfSignedCertificatePath`.
|
|
- `:HTTP3`: QUIC varints, HTTP/3 frames, the QPACK static table, Huffman decoding, the control-stream preludes and the WebTransport stream prefixes. Native only: it throws for protocol errors.
|
|
- `:ClientHTTP`, `:ListenerHTTP`: HTTP/3 on `:ClientQUIC` and `:ListenerQUIC`. The listener demuxes every bidirectional stream into an HTTP/3 request or a WebTransport data stream and accepts extended CONNECT when `wtRoutes` is non-empty.
|
|
- `:WebTransport`: `WebTransportSession`, the per-session handle a WT handler gets. Bidirectional streams and session close today; datagrams and capsules are stubs.
|
|
|
|
The variants are decided in `project.cpp`. Any `wasm32-*` target defines `CRAFTER_NETWORK_BROWSER`, compiles nine interfaces (the umbrella, `ClientTCP`, `ListenerTCP`, `ClientHTTP`, `ListenerHTTP`, `HTTP`, `ClientQUIC`, `ListenerQUIC`, `WebTransport`; all but the umbrella, `HTTP`, `ClientHTTP` and `ClientQUIC` empty) with the two `-Browser` units, ships `network-env.js`, and returns before msquic or OpenSSL come up. `--no-quic` defines `CRAFTER_NETWORK_NO_QUIC`, drops the six QUIC-side interfaces and their five units, and leaves msquic out, which is most of a fresh build. The flag is this project's own and is stripped from the args passed on to Crafter.Thread, so a consumer and this project load one Crafter.Thread rather than two with different args.
|
|
|
|
## Dev environment
|
|
|
|
crafter-build is the only tool, from its Flatpak (`alias crafter-build='flatpak run net.catcrafts.CrafterBuild'`; this machine has its `stable` and `beta` branches, and a build installed from a Crafter.Build checkout is branch `local`, chosen with `--branch=`). The host is Linux x86_64. A native build needs network access to `forgejo.catcrafts.net` for Crafter.Thread, which is unpinned and `git 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: `curl` and `python3` for the interop tests, the `openssl` CLI for the mutual-TLS half of `ShouldRequireClientCertificateHTTPS1` (both development certificates are made in-process), and outbound UDP/443 to `cloudflare-quic.com` for `ShouldSend`. The Flatpak sandbox has `openssl`, `curl`, `python3` and `wasmtime` and no `node`; its `/tmp` is a per-app directory under `$XDG_RUNTIME_DIR/.flatpak/net.catcrafts.CrafterBuild/tmp`, shared by instances running at the same time and discarded once none is; its `XDG_CACHE_HOME` is `~/.var/app/net.catcrafts.CrafterBuild/cache`, so the QUIC tests' certificate lives in `crafter-network/quic-cert/` there and survives between runs (delete the directory to start cold; `ShouldShareSelfSignedCertificateQUIC` points `XDG_CACHE_HOME` at a scratch directory of its own); and it sees the host filesystem but not the host's `PATH` (`/run/host/usr/bin` is the host's `/usr/bin`). The interop tests print `skipping the ... half` and pass when a tool is missing: a pass that tested less, so read the log.
|
|
|
|
Build outputs go to `bin/crafter-network-<target>-<march>-<mtune>-<hash>/` (the PCMs, `std.pcm` and `libcrafter-network.a`; the wasm variant also carries `network-env.js`) and `build/<the same>/`. The hash covers the variant, so the default build, `--no-quic` and the browser build keep separate directories, and `bin/` also holds one directory per test. The Flatpak's host triple is `x86_64-unknown-linux-gnu`; the README's `x86_64-pc-linux-gnu` default is a native crafter-build's, and `--target=` with either is the same build in another directory. `crafter-build clean` removes them all.
|
|
|
|
## Build, test and style commands
|
|
|
|
```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: 36 tests
|
|
crafter-build test 'Should*HTTP1' # a name glob
|
|
crafter-build test --no-quic # the TCP, URL, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 19
|
|
crafter-build test --list
|
|
crafter-build test --jobs=1 --timeout=120
|
|
crafter-build clean
|
|
```
|
|
|
|
A `--flag` crafter-build does not know is a project arg under any verb, so `--no-quic` and `--local` go anywhere before a `--`; under `test`, a bare word and everything after `--` are name globs, so `crafter-build test -- --no-quic` runs nothing. `project.cpp` registers no lint rules, so `crafter-build lint` and `format` exit 1 with "no rules"; style is checked by eye (see "Code style"). There is no CI. Before a change is done, `crafter-build test` and `crafter-build test --no-quic` pass, and `crafter-build --target=wasm32-wasip1` still builds when a `#ifndef CRAFTER_NETWORK_BROWSER` block, a browser unit or `network-env.js` was touched. Failed, crashed and timed-out tests write `build/test-logs/<Name>.log`; a later passing run does not delete them, so check the timestamp before trusting one.
|
|
|
|
## Testing instructions
|
|
|
|
Tests are declared, not discovered: one `cfg.AddTest("<Name>").Dependencies({ &cfg })` line in `project.cpp`, inside the block for Linux targets (the browser build runs no tests), in the `if (quic)` branch when the test needs msquic, or in its `else` when it only exists to show msquic stayed out (`ShouldBuildWithoutQUIC`). The source is `tests/<Name>/main.cpp`, linked against the local `libcrafter-network.a` with the variant's defines. A test is an `int main()`: 0 passes, nonzero fails, 77 skips (unused here; the interop tests skip a half by printing and passing). The runner gives each 60 s in its own process, in parallel.
|
|
|
|
Conventions the tests follow:
|
|
|
|
- Names are `Should<Verb><What>`; the `SendRecieve*` and `Interop*` families end in `HTTP1` or `HTTPS1` on the HTTP/1.1 side and `HTTP` or `QUIC` on the QUIC side, and `ShouldSend`, `ShouldNotDropEarlyStreams`, `ShouldFallbackUnknownRoutes` and `ShouldSurviveConnectionChurn` carry no suffix. Open `main.cpp` with a comment on the behaviour under test and the failure it guards against; `tests/ShouldRecieveTCP/main.cpp` and `tests/ShouldSurviveConnectionChurn/main.cpp` show the shape, and a regression test names the bug.
|
|
- A `Check(ok, what)` helper in an anonymous namespace counts failures and prints them (`PASS`/`FAIL` lines in the newer tests), and `main` returns nonzero at the end; the oldest QUIC tests return at the first failure. Do not stop at the first failure unless later steps are meaningless without it.
|
|
- Each test owns fixed ports, distinct across the suite because the runner is parallel: 8090 to 8097 and 8110 to 8114 for HTTP/1.1 listeners, 8082 to 8086 and 8098 for HTTP/3, 9181, 9182, 9187 and 9188 for QUIC, 19200 to 19224 for the churn test; the two TCP tests bind ephemeral ports, as do the QUIC tests written since `ListenerQUIC::Port()` (port 0, read back), and `ShouldBuildWithoutQUIC` and `ShouldSend` bind none. A new test binds port 0 and reads `Port()` (`ListenerTCP` and `ListenerQUIC` both have it); a fixed port is for when it cannot.
|
|
- `ThreadPool::Start()` first, for anything that enqueues: every `*Async` call, `ClientHTTP` (its constructor installs an `OnStream` drain that runs as pool tasks), `ListenerHTTP`, `ListenerQUIC`, and the `*Async`-dispatch `Listen*` variants of `ListenerTCP`; `ListenerHTTP1` and the sync TCP listeners need none, which is why ten tests never call it. `Start()` runs `hardware_concurrency() - 1` workers and `Start(1)` none at all (tasks then wait for a `JoinPool()` nobody calls), so don't use `Start(1)` to make callbacks deterministic.
|
|
- A QUIC test ends in `std::_Exit(code)`, after `std::fflush(stdout)` since `_Exit` flushes nothing (`ShouldReportClosedQUICStream` does; the older ones skip the flush and can lose their last line in the log): msquic's two registrations are closed by static destructors that block until every connection is torn down, and the peers a listener accepted are heap-allocated for the callback's owner, so returning from `main` waits for the idle timeout. `ShouldSurviveConnectionChurn` and `ShouldNotifyClosedConnectionQUIC` are the exceptions on purpose: that wait is their assertion, so they delete everything and return, the first under a watchdog thread that calls `std::_Exit(1)` after 90 s, which the runner's 60 s timeout beats; a watchdog meant to fire goes under 60 s, as the 8 s one in `ShouldNotDropEarlyStreams` and the 45 s one in `ShouldNotifyClosedConnectionQUIC` do. A test that forks (`ShouldNotifyClosedConnectionQUIC`, `ShouldShareSelfSignedCertificateQUIC`) does so before `ThreadPool::Start()` or anything QUIC, and a parent that must stay fork-safe uses `alarm()` as its watchdog. Any test that can hang gets one.
|
|
- Credentials: `QUICServerCredentials{ .selfSigned = true }` against `QUICClientCredentials{ .insecureNoServerValidation = true }`; for TLS, `TLSServerCredentials{ .selfSigned = true }` against a client given `GetSelfSignedCertificatePem().certificate` as `caPem`, so verification is exercised rather than skipped.
|
|
- A `ClientTCP` drives the wire where `ClientHTTP1` would hide the thing under test (pipelining in `ShouldSendRecieveHTTP1`, garbage and a stalled peer in `ShouldSurviveAbuseHTTP1`), and raw POSIX sockets (headers before the imports) where `ClientTCP` itself is under test (`ShouldRecieveTCP`, `ShouldConfigureTCP`, `ShouldBuildWithoutQUIC`).
|
|
- The outside world: `curl`, `python3 -m http.server` and the `openssl` CLI are probed with `command -v`, and a missing one skips that half with a line saying so. `ShouldSend` is the one test that needs the network.
|
|
|
|
A bug fix ships with a test that fails before it and passes after, in its own `test:` commit when it stands on its own; the `fix(quic)` commits in `git log` show the pairing.
|
|
|
|
## Code style
|
|
|
|
Nothing is enforced by tooling; match the file you are in.
|
|
|
|
- The first two lines of every source are exactly `//SPDX-License-Identifier: LGPL-3.0-only` and `//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®`, no space after `//`, then a blank line. The example is MIT in the same shape. (`Crafter.Network-ClientQUIC-Browser.cpp` has stray text after its copyright line; fix it when you touch the file.)
|
|
- Four-space indentation in new code. `Crafter.Network-ClientTCP.cppm`, `-ListenerTCP.cppm` and parts of `-ClientTCP.cpp` and `-ListenerTCP.cpp` are tab-indented from the first version; match the block you are in and do not reindent files, it buries the real diff. `Crafter.Network.cppm` and `Crafter.Network-ListenerTCP.cppm` are CRLF and every other source LF; an editor that normalises line endings rewrites the whole file, the same burying.
|
|
- `import std;` everywhere. C headers (`<sys/socket.h>`, `<msquic.h>`, `<openssl/*.h>`) go in the global module fragment after `module;`, and in an interface only under `#ifndef CRAFTER_NETWORK_BROWSER`, since the wasm sysroot has none of them. Tests `#include` POSIX headers before their imports the same way.
|
|
- `PascalCase` for types and functions, `camelCase` for variables and members, `k`-prefixed constants in the codecs (`HTTP3::kFrameHeaders`). Exceptions are `<Thing>Exception` or `<Codec>ProtocolError`. `std::println` for output in new code; some tests still use `std::cout`.
|
|
- Spellings that are API: `Recieve` in every receive method and test name, and the `ListenSyncSync` family. Consumers build against the default branch, so a rename breaks all of them at once: only on purpose, with the consumers updated in the same move, and never for a new name.
|
|
- Comments say why: the RFC section, the failure mode, the race, the rejected alternative. The codebase carries long comments on purpose (the header of `:HTTP1`, the stream registry in `-ClientQUIC.cpp`, `MakeBidiHandler` in `-ListenerHTTP.cpp`); do not strip them, and write them for new non-obvious code. Commit bodies follow the same rule.
|
|
- Errors are exceptions on the synchronous paths and never leave a pool task: an `*Async` call reports through its callback (`onError(std::exception_ptr)` on TCP, `bool sent` and the empty chunk on QUIC streams, an error string on the HTTP clients), because an exception escaping a `ThreadPool` task ends the program and the browser bridge has no exception channel from JS.
|
|
- Timeouts are per call on `ByteStream`, members on the HTTP/1.1 endpoints, `QUICSettings` on QUIC, and `std::chrono::milliseconds` everywhere. A new blocking path gets a bound.
|
|
|
|
## Design rules
|
|
|
|
Decided deliberately; change them only with the maintainer's say-so.
|
|
|
|
1. **One source, three builds.** Native, `--no-quic` and the browser are the same files, with `#ifndef CRAFTER_NETWORK_BROWSER` around whole partitions, `#ifndef CRAFTER_NETWORK_NO_QUIC` in the umbrella, and two `-Browser` implementation units. No per-platform directory and no host-side `#ifdef` beyond those two.
|
|
2. **The browser is a client, and async.** No listener, no raw TCP and no `*Sync` method there; the JS bridge drives `fetch()` and `WebTransport` and calls back through wasm exports. An API that cannot be made async does not join the shared surface.
|
|
3. **One request and response type, one route map, one dispatch order** across HTTP/1.1 and HTTP/3: exact path, query-stripped path, `fallback`, 404. A handler moves between the two by changing a class name, and `ShouldFallbackUnknownRoutes` replays one table over both to keep it so.
|
|
4. **`fallback` is a hook, not a pattern syntax.** Consumers bring their router; the library never grows a second route table that can disagree with it.
|
|
5. **Ambiguous HTTP/1.1 framing is rejected, not guessed** (RFC 9112 §11.2): conflicting lengths, a length with chunked, whitespace before a colon, CR or LF in a value to send. Compatibility never buys a parse that could smuggle.
|
|
6. **`https://` is a constructor argument, not a class.** The HTTP/1.1 code holds a `ByteStream&` and never learns whether it is a `PlainStream` or a `TLSStream`; `:TLS` is protocol-agnostic and keeps OpenSSL out of the interface.
|
|
7. **One OpenSSL in the process.** msquic builds against the system OpenSSL (3.5 or newer), not its bundled quictls, whose reading of the system `openssl.cnf` broke inside the Flatpak; `:TLS` links the same `libssl`. Where that OpenSSL is too old the answer is `--no-quic`, not a second TLS stack.
|
|
8. **msquic is pinned, Crafter.Thread is not.** Externals from outside Catcrafts get a `commit`; sibling Catcrafts repositories track their default branch on purpose.
|
|
9. **Receives return what arrived.** No padded buffers, no truncation, no exception for a signal, and a receive of zero bytes returns at once. The async variants deliver through callbacks and never throw out of the pool.
|
|
10. **The HTTP/1.1 listener runs a thread per connection**, not pool tasks: keep-alive connections are idle most of their life and would pin the pool.
|
|
11. **msquic handles are refcounted, and streams drain before their connection closes.** `QUICStream::Stop` initiates, the asynchronous `SHUTDOWN_COMPLETE` closes, `~ClientQUIC` waits (bounded) for the registry to empty. Do not short-circuit it: closing a connection with streams open is an msquic bugcheck, and a leaked handle hangs `RegistrationClose` at exit.
|
|
12. **This project's flags stay here.** `--no-quic` is stripped before the args go to Crafter.Thread, so every project in a consumer's graph loads Crafter.Thread with the same args.
|
|
|
|
## Commit and PR guidelines
|
|
|
|
- Branch off `master` (`claude/issue-<n>` for issue work, `claude/<topic>` or `fix/<topic>` otherwise) and open the PR against `master` on the project's Forgejo at `forgejo.catcrafts.net/Catcrafts/Crafter.Network`. `origin` is the HTTPS URL for fetch and push on this clone; Crafter.Build's notes say the server refuses HTTPS pushes, so set an SSH push URL first (`git remote set-url --push origin forgejo@forgejo.catcrafts.net:Catcrafts/Crafter.Network.git`), as the Crafter.Build, Math, Asset and Graphics clones have.
|
|
- Subject: `type(scope): imperative summary`, with `feat`, `fix`, `docs` or `test` and the scopes in use: `tcp`, `tls`, `https`, `http1`, `http`, `quic`, `listener`. Body: what was wrong, why, and why the fix has this shape, wrapped at about 75 columns, with the numbers when a race was measured (`git log` for `fix(quic)` shows the voice), and a `Breaking:` paragraph when a signature changes. Add a `Co-Authored-By:` trailer when an agent wrote it.
|
|
- Separate commits for the fix, its test and the README change when each stands on its own. Update the README in the same PR when user-visible behaviour changes, and `AGENTS.consumer.md` when a signature, a default or a rule consumers lean on changes; consumers pick the notes up on their next build.
|
|
- `crafter-build test` and `crafter-build test --no-quic` green before pushing, and the wasm build when the browser surface was touched. There is no CI.
|
|
- Never commit `bin/`, `build/`, `AGENTS.md` or the example's build outputs. `examples/SimpleClient/cert-hash.txt` is tracked because `cfg.files` needs it to exist; the native server overwrites it at runtime, and a changed hash in a diff is noise.
|
|
|
|
## Gotchas
|
|
|
|
- **`ThreadPool::Enqueue` before `ThreadPool::Start()` is a null dereference**, and most of the library enqueues: every `*Async`, every QUIC `OnStream` and `OnDatagram` delivery, `ClientHTTP` and `ListenerHTTP` entirely. The crash has no message; in a test it shows as 💥.
|
|
- **Two self-signed certificates, both made in-process by `:TLS`.** `QUICServerCredentials::selfSigned` is kept per user at `$XDG_CACHE_HOME/crafter-network/quic-cert/` (else `~/.cache/...`, else `$XDG_RUNTIME_DIR/...`), reused across restarts until a day before it expires so a browser's pinned hash survives a restart, and shared by processes that start together through a record lock on `lock` there; the listener hands msquic the pair as an in-memory PKCS#12, not the paths. `TLSServerCredentials::selfSigned` is new every start and carries `keyEncipherment` the QUIC one lacks. `GetSelfSignedCertificatePath()` is the first, `GetSelfSignedCertificatePem()` the second. To start the QUIC one cold, delete that directory.
|
|
- **`:TLS` declares module-internal functions** (`MakeWebTransportCertificatePem`, `CertificatePairExpiry`, `EncodePkcs12`, `CertificateSha256`) without `export`: `:ListenerQUIC` imports `:TLS` for them so libssl stays behind one partition, and a consumer never sees them.
|
|
- **msquic's registration is closed by a static destructor that waits for every handle.** A test that returns from `main` with an accepted peer alive waits two minutes, the idle timeout; the tests `_Exit`. The exception, `ShouldSurviveConnectionChurn`, exists because a leaked stream handle once hung that close forever (`fix(quic)` in `git log`, with the `SHUTDOWN_ON_FAIL` story).
|
|
- **Callbacks the interfaces say run "on the msquic worker" run as pool tasks**: `ClientQUIC::OnStream` and `OnDatagram` both enqueue. What arrives before a handler is set is queued: streams are delivered on `OnStream`; datagrams go to `RecieveDatagramSync`'s queue and never to a later `OnDatagram`. `OnClosed` is the exception: it does run on the worker, inside `SHUTDOWN_COMPLETE` after the handle is closed (a pool task only when set after the close), because deleting the connection from there is its purpose and must not depend on the pool.
|
|
- **`~ClientQUIC` waits for the connection's `SHUTDOWN_COMPLETE`, up to 1 s, before `ConnectionClose`.** msquic sends `CONNECTION_CLOSE` from a `FLUSH_SEND` it queues while processing `ConnectionShutdown` and ignores once `ShutdownComplete` is set; a close queued right behind the shutdown completed it silently, and the peer only timed the connection out (`fix(quic)` in `git log`, `ShouldTellPeerOfCloseQUIC`). So a destructor on an msquic worker thread (inside a stream callback) blocks that second and then some; only `OnClosed`'s, which has nothing left to close, is free.
|
|
- **`ListenerQUIC::ListenAsync*` run the accept loop on a `std::thread`**, not the pool as their comments say, and `ListenerHTTP::Listen` is `ListenSyncAsync` underneath, so a `ListenerHTTP`'s per-connection setup is a pool task. A saturated pool delays a new connection's SETTINGS; the early streams are buffered meanwhile (`ShouldNotDropEarlyStreams`).
|
|
- **A `ClientQUIC` against an HTTP/3 server must drain the server's unidirectional streams** (control, QPACK encoder and decoder) with an `OnStream` reader, or they back up and msquic may abort the connection. `ClientHTTP` does it; a hand-rolled WebTransport client (`ShouldEchoWebTransport`) does it itself.
|
|
- **`QUICSettings::peerUnidiStreamCount` is the first thing to raise** when a peer stalls opening streams; it was a hardcoded 16 until `ShouldHonourQUICSettings`.
|
|
- **The comments about `-fno-exceptions` in `project.cpp`, the umbrella and the browser unit are history.** crafter-build 4.0.0 compiles wasm with exceptions on and refuses `-fno-exceptions`; `:HTTP3` stays out of the browser build and the browser units still report through callbacks because the JS side has no exception channel, not because `throw` would not compile.
|
|
- **The header comment of `Crafter.Network-HTTP3.cppm` is stale**: the partition is exported from the umbrella natively and has a Huffman decoder. The README is right.
|
|
- **More comments the code outgrew**: `ListenerHTTP.cppm` says `Listen()` dispatches on the calling thread (it is `ListenSyncAsync`); the link-flag comment in `project.cpp` still has msquic bundling quictls (it links the system `libssl`). Fix a comment when you touch its code.
|
|
- **`WebTransportSession::OpenStream(true)` does not throw** as its comment says; it writes the unidirectional prefix. Peer-initiated unidirectional WebTransport streams are drained by the listener and never reach a session.
|
|
- **`ListenerTCP::concurrentClientLimit` is stored and never read**; only `totalClientLimit` ends the loop.
|
|
- **`ListenerTCP(port, ...)` listens on every IPv4 address**; given an address it binds that one, IPv4 or IPv6, and port 0 asks the kernel for one. `ClientTCP` resolves for both families and tries every address; QUIC connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`), and `ListenerQUIC(port, ...)` listens on both. `ListenerQUIC(address, port, ...)` listens on that address only, but msquic binds the dual-stack wildcard UDP socket for the port anyway and filters in `QuicBindingGetListener`: a connection to another local address is closed with `no_application_protocol` (the client sees `0x5c` in milliseconds), not ignored, and no other UDP socket can take that port on another address.
|
|
- **The Flatpak's host triple is `x86_64-unknown-linux-gnu`**, and the tests register on any Linux triple since the TCP-only commit; before it they never ran inside the Flatpak.
|
|
- **A port in use from a crashed run** is `Could not bind port <n>: Address already in use` on the HTTP/1.1 side and a `ListenerStart failed` `QUICException` on the QUIC side; find the stray process rather than changing the port.
|
|
- **A warning is printed only when its unit actually recompiles**; a build with nothing to do is silent and says nothing about warnings.
|