fix(quic): let ~ClientQUIC's CONNECTION_CLOSE reach the peer

~ClientQUIC asked msquic to shut the connection down, waited for its
streams to drain, and closed the handle. With no stream open the drain
returns at once, so ConnectionClose followed ConnectionShutdown within
microseconds. msquic sends the CONNECTION_CLOSE frame from a FLUSH_SEND
operation it queues while it processes the shutdown; the close the
destructor had queued meanwhile reached the worker first, forced the
shutdown to complete silently (QuicConnTryClose: "silent close forced
after we already started the close process"), and the flush, which is
ignored once ShutdownComplete is set, sent nothing. The peer learned of
the close only at its idle timeout, two minutes by default: a server
whose clients each live for one request, such as a debug server driven
by a CLI that connects once per command, kept every one of them that
long.

The destructor now also waits, after the drain, for the connection's
SHUTDOWN_COMPLETE before closing the handle. That comes about one round
trip later once the peer answers the close, or after msquic's closing
period (three PTOs) when it does not; the wait is bounded at 1 s, since
the frame went out at the start either way and a peer that is gone must
not hold the destructor for the whole closing period. The handler sets a
flag for it before it looks at the handle, which the destructor holds,
so the close stays the destructor's.

Measured with ShouldTellPeerOfCloseQUIC: before, the server was not told
of the first of 20 client closes within 2 s (and, counted another way,
8 of 100 peer closes reached the server within 10 s in one run, 0 of
100 in the next); after, all 20 within a millisecond.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 02:06:39 +01:00
commit 795f06178d
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 32 additions and 4 deletions

View file

@ -196,7 +196,10 @@ namespace Crafter {
//
// Lifetime: ~ClientQUIC shuts the connection down 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.
// 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
// are dead once it goes; prefer destroying or moving them out first.
//