2026-07-22 17:55:45 +02:00
|
|
|
//SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
module;
|
|
|
|
|
#include <msquic.h>
|
|
|
|
|
#include <cstring>
|
|
|
|
|
module Crafter.Network:ClientQUIC_impl;
|
|
|
|
|
import :ClientQUIC;
|
|
|
|
|
import Crafter.Thread;
|
|
|
|
|
import std;
|
|
|
|
|
|
|
|
|
|
using namespace Crafter;
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
// Process-wide msquic API table + registration. Initialised lazily on
|
|
|
|
|
// first ClientQUIC/ListenerQUIC construction; tear-down happens at
|
|
|
|
|
// process exit via the destructor of the static object.
|
|
|
|
|
struct MsQuicRuntime {
|
|
|
|
|
const QUIC_API_TABLE* api = nullptr;
|
|
|
|
|
HQUIC registration = nullptr;
|
|
|
|
|
std::mutex initMutex;
|
|
|
|
|
bool initialised = false;
|
|
|
|
|
|
|
|
|
|
void Ensure() {
|
|
|
|
|
std::lock_guard lock(initMutex);
|
|
|
|
|
if (initialised) return;
|
|
|
|
|
QUIC_STATUS s = MsQuicOpen2(&api);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
throw QUICException(std::format("MsQuicOpen2 failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
QUIC_REGISTRATION_CONFIG regConfig{ "crafter.network", QUIC_EXECUTION_PROFILE_LOW_LATENCY };
|
|
|
|
|
s = api->RegistrationOpen(®Config, ®istration);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
MsQuicClose(api);
|
|
|
|
|
api = nullptr;
|
|
|
|
|
throw QUICException(std::format("RegistrationOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
initialised = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
~MsQuicRuntime() {
|
|
|
|
|
if (registration) api->RegistrationClose(registration);
|
|
|
|
|
if (api) MsQuicClose(api);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
MsQuicRuntime& Runtime() {
|
|
|
|
|
static MsQuicRuntime r;
|
|
|
|
|
r.Ensure();
|
|
|
|
|
return r;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 17:06:51 +00:00
|
|
|
// Open-stream bookkeeping, shared by a connection and every stream on it.
|
|
|
|
|
// A stream counts as open from the moment msquic has a callback handler
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// for it until its handle has been StreamClose'd. ~ClientQUIC drains this
|
2026-08-25 17:06:51 +00:00
|
|
|
// before closing the connection: QUICStream::Stop only *initiates* a
|
|
|
|
|
// shutdown whose async completion does the actual close, so without the
|
|
|
|
|
// wait a connection can be closed with streams still open on the msquic
|
|
|
|
|
// side.
|
|
|
|
|
//
|
|
|
|
|
// Reached through a shared_ptr rather than through the owning ClientQUIC*
|
|
|
|
|
// so that a stream finalising after the (bounded) wait gave up decrements
|
|
|
|
|
// a live object instead of a freed connection.
|
|
|
|
|
struct StreamRegistry {
|
|
|
|
|
std::mutex mtx;
|
|
|
|
|
std::condition_variable cv;
|
|
|
|
|
int open = 0;
|
|
|
|
|
|
|
|
|
|
void Add() {
|
|
|
|
|
std::lock_guard lk(mtx);
|
|
|
|
|
++open;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void Remove() {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(mtx);
|
|
|
|
|
--open;
|
|
|
|
|
}
|
|
|
|
|
cv.notify_all();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// False if the timeout expired with streams still open.
|
|
|
|
|
bool WaitForDrain(std::chrono::milliseconds timeout) {
|
|
|
|
|
std::unique_lock lk(mtx);
|
|
|
|
|
return cv.wait_for(lk, timeout, [&]{ return open == 0; });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Bound on how long ~ClientQUIC waits for the connection's streams to
|
|
|
|
|
// finish closing. The decrements arrive on msquic worker threads, so an
|
|
|
|
|
// unbounded wait would turn a dropped peer into a hung destructor.
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
//
|
|
|
|
|
// Expiring is a bug, not a slow path: ConnectionShutdown makes msquic
|
|
|
|
|
// deliver SHUTDOWN_COMPLETE for every stream it knows about, OpenStream
|
|
|
|
|
// passes SHUTDOWN_ON_FAIL so a stream whose start lost the race to that
|
|
|
|
|
// shutdown gets one too, and QUICStream::Stop aborts the receive direction
|
|
|
|
|
// so a bidi stream does not sit waiting on a peer that will never close
|
|
|
|
|
// its send side. With those three the count always reaches zero; the bound
|
|
|
|
|
// only stops an msquic-side surprise from wedging the destructor outright.
|
2026-08-25 17:06:51 +00:00
|
|
|
constexpr std::chrono::milliseconds streamDrainTimeout{ 5000 };
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
// Encode an ALPN string into the wire format msquic expects: a length
|
|
|
|
|
// byte followed by the ASCII characters. Lifetime of the returned buffer
|
|
|
|
|
// matches the caller's storage in `out`.
|
|
|
|
|
QUIC_BUFFER MakeAlpn(const std::string& alpn, std::vector<std::uint8_t>& out) {
|
|
|
|
|
if (alpn.size() > 255) throw QUICException("ALPN string too long (max 255)");
|
|
|
|
|
out.assign(alpn.begin(), alpn.end());
|
|
|
|
|
QUIC_BUFFER b{};
|
|
|
|
|
b.Length = static_cast<std::uint32_t>(out.size());
|
|
|
|
|
b.Buffer = out.data();
|
|
|
|
|
return b;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------- QUICStream::Impl ----------------
|
|
|
|
|
struct QUICStream::Impl {
|
|
|
|
|
ClientQUIC* connection = nullptr;
|
|
|
|
|
|
|
|
|
|
std::mutex mtx;
|
|
|
|
|
std::condition_variable cv;
|
|
|
|
|
std::deque<std::vector<char>> pending;
|
|
|
|
|
bool peerSendClosed = false;
|
|
|
|
|
bool shutdownComplete = false;
|
|
|
|
|
bool sendInFlight = false;
|
|
|
|
|
|
2026-07-22 17:55:45 +02:00
|
|
|
// Lifetime: msquic delivers stream callbacks (RECEIVE, SHUTDOWN_COMPLETE, …)
|
|
|
|
|
// possibly AFTER the owning QUICStream wrapper is destroyed (the wrapper only
|
|
|
|
|
// initiates a graceful shutdown; completion is async). To make that safe, the
|
|
|
|
|
// callback context is a heap `shared_ptr<Impl>*` (selfRef) that holds one
|
|
|
|
|
// strong ref; every callback copies it locally so the Impl stays alive for
|
|
|
|
|
// the callback's duration. The terminal SHUTDOWN_COMPLETE callback closes the
|
|
|
|
|
// stream and releases selfRef, after which the Impl is freed once the last
|
|
|
|
|
// ref (wrapper / in-flight callback copy) drops.
|
|
|
|
|
std::shared_ptr<Impl>* selfRef = nullptr;
|
|
|
|
|
|
2026-08-25 17:06:51 +00:00
|
|
|
// The owning connection's open-stream count. Incremented where the msquic
|
|
|
|
|
// handler is installed (QUICStream's handle constructor / OpenStream),
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// decremented once the handle has been closed.
|
2026-08-25 17:06:51 +00:00
|
|
|
std::shared_ptr<StreamRegistry> registry;
|
|
|
|
|
|
|
|
|
|
// The connection's registry. Defined below ClientQUIC::Impl (which owns
|
|
|
|
|
// it); a member of QUICStream::Impl rather than a free function so it
|
|
|
|
|
// inherits QUICStream's friendship with ClientQUIC.
|
|
|
|
|
static std::shared_ptr<StreamRegistry> RegistryOf(ClientQUIC* connection);
|
|
|
|
|
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// ---- msquic handle ownership ----------------------------------------
|
|
|
|
|
// StreamClose is "equivalent to free": any other msquic call on the handle
|
|
|
|
|
// that overlaps it is a use-after-free, which msquic reports as a bugcheck
|
|
|
|
|
// on whichever thread trips over the wreckage. The handle therefore has a
|
|
|
|
|
// refcount, and whoever drops the last ref closes it. There are two kinds
|
|
|
|
|
// of holder:
|
|
|
|
|
//
|
|
|
|
|
// - The msquic callback holds exactly one ref for the stream's lifetime,
|
|
|
|
|
// released by Retire() once SHUTDOWN_COMPLETE has been delivered (or by
|
|
|
|
|
// the OpenStream error paths, where no callback will ever run).
|
|
|
|
|
// - Every app thread inside an msquic call on the handle holds one for
|
|
|
|
|
// the duration of that call (AcquireHandle / ReleaseHandle).
|
|
|
|
|
//
|
|
|
|
|
// Without the second kind, a thread sending on a stream races the worker
|
|
|
|
|
// thread that closes it when the connection is torn down underneath —
|
|
|
|
|
// the shape any client with periodic acks or keepalives has.
|
|
|
|
|
std::mutex handleMtx;
|
|
|
|
|
HQUIC handle = nullptr; // cleared when the last ref lets go
|
|
|
|
|
int handleRefs = 1; // starts with the callback's ref
|
|
|
|
|
bool retired = false; // the callback's ref has been released
|
|
|
|
|
|
|
|
|
|
// The handle, valid until the matching ReleaseHandle; nullptr once the
|
|
|
|
|
// stream is retired, in which case do not call ReleaseHandle.
|
|
|
|
|
HQUIC AcquireHandle() {
|
|
|
|
|
std::lock_guard lk(handleMtx);
|
|
|
|
|
if (retired || !handle) return nullptr;
|
|
|
|
|
++handleRefs;
|
|
|
|
|
return handle;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ReleaseHandle() {
|
|
|
|
|
HQUIC toClose = nullptr;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(handleMtx);
|
|
|
|
|
if (--handleRefs > 0) return;
|
|
|
|
|
toClose = handle;
|
|
|
|
|
handle = nullptr;
|
|
|
|
|
}
|
|
|
|
|
// Last ref: nobody else can be inside an msquic call on this handle,
|
|
|
|
|
// and AcquireHandle will hand it out no more.
|
|
|
|
|
if (toClose) Runtime().api->StreamClose(toClose);
|
|
|
|
|
std::shared_ptr<StreamRegistry> reg = std::move(registry);
|
|
|
|
|
std::shared_ptr<Impl>* ref = selfRef;
|
|
|
|
|
selfRef = nullptr;
|
|
|
|
|
delete ref; // may destroy *this — touch no members past here
|
|
|
|
|
// Last of all: a destructor blocked in WaitForDrain may run
|
|
|
|
|
// ConnectionClose the moment this hits zero, and msquic wants every
|
|
|
|
|
// StreamClose on the connection to have happened first.
|
|
|
|
|
if (reg) reg->Remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Publish the msquic handle. Called once, while the stream is still local
|
|
|
|
|
// to its constructor and no callback can run on it.
|
|
|
|
|
void SetHandle(HQUIC h) {
|
|
|
|
|
std::lock_guard lk(handleMtx);
|
|
|
|
|
handle = h;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Release the callback's ref. Idempotent, so the SHUTDOWN_COMPLETE handler
|
|
|
|
|
// and an OpenStream error path can both call it without racing.
|
|
|
|
|
void Retire() {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(handleMtx);
|
|
|
|
|
if (retired) return;
|
|
|
|
|
retired = true;
|
|
|
|
|
}
|
|
|
|
|
ReleaseHandle();
|
2026-07-22 17:55:45 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
static QUIC_STATUS QUIC_API Callback(HQUIC stream, void* ctx, QUIC_STREAM_EVENT* ev) {
|
2026-07-22 17:55:45 +02:00
|
|
|
// Keep the Impl alive for the whole callback, even if the wrapper (and
|
|
|
|
|
// its ref) go away concurrently.
|
|
|
|
|
std::shared_ptr<Impl> keepAlive = *static_cast<std::shared_ptr<Impl>*>(ctx);
|
|
|
|
|
Impl* self = keepAlive.get();
|
2026-05-06 04:06:17 +02:00
|
|
|
switch (ev->Type) {
|
|
|
|
|
case QUIC_STREAM_EVENT_RECEIVE: {
|
|
|
|
|
std::vector<char> chunk;
|
|
|
|
|
std::uint64_t total = 0;
|
|
|
|
|
for (std::uint32_t i = 0; i < ev->RECEIVE.BufferCount; ++i) {
|
|
|
|
|
total += ev->RECEIVE.Buffers[i].Length;
|
|
|
|
|
}
|
|
|
|
|
chunk.reserve(static_cast<std::size_t>(total));
|
|
|
|
|
for (std::uint32_t i = 0; i < ev->RECEIVE.BufferCount; ++i) {
|
|
|
|
|
const QUIC_BUFFER& b = ev->RECEIVE.Buffers[i];
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_STREAM_EVENT_SEND_COMPLETE: {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->sendInFlight = false;
|
|
|
|
|
}
|
|
|
|
|
if (ev->SEND_COMPLETE.ClientContext) {
|
|
|
|
|
delete[] static_cast<char*>(ev->SEND_COMPLETE.ClientContext);
|
|
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN:
|
|
|
|
|
case QUIC_STREAM_EVENT_PEER_SEND_ABORTED: {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->peerSendClosed = true;
|
|
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->peerSendClosed = true;
|
|
|
|
|
self->shutdownComplete = true;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// No further SEND_COMPLETE can arrive, so release anyone
|
|
|
|
|
// waiting on one — they throw on shutdownComplete instead.
|
|
|
|
|
self->sendInFlight = false;
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
self->Retire();
|
2026-05-06 04:06:17 +02:00
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-07 00:06:44 +02:00
|
|
|
QUICStream::QUICStream() = default;
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
QUICStream::QUICStream(HQUIC handle, ClientQUIC* connection)
|
2026-07-22 17:55:45 +02:00
|
|
|
: handle(handle), connection(connection), impl(std::make_shared<Impl>())
|
2026-05-06 04:06:17 +02:00
|
|
|
{
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
impl->SetHandle(handle); // handleRefs already accounts for the callback
|
2026-05-06 04:06:17 +02:00
|
|
|
impl->connection = connection;
|
2026-08-25 17:06:51 +00:00
|
|
|
impl->registry = Impl::RegistryOf(connection);
|
|
|
|
|
if (impl->registry) impl->registry->Add();
|
2026-07-22 17:55:45 +02:00
|
|
|
impl->selfRef = new std::shared_ptr<Impl>(impl); // strong ref owned by the callback
|
|
|
|
|
Runtime().api->SetCallbackHandler(handle, reinterpret_cast<void*>(&Impl::Callback), impl->selfRef);
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QUICStream::QUICStream(QUICStream&& other) noexcept
|
2026-05-07 00:06:44 +02:00
|
|
|
: handle(other.handle), connection(other.connection),
|
|
|
|
|
canSend(other.canSend), canReceive(other.canReceive),
|
|
|
|
|
impl(std::move(other.impl))
|
2026-05-06 04:06:17 +02:00
|
|
|
{
|
|
|
|
|
other.handle = nullptr;
|
|
|
|
|
other.connection = nullptr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QUICStream& QUICStream::operator=(QUICStream&& other) noexcept {
|
|
|
|
|
if (this != &other) {
|
|
|
|
|
Stop();
|
|
|
|
|
handle = other.handle;
|
|
|
|
|
connection = other.connection;
|
2026-05-07 00:06:44 +02:00
|
|
|
canSend = other.canSend;
|
|
|
|
|
canReceive = other.canReceive;
|
2026-05-06 04:06:17 +02:00
|
|
|
impl = std::move(other.impl);
|
|
|
|
|
other.handle = nullptr;
|
|
|
|
|
other.connection = nullptr;
|
|
|
|
|
}
|
|
|
|
|
return *this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
QUICStream::~QUICStream() {
|
|
|
|
|
Stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void QUICStream::Stop() {
|
|
|
|
|
if (!handle) return;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
handle = nullptr; // the wrapper is done with the stream either way
|
|
|
|
|
if (!impl) return;
|
|
|
|
|
// 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
|
|
|
|
|
// selfRef, so it safely outlives this wrapper until that callback runs.
|
|
|
|
|
// A null handle means SHUTDOWN_COMPLETE already fired and msquic has closed
|
|
|
|
|
// the stream — shutting it down again would be a use-after-free.
|
|
|
|
|
HQUIC live = impl->AcquireHandle();
|
|
|
|
|
if (!live) return;
|
|
|
|
|
// 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.
|
|
|
|
|
// Without it a bidirectional stream whose peer keeps its send side open
|
|
|
|
|
// never reaches SHUTDOWN_COMPLETE, so its handle is never closed and the
|
|
|
|
|
// connection can never finish tearing down.
|
|
|
|
|
Runtime().api->StreamShutdown(live, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0);
|
|
|
|
|
if (canReceive) {
|
|
|
|
|
Runtime().api->StreamShutdown(live, QUIC_STREAM_SHUTDOWN_FLAG_ABORT_RECEIVE, 0);
|
2026-07-22 17:55:45 +02:00
|
|
|
}
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
impl->ReleaseHandle();
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void QUICStream::SendSync(const void* buffer, std::uint32_t size, bool finish) {
|
2026-05-07 00:06:44 +02:00
|
|
|
if (!handle || !canSend) throw QUICClosedException();
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// Hold the handle across StreamSend: without this a connection torn down
|
|
|
|
|
// on another thread can close the stream between the check above and the
|
|
|
|
|
// call below, and msquic bugchecks on the freed handle.
|
|
|
|
|
HQUIC live = impl->AcquireHandle();
|
|
|
|
|
if (!live) throw QUICClosedException();
|
2026-05-06 04:06:17 +02:00
|
|
|
auto* copy = new char[size];
|
|
|
|
|
std::memcpy(copy, buffer, size);
|
|
|
|
|
QUIC_BUFFER quicBuf{};
|
|
|
|
|
quicBuf.Buffer = reinterpret_cast<std::uint8_t*>(copy);
|
|
|
|
|
quicBuf.Length = size;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(impl->mtx);
|
|
|
|
|
impl->sendInFlight = true;
|
|
|
|
|
}
|
|
|
|
|
QUIC_SEND_FLAGS flags = finish ? QUIC_SEND_FLAG_FIN : QUIC_SEND_FLAG_NONE;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
QUIC_STATUS s = Runtime().api->StreamSend(live, &quicBuf, 1, flags, copy);
|
|
|
|
|
impl->ReleaseHandle();
|
2026-05-06 04:06:17 +02:00
|
|
|
if (QUIC_FAILED(s)) {
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
{
|
|
|
|
|
// No SEND_COMPLETE will arrive for a send msquic never took.
|
|
|
|
|
std::lock_guard lk(impl->mtx);
|
|
|
|
|
impl->sendInFlight = false;
|
|
|
|
|
}
|
|
|
|
|
impl->cv.notify_all();
|
2026-05-06 04:06:17 +02:00
|
|
|
delete[] copy;
|
|
|
|
|
throw QUICException(std::format("StreamSend failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
std::unique_lock lk(impl->mtx);
|
|
|
|
|
impl->cv.wait(lk, [&]{ return !impl->sendInFlight || impl->shutdownComplete; });
|
|
|
|
|
if (impl->shutdownComplete) throw QUICClosedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<char> QUICStream::RecieveSync() {
|
2026-05-07 00:06:44 +02:00
|
|
|
if (!handle || !canReceive) throw QUICClosedException();
|
2026-05-06 04:06:17 +02:00
|
|
|
std::unique_lock lk(impl->mtx);
|
|
|
|
|
impl->cv.wait(lk, [&]{ return !impl->pending.empty() || impl->peerSendClosed || impl->shutdownComplete; });
|
|
|
|
|
if (!impl->pending.empty()) {
|
|
|
|
|
auto out = std::move(impl->pending.front());
|
|
|
|
|
impl->pending.pop_front();
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
throw QUICClosedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<char> QUICStream::RecieveUntilCloseSync() {
|
2026-05-07 00:06:44 +02:00
|
|
|
if (!handle || !canReceive) throw QUICClosedException();
|
2026-05-06 04:06:17 +02:00
|
|
|
std::vector<char> out;
|
|
|
|
|
while (true) {
|
|
|
|
|
std::unique_lock lk(impl->mtx);
|
|
|
|
|
impl->cv.wait(lk, [&]{ return !impl->pending.empty() || impl->peerSendClosed || impl->shutdownComplete; });
|
|
|
|
|
while (!impl->pending.empty()) {
|
|
|
|
|
auto& chunk = impl->pending.front();
|
|
|
|
|
out.insert(out.end(), chunk.begin(), chunk.end());
|
|
|
|
|
impl->pending.pop_front();
|
|
|
|
|
}
|
|
|
|
|
if (impl->peerSendClosed || impl->shutdownComplete) return out;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<char> QUICStream::RecieveUntilFullSync(std::uint32_t bufferSize) {
|
2026-05-07 00:06:44 +02:00
|
|
|
if (!handle || !canReceive) throw QUICClosedException();
|
2026-05-06 04:06:17 +02:00
|
|
|
std::vector<char> out;
|
|
|
|
|
out.reserve(bufferSize);
|
|
|
|
|
while (out.size() < bufferSize) {
|
|
|
|
|
std::unique_lock lk(impl->mtx);
|
|
|
|
|
impl->cv.wait(lk, [&]{ return !impl->pending.empty() || impl->peerSendClosed || impl->shutdownComplete; });
|
|
|
|
|
while (!impl->pending.empty() && out.size() < bufferSize) {
|
|
|
|
|
auto& chunk = impl->pending.front();
|
|
|
|
|
std::size_t want = std::min<std::size_t>(chunk.size(), bufferSize - out.size());
|
|
|
|
|
out.insert(out.end(), chunk.begin(), chunk.begin() + want);
|
|
|
|
|
if (want == chunk.size()) {
|
|
|
|
|
impl->pending.pop_front();
|
|
|
|
|
} else {
|
|
|
|
|
chunk.erase(chunk.begin(), chunk.begin() + want);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (out.size() < bufferSize && (impl->peerSendClosed || impl->shutdownComplete)) {
|
|
|
|
|
throw QUICClosedException();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
void QUICStream::SendAsync(const void* buffer, std::uint32_t size, bool finish,
|
|
|
|
|
std::function<void()> onSent) {
|
|
|
|
|
// Copy now: the caller's buffer may not outlive the enqueued task.
|
|
|
|
|
std::vector<char> copy(static_cast<const char*>(buffer),
|
|
|
|
|
static_cast<const char*>(buffer) + size);
|
|
|
|
|
ThreadPool::Enqueue([this, copy = std::move(copy), finish, onSent = std::move(onSent)]() mutable {
|
|
|
|
|
try { this->SendSync(copy.data(), static_cast<std::uint32_t>(copy.size()), finish); }
|
|
|
|
|
catch (...) { /* swallowed — callback still fires so the caller can move on */ }
|
|
|
|
|
if (onSent) onSent();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
void QUICStream::RecieveAsync(std::function<void(std::vector<char>)> cb) {
|
|
|
|
|
ThreadPool::Enqueue([this, cb]{ cb(this->RecieveSync()); });
|
|
|
|
|
}
|
|
|
|
|
void QUICStream::RecieveUntilCloseAsync(std::function<void(std::vector<char>)> cb) {
|
|
|
|
|
ThreadPool::Enqueue([this, cb]{ cb(this->RecieveUntilCloseSync()); });
|
|
|
|
|
}
|
|
|
|
|
void QUICStream::RecieveUntilFullAsync(std::uint32_t bufferSize, std::function<void(std::vector<char>)> cb) {
|
|
|
|
|
ThreadPool::Enqueue([this, bufferSize, cb]{ cb(this->RecieveUntilFullSync(bufferSize)); });
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 02:53:50 +02:00
|
|
|
void QUICStream::PrependReceived(std::vector<char> bytes) {
|
|
|
|
|
if (bytes.empty() || !impl) return;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(impl->mtx);
|
|
|
|
|
impl->pending.push_front(std::move(bytes));
|
|
|
|
|
}
|
|
|
|
|
impl->cv.notify_all();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::uint64_t QUICStream::GetStreamId() const {
|
|
|
|
|
if (!handle) throw QUICException("GetStreamId: stream is not open");
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// GetParam blocks on the connection's msquic worker, so it must not be the
|
|
|
|
|
// worker that waits for us — hence a refcount rather than an exclusion
|
|
|
|
|
// lock: SHUTDOWN_COMPLETE just drops its ref and returns.
|
|
|
|
|
HQUIC live = impl->AcquireHandle();
|
|
|
|
|
if (!live) throw QUICException("GetStreamId: stream is not open");
|
2026-05-19 02:53:50 +02:00
|
|
|
QUIC_UINT62 id = 0;
|
|
|
|
|
std::uint32_t size = sizeof(id);
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
QUIC_STATUS s = Runtime().api->GetParam(live, QUIC_PARAM_STREAM_ID, &size, &id);
|
|
|
|
|
impl->ReleaseHandle();
|
2026-05-19 02:53:50 +02:00
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
throw QUICException(std::format("GetParam(QUIC_PARAM_STREAM_ID) failed: 0x{:x}",
|
|
|
|
|
static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
return static_cast<std::uint64_t>(id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
// ---------------- ClientQUIC::Impl ----------------
|
|
|
|
|
struct ClientQUIC::Impl {
|
|
|
|
|
HQUIC connection = nullptr;
|
|
|
|
|
HQUIC configuration = nullptr;
|
|
|
|
|
bool ownsConfiguration = true;
|
|
|
|
|
|
|
|
|
|
std::mutex mtx;
|
|
|
|
|
std::condition_variable cv;
|
|
|
|
|
bool connected = false;
|
|
|
|
|
bool closed = false;
|
|
|
|
|
QUIC_STATUS shutdownStatus = QUIC_STATUS_SUCCESS;
|
|
|
|
|
|
|
|
|
|
std::function<void(QUICStream)> onStream;
|
|
|
|
|
std::function<void(std::vector<char>)> onDatagram;
|
|
|
|
|
std::deque<std::vector<char>> datagramQueue;
|
2026-05-07 00:06:44 +02:00
|
|
|
// Streams the peer started before the user installed an OnStream
|
|
|
|
|
// handler. Without this backlog the early streams (e.g. an h3 server's
|
|
|
|
|
// control stream right after handshake) would be aborted in the
|
|
|
|
|
// PEER_STREAM_STARTED branch and the connection would die with
|
|
|
|
|
// H3_MISSING_SETTINGS on the peer side.
|
|
|
|
|
std::deque<QUICStream> pendingStreams;
|
2026-05-06 04:06:17 +02:00
|
|
|
|
2026-08-25 17:06:51 +00:00
|
|
|
// Streams currently open on this connection. Shared with each QUICStream
|
|
|
|
|
// so the count survives a stream that outlives us.
|
|
|
|
|
std::shared_ptr<StreamRegistry> streams = std::make_shared<StreamRegistry>();
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
ClientQUIC* outer = nullptr;
|
|
|
|
|
|
2026-08-25 17:06:51 +00:00
|
|
|
// Take the connection handle away from whoever else might close it.
|
|
|
|
|
// `connection` is the single owner token: exactly one of ~ClientQUIC and
|
|
|
|
|
// the SHUTDOWN_COMPLETE callback wins the claim, and only the winner calls
|
|
|
|
|
// ConnectionClose. Without this the two race and msquic bugchecks on the
|
|
|
|
|
// second close (CXPLAT_TEL_ASSERT(!Connection->State.HandleClosed)).
|
|
|
|
|
HQUIC ClaimConnection() {
|
|
|
|
|
std::lock_guard lk(mtx);
|
|
|
|
|
HQUIC claimed = connection;
|
|
|
|
|
connection = nullptr;
|
|
|
|
|
return claimed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Shut the connection down and close it, waiting (bounded) for every
|
|
|
|
|
// stream on it to reach StreamClose first. No-op if someone else already
|
|
|
|
|
// claimed the handle. Never holds `mtx` across an msquic call: the
|
|
|
|
|
// connection callback takes it too.
|
|
|
|
|
void CloseConnection() {
|
|
|
|
|
HQUIC claimed = ClaimConnection();
|
|
|
|
|
if (!claimed) return;
|
|
|
|
|
// Aborts any stream still open; each one's SHUTDOWN_COMPLETE runs
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// Retire, which closes it and drops the count below.
|
2026-08-25 17:06:51 +00:00
|
|
|
Runtime().api->ConnectionShutdown(claimed, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0);
|
|
|
|
|
streams->WaitForDrain(streamDrainTimeout);
|
|
|
|
|
Runtime().api->ConnectionClose(claimed);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 04:06:17 +02:00
|
|
|
static QUIC_STATUS QUIC_API Callback(HQUIC conn, void* ctx, QUIC_CONNECTION_EVENT* ev) {
|
|
|
|
|
auto* self = static_cast<Impl*>(ctx);
|
|
|
|
|
switch (ev->Type) {
|
|
|
|
|
case QUIC_CONNECTION_EVENT_CONNECTED: {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->connected = true;
|
|
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT:
|
|
|
|
|
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->closed = true;
|
|
|
|
|
if (ev->Type == QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT) {
|
|
|
|
|
self->shutdownStatus = ev->SHUTDOWN_INITIATED_BY_TRANSPORT.Status;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: {
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->closed = true;
|
|
|
|
|
}
|
|
|
|
|
self->cv.notify_all();
|
2026-08-25 17:06:51 +00:00
|
|
|
// AppCloseInProgress means ~ClientQUIC is already inside
|
|
|
|
|
// ConnectionClose; otherwise close the handle here so a
|
|
|
|
|
// connection dropped by the peer does not leak while the
|
|
|
|
|
// owning ClientQUIC lives on. ClaimConnection settles which
|
|
|
|
|
// of the two actually does it. Every stream on the connection
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// has already been shut down (and so closed) by the time
|
|
|
|
|
// msquic reports the connection complete.
|
2026-08-25 17:06:51 +00:00
|
|
|
if (ev->SHUTDOWN_COMPLETE.AppCloseInProgress == 0
|
|
|
|
|
&& self->ClaimConnection() != nullptr) {
|
2026-05-06 04:06:17 +02:00
|
|
|
Runtime().api->ConnectionClose(conn);
|
|
|
|
|
}
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: {
|
|
|
|
|
HQUIC streamHandle = ev->PEER_STREAM_STARTED.Stream;
|
2026-05-07 00:06:44 +02:00
|
|
|
bool unidirectional = (ev->PEER_STREAM_STARTED.Flags
|
|
|
|
|
& QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL) != 0;
|
2026-05-06 04:06:17 +02:00
|
|
|
QUICStream stream(streamHandle, self->outer);
|
2026-05-07 00:06:44 +02:00
|
|
|
if (unidirectional) {
|
|
|
|
|
// Peer-initiated unidi: peer sends, we read; we cannot send.
|
|
|
|
|
stream.canSend = false;
|
|
|
|
|
stream.canReceive = true;
|
|
|
|
|
}
|
|
|
|
|
std::function<void(QUICStream)> cb;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
cb = self->onStream;
|
|
|
|
|
if (!cb) {
|
|
|
|
|
// Buffer until OnStream is installed; OnStream's
|
|
|
|
|
// setter drains this queue.
|
|
|
|
|
self->pendingStreams.push_back(std::move(stream));
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
2026-05-07 00:06:44 +02:00
|
|
|
auto* shared = new QUICStream(std::move(stream));
|
|
|
|
|
ThreadPool::Enqueue([cb, shared]{
|
|
|
|
|
cb(std::move(*shared));
|
|
|
|
|
delete shared;
|
|
|
|
|
});
|
2026-05-06 04:06:17 +02:00
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_CONNECTION_EVENT_DATAGRAM_RECEIVED: {
|
|
|
|
|
std::vector<char> chunk(ev->DATAGRAM_RECEIVED.Buffer->Buffer,
|
|
|
|
|
ev->DATAGRAM_RECEIVED.Buffer->Buffer + ev->DATAGRAM_RECEIVED.Buffer->Length);
|
|
|
|
|
if (self->onDatagram) {
|
|
|
|
|
auto cb = self->onDatagram;
|
|
|
|
|
ThreadPool::Enqueue([cb, chunk = std::move(chunk)]() mutable { cb(std::move(chunk)); });
|
|
|
|
|
} else {
|
|
|
|
|
std::lock_guard lk(self->mtx);
|
|
|
|
|
self->datagramQueue.push_back(std::move(chunk));
|
|
|
|
|
self->cv.notify_all();
|
|
|
|
|
}
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
case QUIC_CONNECTION_EVENT_DATAGRAM_SEND_STATE_CHANGED: {
|
|
|
|
|
// msquic fires this event multiple times per datagram (e.g.
|
|
|
|
|
// SENT -> ACKNOWLEDGED). Free the combined QUIC_BUFFER+payload
|
|
|
|
|
// allocation only on a terminal state — SENT and LOST_SUSPECT
|
|
|
|
|
// are intermediate and may be followed by another transition.
|
|
|
|
|
auto state = ev->DATAGRAM_SEND_STATE_CHANGED.State;
|
|
|
|
|
if (ev->DATAGRAM_SEND_STATE_CHANGED.ClientContext &&
|
|
|
|
|
(state == QUIC_DATAGRAM_SEND_LOST_DISCARDED
|
|
|
|
|
|| state == QUIC_DATAGRAM_SEND_ACKNOWLEDGED
|
|
|
|
|
|| state == QUIC_DATAGRAM_SEND_ACKNOWLEDGED_SPURIOUS
|
|
|
|
|
|| state == QUIC_DATAGRAM_SEND_CANCELED)) {
|
|
|
|
|
::operator delete(ev->DATAGRAM_SEND_STATE_CHANGED.ClientContext);
|
|
|
|
|
}
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
return QUIC_STATUS_SUCCESS;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
static HQUIC OpenClientConfiguration(const std::string& alpn, const QUICClientCredentials& creds) {
|
|
|
|
|
std::vector<std::uint8_t> alpnBuf;
|
|
|
|
|
QUIC_BUFFER alpnBuffer = MakeAlpn(alpn, alpnBuf);
|
|
|
|
|
|
|
|
|
|
QUIC_SETTINGS settings{};
|
|
|
|
|
settings.IsSet.IdleTimeoutMs = 1;
|
2026-08-25 19:17:22 +02:00
|
|
|
settings.IdleTimeoutMs = creds.settings.idleTimeoutMs;
|
|
|
|
|
settings.IsSet.HandshakeIdleTimeoutMs = 1;
|
|
|
|
|
settings.HandshakeIdleTimeoutMs = creds.settings.handshakeIdleTimeoutMs;
|
2026-07-22 17:55:45 +02:00
|
|
|
// Keep the connection alive across long idle gaps. msquic sends PING frames
|
|
|
|
|
// on its own timer thread (independent of the app), so a request/response
|
|
|
|
|
// connection survives even while the app is blocked for a long time between
|
|
|
|
|
// requests (e.g. a client waiting on slow LLM inference). Interval < idle
|
|
|
|
|
// timeout so the peer's idle timer never expires.
|
|
|
|
|
settings.IsSet.KeepAliveIntervalMs = 1;
|
2026-08-25 19:17:22 +02:00
|
|
|
settings.KeepAliveIntervalMs = creds.settings.keepAliveIntervalMs;
|
2026-05-06 04:06:17 +02:00
|
|
|
settings.IsSet.DatagramReceiveEnabled = 1;
|
2026-08-25 19:17:22 +02:00
|
|
|
settings.DatagramReceiveEnabled = creds.settings.datagramReceiveEnabled ? 1 : 0;
|
2026-05-07 00:06:44 +02:00
|
|
|
// Allow the server to open unidi/bidi streams to us. msquic defaults
|
|
|
|
|
// both peer-stream-count limits to 0; with that, the server's HTTP/3
|
|
|
|
|
// control stream + QPACK encoder/decoder streams can't be created and
|
|
|
|
|
// most h3 servers will close the connection after handshake. We don't
|
|
|
|
|
// currently use server push (h3 pushes ride on unidi 0x01 streams) but
|
|
|
|
|
// the bidi cap is harmless to grant.
|
|
|
|
|
settings.IsSet.PeerUnidiStreamCount = 1;
|
2026-08-25 19:17:22 +02:00
|
|
|
settings.PeerUnidiStreamCount = creds.settings.peerUnidiStreamCount;
|
2026-05-07 00:06:44 +02:00
|
|
|
settings.IsSet.PeerBidiStreamCount = 1;
|
2026-08-25 19:17:22 +02:00
|
|
|
settings.PeerBidiStreamCount = creds.settings.peerBidiStreamCount;
|
2026-05-06 04:06:17 +02:00
|
|
|
|
|
|
|
|
HQUIC cfg = nullptr;
|
|
|
|
|
QUIC_STATUS s = Runtime().api->ConfigurationOpen(Runtime().registration, &alpnBuffer, 1,
|
|
|
|
|
&settings, sizeof(settings), nullptr, &cfg);
|
|
|
|
|
if (QUIC_FAILED(s)) throw QUICException(std::format("ConfigurationOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
|
|
|
|
|
QUIC_CREDENTIAL_CONFIG cc{};
|
|
|
|
|
cc.Type = QUIC_CREDENTIAL_TYPE_NONE;
|
|
|
|
|
cc.Flags = QUIC_CREDENTIAL_FLAG_CLIENT;
|
|
|
|
|
if (creds.insecureNoServerValidation) {
|
|
|
|
|
cc.Flags |= QUIC_CREDENTIAL_FLAG_NO_CERTIFICATE_VALIDATION;
|
|
|
|
|
}
|
|
|
|
|
s = Runtime().api->ConfigurationLoadCredential(cfg, &cc);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
Runtime().api->ConfigurationClose(cfg);
|
|
|
|
|
throw QUICException(std::format("ConfigurationLoadCredential failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
return cfg;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ClientQUIC::ClientQUIC(const char* host, std::uint16_t port, std::string alpnIn,
|
|
|
|
|
QUICClientCredentials creds)
|
|
|
|
|
: alpn(std::move(alpnIn)), impl(std::make_unique<Impl>())
|
|
|
|
|
{
|
|
|
|
|
impl->outer = this;
|
|
|
|
|
impl->configuration = OpenClientConfiguration(alpn, creds);
|
|
|
|
|
|
|
|
|
|
QUIC_STATUS s = Runtime().api->ConnectionOpen(Runtime().registration,
|
|
|
|
|
reinterpret_cast<QUIC_CONNECTION_CALLBACK_HANDLER>(&Impl::Callback),
|
|
|
|
|
impl.get(), &impl->connection);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
Runtime().api->ConfigurationClose(impl->configuration);
|
|
|
|
|
throw QUICException(std::format("ConnectionOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
s = Runtime().api->ConnectionStart(impl->connection, impl->configuration, QUIC_ADDRESS_FAMILY_UNSPEC, host, port);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
Runtime().api->ConnectionClose(impl->connection);
|
|
|
|
|
Runtime().api->ConfigurationClose(impl->configuration);
|
|
|
|
|
throw QUICException(std::format("ConnectionStart failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-25 17:06:51 +00:00
|
|
|
QUIC_STATUS handshakeStatus = QUIC_STATUS_SUCCESS;
|
|
|
|
|
{
|
|
|
|
|
std::unique_lock lk(impl->mtx);
|
2026-08-25 17:06:51 +00:00
|
|
|
// Bounded by HandshakeIdleTimeoutMs above: an unreachable peer ends in
|
|
|
|
|
// SHUTDOWN_INITIATED_BY_TRANSPORT rather than waiting here forever.
|
2026-08-25 17:06:51 +00:00
|
|
|
impl->cv.wait(lk, [&]{ return impl->connected || impl->closed; });
|
|
|
|
|
if (impl->connected) return;
|
|
|
|
|
handshakeStatus = impl->shutdownStatus;
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
2026-08-25 17:06:51 +00:00
|
|
|
// Throwing from here means ~ClientQUIC never runs, so tear the msquic
|
|
|
|
|
// handles down by hand — the connection callback holds `impl`, which is
|
|
|
|
|
// about to be destroyed with the half-built object.
|
|
|
|
|
impl->CloseConnection();
|
|
|
|
|
Runtime().api->ConfigurationClose(impl->configuration);
|
|
|
|
|
impl->configuration = nullptr;
|
|
|
|
|
throw QUICException(std::format("QUIC handshake failed: 0x{:x}",
|
|
|
|
|
static_cast<unsigned>(handshakeStatus)));
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ClientQUIC::ClientQUIC(std::string host, std::uint16_t port, std::string alpnIn, QUICClientCredentials creds)
|
|
|
|
|
: ClientQUIC(host.c_str(), port, std::move(alpnIn), std::move(creds)) {}
|
|
|
|
|
|
|
|
|
|
ClientQUIC::ClientQUIC(HQUIC connectionHandle, HQUIC serverConfiguration, std::string alpnIn)
|
|
|
|
|
: alpn(std::move(alpnIn)), impl(std::make_unique<Impl>())
|
|
|
|
|
{
|
|
|
|
|
impl->outer = this;
|
|
|
|
|
impl->connection = connectionHandle;
|
|
|
|
|
impl->configuration = serverConfiguration;
|
|
|
|
|
impl->ownsConfiguration = false;
|
|
|
|
|
impl->connected = true;
|
|
|
|
|
Runtime().api->SetCallbackHandler(connectionHandle,
|
|
|
|
|
reinterpret_cast<void*>(&Impl::Callback),
|
|
|
|
|
impl.get());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ClientQUIC::ClientQUIC(ClientQUIC&& other) noexcept
|
|
|
|
|
: alpn(std::move(other.alpn)), impl(std::move(other.impl))
|
|
|
|
|
{
|
|
|
|
|
if (impl) impl->outer = this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ClientQUIC::~ClientQUIC() {
|
|
|
|
|
if (!impl) return;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// Before the connection goes: streams the peer started that no OnStream
|
|
|
|
|
// handler ever claimed. Destroying them here rather than with the rest of
|
|
|
|
|
// `impl` keeps every stream teardown ahead of ConnectionClose, so the
|
|
|
|
|
// drain below actually covers them.
|
|
|
|
|
{
|
|
|
|
|
std::deque<QUICStream> backlog;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(impl->mtx);
|
|
|
|
|
std::swap(backlog, impl->pendingStreams);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-25 17:06:51 +00:00
|
|
|
impl->CloseConnection();
|
2026-05-06 04:06:17 +02:00
|
|
|
if (impl->configuration && impl->ownsConfiguration) {
|
|
|
|
|
Runtime().api->ConfigurationClose(impl->configuration);
|
|
|
|
|
impl->configuration = nullptr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ClientQUIC::Stop() {
|
2026-08-25 17:06:51 +00:00
|
|
|
if (!impl) return;
|
|
|
|
|
// Read the handle under the lock: the SHUTDOWN_COMPLETE callback may be
|
|
|
|
|
// closing the connection concurrently on an msquic worker.
|
|
|
|
|
HQUIC conn = nullptr;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(impl->mtx);
|
|
|
|
|
conn = impl->connection;
|
|
|
|
|
}
|
|
|
|
|
if (conn) Runtime().api->ConnectionShutdown(conn, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, 0);
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 00:06:44 +02:00
|
|
|
QUICStream ClientQUIC::OpenStream(bool unidirectional) {
|
2026-05-06 04:06:17 +02:00
|
|
|
HQUIC streamHandle = nullptr;
|
|
|
|
|
QUICStream stream;
|
2026-07-22 17:55:45 +02:00
|
|
|
stream.impl = std::make_shared<QUICStream::Impl>();
|
2026-05-06 04:06:17 +02:00
|
|
|
stream.impl->connection = this;
|
2026-08-25 17:06:51 +00:00
|
|
|
// Count the stream as open before StreamOpen registers the callback, so a
|
|
|
|
|
// callback can never observe a half-initialised Impl. The two failure
|
|
|
|
|
// paths below undo it.
|
|
|
|
|
stream.impl->registry = impl->streams;
|
|
|
|
|
stream.impl->registry->Add();
|
2026-07-22 17:55:45 +02:00
|
|
|
stream.impl->selfRef = new std::shared_ptr<QUICStream::Impl>(stream.impl);
|
2026-05-07 00:06:44 +02:00
|
|
|
QUIC_STREAM_OPEN_FLAGS openFlags = unidirectional
|
|
|
|
|
? QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL
|
|
|
|
|
: QUIC_STREAM_OPEN_FLAG_NONE;
|
|
|
|
|
QUIC_STATUS s = Runtime().api->StreamOpen(impl->connection, openFlags,
|
2026-05-06 04:06:17 +02:00
|
|
|
reinterpret_cast<QUIC_STREAM_CALLBACK_HANDLER>(&QUICStream::Impl::Callback),
|
2026-07-22 17:55:45 +02:00
|
|
|
stream.impl->selfRef, &streamHandle);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
delete stream.impl->selfRef;
|
|
|
|
|
stream.impl->selfRef = nullptr;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// No msquic stream exists, so there is no handle to release.
|
2026-08-25 17:06:51 +00:00
|
|
|
std::shared_ptr<StreamRegistry> registry = std::move(stream.impl->registry);
|
|
|
|
|
registry->Remove();
|
2026-07-22 17:55:45 +02:00
|
|
|
throw QUICException(std::format("StreamOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
2026-05-06 04:06:17 +02:00
|
|
|
stream.handle = streamHandle;
|
|
|
|
|
stream.connection = this;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
stream.impl->SetHandle(streamHandle);
|
2026-05-07 00:06:44 +02:00
|
|
|
if (unidirectional) {
|
|
|
|
|
// We initiated the unidi stream: we send, peer reads.
|
|
|
|
|
stream.canSend = true;
|
|
|
|
|
stream.canReceive = false;
|
|
|
|
|
}
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// SHUTDOWN_ON_FAIL, not NONE. StreamStart is asynchronous — it returns
|
|
|
|
|
// QUIC_STATUS_PENDING and queues the start onto the connection — so it can
|
|
|
|
|
// still fail later, and it does exactly that (QUIC_STATUS_INVALID_STATE)
|
|
|
|
|
// whenever the connection is shut down before the queued start runs. That
|
|
|
|
|
// is an everyday race for a client that sends periodically: opening a
|
|
|
|
|
// stream just as the connection goes away. Without this flag msquic leaves
|
|
|
|
|
// such a stream neither started nor shut down, so SHUTDOWN_COMPLETE never
|
|
|
|
|
// arrives, the handle is never closed, and the registration's rundown at
|
|
|
|
|
// exit() blocks forever waiting for it. With it, the failed start is
|
|
|
|
|
// followed by SHUTDOWN_COMPLETE like any other stream.
|
|
|
|
|
s = Runtime().api->StreamStart(streamHandle, QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL);
|
2026-05-06 04:06:17 +02:00
|
|
|
if (QUIC_FAILED(s)) {
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
// Synchronous failure: the stream never started, so no callback will
|
|
|
|
|
// ever run. Release the callback's ref by hand, which closes the
|
|
|
|
|
// handle, and clear the wrapper's so ~QUICStream is a no-op.
|
2026-07-22 17:55:45 +02:00
|
|
|
stream.handle = nullptr;
|
fix(quic): close stream handles whose async StreamStart lost the teardown race
A client that sends on a stream while its connection is being torn down —
the shape any long-lived client with periodic acks or keepalives has —
would hang at exit inside MsQuicRegistrationClose, and occasionally
bugcheck on an msquic worker thread instead.
Root cause: StreamStart is asynchronous. It returns QUIC_STATUS_PENDING
and queues the start onto the connection, so it can still fail later, and
it fails with QUIC_STATUS_INVALID_STATE whenever the connection is shut
down before the queued start runs. Passed QUIC_STREAM_START_FLAG_NONE,
msquic leaves such a stream neither started nor shut down, so
SHUTDOWN_COMPLETE never arrives. The stream close is driven entirely off
that event, so the handle stays open: ~ClientQUIC's bounded drain expires,
the connection is closed with a stream msquic still considers open, and
the registration's rundown at exit() waits forever for the handle.
Instrumenting the handle lifecycle over the reproducer shows one leaked
locally-opened stream per failing run, correlating 1:1 with a
START_COMPLETE carrying 0x1 (INVALID_STATE).
Fixes:
- OpenStream passes QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL, so a start
that loses the race is followed by SHUTDOWN_COMPLETE like any other
stream and its handle is closed.
- QUICStream::Stop also aborts the receive direction. GRACEFUL closes
only the send side and msquic rejects it combined with any other flag,
so a bidirectional stream whose peer keeps its send side open never
reached SHUTDOWN_COMPLETE either.
- The msquic stream handle is now refcounted, held by the callback plus
every app thread inside an msquic call on it, and closed by whoever
lets go last. StreamSend/StreamShutdown/GetParam could previously run
against a handle a worker thread had just closed — a use-after-free
msquic reports as a bugcheck. A refcount rather than an exclusion lock
because GetParam blocks on the connection's worker, which must never
be the thread waiting.
- ~ClientQUIC drops undispatched peer streams before closing the
connection rather than with the rest of Impl afterwards, so the drain
covers them.
The reproducer (tests/ShouldSurviveConnectionChurn) grows a client-opened
bidirectional control stream with a thread sending across the teardown,
and no longer ends in std::_Exit — returning from main runs the static
MsQuicRuntime destructor, and its RegistrationClose is where a leaked
handle shows up. Before: 5 of 15 runs clean, 10 hung. After: 90 of 90
clean, with every opened handle observed closed and the drain never
expiring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:18:01 +00:00
|
|
|
stream.impl->Retire();
|
2026-05-06 04:06:17 +02:00
|
|
|
throw QUICException(std::format("StreamStart failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
return stream;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ClientQUIC::SendDatagram(const void* buffer, std::uint32_t size) {
|
|
|
|
|
// msquic stores the QUIC_BUFFER pointer (not a copy) on the send queue
|
|
|
|
|
// and serialises async on a worker thread. Both the QUIC_BUFFER and the
|
|
|
|
|
// payload it points at must outlive the call until DATAGRAM_SEND_STATE
|
|
|
|
|
// reports a terminal state. Pack them together in a single allocation.
|
|
|
|
|
auto* mem = static_cast<std::uint8_t*>(::operator new(sizeof(QUIC_BUFFER) + size));
|
|
|
|
|
auto* hdr = reinterpret_cast<QUIC_BUFFER*>(mem);
|
|
|
|
|
auto* payload = mem + sizeof(QUIC_BUFFER);
|
|
|
|
|
std::memcpy(payload, buffer, size);
|
|
|
|
|
hdr->Buffer = payload;
|
|
|
|
|
hdr->Length = size;
|
|
|
|
|
QUIC_STATUS s = Runtime().api->DatagramSend(impl->connection, hdr, 1,
|
|
|
|
|
QUIC_SEND_FLAG_NONE, mem);
|
|
|
|
|
if (QUIC_FAILED(s)) {
|
|
|
|
|
::operator delete(mem);
|
|
|
|
|
throw QUICException(std::format("DatagramSend failed: 0x{:x}", static_cast<unsigned>(s)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ClientQUIC::OnStream(std::function<void(QUICStream)> cb) {
|
2026-05-07 00:06:44 +02:00
|
|
|
std::deque<QUICStream> backlog;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard lk(impl->mtx);
|
|
|
|
|
impl->onStream = cb;
|
|
|
|
|
std::swap(backlog, impl->pendingStreams);
|
|
|
|
|
}
|
|
|
|
|
while (!backlog.empty()) {
|
|
|
|
|
auto* shared = new QUICStream(std::move(backlog.front()));
|
|
|
|
|
backlog.pop_front();
|
|
|
|
|
auto handler = cb;
|
|
|
|
|
ThreadPool::Enqueue([handler, shared]{
|
|
|
|
|
handler(std::move(*shared));
|
|
|
|
|
delete shared;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-06 04:06:17 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ClientQUIC::OnDatagram(std::function<void(std::vector<char>)> cb) {
|
|
|
|
|
impl->onDatagram = std::move(cb);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::vector<char> ClientQUIC::RecieveDatagramSync() {
|
|
|
|
|
std::unique_lock lk(impl->mtx);
|
|
|
|
|
impl->cv.wait(lk, [&]{ return !impl->datagramQueue.empty() || impl->closed; });
|
|
|
|
|
if (!impl->datagramQueue.empty()) {
|
|
|
|
|
auto out = std::move(impl->datagramQueue.front());
|
|
|
|
|
impl->datagramQueue.pop_front();
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
throw QUICClosedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
HQUIC ClientQUIC::GetHandle() const { return impl ? impl->connection : nullptr; }
|
2026-08-25 17:06:51 +00:00
|
|
|
|
|
|
|
|
std::shared_ptr<StreamRegistry> QUICStream::Impl::RegistryOf(ClientQUIC* connection) {
|
|
|
|
|
if (!connection || !connection->impl) return nullptr;
|
|
|
|
|
return connection->impl->streams;
|
|
|
|
|
}
|