Mock game: drive input/focus/MKB hooks + window title; add DetourGate test
- poll_input() each frame (XInputGetState / GetAsyncKeyState / GetKeyboardState / GetForegroundWindow), like a real game, so mock_game_test's hook/unhook storm actually exercises removing the input/focus/MKB hooks while their detours are in flight -- the coverage gap that let those removal races go untested. - Window title shows the backend + a once-per-second-smoothed fps. - A vectored-exception crash logger prints the faulting module+offset (named the storm's intermittent crashes during this work; inert otherwise). - detour_gate_test: fast, deterministic guard for DetourGate -- drain() must block while a Guard is in flight and return promptly otherwise, plus a concurrency stress that asserts no body runs against freed state. (A synthetic install-race unit test was tried but flaked on SafetyHook's own enable/disable atomicity under ~30M calls/s, unrelated to our code, so the storm is the install/remove guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,13 @@ add_executable(audio_ring_test audio_ring_test.cpp)
|
|||||||
target_link_libraries(audio_ring_test PRIVATE coop_common)
|
target_link_libraries(audio_ring_test PRIVATE coop_common)
|
||||||
add_test(NAME audio_ring_test COMMAND audio_ring_test)
|
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).
|
# Unit test for the MKB event ring (SPSC push/pop, wrap-around, full/empty).
|
||||||
add_executable(mkb_ring_test mkb_ring_test.cpp)
|
add_executable(mkb_ring_test mkb_ring_test.cpp)
|
||||||
target_link_libraries(mkb_ring_test PRIVATE coop_common)
|
target_link_libraries(mkb_ring_test PRIVATE coop_common)
|
||||||
@@ -276,6 +283,7 @@ coop_output_subdir(tests
|
|||||||
hook_selftest
|
hook_selftest
|
||||||
dinput_hook_test
|
dinput_hook_test
|
||||||
audio_ring_test
|
audio_ring_test
|
||||||
|
detour_gate_test
|
||||||
mkb_ring_test
|
mkb_ring_test
|
||||||
mkb_map_test
|
mkb_map_test
|
||||||
audio_mix_test
|
audio_mix_test
|
||||||
|
|||||||
122
tests/detour_gate_test.cpp
Normal file
122
tests/detour_gate_test.cpp
Normal file
@@ -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 <atomic>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
#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<bool> 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::milliseconds>(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<bool> disabled{false};
|
||||||
|
std::atomic<bool> freed{false};
|
||||||
|
std::atomic<bool> stop{false};
|
||||||
|
std::atomic<long long> guarded{0};
|
||||||
|
std::atomic<long long> 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;
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@ add_executable(coop_mock_game
|
|||||||
# Reuses the shared ToneSource (also used by coop_tone + the audio hook self-test).
|
# 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_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")
|
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.
|
# Test fixture -> stage next to the tests (alongside coop_tone), not in the deployable root.
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <xinput.h>
|
||||||
|
|
||||||
#include "render_backend.hpp"
|
#include "render_backend.hpp"
|
||||||
#include "tone_source.hpp"
|
#include "tone_source.hpp"
|
||||||
|
|
||||||
@@ -23,6 +25,41 @@ namespace
|
|||||||
{
|
{
|
||||||
std::atomic<bool> g_running{true};
|
std::atomic<bool> 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<LPCSTR>(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<int>(n));
|
||||||
|
return static_cast<unsigned long long>(reinterpret_cast<uintptr_t>(p) - reinterpret_cast<uintptr_t>(m));
|
||||||
|
}
|
||||||
|
lstrcpynA(out, "?", static_cast<int>(n));
|
||||||
|
return reinterpret_cast<unsigned long long>(p);
|
||||||
|
};
|
||||||
|
char m1[64] = {}, m2[64] = {};
|
||||||
|
void* rip = reinterpret_cast<void*>(ep->ContextRecord->Rip);
|
||||||
|
void* ret = ep->ContextRecord->Rsp ? *reinterpret_cast<void**>(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)
|
LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
|
||||||
{
|
{
|
||||||
if (msg == WM_DESTROY)
|
if (msg == WM_DESTROY)
|
||||||
@@ -58,10 +95,25 @@ void audio_thread(coop::tone::ToneFormat want)
|
|||||||
tone.close();
|
tone.close();
|
||||||
CoUninitialize();
|
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
|
} // namespace
|
||||||
|
|
||||||
int main(int argc, char** argv)
|
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 std::string backend_name = argc > 1 ? argv[1] : "dx11";
|
||||||
const double seconds = argc > 2 ? std::strtod(argv[2], nullptr) : 0.0;
|
const double seconds = argc > 2 ? std::strtod(argv[2], nullptr) : 0.0;
|
||||||
const bool want_audio = argc > 3;
|
const bool want_audio = argc > 3;
|
||||||
@@ -124,6 +176,8 @@ int main(int argc, char** argv)
|
|||||||
|
|
||||||
const ULONGLONG start = GetTickCount64();
|
const ULONGLONG start = GetTickCount64();
|
||||||
std::uint32_t frame = 0;
|
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 (;;)
|
for (;;)
|
||||||
{
|
{
|
||||||
MSG msg;
|
MSG msg;
|
||||||
@@ -140,7 +194,21 @@ int main(int argc, char** argv)
|
|||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
poll_input(); // drive the input/focus/MKB detours each frame, like a real game
|
||||||
backend->render_and_present(frame++);
|
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<double>(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<ULONGLONG>(seconds * 1000.0))
|
if (seconds > 0.0 && (GetTickCount64() - start) >= static_cast<ULONGLONG>(seconds * 1000.0))
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user