Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
79a2f12629 |
|||
|
4876ff14a8 |
|||
|
9ea07c54f1 |
|||
|
f346237944 |
|||
|
f29c4a83d2 |
|||
|
bdca27b871 |
|||
|
c5b4e18eb8 |
|||
|
12b15ceea2 |
|||
|
328561fa91 |
|||
|
44706f29e7 |
|||
|
1b7417c2ba |
|||
|
3be8642d32 |
|||
|
5bc8d87e9b |
|||
|
f0769838e5 |
|||
|
be55036b18 |
|||
|
b1b0633a27 |
|||
|
793b518718 |
|||
|
795f06178d |
|||
|
76e927595d |
|||
|
6b8b485e38 |
|||
|
61d3e8c26a |
|||
|
5068f0acd7 |
|||
|
a50e3f415c |
|||
|
bffb757ab7 |
|||
|
fdb0ac2524 |
|||
|
3457ff08ce |
|||
|
7dace8bb03 |
19 changed files with 3468 additions and 260 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:
|
`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.
|
- `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`.
|
- `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.
|
- `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,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.
|
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), the native `QUICStream::SendAsync` callback, and `QUICStream::OnRecieve`'s callbacks (except the bytes waiting when it is called, which it delivers on the calling thread). Code there must not block either; see "QUIC". These are the way to serve a stream without holding a thread.
|
||||||
|
|
||||||
### HTTP, shared by both versions
|
### 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 `?`.
|
`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,12 +81,13 @@ 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`.
|
- **`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.
|
- **`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`.
|
- `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.
|
- **`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.
|
||||||
- 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.
|
- **`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. **`OnRecieve(onData(std::span<const char>), onEnd(bool clean))`** receives without a thread: `onData` gets each run of bytes as it arrives (the span is valid for the call only), `onEnd` fires once after the last of them, `true` for the peer's FIN, `false` for a reset by either side or a connection that went first. Bytes that arrived before the call are delivered by it, on the calling thread, before it returns (with the end, if that came too); afterwards the callbacks run on the msquic worker. No two callbacks of one stream, `onSent` included, ever run at once. Inside them: `SendAsync`, `Abort`, the in-flight counters, destroying or moving the wrapper; never a `*Sync` call (it waits for the worker it runs on), `~ClientQUIC`, anything that blocks, or a throw. Destroying the wrapper or `Stop()` unregisters both, `std::stop_callback`-style: neither runs once it returns, it waits for one running on another thread (so never destroy a stream under a lock its callbacks take), and an `onEnd` not yet delivered never is. To reach the stream from its own callbacks, hold it in a `shared_ptr` and capture a `weak_ptr`. Once per stream (a second call throws `QUICException`), not mixed with the `Recieve*` calls, which then get nothing; a stream that cannot receive, or a closed one, gets `onEnd(false)` at once. **`Abort(errorCode = 0)`** resets both directions with a 62-bit application error code, from any thread or from inside `onData` (a byte cap): sends msquic has not taken yet report `false` (those already in its send buffer reported `true` and are discarded by the reset), no `onData` follows, `onEnd(false)` does; `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 lets the streams already stopped finish (up to 1 s, a round trip when the peer answers), so a reply sent with `SendAsync(..., finish = true)` on a stream let go just before still arrives, then shuts the connection down, which aborts any stream still open, 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. 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` shells out to the `openssl` CLI (install it, or pass `certPath`/`keyPath`; without it the constructor throws `QUICException`) and caches the pair at `/tmp/crafter-network-quic-cert/{cert,key}.pem`, reused across restarts while valid (inside crafter-build's Flatpak, `/tmp` is a per-app directory discarded once no instance is running, so there the pair rarely survives between runs; pass `certPath`/`keyPath` for a stable certificate): ECDSA P-256, `CN=localhost`, SANs `localhost`, `127.0.0.1`, `::1`, 10 days, with the extensions Chromium's `serverCertificateHashes` demands. `GetSelfSignedCertificatePath()` is that file, and `ComputeCertificateHashSHA256(certPath)` (also through `openssl`) is the 32-byte digest a browser pins, see "Browser build". A different certificate from the TLS partition's: a client of both needs both.
|
- `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.
|
||||||
|
|
||||||
### HTTP/3 and WebTransport
|
### HTTP/3 and WebTransport
|
||||||
|
|
||||||
|
|
@ -122,7 +125,7 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
||||||
| `HTTP1::MessageParser`, `SerializeRequest`/`SerializeResponse` | `HTTP1::HTTP1ProtocolError` |
|
| `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") |
|
| `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::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` |
|
| `ClientHTTP::Send` | `QUICException`, `QUICClosedException`, `HTTP3::HTTP3ProtocolError` |
|
||||||
| Route handlers and fallbacks | an exception becomes a 500; a WT handler's exception is swallowed |
|
| Route handlers and fallbacks | an exception becomes a 500; a WT handler's exception is swallowed |
|
||||||
|
|
||||||
|
|
@ -135,6 +138,6 @@ On a `wasm32-*` target the same `ClientHTTP` and `ClientQUIC` compile against th
|
||||||
### Testing a consumer
|
### Testing a consumer
|
||||||
|
|
||||||
- Start the pool first, and end a QUIC test with `std::fflush(stdout); std::_Exit(code);`, or delete every accepted peer and `Stop()` every client before `main` returns; otherwise the exit waits for the idle timeout.
|
- Start the pool first, and end a QUIC test with `std::fflush(stdout); std::_Exit(code);`, or delete every accepted peer and `Stop()` every client before `main` returns; otherwise the exit waits for the idle timeout.
|
||||||
- crafter-build runs tests in parallel, so each test binds a port of its own. `ListenerTCP` and `ListenerHTTP1` set `SO_REUSEADDR`, so a port in `TIME_WAIT` from the previous run is fine; one held by a stray server from a crashed run is `Could not bind port`. For an ephemeral port, bind a socket yourself and read it back with `getsockname`, as `tests/ShouldConfigureTCP` does, then hand it to a `ClientTCP`.
|
- crafter-build runs tests in parallel, so each test binds a port of its own. `ListenerTCP` and `ListenerHTTP1` set `SO_REUSEADDR`, so a port in `TIME_WAIT` from the previous run is fine; one held by a stray server from a crashed run is `Could not bind port`. Simpler is no fixed port at all: `ListenerTCP(address, 0, ...)` and `ListenerQUIC(0, ...)` take whatever port is free and report it with `Port()`.
|
||||||
- `QUICServerCredentials{ .selfSigned = true }` against `QUICClientCredentials{ .insecureNoServerValidation = true }` is the local pair; `TLSServerCredentials{ .selfSigned = true }` against `TLSClientCredentials{ .caPem = GetSelfSignedCertificatePem().certificate }` exercises real verification. Neither needs anything installed except, for QUIC, the `openssl` CLI (the Flatpak has it).
|
- `QUICServerCredentials{ .selfSigned = true }` against `QUICClientCredentials{ .insecureNoServerValidation = true }` is the local pair; `TLSServerCredentials{ .selfSigned = true }` against `TLSClientCredentials{ .caPem = GetSelfSignedCertificatePem().certificate }` exercises real verification. Neither needs anything installed.
|
||||||
- Give a blocked reader (`RecieveSync` in a loop) a `std::thread`, not a pool task, and a test that could hang a watchdog thread that calls `std::_Exit(1)` sooner than the runner's 60 s timeout, as `tests/ShouldNotDropEarlyStreams` does at 8 s (the churn test's 90 s watchdog never fires first).
|
- Give a blocked reader (`RecieveSync` in a loop) a `std::thread`, not a pool task, and a test that could hang a watchdog thread that calls `std::_Exit(1)` sooner than the runner's 60 s timeout, as `tests/ShouldNotDropEarlyStreams` does at 8 s (the churn test's 90 s watchdog never fires first).
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ 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.
|
- `: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.
|
- `: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`).
|
- `: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 per-stream delivery of `OnRecieve`'s and `SendAsync`'s callbacks (whichever thread finds nobody delivering runs what is ready; the rest queue and return), the `CloseNotice` behind `OnClosed`, and the drain in `~ClientQUIC` (the streams already stopped, then every stream, then the connection's own `SHUTDOWN_COMPLETE`).
|
||||||
- `:ListenerQUIC`: the msquic listener, its four `Listen*` variants, the self-signed certificate (made by the `openssl` CLI and cached under `/tmp/crafter-network-quic-cert/`), `ComputeCertificateHashSHA256`, `GetSelfSignedCertificatePath`.
|
- `: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.
|
- `: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.
|
- `: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.
|
- `:WebTransport`: `WebTransportSession`, the per-session handle a WT handler gets. Bidirectional streams and session close today; datagrams and capsules are stubs.
|
||||||
|
|
@ -40,7 +40,7 @@ The variants are decided in `project.cpp`. Any `wasm32-*` target defines `CRAFTE
|
||||||
|
|
||||||
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.
|
crafter-build is the only tool, from its Flatpak (`alias crafter-build='flatpak run net.catcrafts.CrafterBuild'`; this machine has its `stable` and `beta` branches, and a build installed from a Crafter.Build checkout is branch `local`, chosen with `--branch=`). The host is Linux x86_64. A native build needs network access to `forgejo.catcrafts.net` for Crafter.Thread, which is unpinned and `git pull`ed by every verb that loads `project.cpp`, `test --list`, `lint` and `format` included (`--local` takes `../Crafter.Thread` instead, and is forwarded so Crafter.Thread resolves the same way), and to `github.com` once for msquic, pinned to v2.6.3 (commit `16ca4d8d`) with its submodules and built by CMake into `~/.var/app/net.catcrafts.CrafterBuild/cache/crafter.build/external/msquic-<hash>/` (a native crafter-build uses `~/.cache/crafter.build/`): several minutes the first time, then shared by every project with the same spec. msquic is configured with `QUIC_TLS_LIB=openssl` against the OpenSSL the sandbox provides, 3.5 or newer (the Flatpak runtime has 3.5.9), as a shared `libmsquic.so` that the test binaries and consumers find through an absolute runpath into that cache (a directory named after msquic's spec hash, which `crafter-build clean` never touches). msquic links `libnuma` when the build host has it: a native crafter-build on Arch does, the Flatpak does not.
|
||||||
|
|
||||||
What the suite needs beyond that: the `openssl` CLI (the QUIC self-signed certificate and its hash; the TLS partition makes its own certificate in-process), `curl` and `python3` for the interop tests, and outbound UDP/443 to `cloudflare-quic.com` for `ShouldSend`. The Flatpak sandbox has `openssl`, `curl`, `python3` and `wasmtime` and no `node`; its `/tmp` is a per-app directory under `$XDG_RUNTIME_DIR/.flatpak/net.catcrafts.CrafterBuild/tmp`, shared by instances running at the same time and discarded once none is, so the QUIC certificate cache under `/tmp` rarely survives between runs; and it sees the host filesystem but not the host's `PATH` (`/run/host/usr/bin` is the host's `/usr/bin`). The interop tests print `skipping the ... half` and pass when a tool is missing: a pass that tested less, so read the log.
|
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 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.
|
||||||
|
|
||||||
|
|
@ -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 --no-quic # native without QUIC, HTTP/3, WebTransport and msquic
|
||||||
crafter-build --local # Crafter.Thread from ../Crafter.Thread
|
crafter-build --local # Crafter.Thread from ../Crafter.Thread
|
||||||
crafter-build --target=wasm32-wasip1 # the browser library
|
crafter-build --target=wasm32-wasip1 # the browser library
|
||||||
crafter-build test # the suite, native: 30 tests
|
crafter-build test # the suite, native: 39 tests
|
||||||
crafter-build test 'Should*HTTP1' # a name glob
|
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 --no-quic # the TCP, URL, TLS and HTTP/1.1 tests plus ShouldBuildWithoutQUIC: 19
|
||||||
crafter-build test --list
|
crafter-build test --list
|
||||||
|
|
@ -69,9 +69,9 @@ 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.
|
- 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.
|
- A `Check(ok, what)` helper in an anonymous namespace counts failures and prints them (`PASS`/`FAIL` lines in the newer tests), and `main` returns nonzero at the end; the oldest QUIC tests return at the first failure. Do not stop at the first failure unless later steps are meaningless without it.
|
||||||
- Each test owns fixed ports, distinct across the suite because the runner is parallel: 8090 to 8097 and 8110 to 8114 for HTTP/1.1 listeners, 8082 to 8086 and 8098 for HTTP/3, 9181, 9182, 9187 and 9188 for QUIC, 19200 to 19224 for the churn test; the two TCP tests bind ephemeral ports, and `ShouldBuildWithoutQUIC` and `ShouldSend` bind none. Pick an unused one for a new test, or bind an ephemeral port yourself (`LoopbackListener` in `ShouldConfigureTCP`).
|
- 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.
|
- `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.
|
- 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`).
|
- 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.
|
- 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.
|
||||||
|
|
@ -119,19 +119,21 @@ Decided deliberately; change them only with the maintainer's say-so.
|
||||||
## Gotchas
|
## 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 💥.
|
- **`ThreadPool::Enqueue` before `ThreadPool::Start()` is a null dereference**, and most of the library enqueues: every `*Async`, every QUIC `OnStream` and `OnDatagram` delivery, `ClientHTTP` and `ListenerHTTP` entirely. The crash has no message; in a test it shows as 💥.
|
||||||
- **Two self-signed certificates.** `QUICServerCredentials::selfSigned` is made by the `openssl` CLI and cached at `/tmp/crafter-network-quic-cert/`, reused across restarts while valid so a browser's pinned hash survives a restart, except inside the Flatpak, whose `/tmp` goes away with its last instance (see "Dev environment"); `TLSServerCredentials::selfSigned` is made in-process by `:TLS` and is new every start. `GetSelfSignedCertificatePath()` is the first, `GetSelfSignedCertificatePem()` the second.
|
- **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.
|
||||||
- **`ComputeCertificateHashSHA256` and the QUIC certificate shell out to `openssl`** (`std::system`, `popen`) and throw `QUICException` without the CLI. `:TLS` could do both in-process; nobody has moved them yet.
|
- **`: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).
|
- **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`, the native `QUICStream::SendAsync` callback and `OnRecieve`'s callbacks 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), `onData`/`onEnd` inside `RECEIVE` and the shutdown events (on the caller's thread for what `OnRecieve` finds waiting), so that none depends on a pool the host shares. A blocking call inside any of them stalls every connection on that worker.
|
||||||
|
- **`~ClientQUIC` lets the streams already stopped finish, up to 1 s, before `ConnectionShutdown`,** which aborts every stream and whatever msquic had not sent of it: a server that replied with `SendAsync(..., finish = true)`, let the stream go and deleted the connection lost the reply every time in `ShouldFinishStoppedStreamsQUIC` before. Streams count as finishing from `QUICStream::Stop` (a stream that can send) to `SHUTDOWN_COMPLETE`, in the registry's `finishing`.
|
||||||
|
- **`~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`).
|
- **`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.
|
- **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`.
|
- **`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 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.
|
- **The header comment of `Crafter.Network-HTTP3.cppm` is stale**: the partition is exported from the umbrella natively and has a Huffman decoder. The README is right.
|
||||||
- **More comments the code outgrew**: `-ListenerQUIC.cpp` says the certificate pair lives in a `mkdtemp`'d directory for the life of the process (it is the fixed `/tmp/crafter-network-quic-cert/`, reused) and is valid 13 days (`-days 10`); `ClientQUIC.cppm` calls `selfSigned` an in-memory certificate (it is on disk, made by the CLI); `ListenerHTTP.cppm` says `Listen()` dispatches on the calling thread (it is `ListenSyncAsync`); the link-flag comment in `project.cpp` still has msquic bundling quictls (it links the system `libssl`). Fix a comment when you touch its code.
|
- **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.
|
- **`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::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 listens and connects on either family (`QUIC_ADDRESS_FAMILY_UNSPEC`).
|
- **`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.
|
- **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 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.
|
- **A warning is printed only when its unit actually recompiles**; a build with nothing to do is silent and says nothing about warnings.
|
||||||
|
|
|
||||||
94
README.md
94
README.md
|
|
@ -85,6 +85,85 @@ std::thread([&] { broker.ListenSyncSync(); }).detach();
|
||||||
// ... broker.Stop() ends the loop from another thread.
|
// ... broker.Stop() ends the loop from another thread.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### QUIC Components
|
||||||
|
|
||||||
|
Plain QUIC under an ALPN of your own, for a protocol that is not HTTP. A browser cannot reach these: it speaks QUIC only as WebTransport, see [WebTransport Components](#webtransport-components).
|
||||||
|
|
||||||
|
#### ListenerQUIC
|
||||||
|
```cpp
|
||||||
|
Crafter::QUICServerCredentials creds;
|
||||||
|
creds.selfSigned = true; // dev-only, see "Self-signed certs & browser peers"
|
||||||
|
Crafter::ListenerQUIC listener(0, "myproto/1", creds, [](Crafter::ClientQUIC* peer) {
|
||||||
|
// An accepted connection, heap-allocated and yours to delete.
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
const std::uint16_t port = listener.Port(); // the port msquic picked for port 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`Port()` reports the UDP port the listener is bound to, so port 0 is usable: the listener takes whatever port is free and tells you which.
|
||||||
|
|
||||||
|
The port-only constructor listens on every address of both families. To keep a listener to this machine, name the address first, as with `ListenerTCP`; it must be numeric (`127.0.0.1`, `::1`, `0.0.0.0`, `::`), and a name such as `localhost` is refused. msquic still binds the UDP port on every address, but turns away a connection sent to any other address of the machine during its first flight, so such a client fails at once rather than at its handshake timeout. A client of a listener on `127.0.0.1` must dial `127.0.0.1`: `::1` is another address.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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. Before that it lets the streams already stopped finish, bounded the same way: a reply sent with `SendAsync(..., finish = true)` on a stream that was then let go still arrives when the connection is deleted right after.
|
||||||
|
|
||||||
|
#### 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.Abort(); // 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.
|
||||||
|
|
||||||
|
#### Receiving by callback
|
||||||
|
|
||||||
|
`QUICStream::OnRecieve` hands a stream's bytes to one callback as they arrive and tells another when the peer is done, holding no thread in between: no reader parked in `RecieveSync`, no pool task blocked in `RecieveUntilCloseAsync`. A server answering one request per stream, with a size cap:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
peer->OnStream([](Crafter::QUICStream incoming) {
|
||||||
|
auto stream = std::make_shared<Crafter::QUICStream>(std::move(incoming));
|
||||||
|
auto request = std::make_shared<std::string>();
|
||||||
|
std::weak_ptr<Crafter::QUICStream> weak = stream;
|
||||||
|
stream->OnRecieve(
|
||||||
|
[request, weak](std::span<const char> bytes) {
|
||||||
|
request->append(bytes.begin(), bytes.end());
|
||||||
|
if (request->size() > 64 * 1024) {
|
||||||
|
if (auto s = weak.lock()) s->Abort(1); // too big: reset both directions
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request, weak](bool clean) {
|
||||||
|
if (!clean) return; // reset, or the connection went
|
||||||
|
if (auto s = weak.lock()) {
|
||||||
|
const std::string answer = Answer(*request); // or later, from any thread
|
||||||
|
s->SendAsync(answer.data(), static_cast<std::uint32_t>(answer.size()), /*finish=*/true, nullptr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Keep(stream); // destroying the wrapper unregisters its callbacks
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Bytes that arrived before `OnRecieve` are delivered by it, on the calling thread, before it returns; after that the callbacks run on msquic's worker thread. No two callbacks of one stream, `SendAsync`'s included, ever run at once. `onEnd` fires once, after the last `onData`: `true` when the peer finished its side, `false` for a reset by either side or a lost connection. Inside the callbacks, `SendAsync`, `Abort` and destroying or moving the wrapper are fine; nothing that blocks (`*Sync` calls wait for the very worker they run on) and nothing that throws. Destroying the wrapper unregisters both, as `std::stop_callback` does: once the destructor returns neither runs again, and it waits for one running on another thread, so don't destroy it while holding a lock the callbacks take. `Abort(code)` resets both directions at once, from any thread or from inside `onData`; no `onData` follows it and `onEnd(false)` does. Don't mix `OnRecieve` with the `Recieve*` calls on one stream.
|
||||||
|
|
||||||
### HTTP/3 Components
|
### 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).
|
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).
|
||||||
|
|
@ -276,7 +355,7 @@ Browser-initiated bidi/unidi WebTransport streams arrive via `session.OnStream(.
|
||||||
|
|
||||||
### Self-signed certs & browser peers
|
### Self-signed certs & browser peers
|
||||||
|
|
||||||
Passing `QUICServerCredentials{selfSigned = true}` makes the listener generate (and cache) an ephemeral cert at `/tmp/crafter-network-quic-cert/{cert,key}.pem` and reuse it across server restarts while it's still valid. The cert is shaped to satisfy Chromium's `WebTransport.serverCertificateHashes` rules: ECDSA P-256, signed with ECDSA-SHA256, validity ≤14 days (10 in practice), `BasicConstraints CA:FALSE`, `KeyUsage digitalSignature`, `EKU serverAuth`, `SAN=DNS:localhost,IP:127.0.0.1,IP:::1`. To let a browser peer pin this cert without trusting a CA:
|
Passing `QUICServerCredentials{selfSigned = true}` makes the listener serve a development certificate it makes in-process (no `openssl` CLI) and keeps per user at `$XDG_CACHE_HOME/crafter-network/quic-cert/{cert,key}.pem` (`~/.cache/...` without `XDG_CACHE_HOME`, `$XDG_RUNTIME_DIR/...` without a home), in a directory only that user can enter. It is reused across server restarts until it has less than a day left, then replaced by the next process that needs it. Processes starting at the same moment share one pair: a lock covers the check and the replacement, each file is renamed into place whole, and the listener loads the pair from memory, so none ends up with one run's key and another's certificate. The cert is shaped to satisfy Chromium's `WebTransport.serverCertificateHashes` rules: ECDSA P-256, signed with ECDSA-SHA256, validity ≤14 days (10 in practice), `BasicConstraints CA:FALSE`, `KeyUsage digitalSignature`, `EKU serverAuth`, `SAN=DNS:localhost,IP:127.0.0.1,IP:::1`. To let a browser peer pin this cert without trusting a CA:
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
auto certPath = Crafter::GetSelfSignedCertificatePath();
|
auto certPath = Crafter::GetSelfSignedCertificatePath();
|
||||||
|
|
@ -316,7 +395,15 @@ The library includes tests covering:
|
||||||
- HTTP/3 external interop (`ShouldSend`) — live fetch from `cloudflare-quic.com:443`, exercises real TLS chain validation, mandatory control stream, peer-initiated unidi streams, and QPACK Huffman decoding
|
- HTTP/3 external interop (`ShouldSend`) — live fetch from `cloudflare-quic.com:443`, exercises real TLS chain validation, mandatory control stream, peer-initiated unidi streams, and QPACK Huffman decoding
|
||||||
- QUIC reliable streams (`ShouldSendRecieveQUICStream`)
|
- QUIC reliable streams (`ShouldSendRecieveQUICStream`)
|
||||||
- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`)
|
- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`)
|
||||||
- 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 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; 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
|
||||||
|
- Receiving by callback (`ShouldRecieveByCallbackQUIC`) — bytes waiting before `OnRecieve` are delivered by it; registered mid-flow no two callbacks overlap and every byte arrives in order; a stream is echoed both ways with every pool worker parked; resets and lost connections end it once, not clean; `Abort` inside `onData` enforces a byte cap; and a wrapper destroyed or moved under its callbacks behaves as documented
|
||||||
|
- 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
|
- 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 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
|
- HTTP/1.1 round-trip (`ShouldSendRecieveHTTP1`) — routing, query strings, HEAD, 404 and a throwing handler
|
||||||
|
|
@ -342,8 +429,7 @@ The external-interop test requires outbound UDP/443; if your network blocks it t
|
||||||
- **Crafter.Thread**: Thread pool management for asynchronous operations.
|
- **Crafter.Thread**: Thread pool management for asynchronous operations.
|
||||||
- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` at a pinned release (v2.6.3), with the submodules that release records, into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=openssl` against the system OpenSSL, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds.
|
- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` at a pinned release (v2.6.3), with the submodules that release records, into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=openssl` against the system OpenSSL, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds.
|
||||||
- On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros).
|
- On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros).
|
||||||
- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The same path also produces the cert hash that browser peers need for `serverCertificateHashes`.
|
- **libssl / libcrypto** (OpenSSL 3, native target only) — a *system* package, unlike msquic, linked via `-lssl -lcrypto`. Backs `Crafter.Network:TLS`, i.e. `https://` on the HTTP/1.1 client and listener. It is not built here on purpose, and msquic uses it too, so the process has one OpenSSL: msquic's QUIC support needs OpenSSL 3.5 or newer (Arch has it, as does the Flatpak runtime crafter-build builds in). msquic's own bundled fork (quictls) is not used: it is an older OpenSSL that still reads the system's `openssl.cnf`, and a config written for the system's newer OpenSSL (the Flatpak runtime's loads a PKCS#11 engine, with `config_diagnostics` on) made its initialisation fail. Install your distro's OpenSSL development package (`openssl` on Arch, `libssl-dev` on Debian/Ubuntu). Where that OpenSSL is older than 3.5 (Debian 12 and Ubuntu 24.04 ship 3.0), msquic doesn't build against it: build with `--no-quic`, which keeps TCP, TLS and HTTP/1.1. Certificate generation for `TLSServerCredentials{selfSigned = true}` and `QUICServerCredentials{selfSigned = true}`, and the hash `ComputeCertificateHashSHA256` gives browser peers, happen in-process through this library — no `openssl` CLI needed.
|
||||||
- **libssl / libcrypto** (OpenSSL 3, native target only) — a *system* package, unlike msquic, linked via `-lssl -lcrypto`. Backs `Crafter.Network:TLS`, i.e. `https://` on the HTTP/1.1 client and listener. It is not built here on purpose, and msquic uses it too, so the process has one OpenSSL: msquic's QUIC support needs OpenSSL 3.5 or newer (Arch has it, as does the Flatpak runtime crafter-build builds in). msquic's own bundled fork (quictls) is not used: it is an older OpenSSL that still reads the system's `openssl.cnf`, and a config written for the system's newer OpenSSL (the Flatpak runtime's loads a PKCS#11 engine, with `config_diagnostics` on) made its initialisation fail. Install your distro's OpenSSL development package (`openssl` on Arch, `libssl-dev` on Debian/Ubuntu). Where that OpenSSL is older than 3.5 (Debian 12 and Ubuntu 24.04 ship 3.0), msquic doesn't build against it: build with `--no-quic`, which keeps TCP, TLS and HTTP/1.1. Certificate generation for `TLSServerCredentials{selfSigned = true}` happens in-process through this library — no `openssl` CLI needed.
|
|
||||||
- Plaintext HTTP/1.1 needs nothing beyond POSIX sockets: `ClientHTTP1`/`ListenerHTTP1` built without credentials pull in no msquic and touch no TLS code path.
|
- Plaintext HTTP/1.1 needs nothing beyond POSIX sockets: `ClientHTTP1`/`ListenerHTTP1` built without credentials pull in no msquic and touch no TLS code path.
|
||||||
- **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`.
|
- **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,25 +65,102 @@ namespace {
|
||||||
std::mutex mtx;
|
std::mutex mtx;
|
||||||
std::condition_variable cv;
|
std::condition_variable cv;
|
||||||
int open = 0;
|
int open = 0;
|
||||||
|
// Of those, the ones whose owner stopped them gracefully: what was
|
||||||
|
// sent on them, the FIN last, is still on its way to the peer.
|
||||||
|
int finishing = 0;
|
||||||
|
|
||||||
void Add() {
|
void Add() {
|
||||||
std::lock_guard lk(mtx);
|
std::lock_guard lk(mtx);
|
||||||
++open;
|
++open;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Remove() {
|
// `wasFinishing`: the stream had been counted by Finishing().
|
||||||
|
void Remove(bool wasFinishing) {
|
||||||
{
|
{
|
||||||
std::lock_guard lk(mtx);
|
std::lock_guard lk(mtx);
|
||||||
--open;
|
--open;
|
||||||
|
if (wasFinishing) --finishing;
|
||||||
}
|
}
|
||||||
cv.notify_all();
|
cv.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Finishing() {
|
||||||
|
std::lock_guard lk(mtx);
|
||||||
|
++finishing;
|
||||||
|
}
|
||||||
|
|
||||||
// False if the timeout expired with streams still open.
|
// False if the timeout expired with streams still open.
|
||||||
bool WaitForDrain(std::chrono::milliseconds timeout) {
|
bool WaitForDrain(std::chrono::milliseconds timeout) {
|
||||||
std::unique_lock lk(mtx);
|
std::unique_lock lk(mtx);
|
||||||
return cv.wait_for(lk, timeout, [&]{ return open == 0; });
|
return cv.wait_for(lk, timeout, [&]{ return open == 0; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// False if the timeout expired with stopped streams still finishing.
|
||||||
|
bool WaitForFinishing(std::chrono::milliseconds timeout) {
|
||||||
|
std::unique_lock lk(mtx);
|
||||||
|
return cv.wait_for(lk, timeout, [&]{ return finishing == 0; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ClientQUIC::OnClosed's state, shared by the ClientQUIC and whoever
|
||||||
|
// fires the callback: the SHUTDOWN_COMPLETE handler on an msquic worker,
|
||||||
|
// or a pool task when OnClosed is called after the close. Reached
|
||||||
|
// through a shared_ptr because the handler fires it after closing the
|
||||||
|
// connection handle, and from then on ~ClientQUIC, finding no handle
|
||||||
|
// left to claim, no longer waits for the worker and may free the Impl
|
||||||
|
// under it.
|
||||||
|
//
|
||||||
|
// The rules are std::stop_callback's: once ~ClientQUIC has set
|
||||||
|
// `ownerGone` the callback never starts; one already running on another
|
||||||
|
// thread is waited for, so the owner may free what the callback uses as
|
||||||
|
// soon as the destructor returns; one running on the destructor's own
|
||||||
|
// thread, a callback deleting its own connection, cannot be and is not.
|
||||||
|
struct CloseNotice {
|
||||||
|
std::mutex mtx;
|
||||||
|
std::condition_variable idle;
|
||||||
|
std::function<void()> callback;
|
||||||
|
bool closed = false; // shut down, and not by ~ClientQUIC
|
||||||
|
bool fired = false;
|
||||||
|
bool ownerGone = false; // ~ClientQUIC has begun
|
||||||
|
bool running = false;
|
||||||
|
std::thread::id runningOn;
|
||||||
|
|
||||||
|
// Runs the callback if it is due: closed, set, not yet run, and the
|
||||||
|
// owner still there.
|
||||||
|
static void Fire(const std::shared_ptr<CloseNotice>& notice) {
|
||||||
|
std::function<void()> callback;
|
||||||
|
{
|
||||||
|
std::lock_guard lk(notice->mtx);
|
||||||
|
if (!notice->closed || notice->fired || notice->ownerGone || !notice->callback) return;
|
||||||
|
notice->fired = true;
|
||||||
|
notice->running = true;
|
||||||
|
notice->runningOn = std::this_thread::get_id();
|
||||||
|
callback.swap(notice->callback);
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
// Destroyed before `running` drops, so a destructor waiting for
|
||||||
|
// us is never racing what the callback captured.
|
||||||
|
callback = nullptr;
|
||||||
|
{
|
||||||
|
std::lock_guard lk(notice->mtx);
|
||||||
|
notice->running = false;
|
||||||
|
}
|
||||||
|
notice->idle.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~ClientQUIC's half: from here no callback starts, and one running
|
||||||
|
// on another thread has returned.
|
||||||
|
void OwnerGone() {
|
||||||
|
std::function<void()> dropped;
|
||||||
|
{
|
||||||
|
std::unique_lock lk(mtx);
|
||||||
|
ownerGone = true;
|
||||||
|
if (running && runningOn != std::this_thread::get_id()) {
|
||||||
|
idle.wait(lk, [&] { return !running; });
|
||||||
|
}
|
||||||
|
dropped.swap(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bound on how long ~ClientQUIC waits for the connection's streams to
|
// Bound on how long ~ClientQUIC waits for the connection's streams to
|
||||||
|
|
@ -99,6 +176,33 @@ namespace {
|
||||||
// only stops an msquic-side surprise from wedging the destructor outright.
|
// only stops an msquic-side surprise from wedging the destructor outright.
|
||||||
constexpr std::chrono::milliseconds streamDrainTimeout{ 5000 };
|
constexpr std::chrono::milliseconds streamDrainTimeout{ 5000 };
|
||||||
|
|
||||||
|
// Bound on how long ~ClientQUIC then waits for the connection's shutdown
|
||||||
|
// to complete before it closes the handle. Closing earlier loses the
|
||||||
|
// CONNECTION_CLOSE frame: msquic sends it from a flush it queues while it
|
||||||
|
// processes ConnectionShutdown, a ConnectionClose that reaches the worker
|
||||||
|
// before that flush completes the shutdown silently (QuicConnTryClose's
|
||||||
|
// "silent close forced" path), and the flush is then skipped (FLUSH_SEND
|
||||||
|
// is ignored once ShutdownComplete is set). The peer learned of the close
|
||||||
|
// only at its idle timeout, two minutes by default; with no stream open
|
||||||
|
// the drain above returns at once, so that was the usual outcome.
|
||||||
|
// SHUTDOWN_COMPLETE comes about a round trip later, once the peer answers
|
||||||
|
// the close, or after msquic's closing period (three PTOs) when it does
|
||||||
|
// not. Either way the frame went out at the start, so the bound only
|
||||||
|
// stops a peer that is gone from holding the destructor that long.
|
||||||
|
constexpr std::chrono::milliseconds closeNotifyTimeout{ 1000 };
|
||||||
|
|
||||||
|
// Bound on how long ~ClientQUIC first waits for the streams their owners
|
||||||
|
// stopped to finish. ConnectionShutdown aborts every stream, and with it
|
||||||
|
// whatever msquic still holds of theirs: a server that replied with
|
||||||
|
// SendAsync(..., finish = true), let the stream go and then deleted the
|
||||||
|
// connection lost the reply whenever msquic had not sent it yet, which
|
||||||
|
// the shutdown, queued behind the send, made likely (4 runs in 30 of a
|
||||||
|
// Crafter.Debug test lost the reply its session gave on shutdown). A
|
||||||
|
// stopped stream finishes once the peer has acknowledged its FIN, about
|
||||||
|
// a round trip; the bound only stops a peer that no longer answers, or
|
||||||
|
// one that never reads, from holding the destructor longer.
|
||||||
|
constexpr std::chrono::milliseconds finishTimeout{ 1000 };
|
||||||
|
|
||||||
// Encode an ALPN string into the wire format msquic expects: a length
|
// Encode an ALPN string into the wire format msquic expects: a length
|
||||||
// byte followed by the ASCII characters. Lifetime of the returned buffer
|
// byte followed by the ASCII characters. Lifetime of the returned buffer
|
||||||
// matches the caller's storage in `out`.
|
// matches the caller's storage in `out`.
|
||||||
|
|
@ -114,6 +218,35 @@ namespace {
|
||||||
|
|
||||||
// ---------------- QUICStream::Impl ----------------
|
// ---------------- QUICStream::Impl ----------------
|
||||||
struct QUICStream::Impl {
|
struct QUICStream::Impl {
|
||||||
|
// How one SendSync went, written by its SEND_COMPLETE under the stream's
|
||||||
|
// mutex. Shared with the waiting thread so the request can be freed by
|
||||||
|
// whichever side finishes with it, in either order.
|
||||||
|
struct SendOutcome {
|
||||||
|
bool done = false;
|
||||||
|
bool sent = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One send handed to msquic: the copy it reads from, and the QUIC_BUFFER
|
||||||
|
// describing it, which msquic keeps a pointer to rather than copying.
|
||||||
|
// Owned by msquic from a successful StreamSend until that send's
|
||||||
|
// SEND_COMPLETE, which msquic delivers exactly once for every send it
|
||||||
|
// took (Canceled when the stream or the connection ended first) and
|
||||||
|
// always before the stream's SHUTDOWN_COMPLETE; that handler frees it.
|
||||||
|
// Who hears how it went: SendSync's waiting thread through `outcome`,
|
||||||
|
// or SendAsync's `onSent`.
|
||||||
|
struct SendRequest {
|
||||||
|
std::vector<char> bytes;
|
||||||
|
QUIC_BUFFER buffer{};
|
||||||
|
std::shared_ptr<SendOutcome> outcome;
|
||||||
|
std::function<void(bool sent)> onSent;
|
||||||
|
|
||||||
|
SendRequest(const void* data, std::uint32_t size)
|
||||||
|
: bytes(static_cast<const char*>(data), static_cast<const char*>(data) + size) {
|
||||||
|
buffer.Buffer = reinterpret_cast<std::uint8_t*>(bytes.data());
|
||||||
|
buffer.Length = size;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
ClientQUIC* connection = nullptr;
|
ClientQUIC* connection = nullptr;
|
||||||
|
|
||||||
std::mutex mtx;
|
std::mutex mtx;
|
||||||
|
|
@ -121,7 +254,177 @@ struct QUICStream::Impl {
|
||||||
std::deque<std::vector<char>> pending;
|
std::deque<std::vector<char>> pending;
|
||||||
bool peerSendClosed = false;
|
bool peerSendClosed = false;
|
||||||
bool shutdownComplete = false;
|
bool shutdownComplete = false;
|
||||||
bool sendInFlight = false;
|
|
||||||
|
// Sends handed to msquic whose SEND_COMPLETE has not come yet, under mtx.
|
||||||
|
std::uint64_t bytesInFlight = 0;
|
||||||
|
std::size_t sendsInFlight = 0;
|
||||||
|
|
||||||
|
// StreamSend for one request, counted in flight while msquic has it.
|
||||||
|
// False, with the request still the caller's, when msquic refused it:
|
||||||
|
// then no SEND_COMPLETE will come.
|
||||||
|
bool Send(HQUIC live, SendRequest* request, bool finish, QUIC_STATUS& status) {
|
||||||
|
{
|
||||||
|
std::lock_guard lk(mtx);
|
||||||
|
bytesInFlight += request->bytes.size();
|
||||||
|
++sendsInFlight;
|
||||||
|
}
|
||||||
|
// Counted first: SEND_COMPLETE can run on the worker before
|
||||||
|
// StreamSend has returned here.
|
||||||
|
status = Runtime().api->StreamSend(live, &request->buffer, 1,
|
||||||
|
finish ? QUIC_SEND_FLAG_FIN : QUIC_SEND_FLAG_NONE, request);
|
||||||
|
if (QUIC_SUCCEEDED(status)) return true;
|
||||||
|
std::lock_guard lk(mtx);
|
||||||
|
bytesInFlight -= request->bytes.size();
|
||||||
|
--sendsInFlight;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Callbacks: OnRecieve's onData and onEnd, SendAsync's onSent ----
|
||||||
|
// They run one at a time per stream, in order, and never under `mtx`.
|
||||||
|
// Whichever thread finds nobody delivering becomes the delivering thread
|
||||||
|
// and runs what is ready, picking up whatever others queue meanwhile; the
|
||||||
|
// others only queue and return. Normally that thread is the msquic
|
||||||
|
// worker, inside the event that made something ready, so no thread is
|
||||||
|
// held while nothing is; it is the caller's thread for the bytes
|
||||||
|
// OnRecieve finds waiting, and a pool task for a send that never reached
|
||||||
|
// msquic. Nothing blocks to wait for the delivering thread, the worker
|
||||||
|
// least of all, except Unregister, as std::stop_callback does.
|
||||||
|
//
|
||||||
|
// A blocking alternative, one mutex held across every callback, would
|
||||||
|
// make the worker wait whenever the caller's thread was delivering, and a
|
||||||
|
// callback that called into msquic and waited for that worker
|
||||||
|
// (GetStreamId does) would deadlock; a pool task per delivery would make
|
||||||
|
// every byte depend on a pool the host shares.
|
||||||
|
std::function<void(std::span<const char>)> onData;
|
||||||
|
std::function<void(bool)> onEnd;
|
||||||
|
bool handlerSet = false; // OnRecieve was called
|
||||||
|
bool unregistered = false; // Stop(): onData and onEnd never again
|
||||||
|
bool aborted = false; // Abort(): no more onData, what waits is dropped
|
||||||
|
enum class End { Open, Clean, Unclean };
|
||||||
|
End end = End::Open; // how the receive direction ended, once it has
|
||||||
|
bool endDelivered = false;
|
||||||
|
bool delivering = false;
|
||||||
|
std::thread::id deliveringThread;
|
||||||
|
std::condition_variable deliveryIdle;
|
||||||
|
std::deque<std::pair<std::function<void(bool)>, bool>> completions; // onSent calls waiting their turn
|
||||||
|
|
||||||
|
// Becomes the delivering thread if there is none. Under mtx.
|
||||||
|
bool BeginDelivery() {
|
||||||
|
if (delivering) return false;
|
||||||
|
delivering = true;
|
||||||
|
deliveringThread = std::this_thread::get_id();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs what is ready, one callback at a time and none under the lock,
|
||||||
|
// until nothing is, then stops delivering. Entered as the delivering
|
||||||
|
// thread with `lk` held; returns with it released.
|
||||||
|
void DeliverReady(std::unique_lock<std::mutex>& lk) {
|
||||||
|
for (;;) {
|
||||||
|
if (!completions.empty()) {
|
||||||
|
std::function<void(bool)> onSent = std::move(completions.front().first);
|
||||||
|
const bool sent = completions.front().second;
|
||||||
|
completions.pop_front();
|
||||||
|
lk.unlock();
|
||||||
|
onSent(sent);
|
||||||
|
onSent = nullptr;
|
||||||
|
lk.lock();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!handlerSet || unregistered) break;
|
||||||
|
if (aborted) pending.clear();
|
||||||
|
if (!pending.empty()) {
|
||||||
|
std::vector<char> chunk = std::move(pending.front());
|
||||||
|
pending.pop_front();
|
||||||
|
lk.unlock();
|
||||||
|
onData(std::span<const char>(chunk.data(), chunk.size()));
|
||||||
|
lk.lock();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only once everything before it has been handed over.
|
||||||
|
if (end != End::Open && !endDelivered) {
|
||||||
|
endDelivered = true;
|
||||||
|
const bool clean = end == End::Clean;
|
||||||
|
lk.unlock();
|
||||||
|
onEnd(clean);
|
||||||
|
lk.lock();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Nothing calls onData or onEnd again once the end is out or the
|
||||||
|
// owner unregistered, so let them go, with what they captured: a
|
||||||
|
// callback holding its own stream would otherwise keep it forever.
|
||||||
|
// Destroyed outside the lock, where a capture's destructor may do
|
||||||
|
// anything.
|
||||||
|
std::function<void(std::span<const char>)> dataHandler;
|
||||||
|
std::function<void(bool)> endHandler;
|
||||||
|
if (handlerSet && (endDelivered || unregistered)) {
|
||||||
|
dataHandler.swap(onData);
|
||||||
|
endHandler.swap(onEnd);
|
||||||
|
}
|
||||||
|
delivering = false;
|
||||||
|
lk.unlock();
|
||||||
|
deliveryIdle.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivers what is ready if no thread is, and releases `lk` either way.
|
||||||
|
void DeliverIfIdle(std::unique_lock<std::mutex>& lk) {
|
||||||
|
if (BeginDelivery()) {
|
||||||
|
DeliverReady(lk);
|
||||||
|
} else {
|
||||||
|
lk.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queues onSent(sent) behind the stream's other callbacks.
|
||||||
|
void Complete(std::function<void(bool)> onSent, bool sent, std::unique_lock<std::mutex>& lk) {
|
||||||
|
completions.emplace_back(std::move(onSent), sent);
|
||||||
|
DeliverIfIdle(lk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A SendAsync that never reached msquic still reports, exactly once, but
|
||||||
|
// never from inside SendAsync, which would deadlock a caller holding a
|
||||||
|
// lock its own callback takes: after the callback that made it, when one
|
||||||
|
// is being delivered, else from a pool task.
|
||||||
|
static void FailSend(const std::shared_ptr<Impl>& self, std::function<void(bool sent)> onSent) {
|
||||||
|
if (!onSent) return;
|
||||||
|
if (self) {
|
||||||
|
std::lock_guard lk(self->mtx);
|
||||||
|
if (self->delivering) {
|
||||||
|
self->completions.emplace_back(std::move(onSent), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ThreadPool::Enqueue([self, onSent = std::move(onSent)]() mutable {
|
||||||
|
if (!self) {
|
||||||
|
onSent(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::unique_lock lk(self->mtx);
|
||||||
|
self->Complete(std::move(onSent), false, lk);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop()'s half: onData and onEnd never run once this returns, and one
|
||||||
|
// running on another thread has finished. One running on this thread is
|
||||||
|
// the caller, a callback destroying its own wrapper; it cannot be waited
|
||||||
|
// for, and the delivering loop lets the handlers go when it unwinds.
|
||||||
|
void Unregister() {
|
||||||
|
std::function<void(std::span<const char>)> dataHandler;
|
||||||
|
std::function<void(bool)> endHandler;
|
||||||
|
std::unique_lock lk(mtx);
|
||||||
|
unregistered = true;
|
||||||
|
if (!handlerSet) return;
|
||||||
|
if (delivering && deliveringThread != std::this_thread::get_id()) {
|
||||||
|
deliveryIdle.wait(lk, [&] { return !delivering; });
|
||||||
|
}
|
||||||
|
if (!delivering) {
|
||||||
|
dataHandler.swap(onData);
|
||||||
|
endHandler.swap(onEnd);
|
||||||
|
}
|
||||||
|
lk.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
// Up to `count` received bytes, waiting for them; fewer once the peer
|
// Up to `count` received bytes, waiting for them; fewer once the peer
|
||||||
// has closed its side and the rest is taken.
|
// has closed its side and the rest is taken.
|
||||||
|
|
@ -184,6 +487,9 @@ struct QUICStream::Impl {
|
||||||
// handler is installed (QUICStream's handle constructor / OpenStream),
|
// handler is installed (QUICStream's handle constructor / OpenStream),
|
||||||
// decremented once the handle has been closed.
|
// decremented once the handle has been closed.
|
||||||
std::shared_ptr<StreamRegistry> registry;
|
std::shared_ptr<StreamRegistry> registry;
|
||||||
|
// Counted in the registry's `finishing` since Stop() shut the send side
|
||||||
|
// down gracefully; under handleMtx.
|
||||||
|
bool finishing = false;
|
||||||
|
|
||||||
// The connection's registry. Defined below ClientQUIC::Impl (which owns
|
// The connection's registry. Defined below ClientQUIC::Impl (which owns
|
||||||
// it); a member of QUICStream::Impl rather than a free function so it
|
// it); a member of QUICStream::Impl rather than a free function so it
|
||||||
|
|
@ -222,11 +528,13 @@ struct QUICStream::Impl {
|
||||||
|
|
||||||
void ReleaseHandle() {
|
void ReleaseHandle() {
|
||||||
HQUIC toClose = nullptr;
|
HQUIC toClose = nullptr;
|
||||||
|
bool wasFinishing = false;
|
||||||
{
|
{
|
||||||
std::lock_guard lk(handleMtx);
|
std::lock_guard lk(handleMtx);
|
||||||
if (--handleRefs > 0) return;
|
if (--handleRefs > 0) return;
|
||||||
toClose = handle;
|
toClose = handle;
|
||||||
handle = nullptr;
|
handle = nullptr;
|
||||||
|
wasFinishing = finishing;
|
||||||
}
|
}
|
||||||
// Last ref: nobody else can be inside an msquic call on this handle,
|
// Last ref: nobody else can be inside an msquic call on this handle,
|
||||||
// and AcquireHandle will hand it out no more.
|
// and AcquireHandle will hand it out no more.
|
||||||
|
|
@ -238,7 +546,21 @@ struct QUICStream::Impl {
|
||||||
// Last of all: a destructor blocked in WaitForDrain may run
|
// Last of all: a destructor blocked in WaitForDrain may run
|
||||||
// ConnectionClose the moment this hits zero, and msquic wants every
|
// ConnectionClose the moment this hits zero, and msquic wants every
|
||||||
// StreamClose on the connection to have happened first.
|
// StreamClose on the connection to have happened first.
|
||||||
if (reg) reg->Remove();
|
if (reg) reg->Remove(wasFinishing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop()'s mark, with a handle ref held so the stream cannot be retired
|
||||||
|
// meanwhile: ~ClientQUIC lets the stream finish before it closes the
|
||||||
|
// connection. Once per stream.
|
||||||
|
void MarkFinishing() {
|
||||||
|
std::shared_ptr<StreamRegistry> reg;
|
||||||
|
{
|
||||||
|
std::lock_guard lk(handleMtx);
|
||||||
|
if (finishing || retired) return;
|
||||||
|
finishing = true;
|
||||||
|
reg = registry;
|
||||||
|
}
|
||||||
|
if (reg) reg->Finishing();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish the msquic handle. Called once, while the stream is still local
|
// Publish the msquic handle. Called once, while the stream is still local
|
||||||
|
|
@ -266,6 +588,19 @@ struct QUICStream::Impl {
|
||||||
Impl* self = keepAlive.get();
|
Impl* self = keepAlive.get();
|
||||||
switch (ev->Type) {
|
switch (ev->Type) {
|
||||||
case QUIC_STREAM_EVENT_RECEIVE: {
|
case QUIC_STREAM_EVENT_RECEIVE: {
|
||||||
|
// Everything is consumed here (SUCCESS), whoever ends up with
|
||||||
|
// it: a pending receive would hold the stream's shutdown back
|
||||||
|
// until a StreamReceiveComplete that nothing would ever call.
|
||||||
|
std::unique_lock lk(self->mtx);
|
||||||
|
// What Abort() has not stopped yet is dropped, and so is
|
||||||
|
// whatever comes once nothing will hand it over.
|
||||||
|
if (self->aborted
|
||||||
|
|| (self->handlerSet && (self->unregistered || self->endDelivered))) {
|
||||||
|
return QUIC_STATUS_SUCCESS;
|
||||||
|
}
|
||||||
|
if (!self->handlerSet || self->delivering || !self->pending.empty()) {
|
||||||
|
// For a Recieve* call, or behind what another thread is
|
||||||
|
// still handing over: a copy, in order.
|
||||||
std::vector<char> chunk;
|
std::vector<char> chunk;
|
||||||
std::uint64_t total = 0;
|
std::uint64_t total = 0;
|
||||||
for (std::uint32_t i = 0; i < ev->RECEIVE.BufferCount; ++i) {
|
for (std::uint32_t i = 0; i < ev->RECEIVE.BufferCount; ++i) {
|
||||||
|
|
@ -276,41 +611,87 @@ struct QUICStream::Impl {
|
||||||
const QUIC_BUFFER& b = ev->RECEIVE.Buffers[i];
|
const QUIC_BUFFER& b = ev->RECEIVE.Buffers[i];
|
||||||
chunk.insert(chunk.end(), b.Buffer, b.Buffer + b.Length);
|
chunk.insert(chunk.end(), b.Buffer, b.Buffer + b.Length);
|
||||||
}
|
}
|
||||||
{
|
|
||||||
std::lock_guard lk(self->mtx);
|
|
||||||
if (!chunk.empty()) self->pending.push_back(std::move(chunk));
|
if (!chunk.empty()) self->pending.push_back(std::move(chunk));
|
||||||
}
|
if (!self->handlerSet) {
|
||||||
|
lk.unlock();
|
||||||
self->cv.notify_all();
|
self->cv.notify_all();
|
||||||
return QUIC_STATUS_SUCCESS;
|
return QUIC_STATUS_SUCCESS;
|
||||||
}
|
}
|
||||||
|
self->DeliverIfIdle(lk);
|
||||||
|
return QUIC_STATUS_SUCCESS;
|
||||||
|
}
|
||||||
|
// Nothing waiting and nobody delivering: hand msquic's own
|
||||||
|
// buffers over, valid until this event returns, without a copy.
|
||||||
|
self->BeginDelivery();
|
||||||
|
lk.unlock();
|
||||||
|
for (std::uint32_t i = 0; i < ev->RECEIVE.BufferCount; ++i) {
|
||||||
|
const QUIC_BUFFER& b = ev->RECEIVE.Buffers[i];
|
||||||
|
if (b.Length == 0) continue;
|
||||||
|
if (i > 0) {
|
||||||
|
// The last onData may have aborted or dropped the stream.
|
||||||
|
std::lock_guard check(self->mtx);
|
||||||
|
if (self->aborted || self->unregistered) break;
|
||||||
|
}
|
||||||
|
self->onData(std::span<const char>(reinterpret_cast<const char*>(b.Buffer), b.Length));
|
||||||
|
}
|
||||||
|
lk.lock();
|
||||||
|
self->DeliverReady(lk);
|
||||||
|
return QUIC_STATUS_SUCCESS;
|
||||||
|
}
|
||||||
case QUIC_STREAM_EVENT_SEND_COMPLETE: {
|
case QUIC_STREAM_EVENT_SEND_COMPLETE: {
|
||||||
|
auto* request = static_cast<SendRequest*>(ev->SEND_COMPLETE.ClientContext);
|
||||||
|
if (request == nullptr) return QUIC_STATUS_SUCCESS;
|
||||||
|
const bool sent = !ev->SEND_COMPLETE.Canceled;
|
||||||
{
|
{
|
||||||
std::lock_guard lk(self->mtx);
|
std::lock_guard lk(self->mtx);
|
||||||
self->sendInFlight = false;
|
self->bytesInFlight -= request->bytes.size();
|
||||||
|
--self->sendsInFlight;
|
||||||
|
if (request->outcome) {
|
||||||
|
request->outcome->done = true;
|
||||||
|
request->outcome->sent = sent;
|
||||||
}
|
}
|
||||||
if (ev->SEND_COMPLETE.ClientContext) {
|
|
||||||
delete[] static_cast<char*>(ev->SEND_COMPLETE.ClientContext);
|
|
||||||
}
|
}
|
||||||
self->cv.notify_all();
|
if (request->outcome) self->cv.notify_all();
|
||||||
|
std::function<void(bool)> onSent = std::move(request->onSent);
|
||||||
|
delete request;
|
||||||
|
// SendAsync's, on the worker, in turn with the stream's other
|
||||||
|
// callbacks: msquic is done with the copy, and no thread was
|
||||||
|
// held while it had it.
|
||||||
|
if (onSent) {
|
||||||
|
std::unique_lock lk(self->mtx);
|
||||||
|
self->Complete(std::move(onSent), sent, lk);
|
||||||
|
}
|
||||||
return QUIC_STATUS_SUCCESS;
|
return QUIC_STATUS_SUCCESS;
|
||||||
}
|
}
|
||||||
case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN:
|
case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN:
|
||||||
case QUIC_STREAM_EVENT_PEER_SEND_ABORTED: {
|
case QUIC_STREAM_EVENT_PEER_SEND_ABORTED: {
|
||||||
|
// PEER_SEND_SHUTDOWN comes once every byte up to the FIN has
|
||||||
|
// been taken from RECEIVE events, so onEnd(true) follows the
|
||||||
|
// last onData.
|
||||||
{
|
{
|
||||||
std::lock_guard lk(self->mtx);
|
std::unique_lock lk(self->mtx);
|
||||||
self->peerSendClosed = true;
|
self->peerSendClosed = true;
|
||||||
|
if (self->end == End::Open) {
|
||||||
|
self->end = ev->Type == QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN ? End::Clean : End::Unclean;
|
||||||
|
}
|
||||||
|
self->DeliverIfIdle(lk);
|
||||||
}
|
}
|
||||||
self->cv.notify_all();
|
self->cv.notify_all();
|
||||||
return QUIC_STATUS_SUCCESS;
|
return QUIC_STATUS_SUCCESS;
|
||||||
}
|
}
|
||||||
case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: {
|
case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: {
|
||||||
{
|
{
|
||||||
std::lock_guard lk(self->mtx);
|
std::unique_lock lk(self->mtx);
|
||||||
self->peerSendClosed = true;
|
self->peerSendClosed = true;
|
||||||
|
// Every send msquic took has had its SEND_COMPLETE by now;
|
||||||
|
// a SendSync still waiting is only woken to find out.
|
||||||
self->shutdownComplete = true;
|
self->shutdownComplete = true;
|
||||||
// No further SEND_COMPLETE can arrive, so release anyone
|
// Neither a FIN nor the peer's reset ended the receive
|
||||||
// waiting on one — they throw on shutdownComplete instead.
|
// side: the connection went, or this side aborted it.
|
||||||
self->sendInFlight = false;
|
if (self->end == End::Open) self->end = End::Unclean;
|
||||||
|
// Before Retire, so the handle is still valid for the
|
||||||
|
// callbacks, as in every other event.
|
||||||
|
self->DeliverIfIdle(lk);
|
||||||
}
|
}
|
||||||
self->cv.notify_all();
|
self->cv.notify_all();
|
||||||
self->Retire();
|
self->Retire();
|
||||||
|
|
@ -366,6 +747,8 @@ void QUICStream::Stop() {
|
||||||
if (!handle) return;
|
if (!handle) return;
|
||||||
handle = nullptr; // the wrapper is done with the stream either way
|
handle = nullptr; // the wrapper is done with the stream either way
|
||||||
if (!impl) return;
|
if (!impl) return;
|
||||||
|
// The owner is done with OnRecieve's callbacks too.
|
||||||
|
impl->Unregister();
|
||||||
// Only INITIATE the shutdown here; the async SHUTDOWN_COMPLETE callback
|
// Only INITIATE the shutdown here; the async SHUTDOWN_COMPLETE callback
|
||||||
// releases the handle, and the Impl is a shared_ptr held by the callback's
|
// releases the handle, and the Impl is a shared_ptr held by the callback's
|
||||||
// selfRef, so it safely outlives this wrapper until that callback runs.
|
// selfRef, so it safely outlives this wrapper until that callback runs.
|
||||||
|
|
@ -373,6 +756,11 @@ void QUICStream::Stop() {
|
||||||
// the stream — shutting it down again would be a use-after-free.
|
// the stream — shutting it down again would be a use-after-free.
|
||||||
HQUIC live = impl->AcquireHandle();
|
HQUIC live = impl->AcquireHandle();
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
|
// What was sent, and the FIN, still has to reach the peer: counted until
|
||||||
|
// SHUTDOWN_COMPLETE, so ~ClientQUIC waits for it before closing the
|
||||||
|
// connection, which would abort it. A stream that cannot send has
|
||||||
|
// nothing on its way.
|
||||||
|
if (canSend) impl->MarkFinishing();
|
||||||
// GRACEFUL closes our send direction only, and msquic rejects it combined
|
// GRACEFUL closes our send direction only, and msquic rejects it combined
|
||||||
// with any other flag, so the receive direction needs a call of its own.
|
// with any other flag, so the receive direction needs a call of its own.
|
||||||
// Without it a bidirectional stream whose peer keeps its send side open
|
// Without it a bidirectional stream whose peer keeps its send side open
|
||||||
|
|
@ -392,31 +780,27 @@ void QUICStream::SendSync(const void* buffer, std::uint32_t size, bool finish) {
|
||||||
// call below, and msquic bugchecks on the freed handle.
|
// call below, and msquic bugchecks on the freed handle.
|
||||||
HQUIC live = impl->AcquireHandle();
|
HQUIC live = impl->AcquireHandle();
|
||||||
if (!live) throw QUICClosedException();
|
if (!live) throw QUICClosedException();
|
||||||
auto* copy = new char[size];
|
// This send's own outcome, not the stream's state. The flag this used to
|
||||||
std::memcpy(copy, buffer, size);
|
// wait on was one per stream, and the wait then threw whenever the
|
||||||
QUIC_BUFFER quicBuf{};
|
// stream had finished shutting down by the time it woke: a peer that
|
||||||
quicBuf.Buffer = reinterpret_cast<std::uint8_t*>(copy);
|
// answered and finished the stream within a round trip, quicker than a
|
||||||
quicBuf.Length = size;
|
// busy machine wakes this thread, made a send that went out throw.
|
||||||
{
|
auto outcome = std::make_shared<Impl::SendOutcome>();
|
||||||
std::lock_guard lk(impl->mtx);
|
auto* request = new Impl::SendRequest(buffer, size);
|
||||||
impl->sendInFlight = true;
|
request->outcome = outcome;
|
||||||
}
|
QUIC_STATUS s = QUIC_STATUS_SUCCESS;
|
||||||
QUIC_SEND_FLAGS flags = finish ? QUIC_SEND_FLAG_FIN : QUIC_SEND_FLAG_NONE;
|
const bool taken = impl->Send(live, request, finish, s);
|
||||||
QUIC_STATUS s = Runtime().api->StreamSend(live, &quicBuf, 1, flags, copy);
|
|
||||||
impl->ReleaseHandle();
|
impl->ReleaseHandle();
|
||||||
if (QUIC_FAILED(s)) {
|
if (!taken) {
|
||||||
{
|
|
||||||
// No SEND_COMPLETE will arrive for a send msquic never took.
|
// No SEND_COMPLETE will arrive for a send msquic never took.
|
||||||
std::lock_guard lk(impl->mtx);
|
delete request;
|
||||||
impl->sendInFlight = false;
|
|
||||||
}
|
|
||||||
impl->cv.notify_all();
|
|
||||||
delete[] copy;
|
|
||||||
throw QUICException(std::format("StreamSend failed: 0x{:x}", static_cast<unsigned>(s)));
|
throw QUICException(std::format("StreamSend failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||||
}
|
}
|
||||||
std::unique_lock lk(impl->mtx);
|
std::unique_lock lk(impl->mtx);
|
||||||
impl->cv.wait(lk, [&]{ return !impl->sendInFlight || impl->shutdownComplete; });
|
// SEND_COMPLETE always precedes the stream's SHUTDOWN_COMPLETE; waking on
|
||||||
if (impl->shutdownComplete) throw QUICClosedException();
|
// the latter only keeps an msquic surprise from hanging this thread.
|
||||||
|
impl->cv.wait(lk, [&]{ return outcome->done || impl->shutdownComplete; });
|
||||||
|
if (!outcome->done || !outcome->sent) throw QUICClosedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<char> QUICStream::RecieveSync() {
|
std::vector<char> QUICStream::RecieveSync() {
|
||||||
|
|
@ -456,19 +840,41 @@ std::vector<char> QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) {
|
||||||
|
|
||||||
void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
||||||
std::function<void(bool sent)> onSent) {
|
std::function<void(bool sent)> onSent) {
|
||||||
// Copy now: the caller's buffer may not outlive the enqueued task.
|
// This used to be SendSync run as a pool task: a pool thread held for as
|
||||||
std::vector<char> copy(static_cast<const char*>(buffer),
|
// long as msquic took to accept the buffer (a peer that reads slowly
|
||||||
static_cast<const char*>(buffer) + size);
|
// stretches that to its pace), and a task that reached back through
|
||||||
ThreadPool::Enqueue([this, copy = std::move(copy), finish, onSent = std::move(onSent)]() mutable {
|
// `this`, so moving or destroying the wrapper before it ran was a
|
||||||
// Nothing may leave a pool task; the callback hears how it went.
|
// use-after-free. Now the send goes to msquic here and its SEND_COMPLETE
|
||||||
bool sent = true;
|
// reports, on the worker. Only `impl` is read, not `handle`, which Stop()
|
||||||
try {
|
// clears: SendAsync may run on another thread than the owner's, inside a
|
||||||
this->SendSync(copy.data(), static_cast<std::uint32_t>(copy.size()), finish);
|
// callback, and a stopped stream refuses or cancels the send anyway.
|
||||||
} catch (...) {
|
HQUIC live = impl && canSend ? impl->AcquireHandle() : nullptr;
|
||||||
sent = false;
|
if (live == nullptr) {
|
||||||
|
Impl::FailSend(impl, std::move(onSent));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (onSent) onSent(sent);
|
auto* request = new Impl::SendRequest(buffer, size);
|
||||||
});
|
request->onSent = std::move(onSent);
|
||||||
|
QUIC_STATUS status = QUIC_STATUS_SUCCESS;
|
||||||
|
const bool taken = impl->Send(live, request, finish, status);
|
||||||
|
impl->ReleaseHandle();
|
||||||
|
if (!taken) {
|
||||||
|
std::function<void(bool)> callback = std::move(request->onSent);
|
||||||
|
delete request;
|
||||||
|
Impl::FailSend(impl, std::move(callback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t QUICStream::BytesInFlight() const {
|
||||||
|
if (!impl) return 0;
|
||||||
|
std::lock_guard lk(impl->mtx);
|
||||||
|
return impl->bytesInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t QUICStream::SendsInFlight() const {
|
||||||
|
if (!impl) return 0;
|
||||||
|
std::lock_guard lk(impl->mtx);
|
||||||
|
return impl->sendsInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The async receives deliver a closed stream as the browser build does: an
|
// The async receives deliver a closed stream as the browser build does: an
|
||||||
|
|
@ -499,13 +905,65 @@ void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<v
|
||||||
|
|
||||||
void QUICStream::PrependReceived(std::vector<char> bytes) {
|
void QUICStream::PrependReceived(std::vector<char> bytes) {
|
||||||
if (bytes.empty() || !impl) return;
|
if (bytes.empty() || !impl) return;
|
||||||
{
|
std::unique_lock lk(impl->mtx);
|
||||||
std::lock_guard lk(impl->mtx);
|
|
||||||
impl->pending.push_front(std::move(bytes));
|
impl->pending.push_front(std::move(bytes));
|
||||||
|
if (impl->handlerSet && !impl->unregistered) {
|
||||||
|
// Handed to onData next, by this thread or the one delivering.
|
||||||
|
impl->DeliverIfIdle(lk);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
lk.unlock();
|
||||||
impl->cv.notify_all();
|
impl->cv.notify_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void QUICStream::OnRecieve(std::function<void(std::span<const char> data)> onData,
|
||||||
|
std::function<void(bool clean)> onEnd) {
|
||||||
|
// Our own reference: the callbacks delivered below may destroy this
|
||||||
|
// wrapper, and nothing of `this` is touched once they can run.
|
||||||
|
const std::shared_ptr<Impl> self = impl;
|
||||||
|
if (!self || !handle) {
|
||||||
|
if (onEnd) onEnd(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::unique_lock lk(self->mtx);
|
||||||
|
if (self->handlerSet) {
|
||||||
|
throw QUICException("OnRecieve: this stream's callbacks are already set");
|
||||||
|
}
|
||||||
|
self->handlerSet = true;
|
||||||
|
self->onData = onData ? std::move(onData) : [](std::span<const char>) {};
|
||||||
|
self->onEnd = onEnd ? std::move(onEnd) : [](bool) {};
|
||||||
|
// A stream only we send on gets no data and no FIN to end cleanly with.
|
||||||
|
if (!canReceive && self->end == Impl::End::Open) self->end = Impl::End::Unclean;
|
||||||
|
// What arrived before now, and the end if it came: delivered here, unless
|
||||||
|
// another thread is delivering this stream's callbacks (a send's onSent),
|
||||||
|
// which then hands these over after its own.
|
||||||
|
self->DeliverIfIdle(lk);
|
||||||
|
}
|
||||||
|
|
||||||
|
void QUICStream::Abort(std::uint64_t errorCode) {
|
||||||
|
const std::shared_ptr<Impl> self = impl;
|
||||||
|
if (!self) return;
|
||||||
|
{
|
||||||
|
std::lock_guard lk(self->mtx);
|
||||||
|
self->aborted = true;
|
||||||
|
self->pending.clear();
|
||||||
|
if (self->end == Impl::End::Open) self->end = Impl::End::Unclean;
|
||||||
|
}
|
||||||
|
// Inside a callback, the delivering loop hands onEnd(false) over when the
|
||||||
|
// callback returns; otherwise SHUTDOWN_COMPLETE does, soon: IMMEDIATE
|
||||||
|
// makes msquic report it as soon as it processes the abort, instead of
|
||||||
|
// once the peer has acknowledged the reset, which a peer that is gone
|
||||||
|
// never does.
|
||||||
|
HQUIC live = self->AcquireHandle();
|
||||||
|
if (live == nullptr) return;
|
||||||
|
Runtime().api->StreamShutdown(live,
|
||||||
|
QUIC_STREAM_SHUTDOWN_FLAG_ABORT_SEND
|
||||||
|
| QUIC_STREAM_SHUTDOWN_FLAG_ABORT_RECEIVE
|
||||||
|
| QUIC_STREAM_SHUTDOWN_FLAG_IMMEDIATE,
|
||||||
|
std::min<std::uint64_t>(errorCode, QUIC_UINT62_MAX));
|
||||||
|
self->ReleaseHandle();
|
||||||
|
}
|
||||||
|
|
||||||
std::uint64_t QUICStream::GetStreamId() const {
|
std::uint64_t QUICStream::GetStreamId() const {
|
||||||
if (!handle) throw QUICException("GetStreamId: stream is not open");
|
if (!handle) throw QUICException("GetStreamId: stream is not open");
|
||||||
// GetParam blocks on the connection's msquic worker, so it must not be the
|
// GetParam blocks on the connection's msquic worker, so it must not be the
|
||||||
|
|
@ -534,6 +992,7 @@ struct ClientQUIC::Impl {
|
||||||
std::condition_variable cv;
|
std::condition_variable cv;
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
bool closed = false;
|
bool closed = false;
|
||||||
|
bool shutdownComplete = false; // msquic delivered SHUTDOWN_COMPLETE
|
||||||
QUIC_STATUS shutdownStatus = QUIC_STATUS_SUCCESS;
|
QUIC_STATUS shutdownStatus = QUIC_STATUS_SUCCESS;
|
||||||
|
|
||||||
std::function<void(QUICStream)> onStream;
|
std::function<void(QUICStream)> onStream;
|
||||||
|
|
@ -550,6 +1009,9 @@ struct ClientQUIC::Impl {
|
||||||
// so the count survives a stream that outlives us.
|
// so the count survives a stream that outlives us.
|
||||||
std::shared_ptr<StreamRegistry> streams = std::make_shared<StreamRegistry>();
|
std::shared_ptr<StreamRegistry> streams = std::make_shared<StreamRegistry>();
|
||||||
|
|
||||||
|
// OnClosed's callback and the state that decides whether it runs.
|
||||||
|
std::shared_ptr<CloseNotice> closeNotice = std::make_shared<CloseNotice>();
|
||||||
|
|
||||||
ClientQUIC* outer = nullptr;
|
ClientQUIC* outer = nullptr;
|
||||||
|
|
||||||
// Take the connection handle away from whoever else might close it.
|
// Take the connection handle away from whoever else might close it.
|
||||||
|
|
@ -564,17 +1026,29 @@ struct ClientQUIC::Impl {
|
||||||
return claimed;
|
return claimed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shut the connection down and close it, waiting (bounded) for every
|
// Shut the connection down and close it: first waiting (bounded) for
|
||||||
// stream on it to reach StreamClose first. No-op if someone else already
|
// the streams already stopped to finish, so what was sent on them
|
||||||
// claimed the handle. Never holds `mtx` across an msquic call: the
|
// arrives; then for every stream on it to reach StreamClose; then for
|
||||||
// connection callback takes it too.
|
// the shutdown to complete, so the peer is told. No-op if someone else
|
||||||
|
// already claimed the handle. Never holds `mtx` across an msquic call:
|
||||||
|
// the connection callback takes it too.
|
||||||
void CloseConnection() {
|
void CloseConnection() {
|
||||||
HQUIC claimed = ClaimConnection();
|
HQUIC claimed = ClaimConnection();
|
||||||
if (!claimed) return;
|
if (!claimed) return;
|
||||||
|
// See finishTimeout. A peer that closes meanwhile aborts them, which
|
||||||
|
// ends the wait as well.
|
||||||
|
streams->WaitForFinishing(finishTimeout);
|
||||||
// Aborts any stream still open; each one's SHUTDOWN_COMPLETE runs
|
// Aborts any stream still open; each one's SHUTDOWN_COMPLETE runs
|
||||||
// Retire, which closes it and drops the count below.
|
// Retire, which closes it and drops the count below.
|
||||||
Runtime().api->ConnectionShutdown(claimed, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0);
|
Runtime().api->ConnectionShutdown(claimed, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0);
|
||||||
streams->WaitForDrain(streamDrainTimeout);
|
streams->WaitForDrain(streamDrainTimeout);
|
||||||
|
{
|
||||||
|
// See closeNotifyTimeout. The SHUTDOWN_COMPLETE handler sets the
|
||||||
|
// flag before it looks at the handle, which we hold, so it leaves
|
||||||
|
// the close to us.
|
||||||
|
std::unique_lock lk(mtx);
|
||||||
|
cv.wait_for(lk, closeNotifyTimeout, [&] { return shutdownComplete; });
|
||||||
|
}
|
||||||
Runtime().api->ConnectionClose(claimed);
|
Runtime().api->ConnectionClose(claimed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -605,19 +1079,35 @@ struct ClientQUIC::Impl {
|
||||||
{
|
{
|
||||||
std::lock_guard lk(self->mtx);
|
std::lock_guard lk(self->mtx);
|
||||||
self->closed = true;
|
self->closed = true;
|
||||||
|
self->shutdownComplete = true;
|
||||||
}
|
}
|
||||||
self->cv.notify_all();
|
self->cv.notify_all();
|
||||||
// AppCloseInProgress means ~ClientQUIC is already inside
|
// AppCloseInProgress means ~ClientQUIC is already inside
|
||||||
// ConnectionClose; otherwise close the handle here so a
|
// ConnectionClose; otherwise close the handle here so a
|
||||||
// connection dropped by the peer does not leak while the
|
// connection dropped by the peer does not leak while the
|
||||||
// owning ClientQUIC lives on. ClaimConnection settles which
|
// owning ClientQUIC lives on. Claiming the handle settles which
|
||||||
// of the two actually does it. Every stream on the connection
|
// of the two actually does it. Every stream msquic knew of was
|
||||||
// has already been shut down (and so closed) by the time
|
// aborted at the first close (QuicConnTryClose), so each has
|
||||||
// msquic reports the connection complete.
|
// had its own SHUTDOWN_COMPLETE before this one.
|
||||||
if (ev->SHUTDOWN_COMPLETE.AppCloseInProgress == 0
|
if (ev->SHUTDOWN_COMPLETE.AppCloseInProgress != 0) return QUIC_STATUS_SUCCESS;
|
||||||
&& self->ClaimConnection() != nullptr) {
|
std::shared_ptr<CloseNotice> notice;
|
||||||
Runtime().api->ConnectionClose(conn);
|
{
|
||||||
|
// The claim and the notice together, so ~ClientQUIC
|
||||||
|
// cannot slip between them.
|
||||||
|
std::lock_guard lk(self->mtx);
|
||||||
|
if (self->connection == nullptr) return QUIC_STATUS_SUCCESS;
|
||||||
|
self->connection = nullptr;
|
||||||
|
notice = self->closeNotice;
|
||||||
}
|
}
|
||||||
|
Runtime().api->ConnectionClose(conn);
|
||||||
|
// Nothing of `self` from here on: ~ClientQUIC, finding no
|
||||||
|
// handle to claim, does not wait for this worker, and the
|
||||||
|
// OnClosed callback may well be what deletes it.
|
||||||
|
{
|
||||||
|
std::lock_guard lk(notice->mtx);
|
||||||
|
notice->closed = true;
|
||||||
|
}
|
||||||
|
CloseNotice::Fire(notice);
|
||||||
return QUIC_STATUS_SUCCESS;
|
return QUIC_STATUS_SUCCESS;
|
||||||
}
|
}
|
||||||
case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: {
|
case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: {
|
||||||
|
|
@ -794,6 +1284,13 @@ ClientQUIC::ClientQUIC(ClientQUIC&& other) noexcept
|
||||||
|
|
||||||
ClientQUIC::~ClientQUIC() {
|
ClientQUIC::~ClientQUIC() {
|
||||||
if (!impl) return;
|
if (!impl) return;
|
||||||
|
// First of all: the OnClosed callback must not start once the owner has
|
||||||
|
// begun destroying the connection, and one running on another thread
|
||||||
|
// has to be done with it before it goes. A callback that is deleting
|
||||||
|
// this very object runs on this thread and is not waited for; it already
|
||||||
|
// closed the handle, so CloseConnection below finds nothing to claim and
|
||||||
|
// returns at once.
|
||||||
|
impl->closeNotice->OwnerGone();
|
||||||
// Before the connection goes: streams the peer started that no OnStream
|
// Before the connection goes: streams the peer started that no OnStream
|
||||||
// handler ever claimed. Destroying them here rather than with the rest of
|
// handler ever claimed. Destroying them here rather than with the rest of
|
||||||
// `impl` keeps every stream teardown ahead of ConnectionClose, so the
|
// `impl` keeps every stream teardown ahead of ConnectionClose, so the
|
||||||
|
|
@ -846,7 +1343,7 @@ QUICStream ClientQUIC::OpenStream(bool unidirectional) {
|
||||||
stream.impl->selfRef = nullptr;
|
stream.impl->selfRef = nullptr;
|
||||||
// No msquic stream exists, so there is no handle to release.
|
// No msquic stream exists, so there is no handle to release.
|
||||||
std::shared_ptr<StreamRegistry> registry = std::move(stream.impl->registry);
|
std::shared_ptr<StreamRegistry> registry = std::move(stream.impl->registry);
|
||||||
registry->Remove();
|
registry->Remove(false);
|
||||||
throw QUICException(std::format("StreamOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
throw QUICException(std::format("StreamOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||||
}
|
}
|
||||||
stream.handle = streamHandle;
|
stream.handle = streamHandle;
|
||||||
|
|
@ -920,6 +1417,24 @@ void ClientQUIC::OnDatagram(std::function<void(std::vector<char>)> cb) {
|
||||||
impl->onDatagram = std::move(cb);
|
impl->onDatagram = std::move(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ClientQUIC::OnClosed(std::function<void()> callback) {
|
||||||
|
if (!impl) return;
|
||||||
|
const std::shared_ptr<CloseNotice> notice = impl->closeNotice;
|
||||||
|
bool due = false;
|
||||||
|
{
|
||||||
|
std::lock_guard lk(notice->mtx);
|
||||||
|
if (notice->fired) return;
|
||||||
|
// The one replaced is destroyed outside the lock, with `callback`.
|
||||||
|
callback.swap(notice->callback);
|
||||||
|
due = notice->closed && notice->callback;
|
||||||
|
}
|
||||||
|
// Closed already, typically before a ListenerQUIC's connectCallback got
|
||||||
|
// to run: fire from the pool, never from in here, where the caller is
|
||||||
|
// still using the object the callback is likely to delete. Fire checks
|
||||||
|
// again, so a second OnClosed racing this one cannot fire it twice.
|
||||||
|
if (due) ThreadPool::Enqueue([notice] { CloseNotice::Fire(notice); });
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<char> ClientQUIC::RecieveDatagramSync() {
|
std::vector<char> ClientQUIC::RecieveDatagramSync() {
|
||||||
std::unique_lock lk(impl->mtx);
|
std::unique_lock lk(impl->mtx);
|
||||||
impl->cv.wait(lk, [&]{ return !impl->datagramQueue.empty() || impl->closed; });
|
impl->cv.wait(lk, [&]{ return !impl->datagramQueue.empty() || impl->closed; });
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,18 @@
|
||||||
|
|
||||||
module;
|
module;
|
||||||
#include <msquic.h>
|
#include <msquic.h>
|
||||||
|
#include <cerrno>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
module Crafter.Network:ListenerQUIC_impl;
|
module Crafter.Network:ListenerQUIC_impl;
|
||||||
import :ListenerQUIC;
|
import :ListenerQUIC;
|
||||||
import :ClientQUIC;
|
import :ClientQUIC;
|
||||||
|
import :TLS;
|
||||||
import Crafter.Thread;
|
import Crafter.Thread;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
|
|
@ -62,94 +67,268 @@ namespace {
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily generate (and cache) a self-signed cert + key pair on disk by
|
// `bindAddress` at `port`, for ListenerStart. AI_NUMERICHOST, as
|
||||||
// shelling out to the openssl CLI. Used by ListenerQUIC when the caller
|
// ListenerTCP resolves its bind address: `localhost` is refused here
|
||||||
// passes QUICServerCredentials{selfSigned=true}. The pair lives in a
|
// rather than bound to whichever of ::1 and 127.0.0.1 the resolver
|
||||||
// mkdtemp'd directory under /tmp for the lifetime of the process.
|
// ranks first, since the address decides who can reach the listener.
|
||||||
// Intended for dev / LAN play / tests — production should pass real
|
// QUIC_ADDR is the sockaddr_in/sockaddr_in6 union on POSIX, so the
|
||||||
// cert/key paths.
|
// resolver's answer copies straight in.
|
||||||
|
QUIC_ADDR NumericAddress(std::string_view bindAddress, std::uint16_t port) {
|
||||||
|
addrinfo hints{};
|
||||||
|
hints.ai_family = AF_UNSPEC;
|
||||||
|
hints.ai_socktype = SOCK_DGRAM;
|
||||||
|
hints.ai_flags = AI_PASSIVE | AI_NUMERICHOST;
|
||||||
|
const std::string host(bindAddress);
|
||||||
|
addrinfo* resolved = nullptr;
|
||||||
|
const int status = getaddrinfo(host.c_str(), nullptr, &hints, &resolved);
|
||||||
|
if (status != 0 || resolved == nullptr) {
|
||||||
|
throw QUICException(std::format(
|
||||||
|
"could not listen on '{}': not a numeric IPv4 or IPv6 address ({})",
|
||||||
|
host, gai_strerror(status)));
|
||||||
|
}
|
||||||
|
QUIC_ADDR address{};
|
||||||
|
std::memcpy(&address, resolved->ai_addr,
|
||||||
|
std::min<std::size_t>(resolved->ai_addrlen, sizeof(address)));
|
||||||
|
freeaddrinfo(resolved);
|
||||||
|
QuicAddrSetPort(&address, port);
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The self-signed certificate ──────────────────────────────────────
|
||||||
|
// QUICServerCredentials{selfSigned=true} serves one pair per user, kept
|
||||||
|
// on disk so that it, and with it the SHA-256 a browser pins through
|
||||||
|
// serverCertificateHashes, survives a server restart while it is valid:
|
||||||
|
// 3DForts publishes that hash to its pages, and one that changed on
|
||||||
|
// every restart would break every page still holding the old one. The
|
||||||
|
// pair is made in-process by :TLS (MakeWebTransportCertificatePem, whose
|
||||||
|
// comment has the shape Chromium demands), so nothing has to be
|
||||||
|
// installed; it used to take the openssl CLI.
|
||||||
//
|
//
|
||||||
// Cert shape: ECDSA P-256, validity 13 days, SAN={DNS:localhost,
|
// Where: $XDG_CACHE_HOME/crafter-network/quic-cert/, else
|
||||||
// IP:127.0.0.1, IP:::1}. These constraints are mandated by Chromium's
|
// $HOME/.cache/crafter-network/quic-cert/, else
|
||||||
// WebTransport `serverCertificateHashes` (must be ECDSA P-256, validity
|
// $XDG_RUNTIME_DIR/crafter-network/quic-cert/ (missing parents made
|
||||||
// <14 days, SAN with the connect target). msquic accepts the same cert
|
// 0700, as the XDG spec asks). It used to be the fixed
|
||||||
// unchanged, so existing pure-QUIC callers are unaffected.
|
// /tmp/crafter-network-quic-cert/, which every user of the machine
|
||||||
|
// shared: whoever made it first could keep everyone else from writing
|
||||||
|
// there, or plant a pair. The last directory is opened O_NOFOLLOW and
|
||||||
|
// refused unless it is this user's, and made 0700 if it was not.
|
||||||
|
//
|
||||||
|
// Several processes: test binaries the runner starts at once all find no
|
||||||
|
// pair and all make one. With the CLI writing key.pem and cert.pem in
|
||||||
|
// place, one after the other, under an in-process mutex only, a process
|
||||||
|
// could load one run's key with another's certificate, and the listener
|
||||||
|
// failed in ConfigurationLoadCredential. Now:
|
||||||
|
// - a POSIX record lock on `lock` in the directory covers the check
|
||||||
|
// and the replacement, so one process makes the pair and the rest
|
||||||
|
// read it. A record lock rather than flock: it belongs to the
|
||||||
|
// process, so a child forked while another thread holds it does not
|
||||||
|
// inherit it and hold it for its whole life; threads of one process
|
||||||
|
// are kept apart by the mutex in GetSelfSignedCert instead;
|
||||||
|
// - each file is written under a temporary name and renamed over the
|
||||||
|
// old one, so a reader sees an old file or a new one, never half;
|
||||||
|
// - the listener loads the pair from memory, as PKCS#12, not from the
|
||||||
|
// files, so a replacement cannot land between its reads of the two;
|
||||||
|
// - a pair that does not parse, does not match, or has less than a day
|
||||||
|
// left is replaced; so is one a crash tore between the two renames.
|
||||||
struct SelfSignedCert {
|
struct SelfSignedCert {
|
||||||
std::string certPath;
|
std::string certPath;
|
||||||
std::string keyPath;
|
std::string keyPath;
|
||||||
|
TLSCertificatePem pem;
|
||||||
|
std::chrono::system_clock::time_point notAfter;
|
||||||
};
|
};
|
||||||
SelfSignedCert& GetSelfSignedCert() {
|
|
||||||
static std::mutex mtx;
|
// A pair this close to its end is replaced rather than served: a server
|
||||||
|
// started on it would outlive it.
|
||||||
|
constexpr std::chrono::hours renewWithin{ 24 };
|
||||||
|
|
||||||
|
// The lock is held for a read of two small files, or for making a pair,
|
||||||
|
// milliseconds either way; the bound is for a holder that was stopped.
|
||||||
|
constexpr std::chrono::seconds lockTimeout{ 10 };
|
||||||
|
|
||||||
|
// A file descriptor, closed on scope exit.
|
||||||
|
struct Descriptor {
|
||||||
|
int fd = -1;
|
||||||
|
explicit Descriptor(int fd) : fd(fd) {}
|
||||||
|
Descriptor(Descriptor&& other) noexcept : fd(std::exchange(other.fd, -1)) {}
|
||||||
|
Descriptor(const Descriptor&) = delete;
|
||||||
|
Descriptor& operator=(const Descriptor&) = delete;
|
||||||
|
~Descriptor() { if (fd >= 0) ::close(fd); }
|
||||||
|
};
|
||||||
|
|
||||||
|
[[noreturn]] void ThrowCertificateError(std::string_view what, const std::filesystem::path& path) {
|
||||||
|
throw QUICException(std::format("selfSigned: {} {}: {}; pass certPath/keyPath instead",
|
||||||
|
what, path.string(), std::strerror(errno)));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path CertificateDirectory() {
|
||||||
|
// The XDG spec says to ignore a relative path in these.
|
||||||
|
auto absolute = [](const char* name) -> std::optional<std::filesystem::path> {
|
||||||
|
const char* value = std::getenv(name);
|
||||||
|
if (value == nullptr || value[0] != '/') return std::nullopt;
|
||||||
|
return std::filesystem::path(value);
|
||||||
|
};
|
||||||
|
if (auto cache = absolute("XDG_CACHE_HOME")) return *cache / "crafter-network" / "quic-cert";
|
||||||
|
if (auto home = absolute("HOME")) return *home / ".cache" / "crafter-network" / "quic-cert";
|
||||||
|
if (auto runtime = absolute("XDG_RUNTIME_DIR")) return *runtime / "crafter-network" / "quic-cert";
|
||||||
|
throw QUICException("selfSigned: no per-user directory for the certificate "
|
||||||
|
"(XDG_CACHE_HOME, HOME and XDG_RUNTIME_DIR are all unset); "
|
||||||
|
"pass certPath/keyPath instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Makes whatever is missing of `path`, 0700, and opens the last
|
||||||
|
// directory, which must be this user's and nobody else's.
|
||||||
|
Descriptor OpenPrivateDirectory(const std::filesystem::path& path) {
|
||||||
|
std::filesystem::path partial;
|
||||||
|
for (const std::filesystem::path& part : path) {
|
||||||
|
partial /= part;
|
||||||
|
if (::mkdir(partial.c_str(), 0700) != 0 && errno != EEXIST) {
|
||||||
|
ThrowCertificateError("could not create", partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Descriptor directory(::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC));
|
||||||
|
if (directory.fd < 0) ThrowCertificateError("could not open", path);
|
||||||
|
struct stat info {};
|
||||||
|
if (::fstat(directory.fd, &info) != 0) ThrowCertificateError("could not stat", path);
|
||||||
|
if (info.st_uid != ::geteuid()) {
|
||||||
|
throw QUICException(std::format("selfSigned: {} belongs to another user; "
|
||||||
|
"pass certPath/keyPath instead", path.string()));
|
||||||
|
}
|
||||||
|
if ((info.st_mode & 077) != 0 && ::fchmod(directory.fd, 0700) != 0) {
|
||||||
|
ThrowCertificateError("could not make private", path);
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exclusive record lock on `lock` in `directory`, held until the
|
||||||
|
// returned descriptor closes, and given up on after lockTimeout.
|
||||||
|
Descriptor LockDirectory(int directory, const std::filesystem::path& path) {
|
||||||
|
Descriptor lock(::openat(directory, "lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600));
|
||||||
|
if (lock.fd < 0) ThrowCertificateError("could not open", path / "lock");
|
||||||
|
struct flock whole {};
|
||||||
|
whole.l_type = F_WRLCK;
|
||||||
|
whole.l_whence = SEEK_SET;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + lockTimeout;
|
||||||
|
while (::fcntl(lock.fd, F_SETLK, &whole) != 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (errno != EACCES && errno != EAGAIN) ThrowCertificateError("could not lock", path / "lock");
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) {
|
||||||
|
throw QUICException(std::format("selfSigned: another process held {} for {} s",
|
||||||
|
(path / "lock").string(), lockTimeout.count()));
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `name` in `directory`, or nothing when it is missing, unreadable, or
|
||||||
|
// far too big for a PEM.
|
||||||
|
std::optional<std::string> ReadAt(int directory, const char* name) {
|
||||||
|
Descriptor file(::openat(directory, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC));
|
||||||
|
if (file.fd < 0) return std::nullopt;
|
||||||
|
std::string contents;
|
||||||
|
char buffer[4096];
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t got = ::read(file.fd, buffer, sizeof(buffer));
|
||||||
|
if (got == 0) return contents;
|
||||||
|
if (got < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
contents.append(buffer, static_cast<std::size_t>(got));
|
||||||
|
if (contents.size() > 64 * 1024) return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces `name` in `directory` by writing a temporary file and
|
||||||
|
// renaming it over the old one. Only the holder of the lock writes here,
|
||||||
|
// so a temporary file found is one a crashed writer left.
|
||||||
|
void WriteAt(int directory, const std::filesystem::path& path, const std::string& name,
|
||||||
|
const std::string& contents, mode_t mode) {
|
||||||
|
const std::string temporary = name + ".new";
|
||||||
|
::unlinkat(directory, temporary.c_str(), 0);
|
||||||
|
Descriptor file(::openat(directory, temporary.c_str(),
|
||||||
|
O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode));
|
||||||
|
if (file.fd < 0) ThrowCertificateError("could not create", path / temporary);
|
||||||
|
for (std::size_t written = 0; written < contents.size();) {
|
||||||
|
const ssize_t put = ::write(file.fd, contents.data() + written, contents.size() - written);
|
||||||
|
if (put < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
ThrowCertificateError("could not write", path / temporary);
|
||||||
|
}
|
||||||
|
written += static_cast<std::size_t>(put);
|
||||||
|
}
|
||||||
|
if (::fsync(file.fd) != 0) ThrowCertificateError("could not flush", path / temporary);
|
||||||
|
if (::renameat(directory, temporary.c_str(), directory, name.c_str()) != 0) {
|
||||||
|
ThrowCertificateError("could not rename into", path / name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pair on disk if it is good for more than renewWithin, else a new
|
||||||
|
// one written there. Under the directory's lock throughout.
|
||||||
|
SelfSignedCert LoadOrMakeSelfSignedCert() {
|
||||||
|
const std::filesystem::path path = CertificateDirectory();
|
||||||
|
const Descriptor directory = OpenPrivateDirectory(path);
|
||||||
|
const Descriptor lock = LockDirectory(directory.fd, path);
|
||||||
|
SelfSignedCert cert;
|
||||||
|
cert.certPath = (path / "cert.pem").string();
|
||||||
|
cert.keyPath = (path / "key.pem").string();
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
|
||||||
|
std::optional<std::string> certificate = ReadAt(directory.fd, "cert.pem");
|
||||||
|
std::optional<std::string> key = ReadAt(directory.fd, "key.pem");
|
||||||
|
if (certificate && key) {
|
||||||
|
cert.pem = TLSCertificatePem{ std::move(*certificate), std::move(*key) };
|
||||||
|
const auto expiry = CertificatePairExpiry(cert.pem);
|
||||||
|
if (expiry && *expiry - now > renewWithin) {
|
||||||
|
cert.notAfter = *expiry;
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::chrono::system_clock::time_point> expiry;
|
||||||
|
try {
|
||||||
|
cert.pem = MakeWebTransportCertificatePem();
|
||||||
|
expiry = CertificatePairExpiry(cert.pem);
|
||||||
|
} catch (const TLSException& e) {
|
||||||
|
throw QUICException(std::format("selfSigned: {}", e.what()));
|
||||||
|
}
|
||||||
|
if (!expiry) throw QUICException("selfSigned: the certificate just made does not check out");
|
||||||
|
cert.notAfter = *expiry;
|
||||||
|
// The key first, readable by this user alone; the certificate is
|
||||||
|
// public. Each rename is atomic, the pair is not, and that is fine:
|
||||||
|
// every reader holds the lock.
|
||||||
|
WriteAt(directory.fd, path, "key.pem", cert.pem.privateKey, 0600);
|
||||||
|
WriteAt(directory.fd, path, "cert.pem", cert.pem.certificate, 0644);
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This process's pair: loaded on first use and again once it has less
|
||||||
|
// than renewWithin left, so every listener in between serves the same
|
||||||
|
// certificate, the one GetSelfSignedCertificatePath() names.
|
||||||
|
SelfSignedCert GetSelfSignedCert() {
|
||||||
|
static std::mutex mutex;
|
||||||
static std::optional<SelfSignedCert> cached;
|
static std::optional<SelfSignedCert> cached;
|
||||||
std::lock_guard lk(mtx);
|
std::lock_guard lock(mutex);
|
||||||
if (cached) return *cached;
|
if (!cached || cached->notAfter - std::chrono::system_clock::now() <= renewWithin) {
|
||||||
|
cached = LoadOrMakeSelfSignedCert();
|
||||||
// Stable on-disk location so the cert (and therefore its SHA-256) is
|
|
||||||
// reused across server restarts. Without this, a browser peer that
|
|
||||||
// pinned the cert hash on a previous run would see a hash mismatch
|
|
||||||
// the moment we restart. We only regenerate if the cert file is
|
|
||||||
// missing or has expired.
|
|
||||||
std::filesystem::path dir = "/tmp/crafter-network-quic-cert";
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::create_directories(dir, ec);
|
|
||||||
if (ec) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"could not create cert cache dir {}: {}", dir.string(), ec.message()));
|
|
||||||
}
|
}
|
||||||
SelfSignedCert s;
|
|
||||||
s.keyPath = (dir / "key.pem").string();
|
|
||||||
s.certPath = (dir / "cert.pem").string();
|
|
||||||
|
|
||||||
bool needRegen = !std::filesystem::exists(s.certPath, ec)
|
|
||||||
|| !std::filesystem::exists(s.keyPath, ec);
|
|
||||||
if (!needRegen) {
|
|
||||||
// Use openssl to ask whether the cert is still valid. -checkend 0
|
|
||||||
// returns 0 if the cert is still good, non-zero if expired.
|
|
||||||
int rc = std::system(std::format(
|
|
||||||
"openssl x509 -in '{}' -noout -checkend 0 >/dev/null 2>&1",
|
|
||||||
s.certPath).c_str());
|
|
||||||
if (rc != 0) needRegen = true;
|
|
||||||
}
|
|
||||||
if (needRegen) {
|
|
||||||
// Inline openssl config so we get exactly the extensions Chromium's
|
|
||||||
// WebTransport cert-hash verifier accepts (BasicConstraints CA:FALSE,
|
|
||||||
// KeyUsage digitalSignature, EKU serverAuth, SAN). Skipping the
|
|
||||||
// implicit subjectKeyIdentifier / authorityKeyIdentifier that
|
|
||||||
// `openssl req -x509 -addext ...` would otherwise add.
|
|
||||||
std::string cmd = std::format(
|
|
||||||
"openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
|
|
||||||
"-keyout '{}' -out '{}' -days 10 -nodes "
|
|
||||||
"-extensions v3_wt "
|
|
||||||
"-config /dev/stdin >/dev/null 2>&1 <<'CONFIG'\n"
|
|
||||||
"[req]\n"
|
|
||||||
"distinguished_name = req_dn\n"
|
|
||||||
"prompt = no\n"
|
|
||||||
"[req_dn]\n"
|
|
||||||
"CN = localhost\n"
|
|
||||||
"[v3_wt]\n"
|
|
||||||
"basicConstraints = critical, CA:FALSE\n"
|
|
||||||
"keyUsage = critical, digitalSignature\n"
|
|
||||||
"extendedKeyUsage = serverAuth\n"
|
|
||||||
"subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1\n"
|
|
||||||
"CONFIG\n",
|
|
||||||
s.keyPath, s.certPath);
|
|
||||||
int rc = std::system(cmd.c_str());
|
|
||||||
if (rc != 0) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"openssl CLI failed to generate self-signed cert "
|
|
||||||
"(exit {}); install openssl or pass certPath/keyPath",
|
|
||||||
rc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cached = std::move(s);
|
|
||||||
return *cached;
|
return *cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The PKCS#12 a selfSigned listener hands msquic is made and parsed in
|
||||||
|
// memory within one call, so its password protects nothing; msquic's
|
||||||
|
// own code does the same with a random one.
|
||||||
|
constexpr const char* pkcs12Password = "crafter-network";
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ListenerQUIC::Impl {
|
struct ListenerQUIC::Impl {
|
||||||
HQUIC listener = nullptr;
|
HQUIC listener = nullptr;
|
||||||
HQUIC configuration = nullptr;
|
HQUIC configuration = nullptr;
|
||||||
std::vector<std::uint8_t> alpnBuf;
|
std::vector<std::uint8_t> alpnBuf;
|
||||||
|
// The port msquic bound, read back once ListenerStart returned: a port
|
||||||
|
// of 0 asks msquic for an ephemeral one, and only the listener knows
|
||||||
|
// which it got.
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
|
||||||
ListenerQUIC* outer = nullptr;
|
ListenerQUIC* outer = nullptr;
|
||||||
|
|
||||||
|
|
@ -167,6 +346,10 @@ struct ListenerQUIC::Impl {
|
||||||
// by raw pointer; running it past the destructor is a use-after-free).
|
// by raw pointer; running it past the destructor is a use-after-free).
|
||||||
std::thread acceptLoop;
|
std::thread acceptLoop;
|
||||||
|
|
||||||
|
// Open the configuration and the listener and start it on `address`,
|
||||||
|
// or throw with nothing left open. Shared by both constructors.
|
||||||
|
void Start(const QUIC_ADDR& address, const QUICServerCredentials& creds);
|
||||||
|
|
||||||
static QUIC_STATUS QUIC_API Callback(HQUIC, void* ctx, QUIC_LISTENER_EVENT* ev) {
|
static QUIC_STATUS QUIC_API Callback(HQUIC, void* ctx, QUIC_LISTENER_EVENT* ev) {
|
||||||
auto* self = static_cast<Impl*>(ctx);
|
auto* self = static_cast<Impl*>(ctx);
|
||||||
switch (ev->Type) {
|
switch (ev->Type) {
|
||||||
|
|
@ -218,6 +401,36 @@ static HQUIC OpenServerConfiguration(const std::string& alpn,
|
||||||
std::vector<std::uint8_t>& alpnBufOut) {
|
std::vector<std::uint8_t>& alpnBufOut) {
|
||||||
QUIC_BUFFER alpnBuffer = MakeAlpn(alpn, alpnBufOut);
|
QUIC_BUFFER alpnBuffer = MakeAlpn(alpn, alpnBufOut);
|
||||||
|
|
||||||
|
// The credential first, since getting it can throw and nothing is open
|
||||||
|
// yet. selfSigned reaches msquic from memory, as PKCS#12, so the files,
|
||||||
|
// which another process may replace, are read once and under their
|
||||||
|
// lock (see GetSelfSignedCert); a certPath/keyPath pair is msquic's to
|
||||||
|
// read.
|
||||||
|
QUIC_CREDENTIAL_CONFIG cc{};
|
||||||
|
QUIC_CERTIFICATE_FILE certFile{};
|
||||||
|
QUIC_CERTIFICATE_PKCS12 pkcs12{};
|
||||||
|
std::vector<std::uint8_t> pkcs12Blob;
|
||||||
|
std::string certDescription = creds.certPath;
|
||||||
|
if (creds.selfSigned) {
|
||||||
|
const SelfSignedCert cert = GetSelfSignedCert();
|
||||||
|
try {
|
||||||
|
pkcs12Blob = EncodePkcs12(cert.pem, pkcs12Password);
|
||||||
|
} catch (const TLSException& e) {
|
||||||
|
throw QUICException(std::format("selfSigned: {}", e.what()));
|
||||||
|
}
|
||||||
|
pkcs12.Asn1Blob = pkcs12Blob.data();
|
||||||
|
pkcs12.Asn1BlobLength = static_cast<std::uint32_t>(pkcs12Blob.size());
|
||||||
|
pkcs12.PrivateKeyPassword = pkcs12Password;
|
||||||
|
cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12;
|
||||||
|
cc.CertificatePkcs12 = &pkcs12;
|
||||||
|
certDescription = cert.certPath;
|
||||||
|
} else {
|
||||||
|
certFile.CertificateFile = creds.certPath.c_str();
|
||||||
|
certFile.PrivateKeyFile = creds.keyPath.c_str();
|
||||||
|
cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE;
|
||||||
|
cc.CertificateFile = &certFile;
|
||||||
|
}
|
||||||
|
|
||||||
QUIC_SETTINGS settings{};
|
QUIC_SETTINGS settings{};
|
||||||
settings.IsSet.IdleTimeoutMs = 1;
|
settings.IsSet.IdleTimeoutMs = 1;
|
||||||
settings.IdleTimeoutMs = creds.settings.idleTimeoutMs;
|
settings.IdleTimeoutMs = creds.settings.idleTimeoutMs;
|
||||||
|
|
@ -237,33 +450,67 @@ static HQUIC OpenServerConfiguration(const std::string& alpn,
|
||||||
&settings, sizeof(settings), nullptr, &cfg);
|
&settings, sizeof(settings), nullptr, &cfg);
|
||||||
if (QUIC_FAILED(s)) throw QUICException(std::format("ConfigurationOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
if (QUIC_FAILED(s)) throw QUICException(std::format("ConfigurationOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||||
|
|
||||||
QUIC_CREDENTIAL_CONFIG cc{};
|
// Synchronous, without QUIC_CREDENTIAL_FLAG_LOAD_ASYNCHRONOUS: msquic has
|
||||||
QUIC_CERTIFICATE_FILE certFile{};
|
// parsed the PKCS#12 into an SSL_CTX of its own before this returns.
|
||||||
std::string effectiveCertPath;
|
|
||||||
std::string effectiveKeyPath;
|
|
||||||
if (creds.selfSigned) {
|
|
||||||
const SelfSignedCert& s = GetSelfSignedCert();
|
|
||||||
effectiveCertPath = s.certPath;
|
|
||||||
effectiveKeyPath = s.keyPath;
|
|
||||||
} else {
|
|
||||||
effectiveCertPath = creds.certPath;
|
|
||||||
effectiveKeyPath = creds.keyPath;
|
|
||||||
}
|
|
||||||
certFile.CertificateFile = effectiveCertPath.c_str();
|
|
||||||
certFile.PrivateKeyFile = effectiveKeyPath.c_str();
|
|
||||||
cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE;
|
|
||||||
cc.CertificateFile = &certFile;
|
|
||||||
s = Runtime().api->ConfigurationLoadCredential(cfg, &cc);
|
s = Runtime().api->ConfigurationLoadCredential(cfg, &cc);
|
||||||
if (QUIC_FAILED(s)) {
|
if (QUIC_FAILED(s)) {
|
||||||
Runtime().api->ConfigurationClose(cfg);
|
Runtime().api->ConfigurationClose(cfg);
|
||||||
throw QUICException(std::format("ConfigurationLoadCredential failed: 0x{:x}"
|
throw QUICException(std::format("ConfigurationLoadCredential failed: 0x{:x}"
|
||||||
" (selfSigned={}, cert={})",
|
" (selfSigned={}, cert={})",
|
||||||
static_cast<unsigned>(s),
|
static_cast<unsigned>(s),
|
||||||
creds.selfSigned, creds.certPath));
|
creds.selfSigned, certDescription));
|
||||||
}
|
}
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ListenerQUIC::Impl::Start(const QUIC_ADDR& address, const QUICServerCredentials& creds) {
|
||||||
|
configuration = OpenServerConfiguration(outer->alpn, creds, alpnBuf);
|
||||||
|
|
||||||
|
QUIC_STATUS s = Runtime().api->ListenerOpen(Runtime().registration,
|
||||||
|
reinterpret_cast<QUIC_LISTENER_CALLBACK_HANDLER>(&Impl::Callback),
|
||||||
|
this, &listener);
|
||||||
|
if (QUIC_FAILED(s)) {
|
||||||
|
Runtime().api->ConfigurationClose(configuration);
|
||||||
|
configuration = nullptr;
|
||||||
|
throw QUICException(std::format("ListenerOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
QUIC_BUFFER alpnBuffer{};
|
||||||
|
alpnBuffer.Length = static_cast<std::uint32_t>(alpnBuf.size());
|
||||||
|
alpnBuffer.Buffer = alpnBuf.data();
|
||||||
|
|
||||||
|
// Whatever the address, msquic binds the dual-stack wildcard socket for
|
||||||
|
// the port and matches a new connection's local address against this
|
||||||
|
// one itself (listener.c, QuicBindingGetListener).
|
||||||
|
s = Runtime().api->ListenerStart(listener, &alpnBuffer, 1, &address);
|
||||||
|
if (QUIC_FAILED(s)) {
|
||||||
|
Runtime().api->ListenerClose(listener);
|
||||||
|
Runtime().api->ConfigurationClose(configuration);
|
||||||
|
listener = nullptr;
|
||||||
|
configuration = nullptr;
|
||||||
|
throw QUICException(std::format("ListenerStart failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListenerStart writes the port of the UDP socket it bound into the
|
||||||
|
// listener's local address when it was asked for port 0, and only
|
||||||
|
// then; read it back rather than trusting the argument. The UNSPEC
|
||||||
|
// address still carries it: the port sits at the same offset in
|
||||||
|
// sockaddr_in and sockaddr_in6, and QuicAddrGetPort reads the latter
|
||||||
|
// for anything that is not INET.
|
||||||
|
QUIC_ADDR bound{};
|
||||||
|
std::uint32_t boundSize = sizeof(bound);
|
||||||
|
s = Runtime().api->GetParam(listener, QUIC_PARAM_LISTENER_LOCAL_ADDRESS, &boundSize, &bound);
|
||||||
|
if (QUIC_FAILED(s)) {
|
||||||
|
Runtime().api->ListenerClose(listener);
|
||||||
|
Runtime().api->ConfigurationClose(configuration);
|
||||||
|
listener = nullptr;
|
||||||
|
configuration = nullptr;
|
||||||
|
throw QUICException(std::format("GetParam(QUIC_PARAM_LISTENER_LOCAL_ADDRESS) failed: 0x{:x}",
|
||||||
|
static_cast<unsigned>(s)));
|
||||||
|
}
|
||||||
|
port = QuicAddrGetPort(&bound);
|
||||||
|
}
|
||||||
|
|
||||||
ListenerQUIC::ListenerQUIC(std::uint16_t port,
|
ListenerQUIC::ListenerQUIC(std::uint16_t port,
|
||||||
std::string alpnIn,
|
std::string alpnIn,
|
||||||
QUICServerCredentials creds,
|
QUICServerCredentials creds,
|
||||||
|
|
@ -277,30 +524,32 @@ ListenerQUIC::ListenerQUIC(std::uint16_t port,
|
||||||
, impl(std::make_unique<Impl>())
|
, impl(std::make_unique<Impl>())
|
||||||
{
|
{
|
||||||
impl->outer = this;
|
impl->outer = this;
|
||||||
impl->configuration = OpenServerConfiguration(alpn, creds, impl->alpnBuf);
|
// UNSPEC: every address of both families.
|
||||||
|
|
||||||
QUIC_STATUS s = Runtime().api->ListenerOpen(Runtime().registration,
|
|
||||||
reinterpret_cast<QUIC_LISTENER_CALLBACK_HANDLER>(&Impl::Callback),
|
|
||||||
impl.get(), &impl->listener);
|
|
||||||
if (QUIC_FAILED(s)) {
|
|
||||||
Runtime().api->ConfigurationClose(impl->configuration);
|
|
||||||
throw QUICException(std::format("ListenerOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
||||||
}
|
|
||||||
|
|
||||||
QUIC_BUFFER alpnBuffer{};
|
|
||||||
alpnBuffer.Length = static_cast<std::uint32_t>(impl->alpnBuf.size());
|
|
||||||
alpnBuffer.Buffer = impl->alpnBuf.data();
|
|
||||||
|
|
||||||
QUIC_ADDR addr{};
|
QUIC_ADDR addr{};
|
||||||
QuicAddrSetFamily(&addr, QUIC_ADDRESS_FAMILY_UNSPEC);
|
QuicAddrSetFamily(&addr, QUIC_ADDRESS_FAMILY_UNSPEC);
|
||||||
QuicAddrSetPort(&addr, port);
|
QuicAddrSetPort(&addr, port);
|
||||||
|
impl->Start(addr, creds);
|
||||||
s = Runtime().api->ListenerStart(impl->listener, &alpnBuffer, 1, &addr);
|
|
||||||
if (QUIC_FAILED(s)) {
|
|
||||||
Runtime().api->ListenerClose(impl->listener);
|
|
||||||
Runtime().api->ConfigurationClose(impl->configuration);
|
|
||||||
throw QUICException(std::format("ListenerStart failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ListenerQUIC::ListenerQUIC(std::string_view bindAddress,
|
||||||
|
std::uint16_t port,
|
||||||
|
std::string alpnIn,
|
||||||
|
QUICServerCredentials creds,
|
||||||
|
std::function<void(ClientQUIC*)> cb,
|
||||||
|
std::uint32_t concurrentClientLimit,
|
||||||
|
std::uint32_t totalClientLimit)
|
||||||
|
: connectCallback(std::move(cb))
|
||||||
|
, concurrentClientLimit(concurrentClientLimit)
|
||||||
|
, totalClientLimit(totalClientLimit)
|
||||||
|
, alpn(std::move(alpnIn))
|
||||||
|
, impl(std::make_unique<Impl>())
|
||||||
|
{
|
||||||
|
impl->outer = this;
|
||||||
|
impl->Start(NumericAddress(bindAddress, port), creds);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint16_t ListenerQUIC::Port() const {
|
||||||
|
return impl ? impl->port : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
ListenerQUIC::ListenerQUIC(ListenerQUIC&& other) noexcept
|
ListenerQUIC::ListenerQUIC(ListenerQUIC&& other) noexcept
|
||||||
|
|
@ -385,49 +634,16 @@ void ListenerQUIC::ListenAsyncAsync() {
|
||||||
}
|
}
|
||||||
|
|
||||||
std::array<std::uint8_t, 32> Crafter::ComputeCertificateHashSHA256(const std::string& certPath) {
|
std::array<std::uint8_t, 32> Crafter::ComputeCertificateHashSHA256(const std::string& certPath) {
|
||||||
// Convert PEM → DER → SHA-256 via openssl, capture hex digest from stdout.
|
// In-process through :TLS; this used to pipe the file through two
|
||||||
// openssl pipes give us "SHA2-256(stdin)= <hex>\n"; we parse the trailing
|
// openssl CLI invocations and parse the hex back out of the output.
|
||||||
// hex run. Shelling out keeps msquic the sole TLS dependency.
|
std::ifstream file(certPath, std::ios::binary);
|
||||||
std::string cmd = std::format(
|
if (!file) throw QUICException(std::format("could not open {} to hash it", certPath));
|
||||||
"openssl x509 -in '{}' -outform der | openssl dgst -sha256 2>/dev/null",
|
const std::string pem((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||||
certPath);
|
try {
|
||||||
FILE* p = popen(cmd.c_str(), "r");
|
return CertificateSha256(pem);
|
||||||
if (!p) throw QUICException("popen failed while computing cert hash");
|
} catch (const TLSException& e) {
|
||||||
std::string out;
|
throw QUICException(std::format("could not compute the SHA-256 of {}: {}", certPath, e.what()));
|
||||||
char chunk[256];
|
|
||||||
while (auto n = std::fread(chunk, 1, sizeof(chunk), p)) {
|
|
||||||
out.append(chunk, n);
|
|
||||||
}
|
}
|
||||||
int rc = pclose(p);
|
|
||||||
if (rc != 0) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"openssl failed to compute SHA-256 of {} (exit {})", certPath, rc));
|
|
||||||
}
|
|
||||||
auto eq = out.rfind('=');
|
|
||||||
if (eq == std::string::npos) {
|
|
||||||
throw QUICException("could not parse openssl dgst output");
|
|
||||||
}
|
|
||||||
std::string hex;
|
|
||||||
for (std::size_t i = eq + 1; i < out.size(); ++i) {
|
|
||||||
char c = out[i];
|
|
||||||
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
||||||
hex.push_back(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hex.size() != 64) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"unexpected SHA-256 hex length {} (expected 64)", hex.size()));
|
|
||||||
}
|
|
||||||
std::array<std::uint8_t, 32> result{};
|
|
||||||
for (std::size_t i = 0; i < 32; ++i) {
|
|
||||||
auto nibble = [](char c) -> std::uint8_t {
|
|
||||||
if (c >= '0' && c <= '9') return c - '0';
|
|
||||||
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
|
|
||||||
return 10 + (c - 'A');
|
|
||||||
};
|
|
||||||
result[i] = (nibble(hex[2 * i]) << 4) | nibble(hex[2 * i + 1]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Crafter::GetSelfSignedCertificatePath() {
|
std::string Crafter::GetSelfSignedCertificatePath() {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ module;
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
#include <openssl/bio.h>
|
#include <openssl/bio.h>
|
||||||
|
#include <openssl/bn.h>
|
||||||
#include <openssl/err.h>
|
#include <openssl/err.h>
|
||||||
#include <openssl/evp.h>
|
#include <openssl/evp.h>
|
||||||
#include <openssl/pem.h>
|
#include <openssl/pem.h>
|
||||||
|
#include <openssl/pkcs12.h>
|
||||||
#include <openssl/ssl.h>
|
#include <openssl/ssl.h>
|
||||||
#include <openssl/sslerr.h>
|
#include <openssl/sslerr.h>
|
||||||
#include <openssl/x509v3.h>
|
#include <openssl/x509v3.h>
|
||||||
|
|
@ -78,6 +81,8 @@ namespace {
|
||||||
using OwnedBio = Owned<BIO, BIO_free_all>;
|
using OwnedBio = Owned<BIO, BIO_free_all>;
|
||||||
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
|
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
|
||||||
using OwnedCertificate = Owned<X509, X509_free>;
|
using OwnedCertificate = Owned<X509, X509_free>;
|
||||||
|
using OwnedBignum = Owned<BIGNUM, BN_free>;
|
||||||
|
using OwnedPkcs12 = Owned<PKCS12, PKCS12_free>;
|
||||||
|
|
||||||
// Drain OpenSSL's per-thread error queue into a readable message. Without
|
// Drain OpenSSL's per-thread error queue into a readable message. Without
|
||||||
// this every TLS failure reads as "handshake failed" with no hint as to
|
// this every TLS failure reads as "handshake failed" with no hint as to
|
||||||
|
|
@ -167,7 +172,9 @@ namespace {
|
||||||
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
|
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
|
||||||
}
|
}
|
||||||
|
|
||||||
TLSCertificatePem MakeSelfSignedCertificate() {
|
// `keyUsage` is the KeyUsage extension's value; `randomSerial` draws the
|
||||||
|
// serial number instead of using 1.
|
||||||
|
TLSCertificatePem MakeSelfSignedCertificate(const char* keyUsage, bool randomSerial) {
|
||||||
OwnedKey key(EVP_EC_gen("P-256"));
|
OwnedKey key(EVP_EC_gen("P-256"));
|
||||||
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
|
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
|
||||||
|
|
||||||
|
|
@ -177,7 +184,17 @@ namespace {
|
||||||
// X509_set_version takes the zero-based version, so 2 is v3 — which is
|
// X509_set_version takes the zero-based version, so 2 is v3 — which is
|
||||||
// what the extensions below require.
|
// what the extensions below require.
|
||||||
X509_set_version(certificate.get(), 2);
|
X509_set_version(certificate.get(), 2);
|
||||||
|
if (randomSerial) {
|
||||||
|
// 127 bits: positive, and within the 20 octets RFC 5280 allows.
|
||||||
|
OwnedBignum serial(BN_new());
|
||||||
|
if (!serial
|
||||||
|
|| BN_rand(serial.get(), 127, BN_RAND_TOP_ANY, BN_RAND_BOTTOM_ANY) != 1
|
||||||
|
|| BN_to_ASN1_INTEGER(serial.get(), X509_get_serialNumber(certificate.get())) == nullptr) {
|
||||||
|
throw TLSException(Describe("could not draw a serial number"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
|
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
|
||||||
|
}
|
||||||
// Backdated an hour so a peer whose clock runs slightly behind ours
|
// Backdated an hour so a peer whose clock runs slightly behind ours
|
||||||
// does not reject a certificate we just minted.
|
// does not reject a certificate we just minted.
|
||||||
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
|
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
|
||||||
|
|
@ -198,8 +215,7 @@ namespace {
|
||||||
nullptr, nullptr, 0);
|
nullptr, nullptr, 0);
|
||||||
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
|
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
|
||||||
"critical,CA:FALSE");
|
"critical,CA:FALSE");
|
||||||
AddExtension(certificate.get(), &extensionContext, NID_key_usage,
|
AddExtension(certificate.get(), &extensionContext, NID_key_usage, keyUsage);
|
||||||
"critical,digitalSignature,keyEncipherment");
|
|
||||||
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
|
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
|
||||||
// The SANs are what a verifying client actually matches on; a bare CN
|
// The SANs are what a verifying client actually matches on; a bare CN
|
||||||
// has not been accepted by anything for years.
|
// has not been accepted by anything for years.
|
||||||
|
|
@ -716,6 +732,79 @@ const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() {
|
||||||
static std::mutex mutex;
|
static std::mutex mutex;
|
||||||
static std::optional<TLSCertificatePem> cached;
|
static std::optional<TLSCertificatePem> cached;
|
||||||
std::lock_guard lock(mutex);
|
std::lock_guard lock(mutex);
|
||||||
if (!cached) cached = MakeSelfSignedCertificate();
|
if (!cached) cached = MakeSelfSignedCertificate("critical,digitalSignature,keyEncipherment", false);
|
||||||
return *cached;
|
return *cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Module-internal: :ListenerQUIC's self-signed credential ──────────────
|
||||||
|
|
||||||
|
TLSCertificatePem Crafter::MakeWebTransportCertificatePem() {
|
||||||
|
// No keyEncipherment, unlike the TLS certificate above: the openssl
|
||||||
|
// invocation this replaces asked for digitalSignature alone, which is
|
||||||
|
// all an ECDSA key can do anyway.
|
||||||
|
return MakeSelfSignedCertificate("critical,digitalSignature", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::chrono::system_clock::time_point>
|
||||||
|
Crafter::CertificatePairExpiry(const TLSCertificatePem& pem) {
|
||||||
|
constexpr std::size_t limit = 1 << 20;
|
||||||
|
if (pem.certificate.empty() || pem.privateKey.empty()
|
||||||
|
|| pem.certificate.size() > limit || pem.privateKey.size() > limit) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
OwnedBio certificateBio(BIO_new_mem_buf(pem.certificate.data(), static_cast<int>(pem.certificate.size())));
|
||||||
|
OwnedBio keyBio(BIO_new_mem_buf(pem.privateKey.data(), static_cast<int>(pem.privateKey.size())));
|
||||||
|
if (!certificateBio || !keyBio) {
|
||||||
|
ERR_clear_error();
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
OwnedCertificate certificate(PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
OwnedKey key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
std::optional<std::chrono::system_clock::time_point> expiry;
|
||||||
|
std::tm notAfter{};
|
||||||
|
// X509_cmp_current_time is -1 for a time already past, so notBefore has
|
||||||
|
// to compare below 0; 0 is an unreadable time.
|
||||||
|
if (certificate && key
|
||||||
|
&& X509_check_private_key(certificate.get(), key.get()) == 1
|
||||||
|
&& X509_cmp_current_time(X509_get0_notBefore(certificate.get())) < 0
|
||||||
|
&& ASN1_TIME_to_tm(X509_get0_notAfter(certificate.get()), ¬After) == 1) {
|
||||||
|
expiry = std::chrono::system_clock::from_time_t(timegm(¬After));
|
||||||
|
}
|
||||||
|
// What a bad pair left on the queue is not anyone else's error.
|
||||||
|
ERR_clear_error();
|
||||||
|
return expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> Crafter::EncodePkcs12(const TLSCertificatePem& pem, const std::string& password) {
|
||||||
|
OwnedBio certificateBio = MemoryBio(pem.certificate);
|
||||||
|
OwnedCertificate certificate(PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!certificate) throw TLSException(Describe("could not read the certificate PEM"));
|
||||||
|
OwnedBio keyBio = MemoryBio(pem.privateKey);
|
||||||
|
OwnedKey key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!key) throw TLSException(Describe("could not read the private key PEM"));
|
||||||
|
// Zeros pick OpenSSL 3's defaults: AES-256-CBC under PBKDF2 for both
|
||||||
|
// bags and an HMAC-SHA-256 MAC, all of which its default provider has.
|
||||||
|
OwnedPkcs12 pkcs12(PKCS12_create(password.c_str(), nullptr, key.get(), certificate.get(),
|
||||||
|
nullptr, 0, 0, 0, 0, 0));
|
||||||
|
if (!pkcs12) throw TLSException(Describe("could not encode the pair as PKCS#12"));
|
||||||
|
unsigned char* der = nullptr;
|
||||||
|
const int length = i2d_PKCS12(pkcs12.get(), &der);
|
||||||
|
if (length <= 0 || der == nullptr) throw TLSException(Describe("could not encode the PKCS#12 as DER"));
|
||||||
|
std::vector<std::uint8_t> encoded(der, der + length);
|
||||||
|
OPENSSL_free(der);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<std::uint8_t, 32> Crafter::CertificateSha256(const std::string& pem) {
|
||||||
|
OwnedBio bio = MemoryBio(pem);
|
||||||
|
OwnedCertificate certificate(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!certificate) throw TLSException(Describe("could not read the certificate PEM"));
|
||||||
|
// X509_digest hashes the DER encoding, which is what a browser pins.
|
||||||
|
std::array<std::uint8_t, 32> digest{};
|
||||||
|
unsigned int length = 0;
|
||||||
|
if (X509_digest(certificate.get(), EVP_sha256(), digest.data(), &length) != 1
|
||||||
|
|| length != digest.size()) {
|
||||||
|
throw TLSException(Describe("could not hash the certificate"));
|
||||||
|
}
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ namespace Crafter {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Server certificate sources. Pick one of (filePaths) or (selfSigned).
|
// Server certificate sources. Pick one of (filePaths) or (selfSigned).
|
||||||
// selfSigned generates an in-memory ephemeral cert — fine for dev/LAN.
|
// selfSigned serves a development certificate made in-process and kept
|
||||||
|
// per user under $XDG_CACHE_HOME/crafter-network/quic-cert/, reused
|
||||||
|
// while valid (see GetSelfSignedCertificatePath) — fine for dev/LAN.
|
||||||
export struct QUICServerCredentials {
|
export struct QUICServerCredentials {
|
||||||
std::string certPath;
|
std::string certPath;
|
||||||
std::string keyPath;
|
std::string keyPath;
|
||||||
|
|
@ -115,7 +117,9 @@ namespace Crafter {
|
||||||
#ifndef CRAFTER_NETWORK_BROWSER
|
#ifndef CRAFTER_NETWORK_BROWSER
|
||||||
// Send a buffer. If finish=true, the send-side of the stream is closed
|
// Send a buffer. If finish=true, the send-side of the stream is closed
|
||||||
// after the buffer is delivered (peer will see graceful shutdown).
|
// after the buffer is delivered (peer will see graceful shutdown).
|
||||||
// Blocks until msquic accepts the buffer; throws on stream/conn close.
|
// Blocks until msquic accepts the buffer; throws QUICClosedException
|
||||||
|
// when the stream or connection closed before it did. SendAsync is
|
||||||
|
// the same without the thread.
|
||||||
void SendSync(const void* buffer, std::uint32_t size, bool finish = false);
|
void SendSync(const void* buffer, std::uint32_t size, bool finish = false);
|
||||||
|
|
||||||
// Block until at least one byte is received; returns the received
|
// Block until at least one byte is received; returns the received
|
||||||
|
|
@ -136,6 +140,27 @@ namespace Crafter {
|
||||||
// accepted the buffer (native) or the WritableStream writer has
|
// accepted the buffer (native) or the WritableStream writer has
|
||||||
// resolved (browser), and with false when the stream was closed or
|
// resolved (browser), and with false when the stream was closed or
|
||||||
// the send failed. Available on both native and browser builds.
|
// the send failed. Available on both native and browser builds.
|
||||||
|
//
|
||||||
|
// Native: holds no thread. It copies the buffer, hands the copy to
|
||||||
|
// msquic and returns; msquic's SEND_COMPLETE for it frees the copy and
|
||||||
|
// fires onSent, exactly once: true when msquic took the whole buffer
|
||||||
|
// (it is in msquic's send buffer or acknowledged, what SendSync's
|
||||||
|
// return means; not a receipt from the peer's application), false
|
||||||
|
// when the stream or the connection closed first. Callable from any
|
||||||
|
// thread, inside this stream's own callbacks included, and as often
|
||||||
|
// as you like: the sends queue in msquic in call order, none waits for
|
||||||
|
// another, and BytesInFlight says how far behind they are.
|
||||||
|
// - onSent runs on the connection's msquic worker thread, never at
|
||||||
|
// the same time as another callback of this stream (see
|
||||||
|
// OnRecieve). A send that fails before msquic takes it (a closed
|
||||||
|
// stream, a moved-from wrapper) reports from a pool task, or
|
||||||
|
// after the callback that made it returns, never from inside
|
||||||
|
// SendAsync, so the caller may hold a lock onSent takes.
|
||||||
|
// - In onSent: SendAsync and the other non-blocking calls are fine;
|
||||||
|
// nothing that blocks, no *Sync call (it waits for this very
|
||||||
|
// worker), no ~ClientQUIC; it must not throw.
|
||||||
|
// - It fires even after the wrapper is gone: destroying it lets the
|
||||||
|
// sends already made go out, then the FIN.
|
||||||
void SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
void SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
||||||
std::function<void(bool sent)> onSent);
|
std::function<void(bool sent)> onSent);
|
||||||
|
|
||||||
|
|
@ -162,6 +187,55 @@ namespace Crafter {
|
||||||
// so the server has to query and remember it at session creation
|
// so the server has to query and remember it at session creation
|
||||||
// time. Throws if the stream is not yet started.
|
// time. Throws if the stream is not yet started.
|
||||||
std::uint64_t GetStreamId() const;
|
std::uint64_t GetStreamId() const;
|
||||||
|
|
||||||
|
// Bytes, and sends, handed to SendAsync or SendSync whose completion
|
||||||
|
// has not come yet: how far behind a slow peer is. A server streaming
|
||||||
|
// to a follower can drop one whose backlog passes a cap. Any thread.
|
||||||
|
std::uint64_t BytesInFlight() const;
|
||||||
|
std::size_t SendsInFlight() const;
|
||||||
|
|
||||||
|
// Receive by callback, holding no thread while nothing arrives.
|
||||||
|
// onData gets each run of bytes as it arrives, as a span valid only
|
||||||
|
// for the call (copy what you keep); onEnd fires once, after the last
|
||||||
|
// onData: clean=true when the peer finished its side (FIN), false
|
||||||
|
// when either side aborted the stream (Abort(), the peer's reset) or
|
||||||
|
// the connection went first.
|
||||||
|
// - What arrived before the call is delivered first, on the calling
|
||||||
|
// thread before OnRecieve returns, and so is onEnd if the stream
|
||||||
|
// had already ended; from then on the callbacks run on the
|
||||||
|
// connection's msquic worker thread. No two callbacks of one
|
||||||
|
// stream (onData, onEnd, SendAsync's onSent) ever run at once.
|
||||||
|
// - Inside them: SendAsync, Abort, BytesInFlight, SendsInFlight,
|
||||||
|
// and destroying or moving this wrapper are fine. Nothing that
|
||||||
|
// blocks: no *Sync call on any stream (it waits for this very
|
||||||
|
// worker), no ~ClientQUIC (it waits for its streams to drain), no
|
||||||
|
// waiting on a thread that may be waiting on this one. A callback
|
||||||
|
// that throws ends the program.
|
||||||
|
// - Destroying the wrapper, or Stop(), unregisters both: once that
|
||||||
|
// returns neither runs again, a callback running on another
|
||||||
|
// thread has finished (as with std::stop_callback; one on this
|
||||||
|
// thread, destroying its own wrapper, is not waited for), and an
|
||||||
|
// onEnd not delivered by then never is. So do not destroy it
|
||||||
|
// while holding a lock its callbacks take. Sends still complete.
|
||||||
|
// - Once per stream: a second call throws QUICException. Do not mix
|
||||||
|
// with the Recieve* calls, which then get nothing. On a stream
|
||||||
|
// that cannot receive, or a closed or moved-from wrapper, onEnd
|
||||||
|
// (false) fires at once.
|
||||||
|
void OnRecieve(std::function<void(std::span<const char> data)> onData,
|
||||||
|
std::function<void(bool clean)> onEnd);
|
||||||
|
|
||||||
|
// Reset both directions at once with a QUIC application error code
|
||||||
|
// (62 bits; larger values are clamped): the peer's receive side sees
|
||||||
|
// a reset and its send side a stop-sending. Returns at once. Sends
|
||||||
|
// msquic has not taken yet report false (one already in its send
|
||||||
|
// buffer reported true, and the reset discards it); no onData
|
||||||
|
// follows (not even the rest of a run being delivered when called
|
||||||
|
// from inside one; from another thread, one already under way
|
||||||
|
// finishes); and onEnd(false) follows unless the end was delivered
|
||||||
|
// already or the wrapper is destroyed first. Callable from any thread
|
||||||
|
// and from inside this stream's callbacks, which is how a server
|
||||||
|
// enforces a byte cap. A no-op on a closed or moved-from wrapper.
|
||||||
|
void Abort(std::uint64_t errorCode = 0);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Shut the stream down in both directions: our send side gracefully
|
// Shut the stream down in both directions: our send side gracefully
|
||||||
|
|
@ -171,6 +245,9 @@ namespace Crafter {
|
||||||
// releases its transport handle. Returns immediately; completion is
|
// releases its transport handle. Returns immediately; completion is
|
||||||
// asynchronous. Called by the destructor, so an explicit call is only
|
// asynchronous. Called by the destructor, so an explicit call is only
|
||||||
// needed to shut a stream down ahead of its wrapper going away.
|
// needed to shut a stream down ahead of its wrapper going away.
|
||||||
|
// Natively it also unregisters OnRecieve's callbacks (see there);
|
||||||
|
// sends already made still go out, ahead of the FIN, and
|
||||||
|
// ~ClientQUIC waits (bounded) for them before closing the connection.
|
||||||
void Stop();
|
void Stop();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
@ -192,9 +269,17 @@ namespace Crafter {
|
||||||
// via OnStream()).
|
// via OnStream()).
|
||||||
// - Unreliable, unordered datagrams (SendDatagram() / OnDatagram()).
|
// - Unreliable, unordered datagrams (SendDatagram() / OnDatagram()).
|
||||||
//
|
//
|
||||||
// Lifetime: ~ClientQUIC shuts the connection down and closes it, waiting
|
// Lifetime: ~ClientQUIC first lets the streams already stopped finish
|
||||||
// (bounded — 5s) for every stream on it to finish closing first, since
|
// (bounded — 1s, about a round trip when the peer answers): what was
|
||||||
// QUICStream::Stop only initiates a shutdown that completes asynchronously.
|
// sent on them, the FIN last, reaches the peer, so a server may reply
|
||||||
|
// with SendAsync(..., finish = true), let the stream go and delete the
|
||||||
|
// connection at once. Then it shuts the connection down, which aborts
|
||||||
|
// any stream still open, and closes it, waiting (bounded — 5s) for every
|
||||||
|
// stream on it to finish closing first, since QUICStream::Stop only
|
||||||
|
// initiates a shutdown that completes asynchronously, and then (bounded
|
||||||
|
// — 1s, about a round trip when the peer answers) for the shutdown to
|
||||||
|
// complete, so the peer is sent the close rather than left to time the
|
||||||
|
// connection out.
|
||||||
// Streams outliving the connection are therefore safe to hold, though they
|
// Streams outliving the connection are therefore safe to hold, though they
|
||||||
// are dead once it goes; prefer destroying or moving them out first.
|
// are dead once it goes; prefer destroying or moving them out first.
|
||||||
//
|
//
|
||||||
|
|
@ -265,6 +350,30 @@ namespace Crafter {
|
||||||
// Block the caller until the next datagram arrives; returns it.
|
// Block the caller until the next datagram arrives; returns it.
|
||||||
// Throws QUICClosedException if the connection closes first.
|
// Throws QUICClosedException if the connection closes first.
|
||||||
std::vector<char> RecieveDatagramSync();
|
std::vector<char> RecieveDatagramSync();
|
||||||
|
|
||||||
|
// Told once when the connection has shut down for any reason other
|
||||||
|
// than this object being destroyed: the peer closed it, the idle
|
||||||
|
// timeout or a transport error ended it, or Stop() was called. This
|
||||||
|
// is how the owner of a connection a ListenerQUIC accepted learns
|
||||||
|
// that it is time to delete it.
|
||||||
|
//
|
||||||
|
// - It runs on the connection's msquic worker thread, once the
|
||||||
|
// connection's handle is closed; if the connection had already
|
||||||
|
// closed when OnClosed was called, it runs as a pool task
|
||||||
|
// instead, never inside OnClosed itself.
|
||||||
|
// - Deleting this ClientQUIC from inside it is safe, and the usual
|
||||||
|
// thing to do. Do nothing else that blocks: deleting a
|
||||||
|
// *different* ClientQUIC, or any *Sync call, waits on msquic
|
||||||
|
// workers, this one perhaps among them. It must not throw; an
|
||||||
|
// exception leaving it ends the program.
|
||||||
|
// - Once ~ClientQUIC has begun it never starts, and a run already
|
||||||
|
// under way on another thread finishes before ~ClientQUIC goes
|
||||||
|
// on, as with std::stop_callback (one on this thread, deleting
|
||||||
|
// its own connection, is not waited for). So do not destroy the
|
||||||
|
// ClientQUIC while holding a lock the callback takes.
|
||||||
|
// - Calling OnClosed again replaces the callback, and an empty one
|
||||||
|
// unregisters it; once it has fired, later calls do nothing.
|
||||||
|
void OnClosed(std::function<void()> callback);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Cleanly shut down the connection.
|
// Cleanly shut down the connection.
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,40 @@ namespace Crafter {
|
||||||
std::uint32_t totalClientLimit;
|
std::uint32_t totalClientLimit;
|
||||||
std::string alpn;
|
std::string alpn;
|
||||||
|
|
||||||
|
// Every address of both families at `port`.
|
||||||
ListenerQUIC(std::uint16_t port,
|
ListenerQUIC(std::uint16_t port,
|
||||||
std::string alpn,
|
std::string alpn,
|
||||||
QUICServerCredentials creds,
|
QUICServerCredentials creds,
|
||||||
std::function<void(ClientQUIC*)> connectCallback,
|
std::function<void(ClientQUIC*)> connectCallback,
|
||||||
std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(),
|
std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(),
|
||||||
std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
|
std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
|
||||||
|
// One local address. `bindAddress` is a numeric IPv4 or IPv6 address
|
||||||
|
// (127.0.0.1 or ::1 for this machine alone, 0.0.0.0 for every IPv4
|
||||||
|
// address, :: for every IPv6 one) and a name such as `localhost` is
|
||||||
|
// refused with QUICException: the address decides who can reach the
|
||||||
|
// listener, so it is spelled out rather than looked up, as
|
||||||
|
// ListenerTCP takes it. msquic still binds the UDP port on every
|
||||||
|
// address and filters by the address a connection was sent to, at
|
||||||
|
// its own layer: one sent to another address of this machine fails
|
||||||
|
// its handshake at once, refused as having no application protocol.
|
||||||
|
ListenerQUIC(std::string_view bindAddress,
|
||||||
|
std::uint16_t port,
|
||||||
|
std::string alpn,
|
||||||
|
QUICServerCredentials creds,
|
||||||
|
std::function<void(ClientQUIC*)> connectCallback,
|
||||||
|
std::uint32_t concurrentClientLimit = std::numeric_limits<std::uint32_t>::max(),
|
||||||
|
std::uint32_t totalClientLimit = std::numeric_limits<std::uint32_t>::max());
|
||||||
|
|
||||||
~ListenerQUIC();
|
~ListenerQUIC();
|
||||||
ListenerQUIC(const ListenerQUIC&) = delete;
|
ListenerQUIC(const ListenerQUIC&) = delete;
|
||||||
ListenerQUIC(ListenerQUIC&&) noexcept;
|
ListenerQUIC(ListenerQUIC&&) noexcept;
|
||||||
|
|
||||||
|
// The UDP port the listener is bound to: the one asked for, or the
|
||||||
|
// one the system picked when that was 0. Read from msquic once the
|
||||||
|
// listener has started, so it is valid as soon as the constructor
|
||||||
|
// returns, and still after Stop(). 0 on a moved-from listener.
|
||||||
|
std::uint16_t Port() const;
|
||||||
|
|
||||||
void Stop();
|
void Stop();
|
||||||
|
|
||||||
// Block on this thread, dispatch each accepted connection on this
|
// Block on this thread, dispatch each accepted connection on this
|
||||||
|
|
@ -63,12 +86,23 @@ namespace Crafter {
|
||||||
// Returns the 32-byte digest. Intended for surfacing the self-signed cert
|
// Returns the 32-byte digest. Intended for surfacing the self-signed cert
|
||||||
// hash to a browser peer (Chrome's WebTransport requires the client to
|
// hash to a browser peer (Chrome's WebTransport requires the client to
|
||||||
// pass this hash via `serverCertificateHashes` when peering against a
|
// pass this hash via `serverCertificateHashes` when peering against a
|
||||||
// cert that's not in the system trust store). Shells out to openssl.
|
// cert that's not in the system trust store). In-process, through
|
||||||
|
// OpenSSL; throws QUICException for a file it cannot read or parse.
|
||||||
export std::array<std::uint8_t, 32> ComputeCertificateHashSHA256(const std::string& certPath);
|
export std::array<std::uint8_t, 32> ComputeCertificateHashSHA256(const std::string& certPath);
|
||||||
|
|
||||||
// Path of the lazily-generated self-signed cert (PEM). Triggers generation
|
// Path of the self-signed cert (PEM) QUICServerCredentials{selfSigned}
|
||||||
// on first call. Useful for piping into ComputeCertificateHashSHA256 so
|
// serves; makes it on first use. Useful for piping into
|
||||||
// a browser peer can be told the hash to put in `serverCertificateHashes`.
|
// ComputeCertificateHashSHA256 so a browser peer can be told the hash to
|
||||||
|
// put in `serverCertificateHashes`.
|
||||||
|
//
|
||||||
|
// One pair per user, in $XDG_CACHE_HOME/crafter-network/quic-cert/
|
||||||
|
// (else ~/.cache/..., else $XDG_RUNTIME_DIR/...), a 0700 directory, made
|
||||||
|
// in-process (no openssl CLI). It is reused across restarts, and so its
|
||||||
|
// hash stays pinned, until it has less than a day of its ten left; then
|
||||||
|
// the next process to need it makes a new one. Processes starting at
|
||||||
|
// once share one pair: a lock covers the check and the replacement.
|
||||||
|
// Throws QUICException when no such directory can be had (none of those
|
||||||
|
// variables set, or the directory belongs to someone else).
|
||||||
export std::string GetSelfSignedCertificatePath();
|
export std::string GetSelfSignedCertificatePath();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -175,5 +175,37 @@ namespace Crafter {
|
||||||
// valid for 10 days. Development and tests only — it is regenerated on
|
// valid for 10 days. Development and tests only — it is regenerated on
|
||||||
// every process start and no peer has any reason to trust it.
|
// every process start and no peer has any reason to trust it.
|
||||||
export const TLSCertificatePem& GetSelfSignedCertificatePem();
|
export const TLSCertificatePem& GetSelfSignedCertificatePem();
|
||||||
|
|
||||||
|
// ── Module-internal, not exported ────────────────────────────────────
|
||||||
|
// The certificate work :ListenerQUIC's self-signed credential needs.
|
||||||
|
// It lives here so that libssl stays behind this one partition: the
|
||||||
|
// listener keeps the pair on disk with plain POSIX calls and leaves
|
||||||
|
// every OpenSSL call to these.
|
||||||
|
|
||||||
|
// A fresh pair shaped for Chromium's WebTransport
|
||||||
|
// serverCertificateHashes, which pins a hash instead of trusting a CA
|
||||||
|
// and in exchange demands: ECDSA P-256 signed with SHA-256, a validity
|
||||||
|
// of under 14 days (10, backdated an hour), BasicConstraints CA:FALSE,
|
||||||
|
// KeyUsage digitalSignature, EKU serverAuth, and SANs covering the name
|
||||||
|
// dialled (localhost, 127.0.0.1, ::1). A random serial, since the pair
|
||||||
|
// is replaced every ten days under the same issuer name. Throws
|
||||||
|
// TLSException.
|
||||||
|
TLSCertificatePem MakeWebTransportCertificatePem();
|
||||||
|
|
||||||
|
// When the certificate in `pem` stops being valid, provided it parses,
|
||||||
|
// has begun being valid, and `pem.privateKey` is its key; nothing for
|
||||||
|
// anything else. Never throws: a bad pair on disk is one to replace.
|
||||||
|
std::optional<std::chrono::system_clock::time_point>
|
||||||
|
CertificatePairExpiry(const TLSCertificatePem& pem);
|
||||||
|
|
||||||
|
// The pair as a DER-encoded PKCS#12 under `password`: the in-memory
|
||||||
|
// credential msquic takes as QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12.
|
||||||
|
// Throws TLSException.
|
||||||
|
std::vector<std::uint8_t> EncodePkcs12(const TLSCertificatePem& pem,
|
||||||
|
const std::string& password);
|
||||||
|
|
||||||
|
// SHA-256 of the DER encoding of the first certificate in `pem`, the
|
||||||
|
// value serverCertificateHashes pins. Throws TLSException.
|
||||||
|
std::array<std::uint8_t, 32> CertificateSha256(const std::string& pem);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -192,18 +192,27 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSurviveAbuseHTTP1").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSurviveAbuseHTTP1").Dependencies({ &cfg });
|
||||||
if (quic) {
|
if (quic) {
|
||||||
|
cfg.AddTest("ShouldBindLoopbackQUIC").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
|
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg });
|
cfg.AddTest("ShouldFallbackUnknownRoutes").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldFinishStoppedStreamsQUIC").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldHonourQUICSettings").Dependencies({ &cfg });
|
cfg.AddTest("ShouldHonourQUICSettings").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
|
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldNotifyClosedConnectionQUIC").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSend").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSend").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveQUICDatagram").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveQUICDatagram").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldSendWithoutBlockingQUIC").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg });
|
cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldRecieveByCallbackQUIC").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldReturnFromSendSyncQUIC").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldShareSelfSignedCertificateQUIC").Dependencies({ &cfg });
|
||||||
cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg });
|
cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg });
|
||||||
|
cfg.AddTest("ShouldTellPeerOfCloseQUIC").Dependencies({ &cfg });
|
||||||
} else {
|
} else {
|
||||||
// Only a --no-quic build can show that msquic stayed out.
|
// Only a --no-quic build can show that msquic stayed out.
|
||||||
cfg.AddTest("ShouldBuildWithoutQUIC").Dependencies({ &cfg });
|
cfg.AddTest("ShouldBuildWithoutQUIC").Dependencies({ &cfg });
|
||||||
|
|
|
||||||
182
tests/ShouldBindLoopbackQUIC/main.cpp
Normal file
182
tests/ShouldBindLoopbackQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// ListenerQUIC can be told which local address to listen on. Before, it
|
||||||
|
// always listened on every address of both families
|
||||||
|
// (QUIC_ADDRESS_FAMILY_UNSPEC), so a server meant for this machine's own
|
||||||
|
// processes, such as a debug server embedded in an app, was reachable from
|
||||||
|
// the network.
|
||||||
|
//
|
||||||
|
// msquic binds the UDP port on every address whatever it is given and
|
||||||
|
// filters by the address a connection was sent to at its own layer, so what
|
||||||
|
// is checked is the connection: one to 127.0.0.1 is served, while one to
|
||||||
|
// this machine's LAN address, or to ::1, fails, and fails at once (msquic
|
||||||
|
// refuses it outright) rather than after the handshake timeout.
|
||||||
|
//
|
||||||
|
// Also: ::1 works as a bind address, a name is refused, and only the
|
||||||
|
// connections that were served reach connectCallback.
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <ifaddrs.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An IPv4 address of this host that is not loopback, if it has one.
|
||||||
|
std::optional<std::string> NonLoopbackIPv4() {
|
||||||
|
ifaddrs* interfaces = nullptr;
|
||||||
|
if (getifaddrs(&interfaces) != 0) return std::nullopt;
|
||||||
|
std::optional<std::string> found;
|
||||||
|
for (const ifaddrs* entry = interfaces; entry != nullptr && !found; entry = entry->ifa_next) {
|
||||||
|
if (entry->ifa_addr == nullptr || entry->ifa_addr->sa_family != AF_INET) continue;
|
||||||
|
const auto* v4 = reinterpret_cast<const sockaddr_in*>(entry->ifa_addr);
|
||||||
|
if (ntohl(v4->sin_addr.s_addr) >> 24 == 127) continue;
|
||||||
|
char text[INET_ADDRSTRLEN] = {};
|
||||||
|
inet_ntop(AF_INET, &v4->sin_addr, text, sizeof(text));
|
||||||
|
found = std::string(text);
|
||||||
|
}
|
||||||
|
freeifaddrs(interfaces);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasIPv6Loopback() {
|
||||||
|
ifaddrs* interfaces = nullptr;
|
||||||
|
if (getifaddrs(&interfaces) != 0) return false;
|
||||||
|
bool found = false;
|
||||||
|
for (const ifaddrs* entry = interfaces; entry != nullptr && !found; entry = entry->ifa_next) {
|
||||||
|
if (entry->ifa_addr == nullptr || entry->ifa_addr->sa_family != AF_INET6) continue;
|
||||||
|
const auto* v6 = reinterpret_cast<const sockaddr_in6*>(entry->ifa_addr);
|
||||||
|
found = IN6_IS_ADDR_LOOPBACK(&v6->sin6_addr);
|
||||||
|
}
|
||||||
|
freeifaddrs(interfaces);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-bind";
|
||||||
|
|
||||||
|
QUICClientCredentials ClientCredentials() {
|
||||||
|
QUICClientCredentials creds;
|
||||||
|
creds.insecureNoServerValidation = true;
|
||||||
|
// Long enough that a connection msquic silently dropped would show as
|
||||||
|
// slow below, short enough to stay inside the watchdog.
|
||||||
|
creds.settings.handshakeIdleTimeoutMs = 6000;
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stream to host:port and back. Empty when the echo did not come back.
|
||||||
|
std::string Echo(const std::string& host, std::uint16_t port, std::string_view text) {
|
||||||
|
ClientQUIC client(host, port, alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync(text.data(), static_cast<std::uint32_t>(text.size()), true);
|
||||||
|
std::vector<char> echoed = stream.RecieveUntilCloseSync();
|
||||||
|
return std::string(echoed.begin(), echoed.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connects to host:port and reports how long it took to be refused; nullopt
|
||||||
|
// when the connection was served instead.
|
||||||
|
std::optional<std::chrono::milliseconds> RefusedAfter(const std::string& host, std::uint16_t port) {
|
||||||
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
try {
|
||||||
|
ClientQUIC client(host, port, alpn, ClientCredentials());
|
||||||
|
return std::nullopt;
|
||||||
|
} catch (const QUICException& e) {
|
||||||
|
const auto took = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - start);
|
||||||
|
std::println(" ({} refused after {} ms: {})", host, took.count(), e.what());
|
||||||
|
return took;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(45));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
std::atomic<int> accepted = 0;
|
||||||
|
auto serve = [&](ClientQUIC* peer) {
|
||||||
|
++accepted;
|
||||||
|
peer->OnStream([](QUICStream stream) {
|
||||||
|
try {
|
||||||
|
std::vector<char> got = stream.RecieveUntilCloseSync();
|
||||||
|
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
|
||||||
|
} catch (...) {}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// A refusal well inside the handshake timeout is msquic turning the
|
||||||
|
// connection away; one at the timeout is a silent drop.
|
||||||
|
constexpr std::chrono::milliseconds atOnce{ 3000 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── 127.0.0.1 ────────────────────────────────────────────────────────
|
||||||
|
ListenerQUIC loopback("127.0.0.1", 0, alpn, serverCreds, serve);
|
||||||
|
loopback.ListenAsyncAsync();
|
||||||
|
const std::uint16_t port = loopback.Port();
|
||||||
|
Check(port != 0, std::format("a listener on 127.0.0.1 at port 0 reports its port ({})", port));
|
||||||
|
Check(Echo("127.0.0.1", port, "loopback") == "loopback", "it serves a client that connects through 127.0.0.1");
|
||||||
|
|
||||||
|
if (const std::optional<std::string> lan = NonLoopbackIPv4()) {
|
||||||
|
const std::optional<std::chrono::milliseconds> refused = RefusedAfter(*lan, port);
|
||||||
|
Check(refused.has_value(), std::format("a client that connects through this host's LAN address {} is not served", *lan));
|
||||||
|
Check(refused && *refused < atOnce, "and is refused at once, not timed out");
|
||||||
|
} else {
|
||||||
|
std::println("skipping the LAN half: this host has no IPv4 address but loopback");
|
||||||
|
}
|
||||||
|
if (HasIPv6Loopback()) {
|
||||||
|
const std::optional<std::chrono::milliseconds> refused = RefusedAfter("::1", port);
|
||||||
|
Check(refused.has_value() && *refused < atOnce, "nor is one through ::1, the other loopback address");
|
||||||
|
}
|
||||||
|
Check(accepted.load() == 1, std::format("only the served connection reached connectCallback ({})", accepted.load()));
|
||||||
|
|
||||||
|
// ── ::1 ──────────────────────────────────────────────────────────────
|
||||||
|
if (HasIPv6Loopback()) {
|
||||||
|
ListenerQUIC loopback6("::1", 0, alpn, serverCreds, serve);
|
||||||
|
loopback6.ListenAsyncAsync();
|
||||||
|
Check(Echo("::1", loopback6.Port(), "v6") == "v6", "a listener on ::1 serves a client that connects through ::1");
|
||||||
|
const std::optional<std::chrono::milliseconds> refused = RefusedAfter("127.0.0.1", loopback6.Port());
|
||||||
|
Check(refused.has_value() && *refused < atOnce, "and refuses one through 127.0.0.1");
|
||||||
|
} else {
|
||||||
|
std::println("skipping the ::1 half: this host has no IPv6 loopback");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A name ───────────────────────────────────────────────────────────
|
||||||
|
bool threw = false;
|
||||||
|
try {
|
||||||
|
ListenerQUIC named("localhost", 0, alpn, serverCreds, serve);
|
||||||
|
} catch (const QUICException& e) {
|
||||||
|
threw = true;
|
||||||
|
std::println(" ({})", e.what());
|
||||||
|
}
|
||||||
|
Check(threw, "a name such as localhost is refused as a bind address");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// As the other QUIC tests: skip the static teardown, which waits for the
|
||||||
|
// accepted peers' idle timeout. _Exit doesn't flush.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
153
tests/ShouldFinishStoppedStreamsQUIC/main.cpp
Normal file
153
tests/ShouldFinishStoppedStreamsQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// ~ClientQUIC lets the streams its owner already stopped finish before it
|
||||||
|
// closes the connection. It used to shut the connection down at once, and
|
||||||
|
// msquic aborts every stream with the connection, along with whatever of
|
||||||
|
// theirs it had not sent yet: a server that answered with
|
||||||
|
// SendAsync(..., finish = true), let the stream go and deleted the
|
||||||
|
// connection lost the answer whenever the shutdown, queued right behind the
|
||||||
|
// send, reached msquic's worker before the flush that would have sent it.
|
||||||
|
// Crafter.Debug's session, which answers every request still waiting when
|
||||||
|
// it ends and then closes its connections, lost that answer in 4 runs of 30.
|
||||||
|
//
|
||||||
|
// Twenty connections in turn. On each, the client sends a request; the
|
||||||
|
// server answers 64 KiB with SendAsync(finish = true), destroys the stream
|
||||||
|
// wrapper and deletes its ClientQUIC at once, as such a server does. The
|
||||||
|
// client must get every byte and a clean end each time, and the deletes
|
||||||
|
// must stay quick: a stopped stream finishes about a round trip after its
|
||||||
|
// last send, well inside the 1 s bound.
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the client's stream received, and how it ended.
|
||||||
|
struct Seen {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::condition_variable changed;
|
||||||
|
std::size_t bytes = 0;
|
||||||
|
bool ended = false;
|
||||||
|
bool clean = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The server's side of the connection being tested, handed over by the
|
||||||
|
// accept thread and the pool task that delivers the stream.
|
||||||
|
struct Accepted {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::condition_variable changed;
|
||||||
|
ClientQUIC* peer = nullptr;
|
||||||
|
std::optional<QUICStream> stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(45));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-finish-stopped";
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
QUICClientCredentials clientCreds;
|
||||||
|
clientCreds.insecureNoServerValidation = true;
|
||||||
|
|
||||||
|
constexpr std::size_t answerSize = 64 * 1024;
|
||||||
|
const std::string answer(answerSize, 'a');
|
||||||
|
auto accepted = std::make_shared<Accepted>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [accepted](ClientQUIC* peer) {
|
||||||
|
peer->OnStream([accepted](QUICStream stream) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(accepted->mutex);
|
||||||
|
accepted->stream.emplace(std::move(stream));
|
||||||
|
}
|
||||||
|
accepted->changed.notify_all();
|
||||||
|
});
|
||||||
|
{
|
||||||
|
std::lock_guard lock(accepted->mutex);
|
||||||
|
accepted->peer = peer;
|
||||||
|
}
|
||||||
|
accepted->changed.notify_all();
|
||||||
|
});
|
||||||
|
listener.ListenAsyncSync();
|
||||||
|
|
||||||
|
int whole = 0;
|
||||||
|
std::chrono::milliseconds slowestDelete{ 0 };
|
||||||
|
for (int i = 0; i < 20; ++i) {
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds);
|
||||||
|
auto seen = std::make_shared<Seen>();
|
||||||
|
QUICStream request = client.OpenStream();
|
||||||
|
request.OnRecieve(
|
||||||
|
[seen](std::span<const char> data) {
|
||||||
|
std::lock_guard lock(seen->mutex);
|
||||||
|
seen->bytes += data.size();
|
||||||
|
},
|
||||||
|
[seen](bool clean) {
|
||||||
|
{
|
||||||
|
std::lock_guard lock(seen->mutex);
|
||||||
|
seen->ended = true;
|
||||||
|
seen->clean = clean;
|
||||||
|
}
|
||||||
|
seen->changed.notify_all();
|
||||||
|
});
|
||||||
|
request.SendSync("ask", 3, true);
|
||||||
|
|
||||||
|
ClientQUIC* peer = nullptr;
|
||||||
|
std::optional<QUICStream> stream;
|
||||||
|
{
|
||||||
|
std::unique_lock lock(accepted->mutex);
|
||||||
|
if (!accepted->changed.wait_for(lock, std::chrono::seconds(5), [&] { return accepted->peer != nullptr && accepted->stream.has_value(); })) {
|
||||||
|
std::println(" connection {}: the server never saw the stream", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
peer = std::exchange(accepted->peer, nullptr);
|
||||||
|
stream = std::move(accepted->stream);
|
||||||
|
accepted->stream.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's last word, and then the end of it all at once.
|
||||||
|
stream->SendAsync(answer.data(), static_cast<std::uint32_t>(answer.size()), true, {});
|
||||||
|
stream.reset();
|
||||||
|
const auto deleting = std::chrono::steady_clock::now();
|
||||||
|
delete peer;
|
||||||
|
slowestDelete = std::max(slowestDelete, std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - deleting));
|
||||||
|
|
||||||
|
std::unique_lock lock(seen->mutex);
|
||||||
|
seen->changed.wait_for(lock, std::chrono::seconds(5), [&] { return seen->ended; });
|
||||||
|
if (seen->ended && seen->clean && seen->bytes == answerSize) {
|
||||||
|
++whole;
|
||||||
|
} else {
|
||||||
|
std::println(" connection {}: {} of {} bytes, {}", i, seen->bytes, answerSize,
|
||||||
|
!seen->ended ? "no end" : seen->clean ? "a clean end" : "reset");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Check(whole == 20, std::format("each of 20 answers arrived whole before its connection closed ({} did)", whole));
|
||||||
|
Check(slowestDelete < std::chrono::milliseconds(500), std::format("each delete waited only for its answer (slowest {} ms)", slowestDelete.count()));
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// msquic's static teardown would wait on what the listener still holds.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
336
tests/ShouldNotifyClosedConnectionQUIC/main.cpp
Normal file
336
tests/ShouldNotifyClosedConnectionQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// ClientQUIC::OnClosed tells the owner of a connection that it is over, so
|
||||||
|
// the owner can delete the ClientQUIC. A ListenerQUIC hands each accepted
|
||||||
|
// connection to its callback as a heap-allocated ClientQUIC the callback
|
||||||
|
// owns, and nothing said when to delete it: msquic's handle of a connection
|
||||||
|
// the peer closed was closed for it, but the object stayed, so a server
|
||||||
|
// either leaked its peers or deleted them blind. One still alive when main
|
||||||
|
// returns holds msquic's registration open at exit until its idle timeout.
|
||||||
|
//
|
||||||
|
// The server here deletes every connection it accepts from inside its
|
||||||
|
// OnClosed callback, which runs on msquic's worker, over 25 rounds of four
|
||||||
|
// clients that connect, echo and close; and main returns normally, so
|
||||||
|
// msquic's RegistrationClose at exit is the check that nothing was left
|
||||||
|
// open, under a 45 s watchdog.
|
||||||
|
//
|
||||||
|
// Also, each exactly once:
|
||||||
|
// - a peer that vanishes is reported: a forked child that _Exits
|
||||||
|
// mid-connection (its closed port answers the server's next packet, a
|
||||||
|
// transport error), and one that stops itself with SIGSTOP, keeping its
|
||||||
|
// port and never answering, which only the idle timeout ends;
|
||||||
|
// - Stop() on the server side fires it there, and the client's own
|
||||||
|
// OnClosed hears the server go;
|
||||||
|
// - a connection that closed before OnClosed was set still gets it, from a
|
||||||
|
// pool task, not from inside OnClosed;
|
||||||
|
// - an owner that deletes the ClientQUIC first never hears it.
|
||||||
|
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-on-closed";
|
||||||
|
|
||||||
|
QUICClientCredentials ClientCredentials() {
|
||||||
|
QUICClientCredentials creds;
|
||||||
|
creds.insecureNoServerValidation = true;
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
QUICServerCredentials ServerCredentials(std::uint32_t idleTimeoutMs = 120'000) {
|
||||||
|
QUICServerCredentials creds;
|
||||||
|
creds.selfSigned = true;
|
||||||
|
creds.settings.idleTimeoutMs = idleTimeoutMs;
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Echo(ClientQUIC* peer) {
|
||||||
|
peer->OnStream([](QUICStream stream) {
|
||||||
|
try {
|
||||||
|
std::vector<char> got = stream.RecieveUntilCloseSync();
|
||||||
|
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
|
||||||
|
} catch (...) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RoundTrip(ClientQUIC& client, std::string_view text) {
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync(text.data(), static_cast<std::uint32_t>(text.size()), true);
|
||||||
|
std::vector<char> echoed = stream.RecieveUntilCloseSync();
|
||||||
|
return std::string(echoed.begin(), echoed.end()) == text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waits, up to `limit`, for `done` to hold.
|
||||||
|
bool WaitFor(std::function<bool()> done, std::chrono::milliseconds limit) {
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + limit;
|
||||||
|
while (!done()) {
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) return false;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server: owns what it accepted, and deletes it when told it closed.
|
||||||
|
struct Server {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::unordered_set<ClientQUIC*> live;
|
||||||
|
std::vector<std::shared_ptr<std::atomic<int>>> fired; // one per connection
|
||||||
|
std::atomic<int> accepted = 0;
|
||||||
|
std::atomic<int> notified = 0;
|
||||||
|
|
||||||
|
// Takes ownership of `peer`; `echo` serves its streams, as a server
|
||||||
|
// would, which the Stop() case replaces with a handler of its own.
|
||||||
|
void Adopt(ClientQUIC* peer, bool echo = true) {
|
||||||
|
auto count = std::make_shared<std::atomic<int>>(0);
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
live.insert(peer);
|
||||||
|
fired.push_back(count);
|
||||||
|
}
|
||||||
|
++accepted;
|
||||||
|
peer->OnClosed([this, peer, count] {
|
||||||
|
++*count;
|
||||||
|
++notified;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
live.erase(peer);
|
||||||
|
}
|
||||||
|
// From inside the notification, on msquic's worker.
|
||||||
|
delete peer;
|
||||||
|
});
|
||||||
|
if (echo) Echo(peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AllFiredOnce() {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
return std::ranges::all_of(fired, [](const auto& count) { return count->load() == 1; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A vanishing peer, in a process of its own: connect, prove the
|
||||||
|
// connection is up with one echo, and disappear without a CONNECTION_CLOSE,
|
||||||
|
// by exiting or by freezing.
|
||||||
|
[[noreturn]] void Vanish(int portPipe, bool freeze) {
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
while (read(portPipe, &port, sizeof(port)) < 0 && errno == EINTR) {}
|
||||||
|
try {
|
||||||
|
ThreadPool::Start();
|
||||||
|
ClientQUIC client("127.0.0.1", port, alpn, ClientCredentials());
|
||||||
|
(void)RoundTrip(client, "going");
|
||||||
|
if (freeze) raise(SIGSTOP);
|
||||||
|
} catch (...) {}
|
||||||
|
std::_Exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Vanisher {
|
||||||
|
pid_t pid = -1;
|
||||||
|
int portPipe = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forks a vanishing peer, waiting for the port of its server on a pipe.
|
||||||
|
Vanisher ForkVanisher(bool freeze) {
|
||||||
|
int ends[2];
|
||||||
|
if (pipe(ends) != 0) return {};
|
||||||
|
const pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
close(ends[1]);
|
||||||
|
Vanish(ends[0], freeze);
|
||||||
|
}
|
||||||
|
close(ends[0]);
|
||||||
|
return { pid, ends[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// Before any thread exists, so the children start clean.
|
||||||
|
const Vanisher exits = ForkVanisher(false);
|
||||||
|
const Vanisher freezes = ForkVanisher(true);
|
||||||
|
if (exits.pid < 0 || freezes.pid < 0) return 1;
|
||||||
|
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(45));
|
||||||
|
std::println("FAIL timed out: a notification never came, or the exit hung");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Delete from inside the notification, over 100 connections ─────────
|
||||||
|
// Each Server is leaked on purpose: a notification that came after
|
||||||
|
// its block on a failure path must not find it destroyed.
|
||||||
|
Server& server = *new Server;
|
||||||
|
{
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, ServerCredentials(),
|
||||||
|
[&](ClientQUIC* peer) { server.Adopt(peer); });
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
std::atomic<int> echoed = 0;
|
||||||
|
for (int round = 0; round < 25; ++round) {
|
||||||
|
std::vector<std::thread> clients;
|
||||||
|
for (int i = 0; i < 4; ++i) {
|
||||||
|
clients.emplace_back([&] {
|
||||||
|
try {
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, ClientCredentials());
|
||||||
|
if (RoundTrip(client, "ping")) ++echoed;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
std::println(" client: {}", e.what());
|
||||||
|
}
|
||||||
|
// ~ClientQUIC closes the connection: the peer close
|
||||||
|
// the server is told about.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (std::thread& client : clients) client.join();
|
||||||
|
}
|
||||||
|
Check(echoed.load() == 100, std::format("100 connections served ({})", echoed.load()));
|
||||||
|
const bool all = WaitFor([&] { return server.notified.load() == server.accepted.load(); },
|
||||||
|
std::chrono::seconds(10));
|
||||||
|
Check(all && server.accepted.load() == 100,
|
||||||
|
std::format("every connection a peer closed was reported closed ({} of {})",
|
||||||
|
server.notified.load(), server.accepted.load()));
|
||||||
|
Check(server.AllFiredOnce(), "each exactly once");
|
||||||
|
std::lock_guard lock(server.mutex);
|
||||||
|
Check(server.live.empty(), "and deleting each from inside its notification left none behind");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Peers that vanish ────────────────────────────────────────────────
|
||||||
|
// Each against a listener whose idle timeout is 1.5 s; `stopped` is
|
||||||
|
// when waitpid saw the child exit or stop.
|
||||||
|
auto vanish = [](const Vanisher& child, int waitFlags, std::string_view how) {
|
||||||
|
Server& server = *new Server;
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, ServerCredentials(1500),
|
||||||
|
[&server](ClientQUIC* peer) { server.Adopt(peer); });
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
const std::uint16_t port = listener.Port();
|
||||||
|
(void)!write(child.portPipe, &port, sizeof(port));
|
||||||
|
close(child.portPipe);
|
||||||
|
int status = 0;
|
||||||
|
while (waitpid(child.pid, &status, waitFlags) < 0 && errno == EINTR) {}
|
||||||
|
const auto stopped = std::chrono::steady_clock::now();
|
||||||
|
const bool notified = WaitFor([&] { return server.notified.load() == 1; }, std::chrono::seconds(10));
|
||||||
|
const auto after = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
std::chrono::steady_clock::now() - stopped);
|
||||||
|
Check(server.accepted.load() == 1 && notified && server.AllFiredOnce(),
|
||||||
|
std::format("a peer that {} is reported closed, once ({} ms later)", how, after.count()));
|
||||||
|
return after;
|
||||||
|
};
|
||||||
|
vanish(exits, 0, "exited without closing");
|
||||||
|
const std::chrono::milliseconds frozen = vanish(freezes, WUNTRACED, "froze, keeping its port,");
|
||||||
|
Check(frozen >= std::chrono::milliseconds(1000),
|
||||||
|
"the frozen one only once the idle timeout ran out");
|
||||||
|
kill(freezes.pid, SIGKILL);
|
||||||
|
while (waitpid(freezes.pid, nullptr, 0) < 0 && errno == EINTR) {}
|
||||||
|
|
||||||
|
// ── Stop() on the server side; the client hears it too ───────────────
|
||||||
|
{
|
||||||
|
Server& stopping = *new Server;
|
||||||
|
// Stop once the client's first stream arrives, so the handshake
|
||||||
|
// is over: stopping in connectCallback can beat the handshake,
|
||||||
|
// and the client's constructor then throws instead.
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, ServerCredentials(), [&](ClientQUIC* peer) {
|
||||||
|
stopping.Adopt(peer, false);
|
||||||
|
peer->OnStream([peer](QUICStream) { peer->Stop(); });
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
std::atomic<int> clientHeard = 0;
|
||||||
|
{
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, ClientCredentials());
|
||||||
|
client.OnClosed([&] { ++clientHeard; });
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("stop", 4, false);
|
||||||
|
Check(WaitFor([&] { return stopping.notified.load() == 1; }, std::chrono::seconds(10))
|
||||||
|
&& stopping.AllFiredOnce(),
|
||||||
|
"Stop() on the server side fires the server's notification, once");
|
||||||
|
Check(WaitFor([&] { return clientHeard.load() == 1; }, std::chrono::seconds(10)),
|
||||||
|
"and the client's own OnClosed hears the server close it");
|
||||||
|
}
|
||||||
|
Check(clientHeard.load() == 1, "destroying the client after that does not fire it again");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Closed before OnClosed was set ───────────────────────────────────
|
||||||
|
{
|
||||||
|
// Leaked, as the Servers are: connectCallback may still be
|
||||||
|
// returning from OnClosed when the check below has passed.
|
||||||
|
struct Late {
|
||||||
|
std::atomic<bool> clientGone = false;
|
||||||
|
std::atomic<bool> inside = false;
|
||||||
|
std::atomic<int> fired = 0;
|
||||||
|
std::atomic<bool> firedInside = false;
|
||||||
|
};
|
||||||
|
Late& late = *new Late;
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, ServerCredentials(), [&late](ClientQUIC* peer) {
|
||||||
|
// Long after the peer closed: the close has been and gone.
|
||||||
|
WaitFor([&] { return late.clientGone.load(); }, std::chrono::seconds(10));
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||||
|
late.inside = true;
|
||||||
|
peer->OnClosed([&late, peer] {
|
||||||
|
++late.fired;
|
||||||
|
late.firedInside = late.inside.load();
|
||||||
|
delete peer;
|
||||||
|
});
|
||||||
|
late.inside = false;
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
{
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, ClientCredentials());
|
||||||
|
}
|
||||||
|
late.clientGone = true;
|
||||||
|
Check(WaitFor([&] { return late.fired.load() == 1; }, std::chrono::seconds(10)),
|
||||||
|
"a connection that closed before OnClosed was set is still reported");
|
||||||
|
Check(!late.firedInside.load(), "from a pool task, not from inside OnClosed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The owner deletes first ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
struct Counts {
|
||||||
|
std::atomic<int> heard = 0;
|
||||||
|
std::atomic<int> deleted = 0;
|
||||||
|
};
|
||||||
|
Counts& counts = *new Counts;
|
||||||
|
std::atomic<int>& heard = counts.heard;
|
||||||
|
std::atomic<int>& deleted = counts.deleted;
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, ServerCredentials(), [&counts](ClientQUIC* peer) {
|
||||||
|
peer->OnClosed([&counts] { ++counts.heard; });
|
||||||
|
delete peer;
|
||||||
|
++counts.deleted;
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
try {
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, ClientCredentials());
|
||||||
|
} catch (...) {}
|
||||||
|
}
|
||||||
|
WaitFor([&] { return deleted.load() == 5; }, std::chrono::seconds(10));
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||||
|
Check(deleted.load() == 5 && heard.load() == 0,
|
||||||
|
std::format("an owner that deletes the connection first never hears it ({} heard)", heard.load()));
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
std::fflush(stdout);
|
||||||
|
// Deliberately no std::_Exit: returning runs msquic's static
|
||||||
|
// RegistrationClose, which blocks until every connection is closed. Every
|
||||||
|
// accepted ClientQUIC was deleted by its notification or its owner, so
|
||||||
|
// this returns at once; a leaked one would hold it until the watchdog.
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
526
tests/ShouldRecieveByCallbackQUIC/main.cpp
Normal file
526
tests/ShouldRecieveByCallbackQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,526 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// QUICStream::OnRecieve delivers a stream's bytes and its end by callback,
|
||||||
|
// holding no thread while nothing arrives. Until now every way to wait for
|
||||||
|
// bytes held one: RecieveSync and RecieveUntilCloseSync block their caller
|
||||||
|
// with no bound, and RecieveAsync and RecieveUntilCloseAsync block a worker
|
||||||
|
// of the pool the host shares, which for a stream a client keeps open (a log
|
||||||
|
// follower) is for good. A server embedded in an app cannot afford either.
|
||||||
|
//
|
||||||
|
// - Bytes that arrived before OnRecieve are delivered by it, before it
|
||||||
|
// returns, and the end with them: clean for a FIN.
|
||||||
|
// - Registered while bytes keep arriving, with a callback slow enough to
|
||||||
|
// let the registering thread and msquic's worker overlap, and an echo
|
||||||
|
// sent from inside onData: no two callbacks of the stream ever run at
|
||||||
|
// once, and every byte arrives in order.
|
||||||
|
// - With every pool worker parked, a stream is echoed both ways, callbacks
|
||||||
|
// and SendAsync only: nothing needed the pool.
|
||||||
|
// - The end fires once: not clean for the peer's Abort() and for a lost
|
||||||
|
// connection.
|
||||||
|
// - Abort() from inside onData enforces a byte cap: no onData after it,
|
||||||
|
// onEnd(false) once, and the peer sees both directions reset.
|
||||||
|
// - The wrapper destroyed inside its own onEnd, or moved while bytes flow,
|
||||||
|
// breaks nothing; destroyed from another thread mid-flow, no callback
|
||||||
|
// runs once its destructor has returned.
|
||||||
|
// - A second OnRecieve throws; a stream that cannot receive, or no stream,
|
||||||
|
// gets onEnd(false) at once.
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WaitFor(std::function<bool()> done, std::chrono::milliseconds limit) {
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + limit;
|
||||||
|
while (!done()) {
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) return false;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
char Pattern(std::uint64_t offset) { return static_cast<char>((offset * 131 + 7) & 0xff); }
|
||||||
|
|
||||||
|
bool FollowsPattern(const std::string& bytes, std::size_t expected) {
|
||||||
|
if (bytes.size() != expected) return false;
|
||||||
|
for (std::size_t i = 0; i < bytes.size(); ++i) {
|
||||||
|
if (bytes[i] != Pattern(i)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-on-recieve";
|
||||||
|
|
||||||
|
QUICClientCredentials ClientCredentials() {
|
||||||
|
QUICClientCredentials creds;
|
||||||
|
creds.insecureNoServerValidation = true;
|
||||||
|
return creds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What one stream's callbacks saw. Shared with them, and leaked, so a
|
||||||
|
// callback that comes late on a failure path finds it alive.
|
||||||
|
struct Seen {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::string bytes;
|
||||||
|
int ends = 0;
|
||||||
|
bool clean = false;
|
||||||
|
int dataAfterEnd = 0;
|
||||||
|
std::atomic<int> inside = 0; // callbacks of this stream running now
|
||||||
|
std::atomic<int> overlaps = 0; // times two ran at once
|
||||||
|
|
||||||
|
void Enter() { if (inside.fetch_add(1) != 0) ++overlaps; }
|
||||||
|
void Leave() { --inside; }
|
||||||
|
|
||||||
|
std::function<void(std::span<const char>)> OnData() {
|
||||||
|
return [this](std::span<const char> data) {
|
||||||
|
Enter();
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
if (ends != 0) ++dataAfterEnd;
|
||||||
|
bytes.append(data.begin(), data.end());
|
||||||
|
}
|
||||||
|
Leave();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
std::function<void(bool)> OnEnd() {
|
||||||
|
return [this](bool wasClean) {
|
||||||
|
Enter();
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
++ends;
|
||||||
|
clean = wasClean;
|
||||||
|
}
|
||||||
|
Leave();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
int Ends() {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
return ends;
|
||||||
|
}
|
||||||
|
std::string Bytes() {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every pool worker parked until Release(), which returns once each has
|
||||||
|
// left, so nothing reads this after its owner is gone.
|
||||||
|
struct Parking {
|
||||||
|
std::atomic<bool> release = false;
|
||||||
|
std::atomic<unsigned> parked = 0;
|
||||||
|
std::atomic<unsigned> left = 0;
|
||||||
|
unsigned tasks = 0;
|
||||||
|
|
||||||
|
bool Park() {
|
||||||
|
const unsigned workers = ThreadPool::activeThreads.load();
|
||||||
|
tasks = workers * 2;
|
||||||
|
for (unsigned i = 0; i < tasks; ++i) {
|
||||||
|
ThreadPool::Enqueue([this] {
|
||||||
|
++parked;
|
||||||
|
while (!release.load()) std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||||
|
++left;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return WaitFor([&] { return parked.load() >= workers; }, std::chrono::seconds(10));
|
||||||
|
}
|
||||||
|
void Release() {
|
||||||
|
release = true;
|
||||||
|
WaitFor([&] { return left.load() == tasks; }, std::chrono::seconds(10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// A listener whose every accepted stream goes to `onStream`, as ListenerQUIC
|
||||||
|
// delivers it: on a pool task. Wrappers the handler keeps go in `kept`.
|
||||||
|
struct Server {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::vector<std::shared_ptr<QUICStream>> kept;
|
||||||
|
std::unique_ptr<ListenerQUIC> listener;
|
||||||
|
|
||||||
|
explicit Server(std::function<void(Server&, std::shared_ptr<QUICStream>)> onStream) {
|
||||||
|
QUICServerCredentials creds;
|
||||||
|
creds.selfSigned = true;
|
||||||
|
listener = std::make_unique<ListenerQUIC>("127.0.0.1", 0, alpn, creds, [this, onStream](ClientQUIC* peer) {
|
||||||
|
peer->OnStream([this, onStream](QUICStream stream) {
|
||||||
|
onStream(*this, std::make_shared<QUICStream>(std::move(stream)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listener->ListenAsyncAsync();
|
||||||
|
}
|
||||||
|
void Keep(std::shared_ptr<QUICStream> stream) {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
kept.push_back(std::move(stream));
|
||||||
|
}
|
||||||
|
std::uint16_t Port() const { return listener->Port(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(50));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── What arrived first is delivered by OnRecieve ─────────────────────
|
||||||
|
{
|
||||||
|
Seen& seen = *new Seen;
|
||||||
|
std::atomic<bool> registering = false;
|
||||||
|
std::atomic<int> deliveredInside = 0;
|
||||||
|
Server& server = *new Server([&](Server& self, std::shared_ptr<QUICStream> stream) {
|
||||||
|
// Long enough for "hello" and its FIN to be waiting.
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
||||||
|
registering = true;
|
||||||
|
auto onData = seen.OnData();
|
||||||
|
auto onEnd = seen.OnEnd();
|
||||||
|
stream->OnRecieve(
|
||||||
|
[&, onData](std::span<const char> data) { if (registering) ++deliveredInside; onData(data); },
|
||||||
|
[&, onEnd](bool clean) { if (registering) ++deliveredInside; onEnd(clean); });
|
||||||
|
registering = false;
|
||||||
|
self.Keep(stream);
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("hello", 5, true);
|
||||||
|
Check(WaitFor([&] { return seen.Ends() == 1; }, std::chrono::seconds(10)) && seen.Bytes() == "hello" && seen.clean,
|
||||||
|
std::format("bytes and a FIN that came before OnRecieve are delivered by it: '{}', clean", seen.Bytes()));
|
||||||
|
Check(deliveredInside.load() == 2, "on the registering thread, before OnRecieve returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registered mid-flow: one callback at a time, every byte in order ──
|
||||||
|
{
|
||||||
|
Seen& seen = *new Seen;
|
||||||
|
auto slow = std::make_shared<std::atomic<int>>(50);
|
||||||
|
Server& server = *new Server([&seen, slow](Server& self, std::shared_ptr<QUICStream> stream) {
|
||||||
|
// Bytes keep coming meanwhile, so the backlog this thread
|
||||||
|
// delivers and the worker's new arrivals overlap in time.
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||||
|
auto onData = seen.OnData();
|
||||||
|
std::weak_ptr<QUICStream> weak = stream;
|
||||||
|
stream->OnRecieve(
|
||||||
|
[&seen, slow, onData, weak](std::span<const char> data) {
|
||||||
|
if (slow->fetch_sub(1) > 0) std::this_thread::sleep_for(std::chrono::microseconds(200));
|
||||||
|
onData(data);
|
||||||
|
// Echoed from inside the callback; its onSent is one
|
||||||
|
// of the stream's callbacks too.
|
||||||
|
if (auto s = weak.lock()) {
|
||||||
|
s->SendAsync(data.data(), static_cast<std::uint32_t>(data.size()), false, [&seen](bool) {
|
||||||
|
seen.Enter();
|
||||||
|
seen.Leave();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[weak, onEnd = seen.OnEnd()](bool clean) {
|
||||||
|
onEnd(clean);
|
||||||
|
if (auto s = weak.lock()) s->SendAsync(nullptr, 0, true, nullptr);
|
||||||
|
});
|
||||||
|
self.Keep(stream);
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
Seen& echo = *new Seen;
|
||||||
|
stream.OnRecieve(echo.OnData(), echo.OnEnd());
|
||||||
|
constexpr std::size_t total = 2000 * 512;
|
||||||
|
std::vector<char> chunk(512);
|
||||||
|
for (std::size_t sent = 0; sent < total; sent += chunk.size()) {
|
||||||
|
for (std::size_t i = 0; i < chunk.size(); ++i) chunk[i] = Pattern(sent + i);
|
||||||
|
stream.SendAsync(chunk.data(), static_cast<std::uint32_t>(chunk.size()), sent + chunk.size() == total, nullptr);
|
||||||
|
}
|
||||||
|
const bool ended = WaitFor([&] { return seen.Ends() == 1 && echo.Ends() == 1; }, std::chrono::seconds(20));
|
||||||
|
Check(ended && seen.clean && FollowsPattern(seen.Bytes(), total) && seen.dataAfterEnd == 0,
|
||||||
|
std::format("registered mid-flow, every byte arrived in order, then a clean end ({} of {})", seen.Bytes().size(), total));
|
||||||
|
Check(seen.overlaps.load() == 0, "and no two of the stream's callbacks, onSent included, ever ran at once");
|
||||||
|
Check(echo.clean && FollowsPattern(echo.Bytes(), total), "the echo sent from inside onData came back whole");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No pool ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
Seen& seen = *new Seen;
|
||||||
|
std::atomic<bool> registered = false;
|
||||||
|
Server& server = *new Server([&](Server& self, std::shared_ptr<QUICStream> stream) {
|
||||||
|
std::weak_ptr<QUICStream> weak = stream;
|
||||||
|
stream->OnRecieve(
|
||||||
|
[&, weak](std::span<const char> data) {
|
||||||
|
seen.OnData()(data);
|
||||||
|
if (auto s = weak.lock()) s->SendAsync(data.data(), static_cast<std::uint32_t>(data.size()), false, nullptr);
|
||||||
|
},
|
||||||
|
[weak](bool) { if (auto s = weak.lock()) s->SendAsync(nullptr, 0, true, nullptr); });
|
||||||
|
self.Keep(stream);
|
||||||
|
registered = true;
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
Seen& echo = *new Seen;
|
||||||
|
stream.OnRecieve(echo.OnData(), echo.OnEnd());
|
||||||
|
const char first = Pattern(0);
|
||||||
|
stream.SendAsync(&first, 1, false, nullptr);
|
||||||
|
// The server's OnStream is a pool task: let it run first.
|
||||||
|
WaitFor([&] { return registered.load(); }, std::chrono::seconds(10));
|
||||||
|
|
||||||
|
Parking& parking = *new Parking;
|
||||||
|
const bool allParked = parking.Park();
|
||||||
|
constexpr std::size_t total = 256 * 1024;
|
||||||
|
std::vector<char> rest(total - 1);
|
||||||
|
for (std::size_t i = 0; i < rest.size(); ++i) rest[i] = Pattern(i + 1);
|
||||||
|
stream.SendAsync(rest.data(), static_cast<std::uint32_t>(rest.size()), true, nullptr);
|
||||||
|
const bool ended = WaitFor([&] { return echo.Ends() == 1; }, std::chrono::seconds(10));
|
||||||
|
Check(allParked && ended && echo.clean && FollowsPattern(echo.Bytes(), total),
|
||||||
|
std::format("with all {} pool workers parked, 256 KiB went out and came back by callback ({} bytes)",
|
||||||
|
parking.parked.load(), echo.Bytes().size()));
|
||||||
|
parking.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unclean ends ─────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
std::mutex mutex;
|
||||||
|
std::vector<Seen*> seens;
|
||||||
|
Server& server = *new Server([&](Server& self, std::shared_ptr<QUICStream> stream) {
|
||||||
|
Seen& seen = *new Seen;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
seens.push_back(&seen);
|
||||||
|
}
|
||||||
|
stream->OnRecieve(seen.OnData(), seen.OnEnd());
|
||||||
|
self.Keep(stream);
|
||||||
|
});
|
||||||
|
auto latest = [&]() -> Seen* {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
return seens.empty() ? nullptr : seens.back();
|
||||||
|
};
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
{
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("x", 1, false);
|
||||||
|
WaitFor([&] { return latest() != nullptr && latest()->Bytes() == "x"; }, std::chrono::seconds(10));
|
||||||
|
stream.Abort(7);
|
||||||
|
Seen* seen = latest();
|
||||||
|
Check(seen && WaitFor([&] { return seen->Ends() == 1; }, std::chrono::seconds(10)) && !seen->clean,
|
||||||
|
"the peer's Abort() ends the stream, not clean");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
ClientQUIC lost("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = lost.OpenStream();
|
||||||
|
Seen* before = latest();
|
||||||
|
stream.SendSync("y", 1, false);
|
||||||
|
WaitFor([&] { return latest() != before && latest()->Bytes() == "y"; }, std::chrono::seconds(10));
|
||||||
|
Seen* seen = latest();
|
||||||
|
lost.Stop(); // the connection goes; the stream gets no FIN
|
||||||
|
Check(seen != before && WaitFor([&] { return seen->Ends() == 1; }, std::chrono::seconds(10)) && !seen->clean,
|
||||||
|
"a lost connection ends it, not clean");
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
Check(seen->Ends() == 1, "each end exactly once");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A byte cap, by Abort() from inside onData ────────────────────────
|
||||||
|
{
|
||||||
|
Seen& seen = *new Seen;
|
||||||
|
std::atomic<int> afterAbort = 0;
|
||||||
|
std::atomic<bool> aborted = false;
|
||||||
|
constexpr std::size_t cap = 64 * 1024;
|
||||||
|
Server& server = *new Server([&](Server& self, std::shared_ptr<QUICStream> stream) {
|
||||||
|
std::weak_ptr<QUICStream> weak = stream;
|
||||||
|
stream->OnRecieve(
|
||||||
|
[&, weak](std::span<const char> data) {
|
||||||
|
if (aborted) ++afterAbort;
|
||||||
|
seen.OnData()(data);
|
||||||
|
if (seen.Bytes().size() > cap && !aborted) {
|
||||||
|
aborted = true;
|
||||||
|
if (auto s = weak.lock()) s->Abort(0x42);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
seen.OnEnd());
|
||||||
|
self.Keep(stream);
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
Seen& back = *new Seen;
|
||||||
|
stream.OnRecieve(back.OnData(), back.OnEnd());
|
||||||
|
constexpr int sends = 16;
|
||||||
|
std::atomic<int> reported = 0;
|
||||||
|
std::atomic<int> refused = 0;
|
||||||
|
const std::vector<char> chunk(64 * 1024, 'z');
|
||||||
|
for (int i = 0; i < sends; ++i) {
|
||||||
|
stream.SendAsync(chunk.data(), static_cast<std::uint32_t>(chunk.size()), false, [&](bool sent) {
|
||||||
|
if (!sent) ++refused;
|
||||||
|
++reported;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bool ended = WaitFor([&] { return seen.Ends() == 1; }, std::chrono::seconds(10));
|
||||||
|
Check(ended && !seen.clean && afterAbort.load() == 0 && seen.Bytes().size() > cap
|
||||||
|
&& seen.Bytes().size() < sends * chunk.size(),
|
||||||
|
std::format("Abort() inside onData stopped the stream at {} KiB: no onData after it, onEnd(false)",
|
||||||
|
seen.Bytes().size() / 1024));
|
||||||
|
// Its sends may all report true: msquic takes them into its send
|
||||||
|
// buffer before the reset arrives, and true means just that. A
|
||||||
|
// send made once the reset has arrived is the one refused.
|
||||||
|
const bool backEnded = WaitFor([&] { return back.Ends() == 1 && reported.load() == sends; }, std::chrono::seconds(10));
|
||||||
|
bool probeRefused = false;
|
||||||
|
for (int attempt = 0; attempt < 100 && !probeRefused; ++attempt) {
|
||||||
|
auto result = std::make_shared<std::atomic<int>>(-1);
|
||||||
|
stream.SendAsync("p", 1, false, [result](bool sent) { *result = sent ? 1 : 0; });
|
||||||
|
WaitFor([&] { return result->load() != -1; }, std::chrono::seconds(5));
|
||||||
|
probeRefused = result->load() == 0;
|
||||||
|
if (!probeRefused) std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||||
|
}
|
||||||
|
Check(backEnded && !back.clean && probeRefused,
|
||||||
|
std::format("the peer saw both directions reset: its receive side ended not clean, and its sends are refused ({} of the first {} already were)",
|
||||||
|
refused.load(), sends));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The wrapper destroyed or moved under its callbacks ───────────────
|
||||||
|
{
|
||||||
|
// Destroyed inside its own onEnd: the callback holds the last
|
||||||
|
// reference.
|
||||||
|
std::atomic<int> destroyedInside = 0;
|
||||||
|
std::atomic<int> streams = 0;
|
||||||
|
Server& server = *new Server([&](Server&, std::shared_ptr<QUICStream> stream) {
|
||||||
|
++streams;
|
||||||
|
auto holder = std::make_shared<std::shared_ptr<QUICStream>>(stream);
|
||||||
|
stream->OnRecieve([](std::span<const char>) {}, [holder, &destroyedInside](bool) {
|
||||||
|
holder->reset();
|
||||||
|
++destroyedInside;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
for (int i = 0; i < 3; ++i) {
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("bye", 3, true);
|
||||||
|
}
|
||||||
|
Check(WaitFor([&] { return destroyedInside.load() == 3; }, std::chrono::seconds(10)),
|
||||||
|
"a wrapper destroyed inside its own onEnd breaks nothing, three times over");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Moved while bytes flow: the callbacks never touch the wrapper.
|
||||||
|
Seen& seen = *new Seen;
|
||||||
|
std::atomic<bool> registered = false;
|
||||||
|
std::shared_ptr<QUICStream> first;
|
||||||
|
Server& server = *new Server([&](Server&, std::shared_ptr<QUICStream> stream) {
|
||||||
|
stream->OnRecieve(seen.OnData(), seen.OnEnd());
|
||||||
|
first = stream;
|
||||||
|
registered = true;
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
const char lead = Pattern(0);
|
||||||
|
stream.SendSync(&lead, 1, false);
|
||||||
|
WaitFor([&] { return registered.load(); }, std::chrono::seconds(10));
|
||||||
|
constexpr std::size_t total = 512 * 1024;
|
||||||
|
std::thread sender([&] {
|
||||||
|
std::vector<char> chunk(1024);
|
||||||
|
for (std::size_t sent = 1; sent < total; sent += chunk.size()) {
|
||||||
|
const std::size_t size = std::min(chunk.size(), total - sent);
|
||||||
|
for (std::size_t i = 0; i < size; ++i) chunk[i] = Pattern(sent + i);
|
||||||
|
stream.SendSync(chunk.data(), static_cast<std::uint32_t>(size), sent + size == total);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
auto second = std::make_shared<QUICStream>(std::move(*first));
|
||||||
|
first.reset();
|
||||||
|
sender.join();
|
||||||
|
Check(WaitFor([&] { return seen.Ends() == 1; }, std::chrono::seconds(10)) && seen.clean
|
||||||
|
&& FollowsPattern(seen.Bytes(), total),
|
||||||
|
"a wrapper moved while bytes flow keeps receiving, every byte in order");
|
||||||
|
second.reset();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Destroyed from another thread mid-flow: no callback after.
|
||||||
|
// What the callbacks touch is leaked, in case they do run late.
|
||||||
|
struct Watch {
|
||||||
|
Seen seen;
|
||||||
|
std::atomic<bool> destroyed = false;
|
||||||
|
std::atomic<int> afterDestroyed = 0;
|
||||||
|
std::shared_ptr<QUICStream> held;
|
||||||
|
std::atomic<bool> registered = false;
|
||||||
|
};
|
||||||
|
Watch& watch = *new Watch;
|
||||||
|
Seen& seen = watch.seen;
|
||||||
|
std::atomic<bool>& destroyed = watch.destroyed;
|
||||||
|
std::atomic<int>& afterDestroyed = watch.afterDestroyed;
|
||||||
|
std::shared_ptr<QUICStream>& held = watch.held;
|
||||||
|
std::atomic<bool>& registered = watch.registered;
|
||||||
|
Server& server = *new Server([&watch](Server&, std::shared_ptr<QUICStream> stream) {
|
||||||
|
stream->OnRecieve(
|
||||||
|
[&watch](std::span<const char>) {
|
||||||
|
watch.seen.Enter();
|
||||||
|
// Slow, so destroying the wrapper often catches one.
|
||||||
|
std::this_thread::sleep_for(std::chrono::microseconds(500));
|
||||||
|
if (watch.destroyed) ++watch.afterDestroyed;
|
||||||
|
watch.seen.Leave();
|
||||||
|
},
|
||||||
|
[&watch](bool) { if (watch.destroyed) ++watch.afterDestroyed; });
|
||||||
|
watch.held = stream;
|
||||||
|
watch.registered = true;
|
||||||
|
});
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("go", 2, false);
|
||||||
|
WaitFor([&] { return registered.load(); }, std::chrono::seconds(10));
|
||||||
|
std::atomic<bool> stop = false;
|
||||||
|
std::thread sender([&] {
|
||||||
|
const std::vector<char> chunk(256, 'q');
|
||||||
|
while (!stop) {
|
||||||
|
stream.SendAsync(chunk.data(), static_cast<std::uint32_t>(chunk.size()), false, nullptr);
|
||||||
|
std::this_thread::sleep_for(std::chrono::microseconds(200));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
held.reset(); // ~QUICStream on this thread, callbacks running on the worker
|
||||||
|
destroyed = true;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||||
|
stop = true;
|
||||||
|
sender.join();
|
||||||
|
Check(afterDestroyed.load() == 0 && seen.overlaps.load() == 0,
|
||||||
|
"once a wrapper destroyed on another thread has gone, no callback of it runs again");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Misuse and edge cases ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
Server& server = *new Server([](Server& self, std::shared_ptr<QUICStream> stream) { self.Keep(stream); });
|
||||||
|
ClientQUIC client("127.0.0.1", server.Port(), alpn, ClientCredentials());
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.OnRecieve([](std::span<const char>) {}, [](bool) {});
|
||||||
|
bool threw = false;
|
||||||
|
try {
|
||||||
|
stream.OnRecieve([](std::span<const char>) {}, [](bool) {});
|
||||||
|
} catch (const QUICException&) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
Check(threw, "a second OnRecieve on one stream throws QUICException");
|
||||||
|
|
||||||
|
QUICStream sendOnly = client.OpenStream(/*unidirectional=*/true);
|
||||||
|
std::atomic<int> sendOnlyEnds = 0;
|
||||||
|
std::atomic<bool> sendOnlyClean = true;
|
||||||
|
sendOnly.OnRecieve([](std::span<const char>) {}, [&](bool clean) { ++sendOnlyEnds; sendOnlyClean = clean; });
|
||||||
|
Check(sendOnlyEnds.load() == 1 && !sendOnlyClean.load(), "a stream that cannot receive gets onEnd(false) at once");
|
||||||
|
|
||||||
|
QUICStream none;
|
||||||
|
std::atomic<int> noneEnds = 0;
|
||||||
|
none.OnRecieve([](std::span<const char>) {}, [&](bool clean) { if (!clean) ++noneEnds; });
|
||||||
|
Check(noneEnds.load() == 1, "so does no stream at all");
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// As the other QUIC tests: skip the static teardown, which waits for the
|
||||||
|
// accepted peers' idle timeout. _Exit doesn't flush.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
107
tests/ShouldReportListenerPortQUIC/main.cpp
Normal file
107
tests/ShouldReportListenerPortQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// ListenerQUIC::Port() says which UDP port the listener is bound to, so port
|
||||||
|
// 0 (let msquic pick a free one) is usable. Before, nothing read it back: a
|
||||||
|
// server had to pick a fixed port and hope nothing else held it, and this
|
||||||
|
// suite hands every QUIC test a port of its own by hand.
|
||||||
|
//
|
||||||
|
// Also: two listeners on port 0 get different ports, a fixed port reads back
|
||||||
|
// as itself, and the port is still there after Stop() and gone from a
|
||||||
|
// moved-from listener.
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Text(const std::vector<char>& bytes) {
|
||||||
|
return std::string(bytes.begin(), bytes.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
|
||||||
|
// A connect that never completes would otherwise sit out the handshake
|
||||||
|
// timeout and the runner's 60 s; fail well before that instead.
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(30));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-listener-port";
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
QUICClientCredentials clientCreds;
|
||||||
|
clientCreds.insecureNoServerValidation = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ── Port 0: msquic picks, Port() says what it picked ─────────────────
|
||||||
|
ListenerQUIC listener(0, alpn, serverCreds, [](ClientQUIC* peer) {
|
||||||
|
peer->OnStream([](QUICStream stream) {
|
||||||
|
try {
|
||||||
|
std::vector<char> got = stream.RecieveUntilCloseSync();
|
||||||
|
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
|
||||||
|
} catch (...) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
const std::uint16_t port = listener.Port();
|
||||||
|
Check(port != 0, std::format("a listener on port 0 reports the port it got ({})", port));
|
||||||
|
|
||||||
|
{
|
||||||
|
ClientQUIC client("localhost", port, alpn, clientCreds);
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("ping", 4, true);
|
||||||
|
const std::string echoed = Text(stream.RecieveUntilCloseSync());
|
||||||
|
Check(echoed == "ping", std::format("a client reaches it at that port ({})", echoed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Another listener on port 0 gets another port ─────────────────────
|
||||||
|
std::uint16_t freed = 0;
|
||||||
|
{
|
||||||
|
ListenerQUIC other(0, alpn, serverCreds, [](ClientQUIC* peer) { delete peer; });
|
||||||
|
freed = other.Port();
|
||||||
|
Check(freed != 0 && freed != port,
|
||||||
|
std::format("a second listener on port 0 gets a port of its own ({} and {})", port, freed));
|
||||||
|
|
||||||
|
ListenerQUIC moved(std::move(other));
|
||||||
|
Check(moved.Port() == freed && other.Port() == 0,
|
||||||
|
"moving a listener moves its port; the moved-from one reports 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A fixed port reads back as itself ────────────────────────────────
|
||||||
|
// The port the listener above let go of: free again, and not one any
|
||||||
|
// other test in the suite binds.
|
||||||
|
{
|
||||||
|
ListenerQUIC fixed(freed, alpn, serverCreds, [](ClientQUIC* peer) { delete peer; });
|
||||||
|
Check(fixed.Port() == freed, std::format("a listener on a fixed port reports that port ({})", fixed.Port()));
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.Stop();
|
||||||
|
Check(listener.Port() == port, "Port() still answers after Stop()");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// As the other QUIC tests: skip the static teardown, which waits for the
|
||||||
|
// accepted peer's idle timeout. _Exit doesn't flush.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
109
tests/ShouldReturnFromSendSyncQUIC/main.cpp
Normal file
109
tests/ShouldReturnFromSendSyncQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// QUICStream::SendSync returns once msquic has taken its buffer, and throws
|
||||||
|
// only when that send failed. It used to wait on one flag per stream and,
|
||||||
|
// once woken, throw QUICClosedException whenever the stream had finished
|
||||||
|
// shutting down meanwhile, though its own send had completed. A peer that
|
||||||
|
// answers at once and finishes the stream (here: echo, FIN, and drop the
|
||||||
|
// stream, whose Stop() stops our sending side too) can complete the stream
|
||||||
|
// within a round trip, which on loopback is shorter than a busy machine can
|
||||||
|
// take to wake the sending thread: the sender threw for a send the peer had
|
||||||
|
// already answered.
|
||||||
|
//
|
||||||
|
// The race needs the sending thread to be descheduled at the wrong moment,
|
||||||
|
// so no test hits it every time. The shape that showed it: the first stream
|
||||||
|
// of a fresh connection, four connections at once, one exchange each; a
|
||||||
|
// throwaway run of that shape threw once in 3000 exchanges, the echo of the
|
||||||
|
// failed send arriving intact. Here 2000 such exchanges must all return
|
||||||
|
// from SendSync, and all be echoed.
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(50));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-send-sync";
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
QUICClientCredentials clientCreds;
|
||||||
|
clientCreds.insecureNoServerValidation = true;
|
||||||
|
|
||||||
|
constexpr int rounds = 500;
|
||||||
|
constexpr int together = 4;
|
||||||
|
std::atomic<int> threw = 0;
|
||||||
|
std::atomic<int> echoed = 0;
|
||||||
|
std::atomic<int> other = 0;
|
||||||
|
try {
|
||||||
|
// Answer each stream with what arrived and a FIN, then drop it: its
|
||||||
|
// destructor's Stop() aborts our receive side, which also ends the
|
||||||
|
// client's sending side.
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [](ClientQUIC* peer) {
|
||||||
|
peer->OnStream([](QUICStream stream) {
|
||||||
|
try {
|
||||||
|
std::vector<char> got = stream.RecieveSync();
|
||||||
|
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
|
||||||
|
} catch (...) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
|
||||||
|
for (int round = 0; round < rounds; ++round) {
|
||||||
|
std::vector<std::thread> clients;
|
||||||
|
for (int i = 0; i < together; ++i) {
|
||||||
|
clients.emplace_back([&] {
|
||||||
|
try {
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds);
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
try {
|
||||||
|
stream.SendSync("ping", 4, false);
|
||||||
|
} catch (const QUICClosedException&) {
|
||||||
|
++threw;
|
||||||
|
}
|
||||||
|
std::vector<char> back = stream.RecieveUntilCloseSync();
|
||||||
|
if (std::string(back.begin(), back.end()) == "ping") ++echoed;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (++other == 1) std::println(" {}", e.what());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (std::thread& client : clients) client.join();
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
constexpr int exchanges = rounds * together;
|
||||||
|
Check(threw.load() == 0,
|
||||||
|
std::format("SendSync returned for every send msquic took ({} of {} threw QUICClosedException)",
|
||||||
|
threw.load(), exchanges));
|
||||||
|
Check(echoed.load() == exchanges && other.load() == 0,
|
||||||
|
std::format("and every echo came back ({} of {}, {} other errors)", echoed.load(), exchanges, other.load()));
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// The accepted peers are left to the process: skip msquic's static
|
||||||
|
// teardown, as the other QUIC tests do. _Exit doesn't flush.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
325
tests/ShouldSendWithoutBlockingQUIC/main.cpp
Normal file
325
tests/ShouldSendWithoutBlockingQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// QUICStream::SendAsync holds no thread. It used to be SendSync run as a
|
||||||
|
// pool task: a pool thread was held until msquic accepted the buffer, which
|
||||||
|
// a slow peer stretches to its own pace, so a server streaming to a slow
|
||||||
|
// follower lost a worker of a pool the host shares, and the task reached back
|
||||||
|
// through the wrapper, which a move or a destructor in the meantime freed.
|
||||||
|
// Now the copy goes to msquic at once and its SEND_COMPLETE reports, on
|
||||||
|
// msquic's worker, exactly once.
|
||||||
|
//
|
||||||
|
// - A burst of 256 sends of 64 KiB returns with most of it still in flight
|
||||||
|
// (BytesInFlight), every send then reports true, in order, the counters
|
||||||
|
// drain to zero, and the peer gets every byte in order.
|
||||||
|
// - With every pool worker parked, a chain of 100 sends, each made from the
|
||||||
|
// previous one's onSent, completes: nothing needed the pool.
|
||||||
|
// - A connection stopped under a burst reports the rest false, each send
|
||||||
|
// exactly once, the trues before the falses.
|
||||||
|
// - Sends made just before the wrapper is destroyed still go out and
|
||||||
|
// report true.
|
||||||
|
// - Four threads sending on one stream at once all complete.
|
||||||
|
// - A send on a closed stream reports false from a pool task, not from
|
||||||
|
// inside SendAsync.
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WaitFor(std::function<bool()> done, std::chrono::milliseconds limit) {
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + limit;
|
||||||
|
while (!done()) {
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) return false;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(2));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte `offset` of a stream, after its one-byte tag.
|
||||||
|
char Pattern(std::uint64_t offset) { return static_cast<char>((offset * 131 + 7) & 0xff); }
|
||||||
|
|
||||||
|
// What the server read from each stream, by the stream's first byte.
|
||||||
|
struct Received {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::map<char, std::vector<char>> streams;
|
||||||
|
|
||||||
|
std::optional<std::vector<char>> Of(char tag) {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
auto it = streams.find(tag);
|
||||||
|
if (it == streams.end()) return std::nullopt;
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
bool FollowsPattern(const std::vector<char>& bytes, std::size_t expected) {
|
||||||
|
if (bytes.size() != expected + 1) return false;
|
||||||
|
for (std::size_t i = 1; i < bytes.size(); ++i) {
|
||||||
|
if (bytes[i] != Pattern(i - 1)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The completions of a set of sends, by index.
|
||||||
|
struct Completions {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::vector<int> calls; // how often each send's onSent fired
|
||||||
|
std::vector<bool> sent; // what it said
|
||||||
|
std::vector<int> order; // indices, in the order they fired
|
||||||
|
|
||||||
|
explicit Completions(std::size_t count) : calls(count, 0), sent(count, false) {}
|
||||||
|
|
||||||
|
std::function<void(bool)> For(std::size_t index) {
|
||||||
|
return [this, index](bool ok) {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
++calls[index];
|
||||||
|
sent[index] = ok;
|
||||||
|
order.push_back(static_cast<int>(index));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
std::size_t Fired() {
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
return order.size();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(45));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-send-async";
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
QUICClientCredentials clientCreds;
|
||||||
|
clientCreds.insecureNoServerValidation = true;
|
||||||
|
|
||||||
|
// Leaked: readers may still be finishing when main has moved on.
|
||||||
|
Received& received = *new Received;
|
||||||
|
std::atomic<int> readersStarted = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Each stream gets a reader on a thread of its own, not on the pool,
|
||||||
|
// which the second part parks.
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [&](ClientQUIC* peer) {
|
||||||
|
peer->OnStream([&](QUICStream stream) {
|
||||||
|
auto shared = std::make_shared<QUICStream>(std::move(stream));
|
||||||
|
++readersStarted;
|
||||||
|
std::thread([&received, shared] {
|
||||||
|
std::vector<char> bytes;
|
||||||
|
try {
|
||||||
|
bytes = shared->RecieveUntilCloseSync();
|
||||||
|
} catch (...) {}
|
||||||
|
if (bytes.empty()) return;
|
||||||
|
std::lock_guard lock(received.mutex);
|
||||||
|
received.streams[bytes.front()] = std::move(bytes);
|
||||||
|
}).detach();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds);
|
||||||
|
|
||||||
|
// ── A burst ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
constexpr std::size_t count = 256;
|
||||||
|
constexpr std::size_t size = 64 * 1024;
|
||||||
|
auto completions = std::make_shared<Completions>(count + 1);
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendAsync("A", 1, false, completions->For(count));
|
||||||
|
std::vector<char> chunk(size);
|
||||||
|
const auto start = std::chrono::steady_clock::now();
|
||||||
|
for (std::size_t i = 0; i < count; ++i) {
|
||||||
|
for (std::size_t j = 0; j < size; ++j) chunk[j] = Pattern(i * size + j);
|
||||||
|
stream.SendAsync(chunk.data(), static_cast<std::uint32_t>(size), i + 1 == count, completions->For(i));
|
||||||
|
}
|
||||||
|
const auto took = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start);
|
||||||
|
const std::uint64_t inFlight = stream.BytesInFlight();
|
||||||
|
Check(inFlight > 0 && stream.SendsInFlight() > 0,
|
||||||
|
std::format("256 sends of 64 KiB returned in {} ms with {} KiB still in flight: none waited for msquic",
|
||||||
|
took.count(), inFlight / 1024));
|
||||||
|
Check(WaitFor([&] { return completions->Fired() == count + 1; }, std::chrono::seconds(20)),
|
||||||
|
"every send reported");
|
||||||
|
{
|
||||||
|
std::lock_guard lock(completions->mutex);
|
||||||
|
const bool once = std::ranges::all_of(completions->calls, [](int calls) { return calls == 1; });
|
||||||
|
const bool allSent = std::ranges::all_of(completions->sent, [](bool ok) { return ok; });
|
||||||
|
std::vector<int> data(completions->order.begin(), completions->order.end());
|
||||||
|
std::erase(data, static_cast<int>(count));
|
||||||
|
Check(once && allSent && std::ranges::is_sorted(data), "each exactly once, true, and in the order sent");
|
||||||
|
}
|
||||||
|
Check(stream.BytesInFlight() == 0 && stream.SendsInFlight() == 0, "after which nothing is in flight");
|
||||||
|
const bool arrived = WaitFor([&] { return received.Of('A').has_value(); }, std::chrono::seconds(20));
|
||||||
|
Check(arrived && FollowsPattern(*received.Of('A'), count * size), "and the peer read all 16 MiB, in order");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No pool ──────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
const int before = readersStarted.load();
|
||||||
|
stream.SendSync("B", 1, false);
|
||||||
|
// The server's OnStream is itself a pool task: let it start the
|
||||||
|
// reader before the pool is parked.
|
||||||
|
WaitFor([&] { return readersStarted.load() > before; }, std::chrono::seconds(10));
|
||||||
|
|
||||||
|
// Leaked, and released only once every parked task has left, so
|
||||||
|
// none reads it after this block.
|
||||||
|
struct Parking {
|
||||||
|
std::atomic<bool> release = false;
|
||||||
|
std::atomic<unsigned> parked = 0;
|
||||||
|
std::atomic<unsigned> left = 0;
|
||||||
|
};
|
||||||
|
Parking& parking = *new Parking;
|
||||||
|
const unsigned workers = ThreadPool::activeThreads.load();
|
||||||
|
for (unsigned i = 0; i < workers * 2; ++i) {
|
||||||
|
ThreadPool::Enqueue([&parking] {
|
||||||
|
++parking.parked;
|
||||||
|
while (!parking.release.load()) std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||||
|
++parking.left;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const bool allParked = WaitFor([&] { return parking.parked.load() >= workers; }, std::chrono::seconds(10));
|
||||||
|
|
||||||
|
constexpr int links = 100;
|
||||||
|
auto done = std::make_shared<std::atomic<int>>(0);
|
||||||
|
auto ok = std::make_shared<std::atomic<bool>>(true);
|
||||||
|
// Each onSent makes the next send, on msquic's worker.
|
||||||
|
auto chain = std::make_shared<std::function<void(int)>>();
|
||||||
|
QUICStream* raw = &stream;
|
||||||
|
*chain = [raw, chain, done, ok](int link) {
|
||||||
|
const char byte = Pattern(static_cast<std::uint64_t>(link));
|
||||||
|
raw->SendAsync(&byte, 1, link + 1 == links, [raw, chain, done, ok, link](bool sent) {
|
||||||
|
if (!sent) *ok = false;
|
||||||
|
++*done;
|
||||||
|
if (link + 1 < links) (*chain)(link + 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
(*chain)(0);
|
||||||
|
const bool finished = WaitFor([&] { return done->load() == links; }, std::chrono::seconds(10));
|
||||||
|
Check(allParked && finished && ok->load(),
|
||||||
|
std::format("with all {} pool workers parked, a chain of {} sends, each from the last one's onSent, completed",
|
||||||
|
workers, done->load()));
|
||||||
|
parking.release = true;
|
||||||
|
WaitFor([&] { return parking.left.load() == workers * 2; }, std::chrono::seconds(10));
|
||||||
|
const bool arrived = WaitFor([&] { return received.Of('B').has_value(); }, std::chrono::seconds(10));
|
||||||
|
Check(arrived && FollowsPattern(*received.Of('B'), links), "and the peer read them all, in order");
|
||||||
|
*chain = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The connection stopped under a burst ─────────────────────────────
|
||||||
|
{
|
||||||
|
ClientQUIC doomed("127.0.0.1", listener.Port(), alpn, clientCreds);
|
||||||
|
QUICStream stream = doomed.OpenStream();
|
||||||
|
constexpr std::size_t count = 256;
|
||||||
|
constexpr std::size_t size = 64 * 1024;
|
||||||
|
auto completions = std::make_shared<Completions>(count);
|
||||||
|
std::vector<char> chunk(size, 'C');
|
||||||
|
for (std::size_t i = 0; i < count; ++i) {
|
||||||
|
stream.SendAsync(chunk.data(), static_cast<std::uint32_t>(size), false, completions->For(i));
|
||||||
|
}
|
||||||
|
doomed.Stop();
|
||||||
|
Check(WaitFor([&] { return completions->Fired() == count; }, std::chrono::seconds(20)),
|
||||||
|
"a connection stopped under a burst reports every send");
|
||||||
|
std::lock_guard lock(completions->mutex);
|
||||||
|
const bool once = std::ranges::all_of(completions->calls, [](int calls) { return calls == 1; });
|
||||||
|
const auto firstFalse = std::ranges::find(completions->sent, false);
|
||||||
|
const bool prefix = std::all_of(firstFalse, completions->sent.end(), [](bool ok) { return !ok; });
|
||||||
|
Check(once && prefix && firstFalse != completions->sent.end(),
|
||||||
|
std::format("each exactly once: {} true, then {} false",
|
||||||
|
firstFalse - completions->sent.begin(), completions->sent.end() - firstFalse));
|
||||||
|
Check(stream.BytesInFlight() == 0, "and nothing is left in flight");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The wrapper destroyed with sends in flight ───────────────────────
|
||||||
|
{
|
||||||
|
constexpr std::size_t count = 64;
|
||||||
|
constexpr std::size_t size = 64 * 1024;
|
||||||
|
auto completions = std::make_shared<Completions>(count + 1);
|
||||||
|
{
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendAsync("D", 1, false, completions->For(count));
|
||||||
|
std::vector<char> chunk(size);
|
||||||
|
for (std::size_t i = 0; i < count; ++i) {
|
||||||
|
for (std::size_t j = 0; j < size; ++j) chunk[j] = Pattern(i * size + j);
|
||||||
|
stream.SendAsync(chunk.data(), static_cast<std::uint32_t>(size), false, completions->For(i));
|
||||||
|
}
|
||||||
|
// ~QUICStream: Stop(), a FIN after what was sent.
|
||||||
|
}
|
||||||
|
Check(WaitFor([&] { return completions->Fired() == count + 1; }, std::chrono::seconds(20)),
|
||||||
|
"sends made just before the wrapper was destroyed all report");
|
||||||
|
{
|
||||||
|
std::lock_guard lock(completions->mutex);
|
||||||
|
Check(std::ranges::all_of(completions->sent, [](bool ok) { return ok; }), "and report true");
|
||||||
|
}
|
||||||
|
const bool arrived = WaitFor([&] { return received.Of('D').has_value(); }, std::chrono::seconds(20));
|
||||||
|
Check(arrived && FollowsPattern(*received.Of('D'), count * size), "and go out, followed by the FIN");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Four threads on one stream ───────────────────────────────────────
|
||||||
|
{
|
||||||
|
constexpr int threads = 4;
|
||||||
|
constexpr int each = 50;
|
||||||
|
auto completions = std::make_shared<Completions>(threads * each + 1);
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendAsync("F", 1, false, completions->For(threads * each));
|
||||||
|
std::vector<std::thread> senders;
|
||||||
|
for (int t = 0; t < threads; ++t) {
|
||||||
|
senders.emplace_back([&, t] {
|
||||||
|
const std::vector<char> message(1024, static_cast<char>('a' + t));
|
||||||
|
for (int i = 0; i < each; ++i) {
|
||||||
|
stream.SendAsync(message.data(), static_cast<std::uint32_t>(message.size()), false,
|
||||||
|
completions->For(static_cast<std::size_t>(t * each + i)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (std::thread& sender : senders) sender.join();
|
||||||
|
stream.SendAsync(nullptr, 0, true, nullptr);
|
||||||
|
Check(WaitFor([&] { return completions->Fired() == threads * each + 1; }, std::chrono::seconds(20)),
|
||||||
|
"four threads sending on one stream at once all hear back");
|
||||||
|
const bool arrived = WaitFor([&] { return received.Of('F').has_value(); }, std::chrono::seconds(20));
|
||||||
|
Check(arrived && received.Of('F')->size() == 1 + threads * each * 1024, "and the peer read every byte");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── A closed stream ──────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
QUICStream closed;
|
||||||
|
auto inside = std::make_shared<std::atomic<bool>>(false);
|
||||||
|
auto fired = std::make_shared<std::atomic<int>>(0);
|
||||||
|
auto sentInside = std::make_shared<std::atomic<bool>>(false);
|
||||||
|
auto said = std::make_shared<std::atomic<bool>>(true);
|
||||||
|
*inside = true;
|
||||||
|
closed.SendAsync("x", 1, false, [inside, fired, sentInside, said](bool ok) {
|
||||||
|
*sentInside = inside->load();
|
||||||
|
*said = ok;
|
||||||
|
++*fired;
|
||||||
|
});
|
||||||
|
*inside = false;
|
||||||
|
Check(WaitFor([&] { return fired->load() == 1; }, std::chrono::seconds(10)) && !said->load() && !sentInside->load(),
|
||||||
|
"a send on a closed stream reports false, from a pool task rather than inside SendAsync");
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// As the other QUIC tests: skip the static teardown, which waits for the
|
||||||
|
// accepted peer's idle timeout. _Exit doesn't flush.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
254
tests/ShouldShareSelfSignedCertificateQUIC/main.cpp
Normal file
254
tests/ShouldShareSelfSignedCertificateQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// QUICServerCredentials{selfSigned = true} from a cold cache, in several
|
||||||
|
// processes at once. The pair used to be made by the openssl CLI straight
|
||||||
|
// into the fixed /tmp/crafter-network-quic-cert/{key,cert}.pem, guarded by an
|
||||||
|
// in-process mutex only: test binaries started together each found no pair,
|
||||||
|
// each ran openssl over the same two files, and one could load another run's
|
||||||
|
// key with its own certificate, failing in ConfigurationLoadCredential. The
|
||||||
|
// directory was shared by every user of the machine, and without the openssl
|
||||||
|
// CLI there was no certificate at all.
|
||||||
|
//
|
||||||
|
// Six processes, forked before any thread or msquic exists, are released at
|
||||||
|
// once; each starts two threads that create a selfSigned listener together,
|
||||||
|
// against an empty cache (XDG_CACHE_HOME pointed at a fresh directory) and
|
||||||
|
// with no openssl on PATH, and connects to both. Every listener must serve,
|
||||||
|
// and every process must report the same certificate: one made the pair and
|
||||||
|
// the rest read it. Then:
|
||||||
|
// - the directory is the user's alone (0700) and so is the key (0600);
|
||||||
|
// - a process started later serves the same certificate, so a browser's
|
||||||
|
// pinned hash survives a restart;
|
||||||
|
// - a mismatched pair left on disk, what the race used to leave behind, is
|
||||||
|
// replaced by the next process rather than loaded.
|
||||||
|
//
|
||||||
|
// The parent never starts msquic or a thread, so forking stays safe: every
|
||||||
|
// listener lives in a child, which answers with one line through a pipe.
|
||||||
|
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-certificate";
|
||||||
|
|
||||||
|
std::string Hex(const std::array<std::uint8_t, 32>& bytes) {
|
||||||
|
std::string text;
|
||||||
|
for (std::uint8_t byte : bytes) text += std::format("{:02x}", byte);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One child: wait for the byte on `go`, start `listeners` selfSigned
|
||||||
|
// listeners together on threads of their own, reach each with a client, and
|
||||||
|
// write "OK <certificate hash> <certificate path>" or "FAIL <why>" to
|
||||||
|
// `report`.
|
||||||
|
[[noreturn]] void Child(int go, int report, int listeners) {
|
||||||
|
// A child that hangs dies and so answers nothing, which the parent
|
||||||
|
// counts as a failure, inside the runner's 60 s.
|
||||||
|
alarm(40);
|
||||||
|
char byte = 0;
|
||||||
|
while (read(go, &byte, 1) < 0 && errno == EINTR) {}
|
||||||
|
close(go);
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
try {
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::latch together(listeners);
|
||||||
|
std::vector<std::string> problems(static_cast<std::size_t>(listeners));
|
||||||
|
std::vector<std::thread> threads;
|
||||||
|
for (int i = 0; i < listeners; ++i) {
|
||||||
|
threads.emplace_back([&, i] {
|
||||||
|
try {
|
||||||
|
together.arrive_and_wait();
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [](ClientQUIC* peer) {
|
||||||
|
peer->OnStream([](QUICStream stream) {
|
||||||
|
try {
|
||||||
|
std::vector<char> got = stream.RecieveUntilCloseSync();
|
||||||
|
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
|
||||||
|
} catch (...) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
listener.ListenAsyncAsync();
|
||||||
|
QUICClientCredentials clientCreds;
|
||||||
|
clientCreds.insecureNoServerValidation = true;
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds);
|
||||||
|
QUICStream stream = client.OpenStream();
|
||||||
|
stream.SendSync("hi", 2, true);
|
||||||
|
std::vector<char> echoed = stream.RecieveUntilCloseSync();
|
||||||
|
if (std::string(echoed.begin(), echoed.end()) != "hi") problems[static_cast<std::size_t>(i)] = "no echo";
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
problems[static_cast<std::size_t>(i)] = e.what();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (std::thread& thread : threads) thread.join();
|
||||||
|
std::string problem;
|
||||||
|
for (const std::string& p : problems) if (!p.empty()) problem = p;
|
||||||
|
if (problem.empty()) {
|
||||||
|
const std::string path = GetSelfSignedCertificatePath();
|
||||||
|
line = std::format("OK {} {}", Hex(ComputeCertificateHashSHA256(path)), path);
|
||||||
|
} else {
|
||||||
|
line = "FAIL " + problem;
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
line = std::string("FAIL ") + e.what();
|
||||||
|
}
|
||||||
|
line += '\n';
|
||||||
|
for (std::size_t written = 0; written < line.size();) {
|
||||||
|
const ssize_t put = write(report, line.data() + written, line.size() - written);
|
||||||
|
if (put < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
written += static_cast<std::size_t>(put);
|
||||||
|
}
|
||||||
|
// The accepted peers are still open: skip msquic's static teardown,
|
||||||
|
// which would wait for their idle timeout.
|
||||||
|
std::_Exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forks `count` children, releases them together, and returns their lines,
|
||||||
|
// "FAIL no answer" for one that died first.
|
||||||
|
std::vector<std::string> RunChildren(int count, int listeners) {
|
||||||
|
int go[2];
|
||||||
|
if (pipe(go) != 0) return std::vector<std::string>(static_cast<std::size_t>(count), "FAIL pipe");
|
||||||
|
std::vector<std::pair<pid_t, int>> children;
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
int report[2];
|
||||||
|
if (pipe(report) != 0) break;
|
||||||
|
const pid_t pid = fork();
|
||||||
|
if (pid == 0) {
|
||||||
|
close(go[1]);
|
||||||
|
close(report[0]);
|
||||||
|
for (const auto& [sibling, siblingReport] : children) close(siblingReport);
|
||||||
|
Child(go[0], report[1], listeners);
|
||||||
|
}
|
||||||
|
close(report[1]);
|
||||||
|
children.emplace_back(pid, report[0]);
|
||||||
|
}
|
||||||
|
close(go[0]);
|
||||||
|
const std::string release(children.size(), 'x');
|
||||||
|
(void)!write(go[1], release.data(), release.size());
|
||||||
|
close(go[1]);
|
||||||
|
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
for (const auto& [pid, report] : children) {
|
||||||
|
std::string line;
|
||||||
|
char buffer[512];
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t got = read(report, buffer, sizeof(buffer));
|
||||||
|
if (got == 0) break;
|
||||||
|
if (got < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
line.append(buffer, static_cast<std::size_t>(got));
|
||||||
|
}
|
||||||
|
close(report);
|
||||||
|
int status = 0;
|
||||||
|
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
|
||||||
|
if (!line.empty() && line.back() == '\n') line.pop_back();
|
||||||
|
lines.push_back(line.empty() ? "FAIL no answer" : line);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Ok(const std::string& line) { return line.starts_with("OK "); }
|
||||||
|
|
||||||
|
// The hash in an "OK <hash> <path>" line.
|
||||||
|
std::string HashOf(const std::string& line) { return Ok(line) ? line.substr(3, 64) : std::string(); }
|
||||||
|
|
||||||
|
// The path in an "OK <hash> <path>" line.
|
||||||
|
std::string PathOf(const std::string& line) { return Ok(line) && line.size() > 68 ? line.substr(68) : std::string(); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
// The watchdog, without a thread: SIGALRM's default action ends the
|
||||||
|
// process, which the runner counts as a failure.
|
||||||
|
alarm(55);
|
||||||
|
|
||||||
|
char scratch[] = "/tmp/crafter-network-certificate-XXXXXX";
|
||||||
|
if (mkdtemp(scratch) == nullptr) {
|
||||||
|
std::println("FAIL mkdtemp: {}", std::strerror(errno));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const std::filesystem::path cache = scratch;
|
||||||
|
const std::filesystem::path directory = cache / "crafter-network" / "quic-cert";
|
||||||
|
setenv("XDG_CACHE_HOME", scratch, 1);
|
||||||
|
// No openssl to fall back on, wherever it is installed.
|
||||||
|
setenv("PATH", (cache / "no-programs-here").c_str(), 1);
|
||||||
|
|
||||||
|
// ── Six processes, two listeners each, from cold ─────────────────────────
|
||||||
|
const std::vector<std::string> cold = RunChildren(6, 2);
|
||||||
|
for (const std::string& line : cold) std::println(" {}", line);
|
||||||
|
Check(std::ranges::all_of(cold, Ok),
|
||||||
|
"twelve listeners in six processes started together from an empty cache, with no openssl CLI, were all served");
|
||||||
|
std::set<std::string> hashes;
|
||||||
|
for (const std::string& line : cold) hashes.insert(HashOf(line));
|
||||||
|
Check(hashes.size() == 1 && !hashes.begin()->empty(),
|
||||||
|
"and all six processes serve one certificate: one made it, the rest read it");
|
||||||
|
const std::string first = HashOf(cold.front());
|
||||||
|
Check(PathOf(cold.front()) == (directory / "cert.pem").string(),
|
||||||
|
std::format("it lives under XDG_CACHE_HOME ({})", PathOf(cold.front())));
|
||||||
|
|
||||||
|
// ── The directory is the user's alone ────────────────────────────────────
|
||||||
|
struct stat info {};
|
||||||
|
Check(stat(directory.c_str(), &info) == 0 && (info.st_mode & 0777) == 0700 && info.st_uid == geteuid(),
|
||||||
|
"the certificate directory is the user's, mode 0700");
|
||||||
|
Check(stat((directory / "key.pem").c_str(), &info) == 0 && (info.st_mode & 077) == 0,
|
||||||
|
"the key is readable by the user alone");
|
||||||
|
bool leftovers = false;
|
||||||
|
std::error_code error;
|
||||||
|
for (const auto& entry : std::filesystem::directory_iterator(directory, error)) {
|
||||||
|
leftovers = leftovers || entry.path().extension() == ".new";
|
||||||
|
}
|
||||||
|
Check(!leftovers, "no temporary file is left behind");
|
||||||
|
|
||||||
|
// ── A restart keeps the certificate ──────────────────────────────────────
|
||||||
|
const std::vector<std::string> restart = RunChildren(1, 1);
|
||||||
|
std::println(" {}", restart.front());
|
||||||
|
Check(Ok(restart.front()) && HashOf(restart.front()) == first,
|
||||||
|
"a process started later serves the same certificate, so a pinned hash survives a restart");
|
||||||
|
|
||||||
|
// ── A mismatched pair is replaced, not loaded ────────────────────────────
|
||||||
|
// Another valid P-256 key, of the TLS partition's certificate: what a
|
||||||
|
// torn replacement leaves on disk.
|
||||||
|
{
|
||||||
|
std::ofstream key(directory / "key.pem", std::ios::binary | std::ios::trunc);
|
||||||
|
key << GetSelfSignedCertificatePem().privateKey;
|
||||||
|
}
|
||||||
|
const std::vector<std::string> mismatched = RunChildren(1, 1);
|
||||||
|
std::println(" {}", mismatched.front());
|
||||||
|
Check(Ok(mismatched.front()) && HashOf(mismatched.front()) != first,
|
||||||
|
"a mismatched pair left on disk is replaced by a new one, which serves");
|
||||||
|
try {
|
||||||
|
Check(Hex(ComputeCertificateHashSHA256((directory / "cert.pem").string())) == HashOf(mismatched.front()),
|
||||||
|
"and the new certificate is the one on disk");
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("hashing the certificate on disk threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::remove_all(cache, error);
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
std::fflush(stdout);
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
121
tests/ShouldTellPeerOfCloseQUIC/main.cpp
Normal file
121
tests/ShouldTellPeerOfCloseQUIC/main.cpp
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// ~ClientQUIC tells the peer that the connection is over. It used to ask
|
||||||
|
// msquic to shut the connection down and then close the handle at once.
|
||||||
|
// msquic sends the CONNECTION_CLOSE frame from a flush it queues while it
|
||||||
|
// processes that shutdown, and the close the destructor queued meanwhile
|
||||||
|
// usually reached the worker first: it completed the shutdown silently, the
|
||||||
|
// flush found the connection finished and sent nothing, and the peer learned
|
||||||
|
// of the close only from its idle timeout, two minutes by default. A server
|
||||||
|
// whose clients each live for one request kept every one of them that long.
|
||||||
|
//
|
||||||
|
// Twenty clients connect and are destroyed in turn. For each, the server
|
||||||
|
// side's RecieveDatagramSync, which throws once the peer has closed the
|
||||||
|
// connection, must return within 2 s; the listener's idle timeout is 20 s, so
|
||||||
|
// a close the peer was never told about shows as a timeout here.
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
import Crafter.Network;
|
||||||
|
import Crafter.Thread;
|
||||||
|
import std;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's side of one connection: when it saw the peer close.
|
||||||
|
struct Seen {
|
||||||
|
std::mutex mutex;
|
||||||
|
std::condition_variable changed;
|
||||||
|
std::optional<std::chrono::steady_clock::time_point> closedAt;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
ThreadPool::Start();
|
||||||
|
std::thread([] {
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(45));
|
||||||
|
std::println("FAIL timed out");
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(1);
|
||||||
|
}).detach();
|
||||||
|
|
||||||
|
const std::string alpn = "crafter/test-close-notify";
|
||||||
|
QUICServerCredentials serverCreds;
|
||||||
|
serverCreds.selfSigned = true;
|
||||||
|
serverCreds.settings.idleTimeoutMs = 20'000;
|
||||||
|
QUICClientCredentials clientCreds;
|
||||||
|
clientCreds.insecureNoServerValidation = true;
|
||||||
|
|
||||||
|
std::mutex mutex;
|
||||||
|
std::deque<std::shared_ptr<Seen>> waiting;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Each accepted connection gets a thread parked in RecieveDatagramSync
|
||||||
|
// until the connection closes. The ClientQUIC is left alone: what is
|
||||||
|
// under test is the peer hearing of the close, not its owner.
|
||||||
|
ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [&](ClientQUIC* peer) {
|
||||||
|
std::shared_ptr<Seen> seen;
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
seen = waiting.front();
|
||||||
|
waiting.pop_front();
|
||||||
|
std::thread([peer, seen] {
|
||||||
|
try {
|
||||||
|
while (true) (void)peer->RecieveDatagramSync();
|
||||||
|
} catch (const QUICClosedException&) {}
|
||||||
|
{
|
||||||
|
std::lock_guard lock(seen->mutex);
|
||||||
|
seen->closedAt = std::chrono::steady_clock::now();
|
||||||
|
}
|
||||||
|
seen->changed.notify_all();
|
||||||
|
}).detach();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listener.ListenAsyncSync();
|
||||||
|
|
||||||
|
int told = 0;
|
||||||
|
std::chrono::milliseconds slowest{ 0 };
|
||||||
|
for (int i = 0; i < 20; ++i) {
|
||||||
|
auto seen = std::make_shared<Seen>();
|
||||||
|
{
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
waiting.push_back(seen);
|
||||||
|
}
|
||||||
|
std::chrono::steady_clock::time_point destroyed;
|
||||||
|
{
|
||||||
|
ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds);
|
||||||
|
destroyed = std::chrono::steady_clock::now();
|
||||||
|
// ~ClientQUIC runs here.
|
||||||
|
}
|
||||||
|
std::unique_lock lock(seen->mutex);
|
||||||
|
if (!seen->changed.wait_for(lock, std::chrono::seconds(2), [&] { return seen->closedAt.has_value(); })) {
|
||||||
|
// Every later connection would wait out its 2 s as well.
|
||||||
|
std::println(" connection {}: the server was not told within 2 s", i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
slowest = std::max(slowest, std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
|
*seen->closedAt - destroyed));
|
||||||
|
++told;
|
||||||
|
}
|
||||||
|
Check(told == 20, std::format("the server heard each of 20 clients close within 2 s ({} did; slowest {} ms)",
|
||||||
|
told, slowest.count()));
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
Check(false, std::format("threw: {}", e.what()));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
// The accepted ClientQUICs are left to the process: skip msquic's static
|
||||||
|
// teardown, as the other QUIC tests do. _Exit doesn't flush.
|
||||||
|
std::fflush(stdout);
|
||||||
|
std::_Exit(failures == 0 ? 0 : 1);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue