docs(quic): SendAsync holds no thread; BytesInFlight and SendsInFlight
The README's QUIC section shows sending without a thread: what onSent's true and false mean, the thread it runs on and what that forbids, and BytesInFlight as the way to drop a follower that cannot keep up. The consumer notes give the full contract, take SendAsync out of the list of what goes through the pool (but for a send msquic never took) and into what runs on msquic's workers; the agent notes count the new test and add onSent to the callbacks that really do run on the worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
44706f29e7
commit
328561fa91
3 changed files with 19 additions and 6 deletions
|
|
@ -25,7 +25,7 @@ The Crafter engine's networking library: a C++ module, `import Crafter.Network;`
|
|||
|
||||
`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`.
|
||||
- `ClientTCP::Recieve*Async`, `QUICStream::Recieve*Async`, `ClientHTTP::SendAsync`, `ClientHTTP1::SendAsync`; `QUICStream::SendAsync` only to report a send that failed before msquic took it.
|
||||
- `ClientHTTP` as such, `Send` included: its constructor installs an `OnStream` drain, and every stream the server opens (its control and QPACK streams, right after the handshake) is delivered as a pool task.
|
||||
- `ClientQUIC::OnStream` and `OnDatagram` callbacks (each delivery is a pool task, whatever the interface comment says about the msquic worker) and `WebTransportSession::OnStream`.
|
||||
- `ListenerTCP::ListenSyncAsync`, `ListenAsyncSync`, `ListenAsyncAsync`; `ListenerQUIC::ListenSyncAsync` and `ListenAsyncAsync` for `connectCallback` (`ListenAsync*` run the accept loop on a thread of their own), and every stream a peer opens reaches `OnStream` through the pool, so a QUIC server needs it regardless.
|
||||
|
|
@ -33,7 +33,7 @@ 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".
|
||||
Not the pool but msquic's own worker threads: `ClientQUIC::OnClosed` (except when set after the connection closed, then a pool task) and the native `QUICStream::SendAsync` callback. Code there must not block either; see "QUIC".
|
||||
|
||||
### HTTP, shared by both versions
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ A route map is `std::unordered_map<std::string, std::function<HTTPResponse(const
|
|||
- **`QUICSettings`**, applied to the msquic configuration: `idleTimeoutMs` 120 000, `handshakeIdleTimeoutMs` 10 000, `keepAliveIntervalMs` 10 000 (client only; keep it below the idle timeout), `peerUnidiStreamCount` 16 and `peerBidiStreamCount` 16 (how many streams the *peer* may have open toward you at once; an HTTP/3 server needs 3 unidirectional ones; each costs flow-control state, and a peer past the cap stalls, as `tests/ShouldHonourQUICSettings` shows), `datagramReceiveEnabled` true.
|
||||
- `OpenStream(unidirectional = false)` returns a `QUICStream` whose start msquic has queued (it starts asynchronously, whatever the interface comment says), or throws `QUICException` when it cannot be opened; `GetStreamId()` throws until the stream has started, so call it once the stream has carried data. `SendDatagram(buffer, size)` is best effort within the path MTU (about 1200 bytes on the open internet) and throws `QUICException` when msquic refuses it. `OnStream(callback(QUICStream))` receives the streams the peer opens; streams that arrived before it was set are queued and delivered when it is, so setting it after the constructor returns is fine. `OnDatagram(callback(std::vector<char>))`; without one, datagrams queue for `RecieveDatagramSync()`, which blocks and throws `QUICClosedException` when the connection closes first. A datagram that arrives before `OnDatagram` is set goes to that queue, never to the callback: a server that sets `OnDatagram` in `connectCallback` can miss one sent right after the handshake (`tests/ShouldSendRecieveQUICDatagram` sleeps 200 ms for it). Both callbacks run as pool tasks. `Stop()` initiates a clean shutdown and returns. `GetHandle()` is the msquic `HQUIC`.
|
||||
- **`OnClosed(callback())`** fires once when the connection has shut down for any reason but its own destruction: the peer closed it, the idle timeout or a transport error ended it, or `Stop()`. It runs on msquic's worker thread, after the handle is closed, and deleting this `ClientQUIC` from inside it is safe, which is what it is for; if the connection had already closed when `OnClosed` was called (the peer went before `connectCallback` ran), it runs as a pool task instead, never inside `OnClosed`. Inside it: nothing that blocks, no `*Sync` call, no deleting a *different* `ClientQUIC` (its destructor waits on msquic workers, this one perhaps among them), no throwing. `std::stop_callback` rules: once `~ClientQUIC` has begun it never starts, and the destructor waits for a run already under way on another thread, so never destroy a `ClientQUIC` while holding a lock the callback takes, or the two deadlock. Calling it again replaces the callback, an empty one unregisters, and once it has fired later calls do nothing. Two owners deleting one `ClientQUIC` (the callback and a shutdown path) is still a double delete: keep peers in a map under a mutex, and let whoever removes one delete it, outside the lock.
|
||||
- **`QUICStream`** is move-only, with `connection`, `canSend`, `canReceive` (a unidirectional stream has one of them false) and the msquic `handle`. `SendSync(buffer, size, finish = false)` blocks until msquic has taken the buffer, throws `QUICClosedException` when the stream or connection was closed before that send completed and `QUICException` when msquic refused it outright; `finish` closes the send side after it (the peer's `RecieveUntilCloseSync` returns). `RecieveSync()` waits for at least one byte and throws `QUICClosedException` once the peer closed its side and the buffer is drained; `RecieveUntilCloseSync()`; `RecieveUntilFullSync(n)` takes exactly `n` or throws and leaves what arrived for the next receive. `SendAsync(buffer, size, finish, onSent(bool))` 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.
|
||||
- **`QUICStream`** is move-only, with `connection`, `canSend`, `canReceive` (a unidirectional stream has one of them false) and the msquic `handle`. `SendSync(buffer, size, finish = false)` blocks until msquic has taken the buffer, throws `QUICClosedException` when the stream or connection was closed before that send completed and `QUICException` when msquic refused it outright; `finish` closes the send side after it (the peer's `RecieveUntilCloseSync` returns). `RecieveSync()` waits for at least one byte and throws `QUICClosedException` once the peer closed its side and the buffer is drained; `RecieveUntilCloseSync()`; `RecieveUntilFullSync(n)` takes exactly `n` or throws and leaves what arrived for the next receive. `SendAsync(buffer, size, finish, onSent(bool))` holds no thread natively: it copies the buffer, hands the copy to msquic and returns, and msquic's `SEND_COMPLETE` fires `onSent` exactly once on the msquic worker, `true` once msquic took the whole buffer (buffered or acknowledged, not a receipt from the peer's application), `false` when the stream or connection closed first; sends queue in call order, from any thread and from inside callbacks, never waiting for one another; a send msquic never took (closed stream, moved-from wrapper) reports `false` from a pool task, never inside `SendAsync`; `onSent` must not block (no `*Sync` call: it would wait for the worker it runs on) or throw, and still fires after the wrapper is destroyed, whose `Stop()` lets the sends already made go out before the FIN. `BytesInFlight()` and `SendsInFlight()` count what `SendAsync`/`SendSync` handed msquic that has not completed: how far behind the peer is, so a server can drop a follower past a cap; `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): `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.
|
||||
|
|
@ -125,7 +125,7 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
|||
| `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` |
|
||||
| `QUICStream::*Async`, `BytesInFlight`, `SendsInFlight` | never throw: an empty chunk, what arrived, or `sent == false` |
|
||||
| `ClientHTTP::Send` | `QUICException`, `QUICClosedException`, `HTTP3::HTTP3ProtocolError` |
|
||||
| Route handlers and fallbacks | an exception becomes a 500; a WT handler's exception is swallowed |
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 36 tests
|
||||
crafter-build test # the suite, native: 37 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
|
||||
|
|
@ -122,7 +122,7 @@ 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`. `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.
|
||||
- **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` and the native `QUICStream::SendAsync` callback are the exceptions: they do run on the worker, `OnClosed` inside `SHUTDOWN_COMPLETE` after the handle is closed (a pool task only when set after the close), `onSent` inside the send's `SEND_COMPLETE` (a pool task only for a send msquic never took), so that neither depends on a pool the host shares.
|
||||
- **`~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.
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -123,6 +123,18 @@ Keep the callback short: it runs on the msquic worker, so no `*Sync` call and no
|
|||
|
||||
`~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.
|
||||
|
||||
#### Sending without a thread
|
||||
|
||||
`QUICStream::SendAsync` copies the buffer, hands it to msquic and returns; nothing waits for msquic to take it. Its callback fires exactly once, on msquic's worker thread when msquic is done with the copy: `true` once msquic has taken the whole buffer, `false` when the stream or the connection closed first. Sends queue in call order, from any thread and from inside callbacks, and `BytesInFlight()` / `SendsInFlight()` say how far behind a slow peer is:
|
||||
|
||||
```cpp
|
||||
stream.SendAsync(line.data(), static_cast<std::uint32_t>(line.size()), /*finish=*/false,
|
||||
[](bool sent) { if (!sent) { /* the peer is gone */ } });
|
||||
if (stream.BytesInFlight() > 4 * 1024 * 1024) stream.Stop(); // a follower that cannot keep up
|
||||
```
|
||||
|
||||
The callback runs on msquic's worker, so it must not block (no `*Sync` call) or throw. A send that fails before msquic takes it, on a closed stream for one, reports from a pool task instead, never from inside `SendAsync`. Sends made before the wrapper is destroyed still go out and report. `SendSync` is the blocking form, and throws only when its own send was canceled.
|
||||
|
||||
### 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).
|
||||
|
|
@ -361,6 +373,7 @@ The library includes tests covering:
|
|||
- 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
|
||||
- Sending without a thread (`ShouldSendWithoutBlockingQUIC`) — a 16 MiB burst of `SendAsync` returns at once and completes in order; a chain of sends from inside callbacks completes with every pool worker parked; a stopped connection, a destroyed wrapper, concurrent senders and a closed stream each report as documented
|
||||
- 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue