feat(quic): QUICStream::SendAsync holds no thread and reports from SEND_COMPLETE

Native SendAsync was SendSync run as a pool task. That held a worker of
Crafter.Thread's pool, which the host shares and which runs each
worker's queue in order, for as long as msquic took to accept the
buffer, and a peer that reads slowly stretches that to its own pace: a
server streaming to a slow follower stalled the tasks queued behind it.
The task also reached back through `this`, so moving or destroying the
wrapper before it ran was a use-after-free, and two sends could reach
msquic out of order from two workers.

Now SendAsync copies the buffer into a SendRequest, hands it to msquic
and returns. SEND_COMPLETE, which msquic delivers exactly once for every
send it took and before the stream's SHUTDOWN_COMPLETE, frees the copy
and calls onSent on the msquic worker: true when msquic took the whole
buffer (in its send buffer or acknowledged, which is what SendSync's
return has always meant), false when it was canceled because the stream
or the connection ended first. Sends queue in msquic in call order and
none waits for another. A send msquic never took (a closed stream, a
moved-from wrapper, StreamSend refusing it) reports false from a pool
task, never from inside SendAsync, so a caller may hold a lock its own
callback takes. Only `impl` is read, not `handle`, so SendAsync is
callable from any thread, a callback included; on a stopped stream
msquic cancels the send.

BytesInFlight() and SendsInFlight() count what SendAsync and SendSync
handed msquic whose SEND_COMPLETE has not come, so a server can drop a
follower that falls too far behind.

Rewritten rather than added beside it: nothing in this repository calls
the native SendAsync (ListenerHTTP, WebTransport and ClientHTTP use
SendSync; the example's SendAsync is the browser build's), nor does any
consumer found here (3DForts uses SendSync natively); its contract is
unchanged, copy now and onSent once with whether it went; and the old
one's pool thread, `this` capture and reordering were defects, not
behaviour to keep. What changes is onSent's thread, msquic's worker
rather than a pool task, so a callback that blocks, a *Sync call above
all, now stalls its connection rather than a pool worker. The browser
build is untouched.

Breaking: native onSent runs on msquic's worker thread, not as a pool
task, and must not block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 02:24:15 +01:00
commit 1b7417c2ba
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 118 additions and 27 deletions

View file

@ -117,7 +117,9 @@ namespace Crafter {
#ifndef CRAFTER_NETWORK_BROWSER
// Send a buffer. If finish=true, the send-side of the stream is closed
// 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);
// Block until at least one byte is received; returns the received
@ -138,6 +140,25 @@ namespace Crafter {
// accepted the buffer (native) or the WritableStream writer has
// resolved (browser), and with false when the stream was closed or
// 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. A send
// that fails before msquic takes it (a closed stream, a moved-from
// wrapper) reports from a pool task instead, 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,
std::function<void(bool sent)> onSent);
@ -164,6 +185,12 @@ namespace Crafter {
// so the server has to query and remember it at session creation
// time. Throws if the stream is not yet started.
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;
#endif
// Shut the stream down in both directions: our send side gracefully