diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt index 936002cc..87f877b2 100644 --- a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt +++ b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt @@ -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() diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp index b5aacb16..0466684f 100644 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -35,6 +35,8 @@ #include #include #include +#include +#include #include #include #include @@ -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)); diff --git a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp new file mode 100644 index 00000000..1fc137a7 --- /dev/null +++ b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include + +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 entering{0}; + std::atomic returned{0}; + std::atomic undefined_return{0}; + std::atomic timed_out{0}; + + std::vector threads; + threads.reserve(static_cast(pollers)); + for (int index = 0; index < pollers; ++index) { + threads.emplace_back([runtime, &entering, &returned, &undefined_return, &timed_out] { + std::vector payload(static_cast(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::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; +}