From 3be8642d329cea26ccddf7707d37e61e9f0ff0f8 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 02:20:04 +0100 Subject: [PATCH] docs(quic): OnClosed, and what ~ClientQUIC and SendSync now promise The README's QUIC section gains how to own accepted connections: the OnClosed callback, the thread it runs on, what not to do in it and the std::stop_callback rule that makes deleting a connection under a lock its callback takes a deadlock. The consumer notes give the same contract with the map-under-a-mutex pattern for a server that also deletes on shutdown, list OnClosed among what runs on msquic's workers rather than the pool, say that ~ClientQUIC now waits up to 1 s so the peer hears the close, and that SendSync throws only for its own send. The agent notes count the three new tests, add OnClosed's thread and the destructor's new wait to the gotchas, and the forking tests and their alarm() watchdog to the testing conventions. Co-Authored-By: Claude Opus 5.5 --- AGENTS.consumer.md | 9 ++++++--- AGENTS.template.md | 9 +++++---- README.md | 20 +++++++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/AGENTS.consumer.md b/AGENTS.consumer.md index 287c366..32e01e5 100644 --- a/AGENTS.consumer.md +++ b/AGENTS.consumer.md @@ -33,6 +33,8 @@ The Crafter engine's networking library: a C++ module, `import Crafter.Network;` What does not: the `*Sync` calls of `ClientTCP`, `QUICStream` and `ClientQUIC`, `ClientHTTP1::Send`, `ListenerHTTP1` (a thread per connection, so an idle keep-alive connection pins no worker), `ListenerTCP::ListenSyncSync`, `ListenerQUIC::ListenSyncSync`, the codecs and the TLS streams. A task that blocks for the life of a connection (a reader parked in `RecieveSync`) holds a worker that long: give such readers a `std::thread`, as the library's tests do. +Not the pool but msquic's own worker threads: `ClientQUIC::OnClosed` (except when set after the connection closed, then a pool task). Code there must not block either; see "QUIC". + ### 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` 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 `?`. @@ -79,10 +81,11 @@ A route map is `std::unordered_map))`; 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. +- **`OnClosed(callback())`** fires once when the connection has shut down for any reason but its own destruction: the peer closed it, the idle timeout or a transport error ended it, or `Stop()`. It runs on msquic's worker thread, after the handle is closed, and deleting this `ClientQUIC` from inside it is safe, which is what it is for; if the connection had already closed when `OnClosed` was called (the peer went before `connectCallback` ran), it runs as a pool task instead, never inside `OnClosed`. Inside it: nothing that blocks, no `*Sync` call, no deleting a *different* `ClientQUIC` (its destructor waits on msquic workers, this one perhaps among them), no throwing. `std::stop_callback` rules: once `~ClientQUIC` has begun it never starts, and the destructor waits for a run already under way on another thread, so never destroy a `ClientQUIC` while holding a lock the callback takes, or the two deadlock. Calling it again replaces the callback, an empty one unregisters, and once it has fired later calls do nothing. Two owners deleting one `ClientQUIC` (the callback and a shutdown path) is still a double delete: keep peers in a map under a mutex, and let whoever removes one delete it, outside the lock. +- **`QUICStream`** is move-only, with `connection`, `canSend`, `canReceive` (a unidirectional stream has one of them false) and the msquic `handle`. `SendSync(buffer, size, finish = false)` blocks until msquic has taken the buffer, throws `QUICClosedException` when the stream or connection was closed before that send completed and `QUICException` when msquic refused it outright; `finish` closes the send side after it (the peer's `RecieveUntilCloseSync` returns). `RecieveSync()` waits for at least one byte and throws `QUICClosedException` once the peer closed its side and the buffer is drained; `RecieveUntilCloseSync()`; `RecieveUntilFullSync(n)` takes exactly `n` or throws and leaves what arrived for the next receive. `SendAsync(buffer, size, finish, onSent(bool))` 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 and then up to 1 s (a round trip, when the peer answers) for the shutdown to complete, so the peer is sent the close and hears of it at once, not at its idle timeout. A connection whose peer is already gone, or one still in its handshake, can take that whole second. Streams may outlive their connection, dead; destroy or `Stop()` them first when you can. msquic has two process-wide registrations here, one for clients and one for listeners and the peers they accept, each closed by a static destructor at exit, and each close blocks until every connection and stream handle opened under it is closed: an accepted peer you never deleted holds the listener's until the idle timeout, two minutes by default. Delete or `Stop()` what you own before `main` returns, or end with `std::_Exit` after flushing, as the library's own tests do. -**`ListenerQUIC(port, alpn, QUICServerCredentials, connectCallback(ClientQUIC*), concurrentClientLimit, totalClientLimit)`** listens on every address of both families and throws `QUICException` when it cannot. **`ListenerQUIC(bindAddress, port, alpn, ...)`**, same arguments after the address, listens on one: numeric only (`127.0.0.1` or `::1` for this machine alone, `0.0.0.0` or `::` for every address of one family; `localhost` throws `QUICException`). msquic still binds the UDP port on every address and drops, at its own layer, a connection sent to another one: the client fails within milliseconds with `QUIC_STATUS_ALPN_NEG_FAILURE` (0x5c) and connectCallback never hears of it. A listener on `127.0.0.1` is not reachable through `::1`, so its clients dial `127.0.0.1`, not `localhost`. `Port()` is the UDP port it bound: pass port 0 to let msquic pick a free one and read it back (still valid after `Stop()`, 0 on a moved-from listener). The callback's `ClientQUIC*` is heap-allocated and yours: set `OnStream` / `OnDatagram` on it, keep it as long as the connection should live, `Stop()` or `delete` it to shut the connection down, and `delete` it in any case, since only that frees it (a connection the peer or the idle timeout ended has its msquic handle closed for it, but the object stays). `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. +**`ListenerQUIC(port, alpn, QUICServerCredentials, connectCallback(ClientQUIC*), concurrentClientLimit, totalClientLimit)`** listens on every address of both families and throws `QUICException` when it cannot. **`ListenerQUIC(bindAddress, port, alpn, ...)`**, same arguments after the address, listens on one: numeric only (`127.0.0.1` or `::1` for this machine alone, `0.0.0.0` or `::` for every address of one family; `localhost` throws `QUICException`). msquic still binds the UDP port on every address and drops, at its own layer, a connection sent to another one: the client fails within milliseconds with `QUIC_STATUS_ALPN_NEG_FAILURE` (0x5c) and connectCallback never hears of it. A listener on `127.0.0.1` is not reachable through `::1`, so its clients dial `127.0.0.1`, not `localhost`. `Port()` is the UDP port it bound: pass port 0 to let msquic pick a free one and read it back (still valid after `Stop()`, 0 on a moved-from listener). The callback's `ClientQUIC*` is heap-allocated and yours: set `OnStream` / `OnDatagram` on it, keep it as long as the connection should live, `Stop()` or `delete` it to shut the connection down, and `delete` it in any case, since only that frees it (a connection the peer or the idle timeout ended has its msquic handle closed for it, but the object stays): `OnClosed` says when, and `peer->OnClosed([peer] { delete peer; })` in the callback is the whole of it for a server that keeps no other reference. `ListenSyncSync()` runs the accept loop on the calling thread and the callback there; `ListenSyncAsync()` runs the callback as a pool task; `ListenAsyncSync()` and `ListenAsyncAsync()` run the loop on a thread of their own, the second with the callback on the pool. `Stop()` stops accepting; the destructor stops, joins, and deletes peers accepted but not yet handed out. `totalClientLimit` ends the hand-off loop after that many connections (later ones queue until the listener is destroyed); `concurrentClientLimit` is stored and not enforced. - `selfSigned` serves one development pair per user, made in-process (nothing to install) and kept at `$XDG_CACHE_HOME/crafter-network/quic-cert/{cert,key}.pem` (else `~/.cache/...`, else `$XDG_RUNTIME_DIR/...`; inside crafter-build's Flatpak `XDG_CACHE_HOME` is `~/.var/app/net.catcrafts.CrafterBuild/cache`). The directory is made 0700 and refused, with `QUICException`, if another user owns it; with none of those variables set the constructor throws too. The pair is reused across restarts until it has less than a day of its 10 left, then the next process that needs it makes a new one, so a browser's pinned hash survives restarts but not that rollover; a process keeps the pair it loaded for all its listeners until then. Processes starting together share one pair (a lock covers check and replacement; the listener loads it from memory), and a pair that does not parse or match is replaced. ECDSA P-256, `CN=localhost`, SANs `localhost`, `127.0.0.1`, `::1`, with the extensions Chromium's `serverCertificateHashes` demands. `GetSelfSignedCertificatePath()` is that file, and `ComputeCertificateHashSHA256(certPath)` is the 32-byte SHA-256 of a PEM file's certificate, computed in-process (`QUICException` for a file it cannot read or parse), the digest a browser pins, see "Browser build". A different certificate from the TLS partition's: a client of both needs both. diff --git a/AGENTS.template.md b/AGENTS.template.md index 4eee32b..88f25c2 100644 --- a/AGENTS.template.md +++ b/AGENTS.template.md @@ -28,7 +28,7 @@ Partitions and what they own: - `: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, and the drain in `~ClientQUIC`. +- `: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. @@ -51,7 +51,7 @@ crafter-build # native: TCP, TLS, HTTP/1.1, QUI 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: 33 tests +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 @@ -71,7 +71,7 @@ Conventions the tests follow: - 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` 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. +- 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. @@ -122,7 +122,8 @@ Decided deliberately; change them only with the maintainer's say-so. - **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`. +- **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`. diff --git a/README.md b/README.md index 6d36806..a0d6e08 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,21 @@ The port-only constructor listens on every address of both families. To keep a l Crafter::ListenerQUIC local("127.0.0.1", 0, "myproto/1", creds, onConnect); ``` +#### Owning accepted connections + +Each connection the listener accepts reaches the callback as a heap-allocated `ClientQUIC*` that the callback owns. `OnClosed` says when to delete it: it fires once when the connection has ended for any reason but its own destruction (the peer closed it, the idle timeout or a transport error ended it, or `Stop()` was called), on msquic's worker thread, and deleting the connection from inside it is safe. + +```cpp +Crafter::ListenerQUIC listener("127.0.0.1", 0, "myproto/1", creds, [](Crafter::ClientQUIC* peer) { + peer->OnClosed([peer] { delete peer; }); + peer->OnStream([](Crafter::QUICStream stream) { /* ... */ }); +}); +``` + +Keep the callback short: it runs on the msquic worker, so no `*Sync` call and no deleting a *different* connection from inside it. If the connection had already closed when `OnClosed` was called, it fires as a pool task instead, never inside `OnClosed`. Once `~ClientQUIC` has begun it never fires, and the destructor waits for one already running on another thread, as `std::stop_callback` does, so don't destroy a connection while holding a lock its callback takes. A client-side `ClientQUIC` has `OnClosed` too. + +`~ClientQUIC` waits, up to 1 s and about a round trip when the peer is there, for its close to reach the peer, so a server learns of a client that went away at once rather than at its idle timeout. + ### HTTP/3 Components HTTP/3 runs over QUIC, which always requires TLS. Pass server credentials when constructing the listener (or set `selfSigned = true` for a development-only ephemeral cert) and matching client credentials when constructing the client (`insecureNoServerValidation = true` for self-signed servers). @@ -342,7 +357,10 @@ The library includes tests covering: - QUIC listener ports (`ShouldReportListenerPortQUIC`) — a listener on port 0 reports the port it got and is reachable there, a second one gets another, and a fixed port reads back as itself - QUIC loopback listeners (`ShouldBindLoopbackQUIC`) — a listener on `127.0.0.1` serves loopback clients and refuses, at once, clients through the host's LAN address or `::1`; `::1` works too, and a name is refused - The QUIC development certificate (`ShouldShareSelfSignedCertificateQUIC`) — six processes start `selfSigned` listeners together from an empty cache, with no `openssl` on `PATH`, and all serve one certificate; its directory is private, a later process reuses it, and a mismatched pair on disk is replaced -- QUIC connection teardown (`ShouldSurviveConnectionChurn`) — 25 build-up/tear-down cycles with reader threads parked inside `RecieveSync`, the shape a reconnect loop has; the rest of the QUIC tests end in `std::_Exit(0)` and so never run a `ClientQUIC` destructor +- QUIC connection teardown (`ShouldSurviveConnectionChurn`) — 25 build-up/tear-down cycles with reader threads parked inside `RecieveSync`, the shape a reconnect loop has; most of the other QUIC tests end in `std::_Exit(0)` +- Closed-connection notices (`ShouldNotifyClosedConnectionQUIC`) — a server deletes each of 100 accepted connections from its `OnClosed` and the process then exits normally; a peer that exits or freezes, a server-side `Stop()`, a callback set after the close and an owner that deletes first each behave as documented +- The peer hears a close (`ShouldTellPeerOfCloseQUIC`) — the server side of each of 20 connections learns at once that the client destroyed its `ClientQUIC`, rather than at the idle timeout +- `SendSync` under a fast peer (`ShouldReturnFromSendSyncQUIC`) — 2000 sends to a peer that answers and finishes the stream at once all return - WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo - HTTP/1.1 wire format (`ShouldParseHTTP1`) — serialisation, incremental parsing fed one byte at a time, chunked bodies with trailers, pipelining, interim 1xx, HTTP/1.0 and HEAD framing, and the malformed inputs the parser is required to reject - HTTP/1.1 round-trip (`ShouldSendRecieveHTTP1`) — routing, query strings, HEAD, 404 and a throwing handler