docs(quic): OnRecieve and Abort, and serving a stream without a thread

The README's QUIC section gains receiving by callback: a server
answering one request per stream with a size cap enforced by Abort,
the threads the callbacks run on, what they may call, and the
std::stop_callback rule for destroying the wrapper; its follower
example now drops a lagging peer with Abort rather than a graceful
Stop. The consumer notes give the full contract, the weak_ptr idiom for
reaching a stream from its own callbacks, and list the callbacks among
what runs on msquic's workers; the agent notes count the new test,
describe the per-stream delivery in the partition list, and add the
callbacks to the worker-thread gotcha.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 02:33:28 +01:00
commit f29c4a83d2
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
3 changed files with 36 additions and 6 deletions

View file

@ -130,11 +130,40 @@ Keep the callback short: it runs on the msquic worker, so no `*Sync` call and no
```cpp
stream.SendAsync(line.data(), static_cast<std::uint32_t>(line.size()), /*finish=*/false,
[](bool sent) { if (!sent) { /* the peer is gone */ } });
if (stream.BytesInFlight() > 4 * 1024 * 1024) stream.Stop(); // a follower that cannot keep up
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 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).
@ -373,6 +402,7 @@ The library includes tests covering:
- Closed-connection notices (`ShouldNotifyClosedConnectionQUIC`) — a server deletes each of 100 accepted connections from its `OnClosed` and the process then exits normally; a peer that exits or freezes, a server-side `Stop()`, a callback set after the close and an owner that deletes first each behave as documented
- The peer hears a close (`ShouldTellPeerOfCloseQUIC`) — the server side of each of 20 connections learns at once that the client destroyed its `ClientQUIC`, rather than at the idle timeout
- `SendSync` under a fast peer (`ShouldReturnFromSendSyncQUIC`) — 2000 sends to a peer that answers and finishes the stream at once all return
- 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
- 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