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:
2026-06-23 11:17:34 +02:00
parent 79582f9fa6
commit 0e58902438
4 changed files with 200 additions and 1 deletions

122
tests/detour_gate_test.cpp Normal file
View 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;
}