From 793b518718dc01ae703ee5548166612478b58465 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 02:06:39 +0100 Subject: [PATCH] test(quic): cover the peer being told that a ClientQUIC closed ShouldTellPeerOfCloseQUIC: twenty clients connect to a listener whose idle timeout is 20 s and are destroyed in turn; for each, the server side's RecieveDatagramSync, which throws once the peer has closed the connection, must return within 2 s. Before the fix the first one never did, since ~ClientQUIC closed its handle before msquic had sent the CONNECTION_CLOSE frame; the loop stops at the first miss, as every later connection would wait out its 2 s too. Co-Authored-By: Claude Opus 5.5 --- project.cpp | 1 + tests/ShouldTellPeerOfCloseQUIC/main.cpp | 121 +++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/ShouldTellPeerOfCloseQUIC/main.cpp diff --git a/project.cpp b/project.cpp index 16817d0..b30f208 100644 --- a/project.cpp +++ b/project.cpp @@ -207,6 +207,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldShareSelfSignedCertificateQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg }); + cfg.AddTest("ShouldTellPeerOfCloseQUIC").Dependencies({ &cfg }); } else { // Only a --no-quic build can show that msquic stayed out. cfg.AddTest("ShouldBuildWithoutQUIC").Dependencies({ &cfg }); diff --git a/tests/ShouldTellPeerOfCloseQUIC/main.cpp b/tests/ShouldTellPeerOfCloseQUIC/main.cpp new file mode 100644 index 0000000..528059b --- /dev/null +++ b/tests/ShouldTellPeerOfCloseQUIC/main.cpp @@ -0,0 +1,121 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// ~ClientQUIC tells the peer that the connection is over. It used to ask +// msquic to shut the connection down and then close the handle at once. +// msquic sends the CONNECTION_CLOSE frame from a flush it queues while it +// processes that shutdown, and the close the destructor queued meanwhile +// usually reached the worker first: it completed the shutdown silently, the +// flush found the connection finished and sent nothing, and the peer learned +// of the close only from its idle timeout, two minutes by default. A server +// whose clients each live for one request kept every one of them that long. +// +// Twenty clients connect and are destroyed in turn. For each, the server +// side's RecieveDatagramSync, which throws once the peer has closed the +// connection, must return within 2 s; the listener's idle timeout is 20 s, so +// a close the peer was never told about shows as a timeout here. + +#include + +import Crafter.Network; +import Crafter.Thread; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// The server's side of one connection: when it saw the peer close. +struct Seen { + std::mutex mutex; + std::condition_variable changed; + std::optional closedAt; +}; + +} // namespace + +int main() { + ThreadPool::Start(); + std::thread([] { + std::this_thread::sleep_for(std::chrono::seconds(45)); + std::println("FAIL timed out"); + std::fflush(stdout); + std::_Exit(1); + }).detach(); + + const std::string alpn = "crafter/test-close-notify"; + QUICServerCredentials serverCreds; + serverCreds.selfSigned = true; + serverCreds.settings.idleTimeoutMs = 20'000; + QUICClientCredentials clientCreds; + clientCreds.insecureNoServerValidation = true; + + std::mutex mutex; + std::deque> waiting; + + try { + // Each accepted connection gets a thread parked in RecieveDatagramSync + // until the connection closes. The ClientQUIC is left alone: what is + // under test is the peer hearing of the close, not its owner. + ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [&](ClientQUIC* peer) { + std::shared_ptr seen; + { + std::lock_guard lock(mutex); + seen = waiting.front(); + waiting.pop_front(); + std::thread([peer, seen] { + try { + while (true) (void)peer->RecieveDatagramSync(); + } catch (const QUICClosedException&) {} + { + std::lock_guard lock(seen->mutex); + seen->closedAt = std::chrono::steady_clock::now(); + } + seen->changed.notify_all(); + }).detach(); + } + }); + listener.ListenAsyncSync(); + + int told = 0; + std::chrono::milliseconds slowest{ 0 }; + for (int i = 0; i < 20; ++i) { + auto seen = std::make_shared(); + { + std::lock_guard lock(mutex); + waiting.push_back(seen); + } + std::chrono::steady_clock::time_point destroyed; + { + ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds); + destroyed = std::chrono::steady_clock::now(); + // ~ClientQUIC runs here. + } + std::unique_lock lock(seen->mutex); + if (!seen->changed.wait_for(lock, std::chrono::seconds(2), [&] { return seen->closedAt.has_value(); })) { + // Every later connection would wait out its 2 s as well. + std::println(" connection {}: the server was not told within 2 s", i); + break; + } + slowest = std::max(slowest, std::chrono::duration_cast( + *seen->closedAt - destroyed)); + ++told; + } + Check(told == 20, std::format("the server heard each of 20 clients close within 2 s ({} did; slowest {} ms)", + told, slowest.count())); + } catch (const std::exception& e) { + Check(false, std::format("threw: {}", e.what())); + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + // The accepted ClientQUICs are left to the process: skip msquic's static + // teardown, as the other QUIC tests do. _Exit doesn't flush. + std::fflush(stdout); + std::_Exit(failures == 0 ? 0 : 1); +}