diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8dee606..d745008 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,13 @@ add_executable(audio_ring_test audio_ring_test.cpp) target_link_libraries(audio_ring_test PRIVATE coop_common) add_test(NAME audio_ring_test COMMAND audio_ring_test) +# Unit test for the DetourGate safe-unhook coordinator (hook/src/hook_guard.hpp): drain() must block +# while a detour Guard is in flight and return promptly otherwise. Fast/deterministic complement to +# the mock_game_test hook/unhook storm. Header-only (just needs the hook include dir + threads). +add_executable(detour_gate_test detour_gate_test.cpp) +target_include_directories(detour_gate_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) +add_test(NAME detour_gate_test COMMAND detour_gate_test) + # Unit test for the MKB event ring (SPSC push/pop, wrap-around, full/empty). add_executable(mkb_ring_test mkb_ring_test.cpp) target_link_libraries(mkb_ring_test PRIVATE coop_common) @@ -276,6 +283,7 @@ coop_output_subdir(tests hook_selftest dinput_hook_test audio_ring_test + detour_gate_test mkb_ring_test mkb_map_test audio_mix_test diff --git a/tests/detour_gate_test.cpp b/tests/detour_gate_test.cpp new file mode 100644 index 0000000..2e5ecc7 --- /dev/null +++ b/tests/detour_gate_test.cpp @@ -0,0 +1,122 @@ +// Unit test for coop::hook::DetourGate (hook/src/hook_guard.hpp) -- the safe-unhook coordination +// every removable hook relies on. The integration-level guard is mock_game_test's hook/unhook storm +// (now driven by an uncapped, input-polling mock), but that's slow and timing-dependent; this is a +// fast, deterministic check of the core contract: drain() must NOT return while a detour body +// (a Guard) is in flight, and must return promptly once none are. A regression that made drain() +// return early would reintroduce the use-after-free the gate exists to prevent. +#include +#include +#include +#include + +#include "hook_guard.hpp" + +using coop::hook::DetourGate; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} +} // namespace + +int main() +{ + // --- Contract 1: drain() blocks while a Guard is held, and returns after it's released. -------- + { + DetourGate gate; + std::atomic drained{false}; + // Hold a Guard (a detour body "in flight") before the drainer starts. + auto* guard = new DetourGate::Guard(gate); + check(gate.active() == 1, "active count reflects a held Guard"); + + std::thread drainer([&] { + gate.drain(); + drained.store(true, std::memory_order_release); + }); + + // While the Guard is held, drain() must not have returned. + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + check(!drained.load(std::memory_order_acquire), "drain() blocks while a detour is in flight"); + + // Release the Guard; drain() must then return promptly. + delete guard; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500); + while (!drained.load(std::memory_order_acquire) && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + check(drained.load(std::memory_order_acquire), "drain() returns once the in-flight detour finishes"); + drainer.join(); + check(gate.active() == 0, "active count back to zero"); + } + + // --- Contract 2: drain() returns promptly when nothing is in flight. --------------------------- + { + DetourGate gate; + const auto t0 = std::chrono::steady_clock::now(); + gate.drain(); + const auto ms = std::chrono::duration_cast(std::chrono::steady_clock::now() - t0) + .count(); + check(ms < 100, "drain() with no in-flight detours returns quickly"); + } + + // --- Contract 3: concurrency stress -- disable -> drain -> "free" -> re-enable, while workers run + // the guarded body. With the gate honoured, no worker ever runs its body while "freed" is set + // (that would be the use-after-free). Models remove_*_hooks vs the game's detour threads. ----- + { + DetourGate gate; + std::atomic disabled{false}; + std::atomic freed{false}; + std::atomic stop{false}; + std::atomic guarded{0}; + std::atomic violations{0}; + + auto worker = [&] { + while (!stop.load(std::memory_order_relaxed)) + { + if (disabled.load(std::memory_order_acquire)) + { + continue; // "hook removed" -> no new detour starts + } + DetourGate::Guard g(gate); + if (freed.load(std::memory_order_acquire)) + { + violations.fetch_add(1, std::memory_order_relaxed); // ran the body on freed state + } + guarded.fetch_add(1, std::memory_order_relaxed); + } + }; + + std::thread workers[6]; + for (auto& w : workers) + { + w = std::thread(worker); + } + for (int c = 0; c < 3000; ++c) + { + disabled.store(true, std::memory_order_release); // disable: no new detours + gate.drain(); // wait for in-flight detours + freed.store(true, std::memory_order_release); // "free" the shared state + freed.store(false, std::memory_order_release); // "rebuild" + disabled.store(false, std::memory_order_release); + } + stop.store(true, std::memory_order_relaxed); + for (auto& w : workers) + { + w.join(); + } + std::printf(" stress: guarded=%lld violations=%lld\n", guarded.load(), violations.load()); + check(guarded.load() > 0, "workers actually ran guarded bodies"); + check(violations.load() == 0, "no detour body ran against freed state (drain + disable hold)"); + } + + std::printf(g_failures == 0 ? "PASS detour_gate_test\n" : "FAILED detour_gate_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tools/mock_game/CMakeLists.txt b/tools/mock_game/CMakeLists.txt index b1dd4c5..ac88369 100644 --- a/tools/mock_game/CMakeLists.txt +++ b/tools/mock_game/CMakeLists.txt @@ -13,7 +13,8 @@ add_executable(coop_mock_game # Reuses the shared ToneSource (also used by coop_tone + the audio hook self-test). target_include_directories(coop_mock_game PRIVATE ${CMAKE_SOURCE_DIR}/tools/audio_tone) -target_link_libraries(coop_mock_game PRIVATE d3d11 d3d12 d3d10 d3d9 dxgi ole32 opengl32 gdi32 volk) +target_link_libraries(coop_mock_game PRIVATE d3d11 d3d12 d3d10 d3d9 dxgi ole32 opengl32 gdi32 volk + xinput user32) # xinput/user32: poll the input + focus APIs the hook intercepts (storm coverage) set_target_properties(coop_mock_game PROPERTIES OUTPUT_NAME "coop_mock_game") # Test fixture -> stage next to the tests (alongside coop_tone), not in the deployable root. diff --git a/tools/mock_game/main.cpp b/tools/mock_game/main.cpp index 64955c1..fc4a9b4 100644 --- a/tools/mock_game/main.cpp +++ b/tools/mock_game/main.cpp @@ -16,6 +16,8 @@ #include +#include + #include "render_backend.hpp" #include "tone_source.hpp" @@ -23,6 +25,41 @@ namespace { std::atomic g_running{true}; +// Diagnostic: on an access violation, log the faulting address and the caller (return address on the +// stack) as module+offset, so the storm's intermittent crashes name the exact hook. Then let it +// crash so the test still detects it. +LONG WINAPI crash_logger(EXCEPTION_POINTERS* ep) +{ + if (ep->ExceptionRecord->ExceptionCode != EXCEPTION_ACCESS_VIOLATION) + { + return EXCEPTION_CONTINUE_SEARCH; + } + auto mod = [](void* p, char* out, size_t n) -> unsigned long long { + HMODULE m = nullptr; + if (p != nullptr && + GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(p), &m) && + m != nullptr) + { + char path[MAX_PATH] = {}; + GetModuleFileNameA(m, path, MAX_PATH); + const char* base = std::strrchr(path, '\\'); + lstrcpynA(out, base ? base + 1 : path, static_cast(n)); + return static_cast(reinterpret_cast(p) - reinterpret_cast(m)); + } + lstrcpynA(out, "?", static_cast(n)); + return reinterpret_cast(p); + }; + char m1[64] = {}, m2[64] = {}; + void* rip = reinterpret_cast(ep->ContextRecord->Rip); + void* ret = ep->ContextRecord->Rsp ? *reinterpret_cast(ep->ContextRecord->Rsp) : nullptr; + const unsigned long long o1 = mod(rip, m1, sizeof(m1)); + const unsigned long long o2 = mod(ret, m2, sizeof(m2)); + std::printf("MOCK_GAME CRASH: AV rip=%s+0x%llx caller=%s+0x%llx\n", m1, o1, m2, o2); + std::fflush(stdout); + return EXCEPTION_CONTINUE_SEARCH; // still crash so the test detects it +} + LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { if (msg == WM_DESTROY) @@ -58,10 +95,25 @@ void audio_thread(coop::tone::ToneFormat want) tone.close(); CoUninitialize(); } + +// Poll the input/focus APIs the hook intercepts, like a real game does each frame. When the +// input / MKB / focus subsystems are hooked (e.g. during mock_game_test's hook/unhook storm) this +// drives their detours -- so removing those hooks while a detour is in-flight is actually exercised +// (the capture path alone never calls them, which is how the input-hook removal race went untested). +void poll_input() +{ + XINPUT_STATE xs{}; + (void)XInputGetState(0, &xs); // XInput hook (XInputGetState/Ex) + (void)GetAsyncKeyState(VK_SPACE); // MKB hook (GetAsyncKeyState) + BYTE kb[256] = {}; + (void)GetKeyboardState(kb); // MKB hook (GetKeyboardState) + (void)GetForegroundWindow(); // focus hook (GetForegroundWindow) +} } // namespace int main(int argc, char** argv) { + AddVectoredExceptionHandler(1, crash_logger); // name the faulting hook on an intermittent storm crash const std::string backend_name = argc > 1 ? argv[1] : "dx11"; const double seconds = argc > 2 ? std::strtod(argv[2], nullptr) : 0.0; const bool want_audio = argc > 3; @@ -124,6 +176,8 @@ int main(int argc, char** argv) const ULONGLONG start = GetTickCount64(); std::uint32_t frame = 0; + ULONGLONG fps_window_start = start; // window-title fps: frames in the last ~second + std::uint32_t fps_window_frames = 0; for (;;) { MSG msg; @@ -140,7 +194,21 @@ int main(int argc, char** argv) { break; } + poll_input(); // drive the input/focus/MKB detours each frame, like a real game backend->render_and_present(frame++); + + // Show the backend + a once-per-second-smoothed fps in the title bar. + ++fps_window_frames; + const ULONGLONG now = GetTickCount64(); + if (now - fps_window_start >= 1000) + { + const double fps = fps_window_frames * 1000.0 / static_cast(now - fps_window_start); + wchar_t title[128]; + swprintf(title, 128, L"CoopMockGame [%hs] - %.0f fps", backend->name(), fps); + SetWindowTextW(hwnd, title); + fps_window_start = now; + fps_window_frames = 0; + } if (seconds > 0.0 && (GetTickCount64() - start) >= static_cast(seconds * 1000.0)) { break;