// Safe-unhook coordination between a hook's removal (worker thread) and the detours still // running on the game's own threads (render / audio / window / input). // // The hazard: remove_*_hooks restores the hook and then frees the shared state the detour // touches (D3D device/context, the keyed-mutex texture, Vulkan read-back resources, the IPC // pointer). A capture detour mid-flight on the game's render thread then uses freed memory -> // use-after-free -> the game crashes. // // The removal sequence that prevents it: // 1. Disable the hook FIRST so no NEW detour can start. For a SafetyHook inline hook that's // `disable_for_removal(hook)` (disable, NOT `= {}` destroy): it restores the original bytes but // keeps the trampoline alive -- hooks are PERSISTENT, never destroyed mid-session, so an in-flight // detour about to call the trampoline never finds it freed (see hook_install.hpp). For a hook the // game reaches by a cached pointer (Vulkan present, the WNDPROC subclass) it's clearing an atomic // gate / restoring the window proc instead. // 2. drain() -- wait (bounded) for detour BODIES already running to finish, since the disable above // does NOT wait for the part of the detour that runs before it calls the trampoline. // 3. Only THEN free the shared state the detour was reading. // // Each detour wraps its whole body in a DetourGate::Guard (an RAII active-count). drain() spins // until that count reaches zero. Detours are microseconds to a few milliseconds, so this returns // almost immediately; the bound keeps a wedged game thread from hanging the worker. #pragma once #include #include namespace coop::hook { class DetourGate { public: // RAII: marks a detour body as in-flight for as long as it's on the stack. class Guard { public: explicit Guard(DetourGate& gate) : m_gate(gate) { m_gate.m_active.fetch_add(1, std::memory_order_acq_rel); } ~Guard() { m_gate.m_active.fetch_sub(1, std::memory_order_acq_rel); } Guard(const Guard&) = delete; Guard& operator=(const Guard&) = delete; private: DetourGate& m_gate; }; // Wait (bounded ~400 ms) for all in-flight detours to finish. Call AFTER the hook is // restored / the gate is closed, so no new detour can start -- otherwise this may never // reach zero on a busy render thread. // // We Sleep(1) *before* each zero-check (not after) to close an entry-window race: a thread can // have already jumped into the detour but not yet reached its Guard constructor (the few- // instruction prologue is unguarded), so m_active reads 0 even though a detour is about to run. // Returning then would free the state out from under it. With the hook disabled no NEW detour can // start, so any such thread reaches its Guard within nanoseconds; a 1 ms settle before concluding // "zero" lets it register. A backend presenting at thousands of frames/s hits this window // reliably, so checking before the first sleep is not safe. void drain() { for (int spins = 0; spins < 400; ++spins) { Sleep(1); if (m_active.load(std::memory_order_acquire) == 0) { return; } } } int active() const { return m_active.load(std::memory_order_acquire); } private: std::atomic m_active{0}; }; // Disable a SafetyHook inline hook as the first step of removal (restore the original bytes, keep the // trampoline alive for the drain). disable() returns a [[nodiscard]] std::expected: a failure leaves // the function patched while removal goes on to free the trampoline -- a use-after-free -- so it must // not be silently discarded. There's no clean recovery mid-unhook, but surface it so it's // diagnosable. Templated so this header needn't depend on SafetyHook (the type is deduced at the // call site, where it's already included). template void disable_for_removal(InlineHook& hook) { if (!hook.disable()) { OutputDebugStringA("coop: SafetyHook InlineHook::disable() failed during removal -- unhook may be unsafe\n"); } } } // namespace coop::hook