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 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 02:06:39 +01:00
commit 793b518718
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 122 additions and 0 deletions

View file

@ -207,6 +207,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg });
cfg.AddTest("ShouldShareSelfSignedCertificateQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldShareSelfSignedCertificateQUIC").Dependencies({ &cfg });
cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg }); cfg.AddTest("ShouldSurviveConnectionChurn").Dependencies({ &cfg });
cfg.AddTest("ShouldTellPeerOfCloseQUIC").Dependencies({ &cfg });
} else { } else {
// Only a --no-quic build can show that msquic stayed out. // Only a --no-quic build can show that msquic stayed out.
cfg.AddTest("ShouldBuildWithoutQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldBuildWithoutQUIC").Dependencies({ &cfg });

View file

@ -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 <cstdio>
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<std::chrono::steady_clock::time_point> 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<std::shared_ptr<Seen>> 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> 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<Seen>();
{
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<std::chrono::milliseconds>(
*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);
}