//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); }