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 <noreply@anthropic.com>
This commit is contained in:
parent
5bc8d87e9b
commit
3be8642d32
3 changed files with 30 additions and 8 deletions
|
|
@ -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<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 `?`.
|
||||
|
|
@ -79,10 +81,11 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
|
|||
- **`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.
|
||||
- **`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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue