Run clang-format (the repo's .clang-format: LLVM base, 120 cols, tabs, Allman functions) over every source file so the tree is formatter-clean. Whitespace only -- no behavior change; full x64 + x86 suites pass. Also set SortIncludes: false in .clang-format. Windows include order is load-bearing (windows.h must precede tlhelp32.h / mmreg.h / xinput.h / dinput.h; winsock2.h must precede windows.h), and the default alphabetical sort reorders tlhelp32.h ahead of windows.h -- a build break. Leaving order alone keeps the manual, correct grouping.
87 lines
4.0 KiB
C++
87 lines
4.0 KiB
C++
// 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 <atomic>
|
|
|
|
#include <windows.h>
|
|
|
|
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<int> 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 <class InlineHook>
|
|
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
|