src/internal/mid_hook_adapter.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_INTERNAL_MID_HOOK_ADAPTER_HPP | ||
| 2 | #define DETOURMODKIT_INTERNAL_MID_HOOK_ADAPTER_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file internal/mid_hook_adapter.hpp | ||
| 6 | * @brief The backend-typed mid-hook dispatch pool: per-hook adapters, exception containment, and rundown. | ||
| 7 | * @details A mid hook's callback is reached from a hand-emitted assembly stub that the backend calls directly. This | ||
| 8 | * pool places a DMK frame between the stub and the user callback, where exceptions can be contained and | ||
| 9 | * callback entries counted. | ||
| 10 | * | ||
| 11 | * The backend's destination type is `void(*)(safetyhook::Context&)` and carries no user-data parameter, so the | ||
| 12 | * only way to reach a per-hook callback from an exactly typed function is to give each hook its own function. | ||
| 13 | * `mid_adapter<I>` is that function, and slot @p I supplies the identity the signature has no room to pass. | ||
| 14 | * | ||
| 15 | * The adapter's exact backend type requires no function-pointer conversion or related warning suppression. | ||
| 16 | * | ||
| 17 | * The opaque hook::MidContext is recovered by a REFERENCE cast inside the adapter, which is the same | ||
| 18 | * pass-through the public accessors in src/hook_mid_context.cpp perform and is sound because MidContext is | ||
| 19 | * forever incomplete and is only ever the Context the backend passed. | ||
| 20 | * | ||
| 21 | * Only the hook sibling TUs (src/hook.cpp and src/internal/mid_hook_adapter.cpp) include this header, so the | ||
| 22 | * backend stays confined to that island. | ||
| 23 | */ | ||
| 24 | |||
| 25 | #include "DetourModKit/hook.hpp" | ||
| 26 | |||
| 27 | #include "platform.hpp" | ||
| 28 | |||
| 29 | #include <safetyhook.hpp> | ||
| 30 | |||
| 31 | #include <array> | ||
| 32 | #include <atomic> | ||
| 33 | #include <cstddef> | ||
| 34 | #include <cstdint> | ||
| 35 | #include <utility> | ||
| 36 | |||
| 37 | namespace DetourModKit::detail | ||
| 38 | { | ||
| 39 | /** | ||
| 40 | * @brief The number of DMK-managed mid hooks that may exist at one time. | ||
| 41 | * @details Each slot costs one generated adapter function, so the pool is a compile-time set. Exhaustion is | ||
| 42 | * reported as ErrorCode::MidHookCapacityExhausted rather than degraded into an untyped failure. | ||
| 43 | */ | ||
| 44 | inline constexpr std::size_t MID_ADAPTER_CAPACITY = 64; | ||
| 45 | |||
| 46 | /** | ||
| 47 | * @brief Process-lifetime dispatch state for one mid hook. | ||
| 48 | * @details Never destroyed. A thread that has already loaded a slot pointer may still be inside its adapter when | ||
| 49 | * teardown releases the slot, so the storage must outlive every hook that ever used it; only the slot's | ||
| 50 | * CONTENTS are recycled, and `claimed` is released only once `adapter_entries` reaches zero. | ||
| 51 | */ | ||
| 52 | struct MidAdapterSlot | ||
| 53 | { | ||
| 54 | /// Owns the slot for one hook's lifetime. Released only after a witnessed drain. | ||
| 55 | std::atomic<bool> claimed{false}; | ||
| 56 | /// The rundown tombstone: false means no further user callback may begin through this slot. | ||
| 57 | std::atomic<bool> live{false}; | ||
| 58 | /// Threads currently inside this slot's adapter body, including ones that back out at the tombstone. | ||
| 59 | std::atomic<std::uint32_t> adapter_entries{0}; | ||
| 60 | /// User callbacks that passed the tombstone recheck and have not returned. | ||
| 61 | std::atomic<std::uint32_t> callbacks_in_flight{0}; | ||
| 62 | /// Callbacks whose thread could not be recorded in the entry chain, so self-entry cannot be disproven. | ||
| 63 | std::atomic<std::uint32_t> untracked_entries{0}; | ||
| 64 | /// The user callback. Only ever read by a thread that has entered and observed `live`. | ||
| 65 | std::atomic<hook::MidHookFn> detour{nullptr}; | ||
| 66 | /// Counts user exceptions contained at this boundary, for diagnostics. | ||
| 67 | std::atomic<std::uint64_t> contained_exceptions{0}; | ||
| 68 | /// The hooked address, carried here so a containment report can name the site without touching Impl storage. | ||
| 69 | std::atomic<std::uintptr_t> target{0}; | ||
| 70 | }; | ||
| 71 | |||
| 72 | /// The pool. Namespace-scope storage with no destructor, by the same never-destroyed discipline the ledger uses. | ||
| 73 | [[nodiscard]] MidAdapterSlot *mid_adapter_slots() noexcept; | ||
| 74 | |||
| 75 | /** | ||
| 76 | * @brief One frame of the current thread's mid-adapter entry chain. | ||
| 77 | * @details Lives on the adapter's own stack, so maintaining the chain allocates nothing. It exists so teardown can | ||
| 78 | * answer "is THIS thread inside THIS slot" exactly, which is what keeps a hook destroyed from inside its | ||
| 79 | * own callback from waiting on itself forever. | ||
| 80 | */ | ||
| 81 | struct MidEntryFrame | ||
| 82 | { | ||
| 83 | const MidAdapterSlot *slot{nullptr}; | ||
| 84 | MidEntryFrame *prev{nullptr}; | ||
| 85 | }; | ||
| 86 | |||
| 87 | /** | ||
| 88 | * @brief The Win32 TLS index holding this thread's MidEntryFrame chain head. | ||
| 89 | * @details Win32 TLS rather than thread_local: MinGW lowers thread_local to __emutls_get_address, which allocates | ||
| 90 | * on every thread's first touch and takes a process-wide lock to do it, inside a callback reached from a | ||
| 91 | * hooked function on an arbitrary host thread. A reserved Win32 index costs a TEB slot read instead. | ||
| 92 | * Reserved by hook::mid_at before any adapter of its slot can run, so a live slot always has a valid | ||
| 93 | * index. Recording a frame under it can still fail (an index past the TEB's inline slots is backed by a | ||
| 94 | * lazily heap-allocated expansion array), which is why an entry that cannot be recorded is counted rather | ||
| 95 | * than assumed absent. | ||
| 96 | */ | ||
| 97 | [[nodiscard]] std::atomic<DWORD> &mid_entry_tls_index() noexcept; | ||
| 98 | |||
| 99 | /// Reserves the TLS index once. Returns false if the process has no index to give. | ||
| 100 | [[nodiscard]] bool ensure_mid_entry_tls() noexcept; | ||
| 101 | |||
| 102 | /// True when the calling thread is currently inside @p slot's adapter body. | ||
| 103 | [[nodiscard]] bool thread_is_inside_mid_adapter(const MidAdapterSlot &slot) noexcept; | ||
| 104 | |||
| 105 | /// Reports a contained user exception. Defined in src/internal/mid_hook_adapter.cpp, where the logger is visible. | ||
| 106 | void note_contained_mid_exception(MidAdapterSlot &slot) noexcept; | ||
| 107 | |||
| 108 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 109 | /** | ||
| 110 | * @brief Stable executable interval exposed by the backend route park. | ||
| 111 | * @note Test hosts that cannot include this header redeclare this enum, because including it would pull the | ||
| 112 | * backend into targets that deliberately carry no safetyhook include path. These values are therefore part | ||
| 113 | * of that cross-target contract: reordering an enumerator fails here rather than silently changing which | ||
| 114 | * interval a redeclaring host selects. | ||
| 115 | */ | ||
| 116 | enum class MidRouteParkStage : std::uint8_t | ||
| 117 | { | ||
| 118 | None, | ||
| 119 | BeforeAdapter, | ||
| 120 | AfterAdapter | ||
| 121 | }; | ||
| 122 | static_assert(static_cast<std::uint8_t>(MidRouteParkStage::None) == 0); | ||
| 123 | static_assert(static_cast<std::uint8_t>(MidRouteParkStage::BeforeAdapter) == 1); | ||
| 124 | static_assert(static_cast<std::uint8_t>(MidRouteParkStage::AfterAdapter) == 2); | ||
| 125 | |||
| 126 | /// Arms one backend-route park, or releases an existing park with None. | ||
| 127 | void set_mid_route_park_for_test(MidRouteParkStage stage) noexcept; | ||
| 128 | |||
| 129 | /// Reports whether the armed backend-route park was reached. | ||
| 130 | [[nodiscard]] bool mid_route_park_reached_for_test() noexcept; | ||
| 131 | |||
| 132 | /** | ||
| 133 | * @brief Fired inside the adapter after the fast-path live check and before the callback commit. | ||
| 134 | * @details The window between those two points is the only one the tombstone recheck below exists to close, and it | ||
| 135 | * is a pure thread race: an entrant that has already passed the fast-path check must still not run a | ||
| 136 | * callback if a rundown completes before it commits. No stress schedule reaches that instant reliably, so | ||
| 137 | * a test parks a thread here and runs the teardown to completion underneath it. | ||
| 138 | */ | ||
| 139 | extern void (*g_mid_adapter_precommit_probe)() noexcept; | ||
| 140 | |||
| 141 | /** | ||
| 142 | * @brief Native id of the thread whose entry-chain store must report failure, or 0 for none. | ||
| 143 | * @details A store into a reserved index past the TEB's inline slots is backed by a lazily heap-allocated | ||
| 144 | * expansion array, so it can fail on a thread that has never used a high index while the reservation | ||
| 145 | * itself stays valid. The untracked accounting that failure selects is what keeps a teardown from waiting | ||
| 146 | * on the very thread running it, and no host can provoke the heap state on demand. Keyed by thread id | ||
| 147 | * rather than held in thread_local storage because MinGW lowers thread_local to emutls, which would | ||
| 148 | * allocate on this exact callback path. | ||
| 149 | */ | ||
| 150 | extern std::atomic<std::uint32_t> g_mid_entry_store_failure_thread; | ||
| 151 | |||
| 152 | /// Counts stores the exact failure seam refused, so a proof cannot pass through the ordinary tracked path. | ||
| 153 | extern std::atomic<std::uint64_t> g_mid_entry_store_failure_hits; | ||
| 154 | |||
| 155 | /// Returns the pool index from the most recent successful claim, or MID_ADAPTER_CAPACITY before any claim. | ||
| 156 | [[nodiscard]] std::size_t last_claimed_mid_slot_for_test() noexcept; | ||
| 157 | |||
| 158 | /// Reports whether pool slot @p index is currently claimed. Out-of-range indices report false. | ||
| 159 | [[nodiscard]] bool mid_slot_claimed_for_test(std::size_t index) noexcept; | ||
| 160 | |||
| 161 | /** | ||
| 162 | * @brief Adjusts slot @p index's adapter-body entry count by @p delta. | ||
| 163 | * @details Stands in for an indefinitely parked adapter entrant. The drains read only this counter, so an | ||
| 164 | * injected entry is indistinguishable from a preempted thread inside the body. The caller must balance | ||
| 165 | * the adjustment. | ||
| 166 | */ | ||
| 167 | void adjust_mid_adapter_entries_for_test(std::size_t index, std::int32_t delta) noexcept; | ||
| 168 | #endif | ||
| 169 | |||
| 170 | /// The one store whose failure decides tracked versus untracked accounting for this entry. | ||
| 171 | 60 | [[nodiscard]] inline bool store_mid_entry(DWORD tls, MidEntryFrame *frame) noexcept | |
| 172 | { | ||
| 173 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 174 |
2/2✓ Branch 10 → 11 taken 2 times.
✓ Branch 10 → 14 taken 58 times.
|
60 | if (g_mid_entry_store_failure_thread.load(std::memory_order_relaxed) == ::GetCurrentThreadId()) |
| 175 | { | ||
| 176 | g_mid_entry_store_failure_hits.fetch_add(1, std::memory_order_relaxed); | ||
| 177 | 2 | return false; | |
| 178 | } | ||
| 179 | #endif | ||
| 180 | 58 | return ::TlsSetValue(tls, frame) != FALSE; | |
| 181 | } | ||
| 182 | |||
| 183 | /// Takes ownership of a free slot, or returns @ref MID_ADAPTER_CAPACITY when the pool is full. | ||
| 184 | [[nodiscard]] std::size_t claim_mid_adapter_slot() noexcept; | ||
| 185 | |||
| 186 | /// Returns a drained slot to the pool. Never call on a slot that has not been run down. | ||
| 187 | void release_mid_adapter_slot(std::size_t index) noexcept; | ||
| 188 | |||
| 189 | /// The outcome of @ref run_down_mid_slot. | ||
| 190 | enum class MidRundown : std::uint8_t | ||
| 191 | { | ||
| 192 | /// No user callback is executing; teardown may proceed. | ||
| 193 | Drained, | ||
| 194 | /** | ||
| 195 | * @brief Waiting cannot be proven to terminate, so the caller must pin instead. | ||
| 196 | * @details Either the calling thread is itself inside the adapter (much the likelier cause), or an entrant | ||
| 197 | * lacks a record and cannot be ruled out as the caller. The two cases are not distinguished | ||
| 198 | * because the required action is the same. | ||
| 199 | */ | ||
| 200 | Unwaitable, | ||
| 201 | /// The bounded wait ended with a callback still in flight. The caller must pin. | ||
| 202 | Expired | ||
| 203 | }; | ||
| 204 | |||
| 205 | /** | ||
| 206 | * @brief Waits for every user callback committed before @p slot was tombstoned. | ||
| 207 | * @details New adapter entries can still arrive through a pinned backend. The live recheck prevents a callback | ||
| 208 | * commit. The function returns Unwaitable to avoid a self-wait. It returns Expired when the bounded wait | ||
| 209 | * ends with a callback still counted. | ||
| 210 | */ | ||
| 211 | [[nodiscard]] MidRundown run_down_mid_slot(MidAdapterSlot &slot) noexcept; | ||
| 212 | |||
| 213 | /** | ||
| 214 | * @brief Waits for every adapter body to leave after entry through the backend has stopped. | ||
| 215 | * @param slot The tombstoned slot to drain. | ||
| 216 | * @return The result is true when no entrant remains. It is false when the bounded wait expires with an occupied | ||
| 217 | * body. A false result never permits reclamation of the slot or stub. | ||
| 218 | * @note Backend route rundown separately spans the generated stub from its stable gateway through its stable exit | ||
| 219 | * thunk. This counter remains the slot-reuse authority for the DMK adapter body itself. | ||
| 220 | */ | ||
| 221 | [[nodiscard]] bool drain_mid_adapter_entries(MidAdapterSlot &slot) noexcept; | ||
| 222 | |||
| 223 | /** | ||
| 224 | * @brief The body every generated adapter shares. | ||
| 225 | * @details Enter, recheck, invoke, leave. The callback-counter/recheck pair and the tombstone/drain pair are a | ||
| 226 | * Dekker seam: both sides are seq_cst so at least one of them observes the other, which is what makes "no | ||
| 227 | * callback begins after rundown returns" hold without a lock on the callback path. | ||
| 228 | * | ||
| 229 | * The reference count is held across the WHOLE body, including the back-out, so a drained slot has no | ||
| 230 | * thread anywhere in this function and its contents may be recycled. | ||
| 231 | */ | ||
| 232 | 1066 | inline void dispatch_mid_adapter(MidAdapterSlot &slot, safetyhook::Context &ctx) noexcept | |
| 233 | { | ||
| 234 | 1066 | slot.adapter_entries.fetch_add(1, std::memory_order_seq_cst); | |
| 235 |
2/2✓ Branch 5 → 6 taken 61 times.
✓ Branch 5 → 39 taken 1005 times.
|
1066 | if (slot.live.load(std::memory_order_seq_cst)) |
| 236 | { | ||
| 237 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 238 |
2/2✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 60 times.
|
61 | if (auto *probe = g_mid_adapter_precommit_probe) |
| 239 | { | ||
| 240 | 1 | probe(); | |
| 241 | } | ||
| 242 | #endif | ||
| 243 | 61 | slot.callbacks_in_flight.fetch_add(1, std::memory_order_seq_cst); | |
| 244 |
2/2✓ Branch 11 → 12 taken 60 times.
✓ Branch 11 → 36 taken 1 time.
|
61 | if (slot.live.load(std::memory_order_seq_cst)) |
| 245 | { | ||
| 246 |
1/2✓ Branch 13 → 14 taken 60 times.
✗ Branch 13 → 36 not taken.
|
60 | if (const hook::MidHookFn detour = slot.detour.load(std::memory_order_acquire)) |
| 247 | { | ||
| 248 | 60 | const DWORD tls = mid_entry_tls_index().load(std::memory_order_acquire); | |
| 249 | 60 | MidEntryFrame frame{&slot, nullptr}; | |
| 250 | 60 | bool tracked = false; | |
| 251 |
1/2✓ Branch 22 → 23 taken 60 times.
✗ Branch 22 → 25 not taken.
|
60 | if (tls != TLS_OUT_OF_INDEXES) |
| 252 | { | ||
| 253 | 60 | frame.prev = static_cast<MidEntryFrame *>(::TlsGetValue(tls)); | |
| 254 | 60 | tracked = store_mid_entry(tls, &frame); | |
| 255 | } | ||
| 256 |
2/2✓ Branch 25 → 26 taken 2 times.
✓ Branch 25 → 29 taken 58 times.
|
60 | if (!tracked) |
| 257 | { | ||
| 258 | // The chain walk cannot see this thread, so a teardown must not conclude it is absent: a wrong | ||
| 259 | // "no" makes the rundown wait on the very thread running it, while a wrong "yes" only pins. | ||
| 260 | // Counted rather than made sticky so the pool recovers once the entry leaves. | ||
| 261 | 2 | slot.untracked_entries.fetch_add(1, std::memory_order_seq_cst); | |
| 262 | } | ||
| 263 | try | ||
| 264 | { | ||
| 265 | // The one boundary that must never let a throw reach the backend's generated mid stub. The | ||
| 266 | // routed gateway, wrapper, and exit thunk are all described by registered unwind data, but the | ||
| 267 | // stub between them adjusts RSP dynamically and is not, so an escaping exception unwinds | ||
| 268 | // through a frame the platform cannot describe: a host crash rather than an error. | ||
| 269 |
2/2✓ Branch 29 → 30 taken 43 times.
✓ Branch 29 → 42 taken 17 times.
|
60 | detour(reinterpret_cast<hook::MidContext &>(ctx)); |
| 270 | } | ||
| 271 | 17 | catch (...) | |
| 272 | { | ||
| 273 | 17 | note_contained_mid_exception(slot); | |
| 274 | 17 | } | |
| 275 |
2/2✓ Branch 30 → 31 taken 58 times.
✓ Branch 30 → 32 taken 2 times.
|
60 | if (tracked) |
| 276 | { | ||
| 277 | 58 | (void)::TlsSetValue(tls, frame.prev); | |
| 278 | } | ||
| 279 | else | ||
| 280 | { | ||
| 281 | 2 | slot.untracked_entries.fetch_sub(1, std::memory_order_seq_cst); | |
| 282 | } | ||
| 283 | } | ||
| 284 | } | ||
| 285 | 61 | slot.callbacks_in_flight.fetch_sub(1, std::memory_order_seq_cst); | |
| 286 | } | ||
| 287 | 1066 | slot.adapter_entries.fetch_sub(1, std::memory_order_seq_cst); | |
| 288 | 1066 | } | |
| 289 | |||
| 290 | /** | ||
| 291 | * @brief The generated per-hook adapter. | ||
| 292 | * @details This IS the backend's destination: a genuine `void(safetyhook::Context&)`, so the backend's call through | ||
| 293 | * it is an ordinary call of a function of its own type. | ||
| 294 | */ | ||
| 295 | 1066 | template <std::size_t Index> void mid_adapter(safetyhook::Context &ctx) noexcept | |
| 296 | { | ||
| 297 | 1066 | dispatch_mid_adapter(mid_adapter_slots()[Index], ctx); | |
| 298 | 1066 | } | |
| 299 | |||
| 300 | template <std::size_t... Indices> | ||
| 301 | [[nodiscard]] constexpr std::array<safetyhook::MidHookFn, sizeof...(Indices)> | ||
| 302 | make_mid_adapter_table(std::index_sequence<Indices...>) noexcept | ||
| 303 | { | ||
| 304 | // Dropping noexcept from each adapter pointer's type is a standard implicit conversion. | ||
| 305 | return {&mid_adapter<Indices>...}; | ||
| 306 | } | ||
| 307 | |||
| 308 | /// Adapter addresses in the exact type the backend consumes. | ||
| 309 | extern constinit const std::array<safetyhook::MidHookFn, MID_ADAPTER_CAPACITY> MID_ADAPTER_TABLE; | ||
| 310 | } // namespace DetourModKit::detail | ||
| 311 | |||
| 312 | #endif // DETOURMODKIT_INTERNAL_MID_HOOK_ADAPTER_HPP | ||
| 313 |