CultLib/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp
Meta Vens 62e53df447 Hold host calls inside the bridge so the quiesce is tested, not assumed
The closerace scenario pinned the order of the count and the notify but never
that a host call is counted at all. Deleting the call scope from
`cultmesh_quic_next_event` outright leaves every configuration green — the
pollers finish before the teardown reaches them — and so does inverting the
comparison that makes a positive timeout block, which empties the library before
the close even begins. A scenario that only sleeps and hopes cannot tell any of
that apart from the real thing.

So the bridge says it itself, in development builds only (section 7 of the
header, behind CULTMESH_QUIC_DEBUG_ASSERTS, absent from every shipped binary):
the peak number of host calls counted inside the library, the number counted
when the close began its wait, and a hold that parks a call inside the library
until the scenario releases it. A held call returns the 0 an empty queue would
have and touches the runtime no further, so a bridge that frees the runtime
under it is reported by the scenario rather than crashing inside it.

`holdclose` drives the quiesce through that seam: the calls are counted, they
were still counted when the close began, and the close did not return while they
were held. `closerace` keeps hunting the race and now also refuses to pass when
a poller left before the close began, which is a scenario that raced nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:23:04 +02:00

292 lines
14 KiB
C++

// Scenario runner for the bridge's runtime lifetime rules.
//
// This is the one part of the bridge a host cannot check by reading: whether
// `cultmesh_quic_runtime_close` is safe against host threads that are inside the
// library when it starts. Everything here drives that path and nothing else. It
// opens a runtime, parks pollers in `cultmesh_quic_next_event`, closes the
// runtime underneath them, and asserts what the header promises: every parked
// poller returns, and none of them touches the runtime after it is gone.
//
// It consumes the bridge through include/cultmesh_quic_native.h — the same
// contract a host has — and nothing else. No MsQuic header, no internals.
//
// The failures it exists to catch are not visible in the return values, because
// a use-after-free is not an error code. Two things make them visible instead:
// a sanitizer build (ThreadSanitizer for the notify/destroy race, AddressSanitizer
// for the freed-runtime dereference), and `CULTMESH_QUIC_DEBUG_ASSERTS`, which
// makes the bridge state the quiesce invariant itself rather than leaving it to
// be caught by an unlucky thread schedule.
//
// cultmesh_quic_native_tests closerace [iterations] [pollers]
// cultmesh_quic_native_tests holdclose [iterations] [pollers]
//
// `closerace` hunts the race: many pollers, many iterations, and whatever the
// schedule gives. `holdclose` takes the guessing out of it — the bridge's
// development seam (section 7 of the header) holds the pollers inside the
// library until this scenario releases them, and reports how many host calls the
// bridge actually counted, so the scenario asserts the quiesce instead of
// assuming its own fixture worked. It needs CULTMESH_QUIC_DEBUG_ASSERTS.
//
// Exit code 0 means every assertion held. Anything else, including a sanitizer
// abort or the bridge's own assertion, is a failure. Under ThreadSanitizer run
// it with `TSAN_OPTIONS=halt_on_error=1`, or a reported race leaves the exit
// code to the last thing that set it.
#include <cultmesh_quic_native.h>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <thread>
#include <vector>
namespace {
// Long enough that a poller is still parked when the close begins. The close is
// what ends the wait; this timeout only bounds the scenario if the close never
// arrives, which is itself the failure.
constexpr int32_t kPollTimeoutMs = 5000;
// A poller asks for a payload buffer, so the close races a `next_event` that has
// somewhere to copy into rather than one that can only ever return 0.
constexpr int32_t kPayloadCapacity = 4096;
int Fail(const std::string& message) {
std::fprintf(stderr, "FAILED: %s\n", message.c_str());
return 1;
}
// One iteration: `pollers` threads parked in `next_event`, then a close under
// them. Returns an empty string when every promise held.
//
// Each poller makes exactly one blocking call and does not call again. Section 5
// of the header puts the ordering on the host — no call may *begin* during a
// close — so a scenario that kept re-entering would be testing a shape the
// bridge declines to support, and its report would be the harness's own fault.
// A call already inside the library is the case the quiesce exists for, and that
// is the one run here.
std::string CloseRaceOnce(int pollers) {
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr)
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
// Counts threads about to enter the blocking call. The close waits for all of
// them, so the race is run with the pollers actually inside rather than with
// threads that have not started yet.
std::atomic<int> entering{0};
std::atomic<int> returned{0};
std::atomic<int> undefined_return{0};
std::atomic<int> timed_out{0};
// Set just before the close, and read by each poller as it returns. A poller
// that is already out by then was never in the race, and a scenario full of
// those passes without testing anything: this is what catches a bridge whose
// blocking crossing has stopped blocking.
std::atomic<bool> closing_started{false};
std::atomic<int> left_before_the_close{0};
std::vector<std::thread> threads;
threads.reserve(static_cast<size_t>(pollers));
for (int index = 0; index < pollers; ++index) {
threads.emplace_back([runtime, &entering, &returned, &undefined_return, &timed_out,
&closing_started, &left_before_the_close] {
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
entering.fetch_add(1);
const auto started = std::chrono::steady_clock::now();
// Parked here when the close arrives. `closing` wakes the wait; the
// call scope this thread holds is what the close must count out
// before it frees anything.
const int32_t result = cultmesh_quic_next_event(
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - started).count();
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
// The close is what ends this wait. Sitting out the whole timeout
// instead means the wake never reached it.
if (elapsed >= kPollTimeoutMs / 2) timed_out.fetch_add(1);
if (!closing_started.load()) left_before_the_close.fetch_add(1);
returned.fetch_add(1);
});
}
while (entering.load() < pollers) std::this_thread::yield();
// Settle so the pollers are inside the blocking wait rather than on their way
// to it. A poller that has not entered yet is a call beginning during the
// close, which is the host's to avoid, not the bridge's to survive.
std::this_thread::sleep_for(std::chrono::milliseconds(50));
closing_started.store(true);
cultmesh_quic_runtime_close(runtime);
for (auto& thread : threads) thread.join();
if (left_before_the_close.load() != 0)
return std::to_string(left_before_the_close.load()) + " of " + std::to_string(pollers) +
" pollers returned before the close began, so they were never in the race";
if (undefined_return.load() != 0)
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
if (timed_out.load() != 0)
return std::to_string(timed_out.load()) + " of " + std::to_string(pollers) +
" pollers sat out the timeout instead of being woken by the close";
if (returned.load() != pollers)
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
return {};
}
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
// How long a poll that does not block is given to prove it by returning, after
// the calls have been counted and before the close begins. A bridge whose
// blocking wait no longer blocks has emptied itself of host calls by the time
// the close starts, and `calls_at_close` then says 0.
constexpr int kSettleMs = 100;
// How long the close is required to stay inside `cultmesh_quic_runtime_close`
// while the pollers are held. The held calls are what it is waiting for, and
// nothing releases them until this elapses, so a close that returns first gave
// up on a call that was still inside. This bounds what the scenario can see: a
// close that waits for longer than this and then gives up is not distinguished
// from one that waits properly, which is the honest limit of the check.
constexpr int kHeldWindowMs = 500;
// How long the pollers are given to be counted inside the library before the
// close begins. Reaching `pollers` is the normal path; the deadline only stops
// a bridge that never counts them from hanging the scenario.
constexpr int kCountedDeadlineMs = 2000;
// One iteration of the quiesce itself, with the guesswork taken out: the pollers
// are held inside the library by the bridge's own development seam until this
// scenario lets them go, and the bridge says how many calls it counted rather
// than the scenario assuming the schedule put them there.
//
// Three promises, and a mutation that breaks any one of them is seen here:
// - the calls are counted (`peak`), so a blocking crossing outside a call scope
// is not merely uncounted-but-lucky, it reads as zero;
// - they were still counted when the close began its wait (`calls_at_close`),
// so a poll that stopped blocking cannot leave the scenario testing an empty
// library;
// - the close did not return while they were held, so a wait that gives up
// after any interval shorter than the hold is not a wait.
std::string HoldCloseOnce(int pollers) {
cultmesh_quic_debug_hold_calls(1);
void* runtime = nullptr;
const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime);
if (opened != 0 || runtime == nullptr) {
cultmesh_quic_debug_hold_calls(0);
return "cultmesh_quic_runtime_open returned " + std::to_string(opened);
}
std::atomic<int> returned{0};
std::atomic<int> undefined_return{0};
std::vector<std::thread> threads;
threads.reserve(static_cast<size_t>(pollers));
for (int index = 0; index < pollers; ++index) {
threads.emplace_back([runtime, &returned, &undefined_return] {
std::vector<uint8_t> payload(static_cast<size_t>(kPayloadCapacity));
cultmesh_quic_event event{};
int32_t required = 0;
const int32_t result = cultmesh_quic_next_event(
runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required);
if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1);
returned.fetch_add(1);
});
}
const auto counted_deadline =
std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs);
while (cultmesh_quic_debug_peak_calls() < pollers &&
std::chrono::steady_clock::now() < counted_deadline)
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs));
std::atomic<bool> closed{false};
std::thread closer([runtime, &closed] {
cultmesh_quic_runtime_close(runtime);
closed.store(true);
});
const auto held_until =
std::chrono::steady_clock::now() + std::chrono::milliseconds(kHeldWindowMs);
while (!closed.load() && std::chrono::steady_clock::now() < held_until)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
const bool closed_while_held = closed.load();
const int32_t peak = cultmesh_quic_debug_peak_calls();
const int32_t at_close = cultmesh_quic_debug_calls_at_close();
cultmesh_quic_debug_hold_calls(0);
closer.join();
for (auto& thread : threads) thread.join();
if (peak != pollers)
return "the library counted a peak of " + std::to_string(peak) + " host call(s) inside it, not " +
std::to_string(pollers) + "; the blocking crossing is not inside a call scope";
if (at_close != pollers)
return "the close began its wait with " + std::to_string(at_close) +
" host call(s) counted inside, not " + std::to_string(pollers) +
"; the pollers were not inside the library when the close started";
if (closed_while_held)
return "the close returned while " + std::to_string(pollers) +
" host call(s) were still held inside the library";
if (undefined_return.load() != 0)
return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define";
if (returned.load() != pollers)
return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned";
return {};
}
int HoldClose(int iterations, int pollers) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = HoldCloseOnce(pollers);
if (!failure.empty())
return Fail("holdclose iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("holdclose %dx%d: ok\n", iterations, pollers);
return 0;
}
#endif // CULTMESH_QUIC_DEBUG_ASSERTS
int CloseRace(int iterations, int pollers) {
for (int iteration = 0; iteration < iterations; ++iteration) {
const std::string failure = CloseRaceOnce(pollers);
if (!failure.empty())
return Fail("closerace iteration " + std::to_string(iteration) + ": " + failure);
}
std::printf("closerace %dx%d: ok\n", iterations, pollers);
return 0;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: %s closerace|holdclose [iterations] [pollers]\n", argv[0]);
return 2;
}
const std::string scenario = argv[1];
const int iterations = argc > 2 ? std::atoi(argv[2]) : 20;
const int pollers = argc > 3 ? std::atoi(argv[3]) : 256;
if (iterations <= 0 || pollers <= 0) {
std::fprintf(stderr, "%s needs a positive iteration and poller count\n", scenario.c_str());
return 2;
}
if (scenario == "closerace") return CloseRace(iterations, pollers);
if (scenario == "holdclose") {
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
return HoldClose(iterations, pollers);
#else
std::fprintf(stderr,
"holdclose needs the development seam: configure with "
"-DCULTMESH_QUIC_DEBUG_ASSERTS=ON\n");
return 2;
#endif
}
std::fprintf(stderr, "unknown scenario '%s'\n", scenario.c_str());
return 2;
}