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>
This commit is contained in:
Meta Vens 2026-09-17 00:23:04 +02:00
commit 62e53df447
4 changed files with 275 additions and 10 deletions

View file

@ -73,6 +73,12 @@ if(CULTMESH_QUIC_BUILD_TESTS)
# The header only. The scenarios drive the bridge as a host does, so they get
# no more of it than a host has.
target_link_libraries(cultmesh_quic_native_tests PRIVATE gamecult_mesh_quic_native)
# The scenarios see the development seam (section 7 of the header) exactly when
# the library has one, so `holdclose` compiles out of the same builds the
# bridge's own assertions compile out of.
if(CULTMESH_QUIC_DEBUG_ASSERTS)
target_compile_definitions(cultmesh_quic_native_tests PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS)
endif()
set_target_properties(cultmesh_quic_native_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
if(NOT WIN32)

View file

@ -71,6 +71,31 @@
#define CULTMESH_QUIC_ASSERT_QUIESCED(count) ((void)0)
#endif
// Development builds only, and never in a shipped binary: the seam the scenario
// runner drives the quiesce through. A scenario that only sleeps and hopes its
// pollers are inside the library cannot tell a bridge that counts host calls
// from one that does not, so the bridge says so itself:
//
// - PEAK raises the high-water mark of calls counted inside, so a scenario can
// assert that the calls it launched were counted rather than assumed.
// - AT_CLOSE records how many were counted when the close began its wait, which
// is the number the wait exists for.
// - HELD parks a call inside the library until the scenario releases it, so the
// close races calls that are genuinely there for as long as the scenario
// wants them there, instead of for as long as the schedule happens to give.
// It returns true when it parked, and touches nothing afterwards: a bridge
// that frees the runtime out from under a held call is then reported by the
// scenario rather than crashing inside it.
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
#define CULTMESH_QUIC_DEBUG_PEAK(count) DebugRaisePeak(count)
#define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) DebugRecordCallsAtClose(count)
#define CULTMESH_QUIC_DEBUG_HELD(lock) DebugHoldCall(lock)
#else
#define CULTMESH_QUIC_DEBUG_PEAK(count) ((void)0)
#define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) ((void)0)
#define CULTMESH_QUIC_DEBUG_HELD(lock) (false)
#endif
namespace {
constexpr uint32_t kApiVersion = 2;
@ -203,6 +228,36 @@ struct Runtime {
int active_calls = 0;
};
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
// The development seam behind the three CULTMESH_QUIC_DEBUG_ macros. None of it
// is compiled into a shipped library.
std::atomic<int> debug_peak_calls{0};
std::atomic<int> debug_calls_at_close{-1};
std::mutex debug_hold_gate;
std::condition_variable debug_hold_signal;
bool debug_hold_armed = false;
void DebugRaisePeak(int count) {
int peak = debug_peak_calls.load(std::memory_order_relaxed);
while (count > peak && !debug_peak_calls.compare_exchange_weak(peak, count)) {
}
}
void DebugRecordCallsAtClose(int count) { debug_calls_at_close.store(count); }
// Parks the calling host thread inside the library until the scenario releases
// it. The runtime gate is dropped first, so nothing else is blocked behind the
// held call, and it is never retaken: the call is still counted, because its
// CallScope is still alive, and that is the whole point.
bool DebugHoldCall(std::unique_lock<std::mutex>& lock) {
std::unique_lock<std::mutex> hold(debug_hold_gate);
if (!debug_hold_armed) return false;
lock.unlock();
debug_hold_signal.wait(hold, [] { return !debug_hold_armed; });
return true;
}
#endif
// Every host-facing export enters through this. It refuses once
// `cultmesh_quic_runtime_close` has started, and otherwise holds the runtime
// open for the length of the call.
@ -213,6 +268,7 @@ public:
std::lock_guard<std::mutex> lock(runtime_->gate);
if (runtime_->closing) return;
++runtime_->active_calls;
CULTMESH_QUIC_DEBUG_PEAK(runtime_->active_calls);
entered_ = true;
}
// The notify stays under `gate`, and that is the whole point of it. Released
@ -817,6 +873,7 @@ CULTMESH_API void cultmesh_quic_runtime_close(void* handle) {
if (runtime->closing) return;
runtime->closing = true;
runtime->signal.notify_all();
CULTMESH_QUIC_DEBUG_AT_CLOSE(runtime->active_calls);
runtime->signal.wait(lock, [runtime] { return runtime->active_calls == 0; });
CULTMESH_QUIC_ASSERT_QUIESCED(runtime->active_calls);
for (auto& entry : runtime->streams) streams.push_back(std::move(entry.second));
@ -1117,6 +1174,10 @@ CULTMESH_API int32_t cultmesh_quic_next_event(
if (runtime->events.empty() && timeout_ms > 0) {
runtime->signal.wait_for(lock, std::chrono::milliseconds(timeout_ms),
[runtime] { return !runtime->events.empty() || runtime->closing; });
// Development builds only; a no-op unless a scenario has armed the hold.
// A held call returns the same 0 an empty queue would have, and touches
// the runtime no further.
if (CULTMESH_QUIC_DEBUG_HELD(lock)) return 0;
}
if (runtime->events.empty()) return 0;
@ -1371,3 +1432,28 @@ CULTMESH_API void cultmesh_quic_close(V1Client* client) {
}
#endif // _WIN32
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
// The development seam's three exports. They exist only in a build configured
// with CULTMESH_QUIC_DEBUG_ASSERTS; a shipped library exports neither these nor
// the counters behind them. See section 7 of the header.
CULTMESH_API void cultmesh_quic_debug_hold_calls(int32_t hold) {
if (hold != 0) {
debug_peak_calls.store(0);
debug_calls_at_close.store(-1);
}
{
std::lock_guard<std::mutex> lock(debug_hold_gate);
debug_hold_armed = hold != 0;
}
debug_hold_signal.notify_all();
}
CULTMESH_API int32_t cultmesh_quic_debug_peak_calls(void) {
return debug_peak_calls.load();
}
CULTMESH_API int32_t cultmesh_quic_debug_calls_at_close(void) {
return debug_calls_at_close.load();
}
#endif

View file

@ -308,6 +308,38 @@ CULTMESH_QUIC_API int32_t cultmesh_quic_last_error(
* when nothing has failed. */
CULTMESH_QUIC_API int32_t cultmesh_quic_last_status(void* runtime);
/* ---------------------------------------------------------------------------
* 7. The development seam
*
* Present only in a library configured with `CULTMESH_QUIC_DEBUG_ASSERTS`, which
* a shipped build never is. It exists for the runtime-lifetime scenarios: the
* quiesce is a promise about host calls that are inside the library, and a
* scenario that can only sleep and hope cannot tell a bridge that counts them
* from one that does not.
*
* It is process-wide, not per-runtime, because the numbers outlive the runtime
* they describe: `cultmesh_quic_runtime_close` frees the runtime, and the count
* it waited on is read afterwards. The scenarios open one runtime at a time.
* --------------------------------------------------------------------------- */
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
/* Non-zero arms the hold and resets both counters: every call that reaches the
* blocking wait in `cultmesh_quic_next_event` parks inside the library instead
* of returning. Zero releases every held call and disarms it. A held call
* returns 0 and touches the runtime no further. */
CULTMESH_QUIC_API void cultmesh_quic_debug_hold_calls(int32_t hold);
/* The high-water mark of host calls counted inside the library since the hold
* was armed. Zero means no call was ever counted. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_peak_calls(void);
/* How many host calls were counted inside the library when the last
* `cultmesh_quic_runtime_close` began its wait, or -1 if no close has begun one
* since the hold was armed. This is the number the wait exists for. */
CULTMESH_QUIC_API int32_t cultmesh_quic_debug_calls_at_close(void);
#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */
#if defined(__cplusplus)
}
#endif

View file

@ -18,6 +18,14 @@
// 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
@ -73,11 +81,18 @@ std::string CloseRaceOnce(int pollers) {
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] {
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;
@ -96,6 +111,7 @@ std::string CloseRaceOnce(int pollers) {
// 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);
});
}
@ -106,9 +122,13 @@ std::string CloseRaceOnce(int pollers) {
// 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)
@ -119,6 +139,119 @@ std::string CloseRaceOnce(int pollers) {
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);
@ -133,18 +266,26 @@ int CloseRace(int iterations, int pollers) {
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: %s closerace [iterations] [pollers]\n", argv[0]);
std::fprintf(stderr, "usage: %s closerace|holdclose [iterations] [pollers]\n", argv[0]);
return 2;
}
const std::string scenario = argv[1];
if (scenario == "closerace") {
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, "closerace needs a positive iteration and poller count\n");
return 2;
}
return CloseRace(iterations, pollers);
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;