test(quic): SendSync returns for a send the peer answered at once

ShouldReturnFromSendSyncQUIC: 500 rounds of four fresh connections, each
sending one message on its first stream to a server that echoes it with
a FIN and drops the stream; every SendSync must return and every echo
come back. The race it guards needs the sending thread descheduled for
a round trip, so it is a stress test, not a deterministic one: against
the previous SendSync it failed 6 runs of 7, with one or two of the 2000
sends throwing each time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 02:16:18 +01:00
commit be55036b18
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
2 changed files with 110 additions and 0 deletions

View file

@ -205,6 +205,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg });
cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldReportClosedQUICStream").Dependencies({ &cfg });
cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg }); cfg.AddTest("ShouldReportListenerPortQUIC").Dependencies({ &cfg });
cfg.AddTest("ShouldReturnFromSendSyncQUIC").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 }); cfg.AddTest("ShouldTellPeerOfCloseQUIC").Dependencies({ &cfg });

View file

@ -0,0 +1,109 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// QUICStream::SendSync returns once msquic has taken its buffer, and throws
// only when that send failed. It used to wait on one flag per stream and,
// once woken, throw QUICClosedException whenever the stream had finished
// shutting down meanwhile, though its own send had completed. A peer that
// answers at once and finishes the stream (here: echo, FIN, and drop the
// stream, whose Stop() stops our sending side too) can complete the stream
// within a round trip, which on loopback is shorter than a busy machine can
// take to wake the sending thread: the sender threw for a send the peer had
// already answered.
//
// The race needs the sending thread to be descheduled at the wrong moment,
// so no test hits it every time. The shape that showed it: the first stream
// of a fresh connection, four connections at once, one exchange each; a
// throwaway run of that shape threw once in 3000 exchanges, the echo of the
// failed send arriving intact. Here 2000 such exchanges must all return
// from SendSync, and all be echoed.
#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;
}
} // namespace
int main() {
ThreadPool::Start();
std::thread([] {
std::this_thread::sleep_for(std::chrono::seconds(50));
std::println("FAIL timed out");
std::fflush(stdout);
std::_Exit(1);
}).detach();
const std::string alpn = "crafter/test-send-sync";
QUICServerCredentials serverCreds;
serverCreds.selfSigned = true;
QUICClientCredentials clientCreds;
clientCreds.insecureNoServerValidation = true;
constexpr int rounds = 500;
constexpr int together = 4;
std::atomic<int> threw = 0;
std::atomic<int> echoed = 0;
std::atomic<int> other = 0;
try {
// Answer each stream with what arrived and a FIN, then drop it: its
// destructor's Stop() aborts our receive side, which also ends the
// client's sending side.
ListenerQUIC listener("127.0.0.1", 0, alpn, serverCreds, [](ClientQUIC* peer) {
peer->OnStream([](QUICStream stream) {
try {
std::vector<char> got = stream.RecieveSync();
stream.SendSync(got.data(), static_cast<std::uint32_t>(got.size()), true);
} catch (...) {}
});
});
listener.ListenAsyncAsync();
for (int round = 0; round < rounds; ++round) {
std::vector<std::thread> clients;
for (int i = 0; i < together; ++i) {
clients.emplace_back([&] {
try {
ClientQUIC client("127.0.0.1", listener.Port(), alpn, clientCreds);
QUICStream stream = client.OpenStream();
try {
stream.SendSync("ping", 4, false);
} catch (const QUICClosedException&) {
++threw;
}
std::vector<char> back = stream.RecieveUntilCloseSync();
if (std::string(back.begin(), back.end()) == "ping") ++echoed;
} catch (const std::exception& e) {
if (++other == 1) std::println(" {}", e.what());
}
});
}
for (std::thread& client : clients) client.join();
}
} catch (const std::exception& e) {
Check(false, std::format("threw: {}", e.what()));
}
constexpr int exchanges = rounds * together;
Check(threw.load() == 0,
std::format("SendSync returned for every send msquic took ({} of {} threw QUICClosedException)",
threw.load(), exchanges));
Check(echoed.load() == exchanges && other.load() == 0,
std::format("and every echo came back ({} of {}, {} other errors)", echoed.load(), exchanges, other.load()));
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
// The accepted peers 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);
}