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 that holds this thread's MidEntryFrame chain head, or TLS_OUT_OF_INDEXES. | ||
| 89 | * @details The adapter uses it on arbitrary host threads, so it is a Win32 TLS index instead of `thread_local` | ||
| 90 | * (`[B-86]`). Each claimed slot owns the index, so a live slot always has a valid index, and the last | ||
| 91 | * released slot returns it. A retained route keeps its slot claimed, so its index stays reserved (proofs: | ||
| 92 | * `Lifecycle.MidEntryTlsIndexReturnsWithTheLastSlot`, `Lifecycle.MidEntryTlsIndexStaysWithARetainedSlot`). | ||
| 93 | * A frame store under it can still fail for an index past the TEB's inline slots. The adapter therefore | ||
| 94 | * counts an entry that it cannot record, and never assumes that entry absent. | ||
| 95 | */ | ||
| 96 | [[nodiscard]] DWORD mid_entry_tls_index() noexcept; | ||
| 97 | |||
| 98 | /// True when the calling thread is currently inside @p slot's adapter body. | ||
| 99 | [[nodiscard]] bool thread_is_inside_mid_adapter(const MidAdapterSlot &slot) noexcept; | ||
| 100 | |||
| 101 | /// Reports a contained user exception. Defined in src/internal/mid_hook_adapter.cpp, where the logger is visible. | ||
| 102 | void note_contained_mid_exception(MidAdapterSlot &slot) noexcept; | ||
| 103 | |||
| 104 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 105 | /** | ||
| 106 | * @brief Stable executable interval exposed by the backend route park. | ||
| 107 | * @note Test hosts that cannot include this header redeclare this enum, because including it would pull the | ||
| 108 | * backend into targets that deliberately carry no safetyhook include path. These values are therefore part | ||
| 109 | * of that cross-target contract: reordering an enumerator fails here rather than silently changing which | ||
| 110 | * interval a redeclaring host selects. | ||
| 111 | */ | ||
| 112 | enum class MidRouteParkStage : std::uint8_t | ||
| 113 | { | ||
| 114 | None, | ||
| 115 | BeforeAdapter, | ||
| 116 | AfterAdapter | ||
| 117 | }; | ||
| 118 | static_assert(static_cast<std::uint8_t>(MidRouteParkStage::None) == 0); | ||
| 119 | static_assert(static_cast<std::uint8_t>(MidRouteParkStage::BeforeAdapter) == 1); | ||
| 120 | static_assert(static_cast<std::uint8_t>(MidRouteParkStage::AfterAdapter) == 2); | ||
| 121 | |||
| 122 | /// Arms one backend-route park, or releases an existing park with None. | ||
| 123 | void set_mid_route_park_for_test(MidRouteParkStage stage) noexcept; | ||
| 124 | |||
| 125 | /// Reports whether the armed backend-route park was reached. | ||
| 126 | [[nodiscard]] bool mid_route_park_reached_for_test() noexcept; | ||
| 127 | |||
| 128 | /** | ||
| 129 | * @brief Fired inside the adapter after the fast-path live check and before the callback commit. | ||
| 130 | * @details The window between those two points is the only one the tombstone recheck below exists to close, and it | ||
| 131 | * is a pure thread race: an entrant that has already passed the fast-path check must still not run a | ||
| 132 | * callback if a rundown completes before it commits. No stress schedule reaches that instant reliably, so | ||
| 133 | * a test parks a thread here and runs the teardown to completion underneath it. | ||
| 134 | */ | ||
| 135 | extern void (*g_mid_adapter_precommit_probe)() noexcept; | ||
| 136 | |||
| 137 | /** | ||
| 138 | * @brief Native id of the thread whose entry-chain store must report failure, or 0 for none. | ||
| 139 | * @details A store into a reserved index past the TEB's inline slots is backed by a lazily heap-allocated | ||
| 140 | * expansion array, so it can fail on a thread that has never used a high index while the reservation | ||
| 141 | * itself stays valid. The untracked accounting that failure selects is what keeps a teardown from waiting | ||
| 142 | * on the very thread running it, and no host can provoke the heap state on demand. Keyed by thread id | ||
| 143 | * rather than held in thread_local storage because MinGW lowers thread_local to emutls, which would | ||
| 144 | * allocate on this exact callback path. | ||
| 145 | */ | ||
| 146 | extern std::atomic<std::uint32_t> g_mid_entry_store_failure_thread; | ||
| 147 | |||
| 148 | /// Counts stores the exact failure seam refused, so a proof cannot pass through the ordinary tracked path. | ||
| 149 | extern std::atomic<std::uint64_t> g_mid_entry_store_failure_hits; | ||
| 150 | |||
| 151 | /// Returns the pool index from the most recent successful claim, or MID_ADAPTER_CAPACITY before any claim. | ||
| 152 | [[nodiscard]] std::size_t last_claimed_mid_slot_for_test() noexcept; | ||
| 153 | |||
| 154 | /// Reports whether pool slot @p index is currently claimed. Out-of-range indices report false. | ||
| 155 | [[nodiscard]] bool mid_slot_claimed_for_test(std::size_t index) noexcept; | ||
| 156 | |||
| 157 | /** | ||
| 158 | * @brief Adjusts slot @p index's adapter-body entry count by @p delta. | ||
| 159 | * @details Stands in for an indefinitely parked adapter entrant. The drains read only this counter, so an | ||
| 160 | * injected entry is indistinguishable from a preempted thread inside the body. The caller must balance | ||
| 161 | * the adjustment. | ||
| 162 | */ | ||
| 163 | void adjust_mid_adapter_entries_for_test(std::size_t index, std::int32_t delta) noexcept; | ||
| 164 | #endif | ||
| 165 | |||
| 166 | /// The one store whose failure decides tracked versus untracked accounting for this entry. | ||
| 167 | 29480074 | [[nodiscard]] inline bool store_mid_entry(DWORD tls, MidEntryFrame *frame) noexcept | |
| 168 | { | ||
| 169 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 170 | 29480074 | if (g_mid_entry_store_failure_thread.load(std::memory_order_relaxed) == ::GetCurrentThreadId()) | |
| 171 | { | ||
| 172 | g_mid_entry_store_failure_hits.fetch_add(1, std::memory_order_relaxed); | ||
| 173 | 2 | return false; | |
| 174 | } | ||
| 175 | #endif | ||
| 176 | 29480072 | return ::TlsSetValue(tls, frame) != FALSE; | |
| 177 | } | ||
| 178 | |||
| 179 | /// The outcome kind of @ref claim_mid_adapter_slot. | ||
| 180 | enum class MidSlotClaimStatus : std::uint8_t | ||
| 181 | { | ||
| 182 | Claimed, | ||
| 183 | CapacityExhausted, | ||
| 184 | EntryIndexUnavailable | ||
| 185 | }; | ||
| 186 | |||
| 187 | /// The outcome of @ref claim_mid_adapter_slot. | ||
| 188 | struct MidSlotClaim | ||
| 189 | { | ||
| 190 | /// The claimed pool index, or @ref MID_ADAPTER_CAPACITY when the claim failed. | ||
| 191 | std::size_t index{MID_ADAPTER_CAPACITY}; | ||
| 192 | MidSlotClaimStatus status{MidSlotClaimStatus::CapacityExhausted}; | ||
| 193 | /// GetLastError() from the failed entry-index reservation. | ||
| 194 | DWORD system_error{ERROR_SUCCESS}; | ||
| 195 | }; | ||
| 196 | |||
| 197 | /// Takes ownership of a free slot and of the entry TLS index that its adapter records frames under. | ||
| 198 | [[nodiscard]] MidSlotClaim claim_mid_adapter_slot() noexcept; | ||
| 199 | |||
| 200 | /// Returns a drained slot and its entry-index ownership to the pool. Never call on a slot that is not run down. | ||
| 201 | void release_mid_adapter_slot(std::size_t index) noexcept; | ||
| 202 | |||
| 203 | /// The outcome of @ref run_down_mid_slot. | ||
| 204 | enum class MidRundown : std::uint8_t | ||
| 205 | { | ||
| 206 | /// No user callback is executing; teardown may proceed. | ||
| 207 | Drained, | ||
| 208 | /** | ||
| 209 | * @brief Waiting cannot be proven to terminate, so the caller must pin instead. | ||
| 210 | * @details Either the calling thread is itself inside the adapter (much the likelier cause), or an entrant | ||
| 211 | * lacks a record and cannot be ruled out as the caller. The two cases are not distinguished | ||
| 212 | * because the required action is the same. | ||
| 213 | */ | ||
| 214 | Unwaitable, | ||
| 215 | /// The bounded wait ended with a callback still in flight. The caller must pin. | ||
| 216 | Expired | ||
| 217 | }; | ||
| 218 | |||
| 219 | /** | ||
| 220 | * @brief Waits for every user callback committed before @p slot was tombstoned. | ||
| 221 | * @details New adapter entries can still arrive through a pinned backend. The live recheck prevents a callback | ||
| 222 | * commit. The function returns Unwaitable to avoid a self-wait. It returns Expired when the bounded wait | ||
| 223 | * ends with a callback still counted. | ||
| 224 | */ | ||
| 225 | [[nodiscard]] MidRundown run_down_mid_slot(MidAdapterSlot &slot) noexcept; | ||
| 226 | |||
| 227 | /** | ||
| 228 | * @brief Waits for every adapter body to leave after entry through the backend has stopped. | ||
| 229 | * @param slot The tombstoned slot to drain. | ||
| 230 | * @return The result is true when no entrant remains. It is false when the bounded wait expires with an occupied | ||
| 231 | * body. A false result never permits reclamation of the slot or stub. | ||
| 232 | * @note Backend route rundown separately spans the generated stub from its stable gateway through its stable exit | ||
| 233 | * thunk. This counter remains the slot-reuse authority for the DMK adapter body itself. | ||
| 234 | */ | ||
| 235 | [[nodiscard]] bool drain_mid_adapter_entries(MidAdapterSlot &slot) noexcept; | ||
| 236 | |||
| 237 | /** | ||
| 238 | * @brief The body every generated adapter shares. | ||
| 239 | * @details Enter, recheck, invoke, leave. The callback-counter/recheck pair and the tombstone/drain pair are a | ||
| 240 | * Dekker seam: both sides are seq_cst so at least one of them observes the other, which is what makes "no | ||
| 241 | * callback begins after rundown returns" hold without a lock on the callback path. | ||
| 242 | * | ||
| 243 | * The reference count is held across the WHOLE body, including the back-out, so a drained slot has no | ||
| 244 | * thread anywhere in this function and its contents may be recycled. | ||
| 245 | */ | ||
| 246 | 29481081 | inline void dispatch_mid_adapter(MidAdapterSlot &slot, safetyhook::Context &ctx) noexcept | |
| 247 | { | ||
| 248 | 29481081 | slot.adapter_entries.fetch_add(1, std::memory_order_seq_cst); | |
| 249 | 29481081 | if (slot.live.load(std::memory_order_seq_cst)) | |
| 250 | { | ||
| 251 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 252 | 29480075 | if (auto *probe = g_mid_adapter_precommit_probe) | |
| 253 | { | ||
| 254 | 1 | probe(); | |
| 255 | } | ||
| 256 | #endif | ||
| 257 | 29480075 | slot.callbacks_in_flight.fetch_add(1, std::memory_order_seq_cst); | |
| 258 | 29480075 | if (slot.live.load(std::memory_order_seq_cst)) | |
| 259 | { | ||
| 260 | 29480074 | if (const hook::MidHookFn detour = slot.detour.load(std::memory_order_acquire)) | |
| 261 | { | ||
| 262 | 29480074 | const DWORD tls = mid_entry_tls_index(); | |
| 263 | 29480074 | MidEntryFrame frame{&slot, nullptr}; | |
| 264 | 29480074 | bool tracked = false; | |
| 265 | 29480074 | if (tls != TLS_OUT_OF_INDEXES) | |
| 266 | { | ||
| 267 | 29480074 | frame.prev = static_cast<MidEntryFrame *>(::TlsGetValue(tls)); | |
| 268 | 29480074 | tracked = store_mid_entry(tls, &frame); | |
| 269 | } | ||
| 270 | 29480074 | if (!tracked) | |
| 271 | { | ||
| 272 | // The chain walk cannot see this thread, so a teardown must not conclude it is absent: a wrong | ||
| 273 | // "no" makes the rundown wait on the very thread running it, while a wrong "yes" only pins. | ||
| 274 | // Counted rather than made sticky so the pool recovers once the entry leaves. | ||
| 275 | 2 | slot.untracked_entries.fetch_add(1, std::memory_order_seq_cst); | |
| 276 | } | ||
| 277 | try | ||
| 278 | { | ||
| 279 | // The one boundary that must never let a throw reach the backend's generated mid stub. The | ||
| 280 | // routed gateway, wrapper, and exit thunk are all described by registered unwind data, but the | ||
| 281 | // stub between them adjusts RSP dynamically and is not, so an escaping exception unwinds | ||
| 282 | // through a frame the platform cannot describe: a host crash rather than an error. | ||
| 283 | 29480074 | detour(reinterpret_cast<hook::MidContext &>(ctx)); | |
| 284 | } | ||
| 285 | 17 | catch (...) | |
| 286 | { | ||
| 287 | 17 | note_contained_mid_exception(slot); | |
| 288 | 17 | } | |
| 289 | 29480074 | if (tracked) | |
| 290 | { | ||
| 291 | 29480072 | (void)::TlsSetValue(tls, frame.prev); | |
| 292 | } | ||
| 293 | else | ||
| 294 | { | ||
| 295 | 2 | slot.untracked_entries.fetch_sub(1, std::memory_order_seq_cst); | |
| 296 | } | ||
| 297 | } | ||
| 298 | } | ||
| 299 | 29480075 | slot.callbacks_in_flight.fetch_sub(1, std::memory_order_seq_cst); | |
| 300 | } | ||
| 301 | 29481081 | slot.adapter_entries.fetch_sub(1, std::memory_order_seq_cst); | |
| 302 | 29481081 | } | |
| 303 | |||
| 304 | /** | ||
| 305 | * @brief The generated per-hook adapter. | ||
| 306 | * @details This IS the backend's destination: a genuine `void(safetyhook::Context&)`, so the backend's call through | ||
| 307 | * it is an ordinary call of a function of its own type. | ||
| 308 | */ | ||
| 309 | 29481081 | template <std::size_t Index> void mid_adapter(safetyhook::Context &ctx) noexcept | |
| 310 | { | ||
| 311 | 29481081 | dispatch_mid_adapter(mid_adapter_slots()[Index], ctx); | |
| 312 | 29481081 | } | |
| 313 | |||
| 314 | template <std::size_t... Indices> | ||
| 315 | [[nodiscard]] constexpr std::array<safetyhook::MidHookFn, sizeof...(Indices)> | ||
| 316 | make_mid_adapter_table(std::index_sequence<Indices...>) noexcept | ||
| 317 | { | ||
| 318 | // Dropping noexcept from each adapter pointer's type is a standard implicit conversion. | ||
| 319 | return {&mid_adapter<Indices>...}; | ||
| 320 | } | ||
| 321 | |||
| 322 | /// Adapter addresses in the exact type the backend consumes. | ||
| 323 | extern constinit const std::array<safetyhook::MidHookFn, MID_ADAPTER_CAPACITY> MID_ADAPTER_TABLE; | ||
| 324 | } // namespace DetourModKit::detail | ||
| 325 | |||
| 326 | #endif // DETOURMODKIT_INTERNAL_MID_HOOK_ADAPTER_HPP | ||
| 327 |