feat(quic): ClientQUIC::OnClosed tells the owner the connection is over

A ListenerQUIC hands each accepted connection to its callback as a
heap-allocated ClientQUIC the callback owns, and nothing said when to
delete it: the SHUTDOWN_COMPLETE handler closed the msquic handle of a
connection the peer or the idle timeout ended, but the object stayed, so
a server either leaked its peers or deleted them blind, and one still
alive at exit holds msquic's registration open until its idle timeout.

OnClosed(callback) is told once when the connection has shut down for
any reason but this object's destruction: the peer closed it, the idle
timeout or a transport error ended it, or Stop() was called. It runs on
the msquic worker, in the SHUTDOWN_COMPLETE handler, after that handler
has claimed and closed the handle, so deleting the ClientQUIC from
inside it is safe: ~ClientQUIC then finds no handle to claim and waits
for nothing. Delivering it there, rather than as a pool task, keeps it
independent of a pool the host shares and possibly saturates; the cost
is the usual worker rule, which the interface spells out (no blocking,
no *Sync call, no deleting another ClientQUIC from inside it). A
callback set after the close, which happens when the peer goes before a
ListenerQUIC's connectCallback runs, fires as a pool task instead, never
from inside OnClosed, where the caller is still using the object.

Its state is a shared CloseNotice rather than part of the Impl, since
the handler fires it after giving up the handle, when ~ClientQUIC on
another thread no longer waits for the worker and may free the Impl.
The rules are std::stop_callback's: ~ClientQUIC marks the owner gone
first, after which the callback never starts; one already running on
another thread is waited for, so the owner may free what it uses as
soon as the destructor returns; one on the destructor's own thread, a
callback deleting its connection, is not. The handler takes the handle
and the notice under one lock, so the destructor cannot slip between.

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

View file

@ -270,6 +270,30 @@ namespace Crafter {
// Block the caller until the next datagram arrives; returns it.
// Throws QUICClosedException if the connection closes first.
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
// Cleanly shut down the connection.