Run the runtime-lifetime scenarios from the repository
The bridge's close path had no committed test. Whether a close is safe against host threads already inside the library is the one thing about it a host cannot establish by reading, and every check of it so far lived in a session scratchpad and is gone. The scenario runner under tests/ drives that path and nothing else: it parks pollers in cultmesh_quic_next_event, closes the runtime under them, and asserts that the close is what woke them. It consumes the bridge through the public header, as a host does. CULTMESH_QUIC_BUILD_TESTS builds it, off by default, because a delivery build has no business producing an executable. CULTMESH_QUIC_DEBUG_ASSERTS, also off by default, makes the bridge state the quiesce invariant itself rather than leaving it to whatever a sanitizer happens to catch. Deleting the quiesce wait is otherwise falsifiable only by luck: AddressSanitizer saw it 4 times in 48 attempts with pollers blocked mid-copy of large frames, and never on release. With the assertion and pollers blocked it fails on the first close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
19d8d5c948
commit
f4186e7427
3 changed files with 209 additions and 0 deletions
|
|
@ -54,3 +54,35 @@ set_target_properties(gamecult_mesh_quic_native PROPERTIES
|
|||
OUTPUT_NAME "gamecult_mesh_quic_native"
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||
|
||||
# The runtime-lifetime scenarios. Off by default: the shipped artifact is the
|
||||
# library alone, and a delivery build has no business producing an executable.
|
||||
# CULTMESH_QUIC_DEBUG_ASSERTS goes with them, never into a shipped binary — it
|
||||
# makes the bridge state the quiesce invariant instead of leaving it to whatever
|
||||
# a sanitizer happens to catch.
|
||||
option(CULTMESH_QUIC_BUILD_TESTS "Build the runtime-lifetime scenario runner" OFF)
|
||||
option(CULTMESH_QUIC_DEBUG_ASSERTS "Abort the bridge on a violated internal invariant" OFF)
|
||||
|
||||
if(CULTMESH_QUIC_DEBUG_ASSERTS)
|
||||
target_compile_definitions(gamecult_mesh_quic_native PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS)
|
||||
endif()
|
||||
|
||||
if(CULTMESH_QUIC_BUILD_TESTS)
|
||||
add_executable(cultmesh_quic_native_tests tests/cultmesh_quic_native_tests.cpp)
|
||||
target_compile_features(cultmesh_quic_native_tests PRIVATE cxx_std_20)
|
||||
# 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)
|
||||
set_target_properties(cultmesh_quic_native_tests PROPERTIES
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
|
||||
if(NOT WIN32)
|
||||
target_link_libraries(cultmesh_quic_native_tests PRIVATE pthread)
|
||||
# The linker resolves the bridge's own NEEDED libmsquic.so.2 while linking
|
||||
# this executable, and the runtime copy only lands beside the binaries after
|
||||
# the build; point it at the extracted package instead.
|
||||
target_link_options(cultmesh_quic_native_tests PRIVATE "-Wl,-rpath-link,${MSQUIC_LIB_DIR}")
|
||||
set_target_properties(cultmesh_quic_native_tests PROPERTIES
|
||||
BUILD_WITH_INSTALL_RPATH TRUE
|
||||
INSTALL_RPATH "$ORIGIN")
|
||||
endif()
|
||||
endif()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@
|
|||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
|
|
@ -46,6 +48,29 @@
|
|||
|
||||
#define CULTMESH_API extern "C" CULTMESH_QUIC_API
|
||||
|
||||
// Development builds only, and never in a shipped binary: after
|
||||
// `cultmesh_quic_runtime_close` has quiesced, no host call may still be inside
|
||||
// the library. The wait is what makes that true, and its absence is otherwise
|
||||
// visible only as a use-after-free a sanitizer catches by luck — a poller has to
|
||||
// be preempted in the right window. With pollers blocked, this states the
|
||||
// invariant directly, so a build without the wait dies on the first close rather
|
||||
// than on the run that happens to be unlucky.
|
||||
#if defined(CULTMESH_QUIC_DEBUG_ASSERTS)
|
||||
#define CULTMESH_QUIC_ASSERT_QUIESCED(count) \
|
||||
do { \
|
||||
const int cultmesh_quic_remaining = (count); \
|
||||
if (cultmesh_quic_remaining != 0) { \
|
||||
std::fprintf(stderr, \
|
||||
"cultmesh_quic_runtime_close: %d host call(s) still inside the " \
|
||||
"library after the quiesce\n", cultmesh_quic_remaining); \
|
||||
std::fflush(stderr); \
|
||||
std::abort(); \
|
||||
} \
|
||||
} while (false)
|
||||
#else
|
||||
#define CULTMESH_QUIC_ASSERT_QUIESCED(count) ((void)0)
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint32_t kApiVersion = 2;
|
||||
|
|
@ -793,6 +818,7 @@ CULTMESH_API void cultmesh_quic_runtime_close(void* handle) {
|
|||
runtime->closing = true;
|
||||
runtime->signal.notify_all();
|
||||
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));
|
||||
for (auto& entry : runtime->connections) connections.push_back(std::move(entry.second));
|
||||
for (auto& entry : runtime->listeners) listeners.push_back(std::move(entry.second));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
// 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]
|
||||
//
|
||||
// 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};
|
||||
|
||||
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] {
|
||||
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);
|
||||
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));
|
||||
|
||||
cultmesh_quic_runtime_close(runtime);
|
||||
for (auto& thread : threads) thread.join();
|
||||
|
||||
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 {};
|
||||
}
|
||||
|
||||
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 [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);
|
||||
}
|
||||
std::fprintf(stderr, "unknown scenario '%s'\n", scenario.c_str());
|
||||
return 2;
|
||||
}
|
||||
Loading…
Reference in a new issue