//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // Regression test for the connection-teardown race in ~ClientQUIC. // // QUICStream::Stop only *initiates* a graceful shutdown; the actual // MsQuicStreamClose happens later, from the terminal SHUTDOWN_COMPLETE // callback on an msquic worker thread. ~ClientQUIC used to call // MsQuicConnectionClose straight away, so a connection could be closed while // streams belonging to it were still open on the msquic side — which trips an // msquic bugcheck and aborts the process. // // Nothing else in the suite exercises this: every other QUIC test ends in // std::_Exit(0) and so never runs a ClientQUIC destructor. The shape that // breaks is a reconnect loop — build a connection, tear it down, build // another — which is exactly what this test does, `cycles` times. // // The blocked readers matter. Each inbound stream gets a thread parked inside // RecieveSync for the life of the connection (the shape a long-lived // streaming client has), so teardown happens with readers still inside the // receive path. An otherwise identical loop whose handler reads once and // returns does not reproduce. import Crafter.Network; import Crafter.Thread; import std; using namespace Crafter; namespace { constexpr int cycles = 25; constexpr int streamsPerConnection = 3; } int main() { ThreadPool::Start(); // On a build where a teardown deadlocks rather than aborts, fail the test // instead of hanging the whole suite. std::thread watchdog([] { std::this_thread::sleep_for(std::chrono::seconds(90)); std::println("timed out — a connection teardown never completed"); std::cout.flush(); std::_Exit(1); }); watchdog.detach(); for (int i = 0; i < cycles; ++i) { const std::uint16_t port = static_cast(19200 + i); const std::string alpn = "churn/1"; QUICServerCredentials serverCreds; serverCreds.selfSigned = true; std::mutex serverMutex; std::vector serverStreams; ClientQUIC* accepted = nullptr; auto listener = std::make_unique( port, alpn, serverCreds, [&](ClientQUIC* peer) { std::lock_guard lock(serverMutex); accepted = peer; for (int s = 0; s < streamsPerConnection; ++s) { QUICStream stream = peer->OpenStream(/*unidirectional=*/true); const char payload[] = "hello"; stream.SendSync(payload, sizeof(payload) - 1, /*finish=*/false); serverStreams.push_back(std::move(stream)); } }); listener->ListenAsyncAsync(); QUICClientCredentials clientCreds; clientCreds.insecureNoServerValidation = true; { ClientQUIC client(std::string("localhost"), port, alpn, clientCreds); std::mutex clientMutex; std::condition_variable clientCv; std::vector readers; int started = 0; client.OnStream([&](QUICStream stream) { auto shared = std::make_shared(std::move(stream)); std::thread reader([shared] { try { while (true) (void)shared->RecieveSync(); } catch (...) { // Connection closed. } }); std::lock_guard lock(clientMutex); readers.push_back(std::move(reader)); ++started; clientCv.notify_all(); }); { std::unique_lock lock(clientMutex); clientCv.wait_for(lock, std::chrono::seconds(5), [&] { return started == streamsPerConnection; }); if (started != streamsPerConnection) { std::println("cycle {}: only {} of {} streams arrived", i, started, streamsPerConnection); return 1; } } client.Stop(); for (std::thread& reader : readers) { if (reader.joinable()) reader.join(); } // `client` is destroyed here, once the readers have unwound. } { std::lock_guard lock(serverMutex); // Deliberately the other order from the client side: destroy the // connection while the app still holds its stream wrappers, so // ~ClientQUIC has to cope with streams that are open as far as // msquic is concerned. delete accepted; accepted = nullptr; serverStreams.clear(); } listener->Stop(); listener.reset(); } std::println("survived {} connection teardowns", cycles); std::cout.flush(); // Skip the static-dtor cleanup: msquic's RegistrationClose blocks until // every connection it ever opened is fully drained, which the suite does // not need to wait on. Everything this test asserts has already happened. std::_Exit(0); }