src/platform.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_PLATFORM_HPP | ||
| 2 | #define DETOURMODKIT_PLATFORM_HPP | ||
| 3 | |||
| 4 | #include "internal/diagnostics_population.hpp" | ||
| 5 | |||
| 6 | #include <windows.h> | ||
| 7 | |||
| 8 | namespace DetourModKit::detail | ||
| 9 | { | ||
| 10 | /** | ||
| 11 | * @brief Checks if the current thread holds the Windows loader lock. | ||
| 12 | * @details Reads the loader-lock CRITICAL_SECTION pointer from the PEB at the architecture-specific offset (0x110 | ||
| 13 | * on x64, 0xA0 on x86), then compares its OwningThread to the current thread id. Thread joins are unsafe | ||
| 14 | * while the loader lock is held (DllMain context), so this probe supplies the veto half of | ||
| 15 | * detail::blocking_teardown_permitted(), which every subsystem teardown reads. | ||
| 16 | * @note Supported Windows x86/x64 layouts keep the PEB and its loader-lock field readable for the process lifetime. | ||
| 17 | * The probe's PEB reads do not fault on those layouts. The probe validates only the loader-lock pointer target | ||
| 18 | * before it reads OwningThread. A null PEB, null pointer, or pointer outside committed readable memory returns | ||
| 19 | * true. A spurious true retains a detached thread and its module reference. A spurious false joins under the | ||
| 20 | * loader lock and deadlocks host unload. Therefore, uncertainty must produce true. | ||
| 21 | * @note This is a fail-closed diagnostic only. A true or indeterminate result can veto a join. A false result never | ||
| 22 | * authorizes a teardown wait. The explicit loader context or bootstrap worker grants that authority. See | ||
| 23 | * detail::teardown_caller_authorized. | ||
| 24 | * @return true if the current thread holds the loader lock, or if ownership cannot be determined. | ||
| 25 | */ | ||
| 26 | 16616 | [[nodiscard]] inline bool is_loader_lock_held_impl() noexcept | |
| 27 | { | ||
| 28 | #ifdef _WIN64 | ||
| 29 | // GCC 14 models mingw-w64's __readgsqword as an array access at address zero and flags -Warray-bounds | ||
| 30 | // under -O2. The read is a segment-register load, so the diagnostic is a false positive. | ||
| 31 | #if defined(__GNUC__) && !defined(__clang__) | ||
| 32 | #pragma GCC diagnostic push | ||
| 33 | #pragma GCC diagnostic ignored "-Warray-bounds" | ||
| 34 | #endif | ||
| 35 | 16616 | auto *peb = reinterpret_cast<char *>(__readgsqword(0x60)); | |
| 36 | #if defined(__GNUC__) && !defined(__clang__) | ||
| 37 | #pragma GCC diagnostic pop | ||
| 38 | #endif | ||
| 39 | 16616 | constexpr size_t LOADER_LOCK_OFFSET = 0x110; | |
| 40 | #else | ||
| 41 | auto *peb = reinterpret_cast<char *>(__readfsdword(0x30)); | ||
| 42 | constexpr size_t LOADER_LOCK_OFFSET = 0xA0; | ||
| 43 | #endif | ||
| 44 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 16616 times.
|
16616 | if (!peb) |
| 45 | ✗ | return true; | |
| 46 | |||
| 47 | 16616 | auto *cs = *reinterpret_cast<PCRITICAL_SECTION *>(peb + LOADER_LOCK_OFFSET); | |
| 48 |
1/2✗ Branch 6 → 7 not taken.
✓ Branch 6 → 8 taken 16616 times.
|
16616 | if (!cs) |
| 49 | ✗ | return true; | |
| 50 | |||
| 51 | // Check that the critical section uses committed, readable memory before the OwningThread read. A stale or | ||
| 52 | // corrupt slot value can fault that read. | ||
| 53 | 16616 | constexpr DWORD READABLE_PROTECT = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ | | |
| 54 | PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY; | ||
| 55 | 16616 | MEMORY_BASIC_INFORMATION mbi{}; | |
| 56 |
1/2✓ Branch 10 → 11 taken 16617 times.
✗ Branch 10 → 13 not taken.
|
33233 | if (VirtualQuery(cs, &mbi, sizeof(mbi)) != sizeof(mbi) || mbi.State != MEM_COMMIT || |
| 57 |
4/8✓ Branch 9 → 10 taken 16617 times.
✗ Branch 9 → 13 not taken.
✓ Branch 11 → 12 taken 16617 times.
✗ Branch 11 → 13 not taken.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 16617 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 16617 times.
|
33234 | (mbi.Protect & READABLE_PROTECT) == 0 || (mbi.Protect & (PAGE_GUARD | PAGE_NOACCESS)) != 0) |
| 58 | { | ||
| 59 | ✗ | return true; | |
| 60 | } | ||
| 61 | |||
| 62 | 16617 | return cs->OwningThread == reinterpret_cast<HANDLE>(static_cast<uintptr_t>(GetCurrentThreadId())); | |
| 63 | } | ||
| 64 | |||
| 65 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 66 | // Forces the probe's verdict so a proof can drive the "heuristic disagrees with the explicit context" case in both | ||
| 67 | // directions, which is the only way to show a false result never authorizes blocking teardown on its own. Defined | ||
| 68 | // in src/internal/lifecycle_context.cpp; set before the participating threads start and cleared after they join. | ||
| 69 | extern bool (*g_loader_lock_override)() noexcept; | ||
| 70 | #endif | ||
| 71 | |||
| 72 | /// Returns @ref is_loader_lock_held_impl, or the forced verdict when a test seam has overridden the probe. | ||
| 73 | 16640 | [[nodiscard]] inline bool is_loader_lock_held() noexcept | |
| 74 | { | ||
| 75 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 76 |
2/2✓ Branch 2 → 3 taken 23 times.
✓ Branch 2 → 4 taken 16617 times.
|
16640 | if (auto *const override_fn = g_loader_lock_override) |
| 77 | { | ||
| 78 | 23 | return override_fn(); | |
| 79 | } | ||
| 80 | #endif | ||
| 81 | 16617 | return is_loader_lock_held_impl(); | |
| 82 | } | ||
| 83 | |||
| 84 | /** | ||
| 85 | * @brief Tries to take a counted reference on the module DetourModKit is linked into. | ||
| 86 | * @details Bumps the module's loader reference count by one, the "bonus LoadLibrary on yourself" a DLL performs so | ||
| 87 | * it cannot be unmapped while it still has code running. The reference is counted, not pinned: a matching | ||
| 88 | * @ref release_module_ref (or a thread's FreeLibraryAndExitThread) balances it, so the module can still | ||
| 89 | * unload once every holder releases, unlike a GET_MODULE_HANDLE_EX_FLAG_PIN reference, which pins the | ||
| 90 | * module for the process lifetime and can never be released. A pin is also useless from a detach path (the | ||
| 91 | * loader refuses to pin a module that is already unloading), which is why this counted reference, taken | ||
| 92 | * while the module is live, is the correct primitive. | ||
| 93 | * | ||
| 94 | * The reference MUST be taken while the module is still fully loaded (before a background thread is | ||
| 95 | * created, or before a hook/callback is published) and NEVER from a DLL_PROCESS_DETACH/unload path. Once | ||
| 96 | * an explicit FreeLibrary has driven the count to zero the loader has already committed to unmapping the | ||
| 97 | * module, and a reference requested at that point does not abort the in-progress unload (GetModuleHandleEx | ||
| 98 | * cannot find a module that is unloading). A thread that needs its code to survive a caller's FreeLibrary | ||
| 99 | * therefore acquires this reference before the thread can run, not when it is told to stop. | ||
| 100 | * | ||
| 101 | * GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS identifies the module from a code address; omitting | ||
| 102 | * GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT is what makes the call take a reference (its default, | ||
| 103 | * count-incrementing behavior) rather than a bare identity lookup. | ||
| 104 | * @param reason Books the outstanding reference under diagnostics::module_pin_count until its release. The | ||
| 105 | * matching release must pass the same reason. | ||
| 106 | * @return The module handle to pass to a matching release, or nullptr without diagnostics when the reference could | ||
| 107 | * not be taken. | ||
| 108 | */ | ||
| 109 | 3247 | [[nodiscard]] inline HMODULE try_acquire_module_ref(diagnostics::ModulePinReason reason) noexcept | |
| 110 | { | ||
| 111 | 3247 | HMODULE module = nullptr; | |
| 112 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 3247 times.
|
3247 | if (!GetModuleHandleExW( |
| 113 | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, | ||
| 114 | reinterpret_cast<LPCWSTR>(&try_acquire_module_ref), | ||
| 115 | &module | ||
| 116 | )) | ||
| 117 | { | ||
| 118 | ✗ | return nullptr; | |
| 119 | } | ||
| 120 | 3247 | module_pin_observability::note_acquired(reason); | |
| 121 | 3247 | return module; | |
| 122 | } | ||
| 123 | |||
| 124 | /** | ||
| 125 | * @brief @ref try_acquire_module_ref with a debugger diagnostic on failure. | ||
| 126 | * @details Same acquisition contract, including the requirement to take the reference while the module is still | ||
| 127 | * fully loaded. Use this wherever a failure is worth reporting; use the plain try form on a path that must | ||
| 128 | * stay silent, such as bootstrap under the loader lock. | ||
| 129 | * @param reason Books the outstanding reference under diagnostics::module_pin_count until its release. | ||
| 130 | * @return The module handle, which the holder must balance with @ref release_module_ref (or, for a thread | ||
| 131 | * releasing its own reference as its final act, FreeLibraryAndExitThread); nullptr on failure, with | ||
| 132 | * GetLastError() preserved across the diagnostic. | ||
| 133 | */ | ||
| 134 | 3194 | [[nodiscard]] inline HMODULE acquire_module_ref(diagnostics::ModulePinReason reason) noexcept | |
| 135 | { | ||
| 136 | 3194 | const HMODULE module = try_acquire_module_ref(reason); | |
| 137 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 8 taken 3194 times.
|
3194 | if (module == nullptr) |
| 138 | { | ||
| 139 | // Preserve the failure code across the debug print: OutputDebugStringA may overwrite the thread's | ||
| 140 | // last-error value, and callers report GetLastError() after this returns nullptr. | ||
| 141 | ✗ | const DWORD last_error = GetLastError(); | |
| 142 | ✗ | OutputDebugStringA( | |
| 143 | "DetourModKit: acquire_module_ref failed; a background thread's code may be unmapped by " | ||
| 144 | "a caller's FreeLibrary.\n" | ||
| 145 | ); | ||
| 146 | ✗ | SetLastError(last_error); | |
| 147 | ✗ | return nullptr; | |
| 148 | } | ||
| 149 | 3194 | return module; | |
| 150 | } | ||
| 151 | |||
| 152 | /** | ||
| 153 | * @brief Releases a counted reference taken by @ref acquire_module_ref, from a thread OTHER than the one whose code | ||
| 154 | * the release might unmap. | ||
| 155 | * @details Call this after a background thread has been JOINED (its code is done running) to balance the | ||
| 156 | * start-of-thread acquire, so the module is no longer held mapped on this account. It is safe only as long | ||
| 157 | * as another reference on the module still exists (the host's own load reference, or another live | ||
| 158 | * worker's), so it can never be the terminal release that unmaps the module out from under the caller, | ||
| 159 | * which is still executing this module's code. A background thread must NOT release its OWN reference this | ||
| 160 | * way as its final act: it uses FreeLibraryAndExitThread so the FreeLibrary's return address is never in | ||
| 161 | * code the release just unmapped. A FreeLibraryAndExitThread caller first invokes | ||
| 162 | * module_pin_observability::note_released for its reason. | ||
| 163 | * @param module A handle returned by @ref acquire_module_ref. A null value changes no count. | ||
| 164 | * @param reason The reason passed to the matching acquire. | ||
| 165 | * The count decrements before FreeLibrary. | ||
| 166 | */ | ||
| 167 | 3900 | inline void release_module_ref(HMODULE module, diagnostics::ModulePinReason reason) noexcept | |
| 168 | { | ||
| 169 |
2/2✓ Branch 2 → 3 taken 3207 times.
✓ Branch 2 → 5 taken 693 times.
|
3900 | if (module != nullptr) |
| 170 | { | ||
| 171 | 3207 | module_pin_observability::note_released(reason); | |
| 172 | 3207 | FreeLibrary(module); | |
| 173 | } | ||
| 174 | 3900 | } | |
| 175 | |||
| 176 | } // namespace DetourModKit::detail | ||
| 177 | |||
| 178 | #endif // DETOURMODKIT_PLATFORM_HPP | ||
| 179 |