src/session.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #include "DetourModKit/session.hpp" | ||
| 2 | |||
| 3 | #include "DetourModKit/config.hpp" | ||
| 4 | #include "DetourModKit/diagnostics.hpp" | ||
| 5 | #include "DetourModKit/input.hpp" | ||
| 6 | #include "DetourModKit/logger.hpp" | ||
| 7 | #include "DetourModKit/memory.hpp" | ||
| 8 | |||
| 9 | #include "internal/config_reload_gate.hpp" | ||
| 10 | #include "internal/diagnostics_population.hpp" | ||
| 11 | #include "internal/input_binding_lifecycle.hpp" | ||
| 12 | #include "internal/input_delivery_scope.hpp" | ||
| 13 | #include "internal/lifecycle_context.hpp" | ||
| 14 | #include "internal/memory_guarded.hpp" | ||
| 15 | #include "platform.hpp" | ||
| 16 | |||
| 17 | #include <windows.h> | ||
| 18 | |||
| 19 | #include <algorithm> | ||
| 20 | #include <array> | ||
| 21 | #include <atomic> | ||
| 22 | #include <chrono> | ||
| 23 | #include <climits> | ||
| 24 | #include <cstdint> | ||
| 25 | #include <cwchar> | ||
| 26 | #include <exception> | ||
| 27 | #include <new> | ||
| 28 | #include <optional> | ||
| 29 | #include <string_view> | ||
| 30 | #include <type_traits> | ||
| 31 | #include <utility> | ||
| 32 | |||
| 33 | namespace DetourModKit | ||
| 34 | { | ||
| 35 | namespace detail | ||
| 36 | { | ||
| 37 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 38 | void (*g_session_configure_test_hook)() = nullptr; | ||
| 39 | #endif | ||
| 40 | |||
| 41 | struct SessionBootstrapAccess | ||
| 42 | { | ||
| 43 | 52 | [[nodiscard]] static Session make(void *instance_mutex) noexcept { return Session(instance_mutex); } | |
| 44 | }; | ||
| 45 | } // namespace detail | ||
| 46 | |||
| 47 | namespace | ||
| 48 | { | ||
| 49 | // Static bootstrap machinery (the async DllMain path only) | ||
| 50 | // The synchronous Session::start path touches none of these: it returns a Session the caller holds directly. | ||
| 51 | // These statics exist only to host a Session on a worker thread across a DllMain attach/detach pair. | ||
| 52 | // request_shutdown() may signal from any thread while a control thread retires the event. The high bit closes | ||
| 53 | // admission; the remaining bits count callers that may already have loaded the handle. | ||
| 54 | constexpr std::uint64_t SHUTDOWN_EVENT_RETIRED = std::uint64_t{1} << 63; | ||
| 55 | constexpr std::uint64_t SHUTDOWN_EVENT_READER_MASK = ~SHUTDOWN_EVENT_RETIRED; | ||
| 56 | constexpr size_t SHUTDOWN_EVENT_DRAIN_YIELDS = 1024; | ||
| 57 | std::atomic<HANDLE> s_shutdown_event{nullptr}; | ||
| 58 | std::atomic<std::uint64_t> s_shutdown_event_access{SHUTDOWN_EVENT_RETIRED}; | ||
| 59 | static_assert(std::atomic<std::uint64_t>::is_always_lock_free); | ||
| 60 | HANDLE s_worker_thread = nullptr; | ||
| 61 | |||
| 62 | enum class BootstrapState : std::uint8_t | ||
| 63 | { | ||
| 64 | Drained, | ||
| 65 | Starting, | ||
| 66 | Ready, | ||
| 67 | Draining, | ||
| 68 | Detached | ||
| 69 | }; | ||
| 70 | |||
| 71 | // The single admission authority for the bootstrap statics, and the serializer for the one terminal action on | ||
| 72 | // them, without taking a lock from DllMain. Every transition into and out of Drained brackets the whole of the | ||
| 73 | // publication or retirement it names, so no other static may be used to decide admission: a drain nulls the | ||
| 74 | // worker handle and the event well before it has finished retiring the callback and the module identity. | ||
| 75 | std::atomic<BootstrapState> s_bootstrap_state{BootstrapState::Drained}; | ||
| 76 | |||
| 77 | constexpr size_t BOOTSTRAP_TEXT_CAPACITY = 32768; | ||
| 78 | |||
| 79 | struct BootstrapLoggerInfo | ||
| 80 | { | ||
| 81 | std::array<char, BOOTSTRAP_TEXT_CAPACITY> name{}; | ||
| 82 | std::array<char, BOOTSTRAP_TEXT_CAPACITY> log_file{}; | ||
| 83 | size_t name_size{0}; | ||
| 84 | size_t log_file_size{0}; | ||
| 85 | size_t queue_capacity{DEFAULT_QUEUE_CAPACITY}; | ||
| 86 | size_t batch_size{DEFAULT_BATCH_SIZE}; | ||
| 87 | std::chrono::milliseconds flush_interval{DEFAULT_FLUSH_INTERVAL}; | ||
| 88 | OverflowPolicy overflow_policy{OverflowPolicy::DropOldest}; | ||
| 89 | size_t spin_backoff_iterations{DEFAULT_SPIN_BACKOFF_ITERATIONS}; | ||
| 90 | std::chrono::milliseconds block_timeout_ms{16}; | ||
| 91 | size_t block_max_spin_iterations{1000}; | ||
| 92 | LogOpenMode log_open_mode{LogOpenMode::Truncate}; | ||
| 93 | LogSourceStampMode log_source_stamp_mode{}; | ||
| 94 | |||
| 95 | 55 | [[nodiscard]] bool stage(const ModInfo &info) noexcept | |
| 96 | { | ||
| 97 | 164 | if (info.name.size() > name.size() || info.log_file.size() > log_file.size()) | |
| 98 | { | ||
| 99 | 1 | return false; | |
| 100 | } | ||
| 101 | |||
| 102 | 54 | std::copy(info.name.begin(), info.name.end(), name.begin()); | |
| 103 | 54 | std::copy(info.log_file.begin(), info.log_file.end(), log_file.begin()); | |
| 104 | 54 | name_size = info.name.size(); | |
| 105 | 54 | log_file_size = info.log_file.size(); | |
| 106 | 54 | queue_capacity = info.log.queue_capacity; | |
| 107 | 54 | batch_size = info.log.batch_size; | |
| 108 | 54 | flush_interval = info.log.flush_interval; | |
| 109 | 54 | overflow_policy = info.log.overflow_policy; | |
| 110 | 54 | spin_backoff_iterations = info.log.spin_backoff_iterations; | |
| 111 | 54 | block_timeout_ms = info.log.block_timeout_ms; | |
| 112 | 54 | block_max_spin_iterations = info.log.block_max_spin_iterations; | |
| 113 | 54 | log_open_mode = info.log_open_mode; | |
| 114 | 54 | log_source_stamp_mode = info.log_source_stamp_mode; | |
| 115 | 54 | return true; | |
| 116 | } | ||
| 117 | |||
| 118 | 52 | [[nodiscard]] std::string_view name_view() const noexcept { return {name.data(), name_size}; } | |
| 119 | 52 | [[nodiscard]] std::string_view log_file_view() const noexcept { return {log_file.data(), log_file_size}; } | |
| 120 | |||
| 121 | 52 | [[nodiscard]] AsyncLoggerConfig logger_config() const | |
| 122 | { | ||
| 123 | 52 | AsyncLoggerConfig config{}; | |
| 124 | 52 | config.queue_capacity = queue_capacity; | |
| 125 | 52 | config.batch_size = batch_size; | |
| 126 | 52 | config.flush_interval = flush_interval; | |
| 127 | 52 | config.overflow_policy = overflow_policy; | |
| 128 | 52 | config.spin_backoff_iterations = spin_backoff_iterations; | |
| 129 | 52 | config.block_timeout_ms = block_timeout_ms; | |
| 130 | 52 | config.block_max_spin_iterations = block_max_spin_iterations; | |
| 131 | 52 | return config; | |
| 132 | } | ||
| 133 | |||
| 134 | 49 | void clear() noexcept | |
| 135 | { | ||
| 136 | 49 | name_size = 0; | |
| 137 | 49 | log_file_size = 0; | |
| 138 | 49 | } | |
| 139 | }; | ||
| 140 | |||
| 141 | BootstrapLoggerInfo s_bootstrap_logger_info; | ||
| 142 | |||
| 143 | // These two objects may still own consumer state when DLL_PROCESS_DETACH runs. Construct them into raw static | ||
| 144 | // storage so the CRT never registers destructors for them: a clean off-loader-lock drain resets their contents, | ||
| 145 | // while loader detach retains them untouched instead of destroying callback captures inside DllMain. | ||
| 146 | using ReadyCallback = std::move_only_function<Result<void>(Session &)>; | ||
| 147 | alignas(std::optional<Session>) unsigned char s_pending_session_storage[sizeof(std::optional<Session>)]; | ||
| 148 | alignas(ReadyCallback) unsigned char s_on_ready_storage[sizeof(ReadyCallback)]; | ||
| 149 | std::optional<Session> &s_pending_session = | ||
| 150 | *::new (static_cast<void *>(s_pending_session_storage)) std::optional<Session>(); | ||
| 151 | ReadyCallback &s_on_ready = *::new (static_cast<void *>(s_on_ready_storage)) ReadyCallback(); | ||
| 152 | |||
| 153 | // This trivial pointer needs no raw-storage treatment. | ||
| 154 | BootstrapReadyFn s_on_ready_fn = nullptr; | ||
| 155 | static_assert( | ||
| 156 | std::is_trivially_copyable_v<BootstrapReadyFn> && std::is_trivially_destructible_v<BootstrapReadyFn>, | ||
| 157 | "the DllMain attach callback must stage no consumer capture (see bootstrap_attach)." | ||
| 158 | ); | ||
| 159 | |||
| 160 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 161 | // Counts signals that reached SetEvent on a handle the kernel had already invalidated. Admission is supposed to | ||
| 162 | // make that impossible, so a nonzero count is the direct observation of the use-after-close this word prevents, | ||
| 163 | // and a test can assert it unconditionally rather than inferring safety from which retirement branch ran. | ||
| 164 | std::atomic<std::uint64_t> s_signal_on_invalid_event{0}; | ||
| 165 | void (*s_bootstrap_pre_setup_probe)() noexcept = nullptr; | ||
| 166 | #endif | ||
| 167 | |||
| 168 | 9096876 | void signal_shutdown_event() noexcept | |
| 169 | { | ||
| 170 | // Observing "not retired" and registering as a reader must be ONE atomic step. A plain load followed by an | ||
| 171 | // unconditional fetch_add lets a caller preempted between the two land its increment on a generation that | ||
| 172 | // was retired and reopened meanwhile: its matching decrement then underflows the reopened word to all-ones | ||
| 173 | // (which has the retired bit set) and no later request can ever signal that generation again. | ||
| 174 | 9543314 | std::uint64_t access = s_shutdown_event_access.load(std::memory_order_acquire); | |
| 175 | do | ||
| 176 | { | ||
| 177 | 12337345 | if ((access & SHUTDOWN_EVENT_RETIRED) != 0) | |
| 178 | { | ||
| 179 | 1230222 | return; | |
| 180 | } | ||
| 181 | } while ( | ||
| 182 | 11587485 | !s_shutdown_event_access | |
| 183 | 22694608 | .compare_exchange_weak(access, access + 1, std::memory_order_acq_rel, std::memory_order_acquire) | |
| 184 | ); | ||
| 185 | |||
| 186 | 8793454 | if (HANDLE event = s_shutdown_event.load(std::memory_order_acquire)) | |
| 187 | { | ||
| 188 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 189 | 7949740 | if (!SetEvent(event)) | |
| 190 | { | ||
| 191 | s_signal_on_invalid_event.fetch_add(1, std::memory_order_relaxed); | ||
| 192 | } | ||
| 193 | #else | ||
| 194 | SetEvent(event); | ||
| 195 | #endif | ||
| 196 | } | ||
| 197 | s_shutdown_event_access.fetch_sub(1, std::memory_order_release); | ||
| 198 | } | ||
| 199 | |||
| 200 | /** | ||
| 201 | * @brief Closes admission and drops the event pointer, retaining any live kernel object. | ||
| 202 | * @return true when a live handle was dropped, so the caller records the retention it just created. | ||
| 203 | */ | ||
| 204 | 6 | [[nodiscard]] bool abandon_shutdown_event() noexcept | |
| 205 | { | ||
| 206 | s_shutdown_event_access.fetch_or(SHUTDOWN_EVENT_RETIRED, std::memory_order_acq_rel); | ||
| 207 | 6 | return s_shutdown_event.exchange(nullptr, std::memory_order_acq_rel) != nullptr; | |
| 208 | } | ||
| 209 | |||
| 210 | 2 | void close_shutdown_event_at_process_exit() noexcept | |
| 211 | { | ||
| 212 | s_shutdown_event_access.fetch_or(SHUTDOWN_EVENT_RETIRED, std::memory_order_acq_rel); | ||
| 213 | 2 | if (HANDLE event = s_shutdown_event.exchange(nullptr, std::memory_order_acq_rel)) | |
| 214 | { | ||
| 215 | 2 | CloseHandle(event); | |
| 216 | } | ||
| 217 | 2 | } | |
| 218 | |||
| 219 | 46 | void retire_shutdown_event_after_drain() noexcept | |
| 220 | { | ||
| 221 | s_shutdown_event_access.fetch_or(SHUTDOWN_EVENT_RETIRED, std::memory_order_acq_rel); | ||
| 222 | 46 | const HANDLE event = s_shutdown_event.exchange(nullptr, std::memory_order_acq_rel); | |
| 223 | 46 | if (event == nullptr) | |
| 224 | { | ||
| 225 | ✗ | return; | |
| 226 | } | ||
| 227 | |||
| 228 | 52 | for (size_t i = 0; i < SHUTDOWN_EVENT_DRAIN_YIELDS; ++i) | |
| 229 | { | ||
| 230 | 52 | if ((s_shutdown_event_access.load(std::memory_order_acquire) & SHUTDOWN_EVENT_READER_MASK) == 0) | |
| 231 | { | ||
| 232 | 46 | CloseHandle(event); | |
| 233 | 46 | return; | |
| 234 | } | ||
| 235 | 6 | SwitchToThread(); | |
| 236 | } | ||
| 237 | |||
| 238 | // A suspended signaler may retain access indefinitely. Keep its handle valid instead of blocking teardown. | ||
| 239 | ✗ | diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Bootstrap); | |
| 240 | } | ||
| 241 | |||
| 242 | 1660 | Result<bool> is_target_process(std::string_view expected, const char *operation) noexcept | |
| 243 | { | ||
| 244 | 1660 | if (expected.empty()) | |
| 245 | { | ||
| 246 | 1639 | return true; | |
| 247 | } | ||
| 248 | |||
| 249 | // The full path can exceed MAX_PATH. Static storage covers Windows' extended path limit without allocating | ||
| 250 | // in a DllMain caller; lifecycle admission serializes every writer of this scratch buffer. | ||
| 251 | 21 | constexpr DWORD MODULE_PATH_CAPACITY = 32768; | |
| 252 | static wchar_t s_exe_path[MODULE_PATH_CAPACITY]; | ||
| 253 | 21 | const DWORD path_size = GetModuleFileNameW(nullptr, s_exe_path, MODULE_PATH_CAPACITY); | |
| 254 | 21 | if (path_size == 0) | |
| 255 | { | ||
| 256 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, operation, GetLastError()}); | |
| 257 | } | ||
| 258 | 21 | if (path_size >= MODULE_PATH_CAPACITY) | |
| 259 | { | ||
| 260 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, operation, ERROR_INSUFFICIENT_BUFFER}); | |
| 261 | } | ||
| 262 | |||
| 263 | 21 | const wchar_t *exe_name = std::wcsrchr(s_exe_path, L'\\'); | |
| 264 | 21 | exe_name = exe_name ? exe_name + 1 : s_exe_path; | |
| 265 | |||
| 266 | // MultiByteToWideChar takes an int input count. Reject only values outside that domain. | ||
| 267 | // The fixed buffer rejects malformed input and values beyond MAX_PATH - 1 UTF-16 code units. | ||
| 268 | 21 | if (expected.size() > static_cast<size_t>(INT_MAX)) | |
| 269 | { | ||
| 270 | ✗ | return false; | |
| 271 | } | ||
| 272 | wchar_t expected_buf[MAX_PATH]; | ||
| 273 | 21 | const int wide_len = MultiByteToWideChar( | |
| 274 | CP_UTF8, | ||
| 275 | MB_ERR_INVALID_CHARS, | ||
| 276 | expected.data(), | ||
| 277 | 21 | static_cast<int>(expected.size()), | |
| 278 | expected_buf, | ||
| 279 | MAX_PATH - 1 | ||
| 280 | ); | ||
| 281 | 21 | if (wide_len <= 0) | |
| 282 | { | ||
| 283 | ✗ | return false; | |
| 284 | } | ||
| 285 | 21 | return CompareStringOrdinal(exe_name, -1, expected_buf, wide_len, TRUE) == CSTR_EQUAL; | |
| 286 | } | ||
| 287 | |||
| 288 | /** | ||
| 289 | * @brief Classifies the outcome of a single-instance mutex acquisition. | ||
| 290 | */ | ||
| 291 | enum class MutexAcquire : std::uint8_t | ||
| 292 | { | ||
| 293 | /// A fresh mutex was created; @p out holds the handle the Session must close. | ||
| 294 | Acquired, | ||
| 295 | /// No prefix was requested; @p out is null and no single-instance guard exists. | ||
| 296 | NoGuard, | ||
| 297 | /// The named mutex already existed: another load of this mod is live. | ||
| 298 | AlreadyHeld, | ||
| 299 | /// CreateMutexW failed; @p err holds GetLastError(). | ||
| 300 | SystemError | ||
| 301 | }; | ||
| 302 | |||
| 303 | // Builds a per-PID named mutex from @p prefix and reports whether this load won the single-instance race. | ||
| 304 | // Static scratch storage avoids heap work in bootstrap; lifecycle admission serializes every writer. | ||
| 305 | 1654 | MutexAcquire acquire_instance_mutex(std::string_view prefix, HANDLE &out, DWORD &err) noexcept | |
| 306 | { | ||
| 307 | 1654 | out = nullptr; | |
| 308 | 1654 | err = 0; | |
| 309 | 1654 | if (prefix.empty()) | |
| 310 | { | ||
| 311 | 1637 | return MutexAcquire::NoGuard; | |
| 312 | } | ||
| 313 | |||
| 314 | 17 | constexpr size_t MUTEX_NAME_CAPACITY = 32768; | |
| 315 | static wchar_t s_mutex_name[MUTEX_NAME_CAPACITY]; | ||
| 316 | 17 | constexpr size_t MAX_PID_DIGITS = 10; | |
| 317 | 17 | if (prefix.size() > MUTEX_NAME_CAPACITY - MAX_PID_DIGITS - 1) | |
| 318 | { | ||
| 319 | ✗ | err = ERROR_FILENAME_EXCED_RANGE; | |
| 320 | ✗ | return MutexAcquire::SystemError; | |
| 321 | } | ||
| 322 | |||
| 323 | 17 | size_t name_size = 0; | |
| 324 | 595 | for (const char character : prefix) | |
| 325 | { | ||
| 326 | 578 | s_mutex_name[name_size++] = static_cast<wchar_t>(static_cast<unsigned char>(character)); | |
| 327 | } | ||
| 328 | |||
| 329 | wchar_t reversed_pid[MAX_PID_DIGITS]; | ||
| 330 | 17 | size_t pid_digits = 0; | |
| 331 | 17 | DWORD pid = GetCurrentProcessId(); | |
| 332 | do | ||
| 333 | { | ||
| 334 | 67 | reversed_pid[pid_digits++] = static_cast<wchar_t>(L'0' + pid % 10); | |
| 335 | 67 | pid /= 10; | |
| 336 | 67 | } while (pid != 0); | |
| 337 | 84 | while (pid_digits != 0) | |
| 338 | { | ||
| 339 | 67 | s_mutex_name[name_size++] = reversed_pid[--pid_digits]; | |
| 340 | } | ||
| 341 | 17 | s_mutex_name[name_size] = L'\0'; | |
| 342 | |||
| 343 | 17 | HANDLE handle = CreateMutexW(nullptr, FALSE, s_mutex_name); | |
| 344 | 17 | if (!handle) | |
| 345 | { | ||
| 346 | ✗ | err = GetLastError(); | |
| 347 | ✗ | return MutexAcquire::SystemError; | |
| 348 | } | ||
| 349 | 17 | if (GetLastError() == ERROR_ALREADY_EXISTS) | |
| 350 | { | ||
| 351 | 5 | CloseHandle(handle); | |
| 352 | 5 | return MutexAcquire::AlreadyHeld; | |
| 353 | } | ||
| 354 | 12 | out = handle; | |
| 355 | 12 | return MutexAcquire::Acquired; | |
| 356 | } | ||
| 357 | |||
| 358 | [[nodiscard]] Result<HANDLE> | ||
| 359 | 1663 | begin_session(const ModInfo &info, const char *operation, detail::LoaderContext loader_context) noexcept | |
| 360 | { | ||
| 361 | 1663 | if (!detail::lifecycle().begin_start()) | |
| 362 | { | ||
| 363 | 3 | return std::unexpected(Error{ErrorCode::SessionAlreadyActive, operation}); | |
| 364 | } | ||
| 365 | |||
| 366 | 1660 | Result<bool> target_process = is_target_process(info.game_process_name, operation); | |
| 367 | 1660 | if (!target_process) | |
| 368 | { | ||
| 369 | ✗ | detail::lifecycle().mark_stopped(); | |
| 370 | ✗ | return std::unexpected(target_process.error()); | |
| 371 | } | ||
| 372 | 1660 | if (!*target_process) | |
| 373 | { | ||
| 374 | 6 | detail::lifecycle().mark_stopped(); | |
| 375 | 6 | return std::unexpected(Error{ErrorCode::ProcessMismatch, operation}); | |
| 376 | } | ||
| 377 | |||
| 378 | 1654 | HANDLE mutex = nullptr; | |
| 379 | 1654 | DWORD error = 0; | |
| 380 | 1654 | switch (acquire_instance_mutex(info.instance_mutex_prefix, mutex, error)) | |
| 381 | { | ||
| 382 | 5 | case MutexAcquire::AlreadyHeld: | |
| 383 | 5 | detail::lifecycle().mark_stopped(); | |
| 384 | 5 | return std::unexpected(Error{ErrorCode::InstanceAlreadyRunning, operation}); | |
| 385 | ✗ | case MutexAcquire::SystemError: | |
| 386 | ✗ | detail::lifecycle().mark_stopped(); | |
| 387 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, operation, error}); | |
| 388 | 1649 | case MutexAcquire::NoGuard: | |
| 389 | case MutexAcquire::Acquired: | ||
| 390 | 1649 | break; | |
| 391 | } | ||
| 392 | |||
| 393 | // This identity takes no reference. The bootstrap worker acquires its separate keepalive before | ||
| 394 | // CreateThread. | ||
| 395 | 1649 | constexpr DWORD CAPTURE_FLAGS = | |
| 396 | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT; | ||
| 397 | 1649 | HMODULE captured_module = nullptr; | |
| 398 | 1649 | if (!GetModuleHandleExW(CAPTURE_FLAGS, reinterpret_cast<LPCWSTR>(&begin_session), &captured_module)) | |
| 399 | { | ||
| 400 | ✗ | error = GetLastError(); | |
| 401 | ✗ | if (mutex != nullptr) | |
| 402 | { | ||
| 403 | ✗ | CloseHandle(mutex); | |
| 404 | } | ||
| 405 | ✗ | detail::lifecycle().mark_stopped(); | |
| 406 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, operation, error}); | |
| 407 | } | ||
| 408 | 1649 | detail::lifecycle().publish_module(captured_module); | |
| 409 | |||
| 410 | // Publish the phase only once the fallible gates have passed. begin_start() already reset the context to | ||
| 411 | // Normal for this epoch, so every rollback above leaves a neutral phase behind rather than stranding a | ||
| 412 | // non-blocking Attach that would fail-close every later teardown in a process that never started. | ||
| 413 | 1649 | detail::lifecycle().set_loader_context(loader_context); | |
| 414 | 1649 | return mutex; | |
| 415 | } | ||
| 416 | |||
| 417 | /** | ||
| 418 | * @brief Applies the subsystem order in the Session contract. | ||
| 419 | * @details SessionTeardown.FullStackTeardownShutsEveryLeafDown proves leaf completion. | ||
| 420 | * Lifecycle.DiagnosticsTlsIndexReturnsWithTheSession pins the diagnostics release after config::clear. | ||
| 421 | */ | ||
| 422 | 1637 | void run_subsystem_teardown() noexcept | |
| 423 | { | ||
| 424 | // First: the watcher thread can fire on_reload into any state below. | ||
| 425 | 1637 | config::disable_auto_reload(); | |
| 426 | 1637 | input::Input::instance().shutdown(); | |
| 427 | 1637 | memory::shutdown_cache(); | |
| 428 | 1637 | config::clear(); | |
| 429 | // After config::clear, because a config callback can own a subscription. | ||
| 430 | 1637 | detail::release_diagnostics_emit_owners( | |
| 431 | detail::DiagnosticsTeardown::Session, | ||
| 432 | 1637 | detail::blocking_teardown_permitted() | |
| 433 | ); | ||
| 434 | // Last: every prior step can still log. | ||
| 435 | 1637 | log().shutdown(); | |
| 436 | 1637 | } | |
| 437 | |||
| 438 | /** | ||
| 439 | * @brief Rolls a pre-publication attach back to a retryable Drained slot. | ||
| 440 | * @param instance_mutex The preflight mutex, or nullptr when no guard was requested. | ||
| 441 | * @details No Session or callback has been published when this runs. Closing the local mutex releases the | ||
| 442 | * instance gate without subsystem teardown or consumer destruction in DllMain. | ||
| 443 | */ | ||
| 444 | 3 | void unwind_bootstrap(HANDLE instance_mutex) noexcept | |
| 445 | { | ||
| 446 | 3 | if (abandon_shutdown_event()) | |
| 447 | { | ||
| 448 | 2 | diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Bootstrap); | |
| 449 | } | ||
| 450 | 3 | if (instance_mutex != nullptr) | |
| 451 | { | ||
| 452 | 1 | CloseHandle(instance_mutex); | |
| 453 | } | ||
| 454 | 3 | s_bootstrap_logger_info.clear(); | |
| 455 | 3 | detail::lifecycle().clear_module(); | |
| 456 | // Retire the Attach phase with the attach it described. Leaving it published would fail-close every later | ||
| 457 | // teardown in a process whose consumer declined the load but kept using the library. | ||
| 458 | 3 | detail::lifecycle().set_loader_context(detail::LoaderContext::Normal); | |
| 459 | 3 | detail::lifecycle().mark_stopped(); | |
| 460 | 3 | s_bootstrap_state.store(BootstrapState::Drained, std::memory_order_release); | |
| 461 | 3 | } | |
| 462 | |||
| 463 | 46 | void retire_bootstrap_after_drain() noexcept | |
| 464 | { | ||
| 465 | // Idempotent after the worker's own clear. It stays because this is the single retirement point for a | ||
| 466 | // caller that arrives without a completed join. LifecycleContext::clear_worker_thread owns the hazard. | ||
| 467 | 46 | detail::lifecycle().clear_worker_thread(); | |
| 468 | 46 | retire_shutdown_event_after_drain(); | |
| 469 | 46 | s_on_ready = nullptr; | |
| 470 | 46 | s_on_ready_fn = nullptr; | |
| 471 | 46 | s_bootstrap_logger_info.clear(); | |
| 472 | 46 | detail::lifecycle().clear_module(); | |
| 473 | // Retire the drain phase with everything else this drain retires, so the published word keeps describing | ||
| 474 | // the phase the process is actually in. Both Normal and ExplicitDrain authorize blocking, so this is a | ||
| 475 | // consistency store rather than a change of permission. | ||
| 476 | 46 | detail::lifecycle().set_loader_context(detail::LoaderContext::Normal); | |
| 477 | 46 | } | |
| 478 | |||
| 479 | /** | ||
| 480 | * @brief Retires the worker identity and releases its module reference on thread exit. | ||
| 481 | * @details FreeLibraryAndExitThread cannot return through code that its final reference release unmaps. | ||
| 482 | */ | ||
| 483 | 49 | [[noreturn]] void exit_bootstrap_worker(HMODULE self_ref) noexcept | |
| 484 | { | ||
| 485 | 49 | detail::lifecycle().clear_worker_thread(); | |
| 486 | 49 | detail::module_pin_observability::note_released(diagnostics::ModulePinReason::Bootstrap); | |
| 487 | 49 | FreeLibraryAndExitThread(self_ref, 0); | |
| 488 | } | ||
| 489 | |||
| 490 | // The bootstrap worker. It finishes Session setup, runs on_ready, and performs teardown off the loader lock. | ||
| 491 | 52 | DWORD WINAPI lifecycle_thread(LPVOID param) noexcept | |
| 492 | { | ||
| 493 | 52 | const HMODULE self_ref = static_cast<HMODULE>(param); | |
| 494 | |||
| 495 | // Publish this thread's identity FIRST, before any consumer code can run on it. Collecting it from | ||
| 496 | // CreateThread's out-parameter instead would leave a window in which on_ready is already executing while | ||
| 497 | // the id is still 0, and a drain requested from inside on_ready would then slip past the self-drain guard | ||
| 498 | // and report success for a session that is fully live. Publishing here closes that window without | ||
| 499 | // resuming a thread from under the loader lock. The same identity authorizes this thread's teardown to | ||
| 500 | // block regardless of the phase the DllMain thread publishes. | ||
| 501 | 52 | detail::lifecycle().publish_worker_thread(); | |
| 502 | |||
| 503 | // bootstrap_core publishes the thread handle before the Session and callback. | ||
| 504 | // An off-loader-lock rich bootstrap requires this short publication wait. | ||
| 505 | 52 | BootstrapState state = s_bootstrap_state.load(std::memory_order_acquire); | |
| 506 | 52 | while (state == BootstrapState::Starting) | |
| 507 | { | ||
| 508 | ✗ | SwitchToThread(); | |
| 509 | ✗ | state = s_bootstrap_state.load(std::memory_order_acquire); | |
| 510 | } | ||
| 511 | |||
| 512 | // A control thread may claim Ready -> Draining before this thread observes Ready. Draining still owns a | ||
| 513 | // fully published Session, callback, and shutdown event; adopt them and let the already-signalled event | ||
| 514 | // drive the ordinary teardown so the waiting control thread can complete the drain. | ||
| 515 | // An empty optional is unreachable. bootstrap_core stages the Session before its Ready release store, and | ||
| 516 | // every state the spin can observe follows that store. Guard anyway. | ||
| 517 | 52 | if ((state != BootstrapState::Ready && state != BootstrapState::Draining) || !s_pending_session) | |
| 518 | { | ||
| 519 | ✗ | exit_bootstrap_worker(self_ref); | |
| 520 | } | ||
| 521 | |||
| 522 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 523 | 52 | if (s_bootstrap_pre_setup_probe != nullptr) | |
| 524 | { | ||
| 525 | 1 | s_bootstrap_pre_setup_probe(); | |
| 526 | } | ||
| 527 | #endif | ||
| 528 | |||
| 529 | // A thread created from DllMain cannot execute its entry point until the loader releases the attach | ||
| 530 | // notification. Publishing Normal retires the Attach phase before logger/file setup begins. | ||
| 531 | 52 | detail::lifecycle().set_loader_context(detail::LoaderContext::Normal); | |
| 532 | |||
| 533 | { | ||
| 534 | 104 | Session session = std::move(*s_pending_session); | |
| 535 | 52 | s_pending_session.reset(); | |
| 536 | |||
| 537 | try | ||
| 538 | { | ||
| 539 | 52 | Logger::configure( | |
| 540 | s_bootstrap_logger_info.name_view(), | ||
| 541 | s_bootstrap_logger_info.log_file_view(), | ||
| 542 | DEFAULT_TIMESTAMP_FORMAT, | ||
| 543 | s_bootstrap_logger_info.log_open_mode, | ||
| 544 | s_bootstrap_logger_info.log_source_stamp_mode | ||
| 545 | ); | ||
| 546 | 52 | DetourModKit::log().enable_async_mode(s_bootstrap_logger_info.logger_config()); | |
| 547 | } | ||
| 548 | ✗ | catch (const std::bad_alloc &) | |
| 549 | { | ||
| 550 | ✗ | OutputDebugStringA( | |
| 551 | "DetourModKit: bootstrap logger setup ran out of memory; continuing without " | ||
| 552 | "guaranteed logging.\n" | ||
| 553 | ); | ||
| 554 | ✗ | } | |
| 555 | ✗ | catch (...) | |
| 556 | { | ||
| 557 | ✗ | OutputDebugStringA( | |
| 558 | "DetourModKit: bootstrap logger setup failed; continuing without guaranteed " | ||
| 559 | "logging.\n" | ||
| 560 | ); | ||
| 561 | ✗ | } | |
| 562 | #if !defined(_MSC_VER) && defined(_WIN64) | ||
| 563 | 52 | detail::reopen_guarded_engine(); | |
| 564 | #endif | ||
| 565 | 52 | detail::lifecycle().mark_running(); | |
| 566 | |||
| 567 | 52 | if (const BootstrapReadyFn ready_fn = s_on_ready_fn; ready_fn != nullptr || s_on_ready) | |
| 568 | { | ||
| 569 | try | ||
| 570 | { | ||
| 571 | 52 | Result<void> ready = ready_fn != nullptr ? ready_fn(session) : s_on_ready(session); | |
| 572 | 51 | if (!ready) | |
| 573 | { | ||
| 574 | 2 | (void)log().try_log( | |
| 575 | LogLevel::Error, | ||
| 576 | "bootstrap: on_ready reported failure: {}", | ||
| 577 | 2 | ready.error().message() | |
| 578 | ); | ||
| 579 | } | ||
| 580 | } | ||
| 581 | 1 | catch (const std::exception &e) | |
| 582 | { | ||
| 583 | 1 | (void)log().try_log(LogLevel::Error, "bootstrap: on_ready threw: {}", e.what()); | |
| 584 | 1 | } | |
| 585 | ✗ | catch (...) | |
| 586 | { | ||
| 587 | ✗ | (void)log().try_log(LogLevel::Error, "bootstrap: on_ready threw an unknown exception."); | |
| 588 | ✗ | } | |
| 589 | } | ||
| 590 | |||
| 591 | 52 | if (HANDLE event = s_shutdown_event.load(std::memory_order_acquire)) | |
| 592 | { | ||
| 593 | 52 | WaitForSingleObject(event, INFINITE); | |
| 594 | } | ||
| 595 | |||
| 596 | // `session` destructs at the end of this inner scope -> ordered teardown off the loader lock -> leaves | ||
| 597 | // JOIN, all while self_ref keeps this module's code mapped through the teardown. | ||
| 598 | 49 | } | |
| 599 | |||
| 600 | // The identity retires only after the teardown it authorized. | ||
| 601 | 49 | exit_bootstrap_worker(self_ref); | |
| 602 | } | ||
| 603 | |||
| 604 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 605 | // Forces worker launch to fail after process and instance gating but before consumer state is published. | ||
| 606 | std::atomic<bool> s_fail_worker_launch{false}; | ||
| 607 | #endif | ||
| 608 | |||
| 609 | // Creates the bootstrap worker. Routed through one function so the test seam fails into CreateThread's own | ||
| 610 | // rollback branch rather than duplicating it. | ||
| 611 | 54 | [[nodiscard]] HANDLE launch_bootstrap_worker(HMODULE worker_ref) noexcept | |
| 612 | { | ||
| 613 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 614 | 54 | if (s_fail_worker_launch.load(std::memory_order_acquire)) | |
| 615 | { | ||
| 616 | 2 | SetLastError(ERROR_NOT_ENOUGH_MEMORY); | |
| 617 | 2 | return nullptr; | |
| 618 | } | ||
| 619 | #endif | ||
| 620 | 52 | return CreateThread(nullptr, 0, lifecycle_thread, worker_ref, 0, nullptr); | |
| 621 | } | ||
| 622 | |||
| 623 | // A generation publishes one callback representation. | ||
| 624 | [[nodiscard]] Result<void> | ||
| 625 | 63 | bootstrap_core(const ModInfo &info, ReadyCallback *rich_on_ready, BootstrapReadyFn fn_on_ready) noexcept | |
| 626 | { | ||
| 627 | // Claim the slot before touching any other static. A drain nulls the worker handle and the shutdown event | ||
| 628 | // early but publishes Drained only after it has also retired the init callback and the module identity, so | ||
| 629 | // admitting on those handles would let this generation publish into the tail of that retirement and have | ||
| 630 | // its own callback destroyed and its own Ready overwritten by the drainer that is still finishing. | ||
| 631 | 63 | BootstrapState expected = BootstrapState::Drained; | |
| 632 | 63 | if (!s_bootstrap_state | |
| 633 | 63 | .compare_exchange_strong(expected, BootstrapState::Starting, std::memory_order_acq_rel)) | |
| 634 | { | ||
| 635 | 4 | if (expected == BootstrapState::Draining) | |
| 636 | { | ||
| 637 | 1 | return std::unexpected(Error{ErrorCode::SessionShutdownInProgress, "bootstrap"}); | |
| 638 | } | ||
| 639 | 3 | if (expected == BootstrapState::Detached) | |
| 640 | { | ||
| 641 | 1 | return std::unexpected(Error{ErrorCode::SessionShutdownUnavailable, "bootstrap"}); | |
| 642 | } | ||
| 643 | // Starting or Ready: another generation owns the slot. | ||
| 644 | 2 | return std::unexpected(Error{ErrorCode::SessionAlreadyActive, "bootstrap"}); | |
| 645 | } | ||
| 646 | |||
| 647 | // A retirement that had to retain its event leaves a previous generation's signaler still inside SetEvent. | ||
| 648 | // Its pending fetch_sub would underflow the access word this generation reopens, so refuse until that | ||
| 649 | // signaler has left rather than start on a corrupted admission count. Checked here as well as at the | ||
| 650 | // reopen so a doomed attach costs nothing: the reopen below is the authority. | ||
| 651 | 59 | if (s_shutdown_event_access.load(std::memory_order_acquire) != SHUTDOWN_EVENT_RETIRED) | |
| 652 | { | ||
| 653 | ✗ | s_bootstrap_state.store(BootstrapState::Drained, std::memory_order_release); | |
| 654 | ✗ | return std::unexpected(Error{ErrorCode::SessionShutdownInProgress, "bootstrap"}); | |
| 655 | } | ||
| 656 | |||
| 657 | // Keep the gates that let DllMain decline the load synchronous, but defer logger/file setup to the worker. | ||
| 658 | 59 | Result<HANDLE> instance_mutex = begin_session(info, "bootstrap", detail::LoaderContext::Attach); | |
| 659 | 59 | if (!instance_mutex) | |
| 660 | { | ||
| 661 | 4 | s_bootstrap_state.store(BootstrapState::Drained, std::memory_order_release); | |
| 662 | 4 | return std::unexpected(instance_mutex.error()); | |
| 663 | } | ||
| 664 | // begin_session publishes the DllMain module identity without a consumer-supplied handle. | ||
| 665 | 55 | (void)DisableThreadLibraryCalls(detail::lifecycle().module()); | |
| 666 | |||
| 667 | 55 | if (!s_bootstrap_logger_info.stage(info)) | |
| 668 | { | ||
| 669 | 1 | unwind_bootstrap(*instance_mutex); | |
| 670 | 1 | return std::unexpected(Error{ErrorCode::InvalidArg, "bootstrap"}); | |
| 671 | } | ||
| 672 | |||
| 673 | // Create into a local first, then publish with a release store so the worker's / consumer's acquire load | ||
| 674 | // observes a fully-constructed handle. Owning Starting is what makes the slot free to publish into. The | ||
| 675 | // TRUE second argument makes this a MANUAL-RESET event: a shutdown request is a one-way latch, so once | ||
| 676 | // request_shutdown() signals it the event stays signaled. The worker observes it whether or not it was | ||
| 677 | // already waiting, and a repeated request_shutdown() is idempotent (an auto-reset event would clear itself | ||
| 678 | // after a single wait woke and could drop a later observer). | ||
| 679 | 54 | const HANDLE shutdown_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); | |
| 680 | 54 | if (!shutdown_event) | |
| 681 | { | ||
| 682 | ✗ | const DWORD err = GetLastError(); | |
| 683 | ✗ | unwind_bootstrap(*instance_mutex); | |
| 684 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, "bootstrap", err}); | |
| 685 | } | ||
| 686 | // Reopen admission by CLAIMING the retired word, never by storing over it: a straggling signaler from the | ||
| 687 | // retired generation can still be counted, and a blind store would discard its registration and leave its | ||
| 688 | // pending decrement to underflow the word. Reopening before the handle is published also keeps a signaler | ||
| 689 | // admitted after this point from ever loading a half-published pointer. | ||
| 690 | 54 | std::uint64_t retired_access = SHUTDOWN_EVENT_RETIRED; | |
| 691 | 54 | if (!s_shutdown_event_access | |
| 692 | 54 | .compare_exchange_strong(retired_access, 0, std::memory_order_acq_rel, std::memory_order_acquire)) | |
| 693 | { | ||
| 694 | ✗ | CloseHandle(shutdown_event); | |
| 695 | ✗ | unwind_bootstrap(*instance_mutex); | |
| 696 | ✗ | return std::unexpected(Error{ErrorCode::SessionShutdownInProgress, "bootstrap"}); | |
| 697 | } | ||
| 698 | 54 | s_shutdown_event.store(shutdown_event, std::memory_order_release); | |
| 699 | |||
| 700 | // Acquire the worker's module reference BEFORE CreateThread. A thread created from DllMain may not execute | ||
| 701 | // its entry point until after the loader releases the attach notification, but the caller can FreeLibrary | ||
| 702 | // immediately after LoadLibrary returns. The reference therefore has to exist before the worker is | ||
| 703 | // scheduled, not at the top of the worker function. | ||
| 704 | 54 | const HMODULE worker_ref = detail::try_acquire_module_ref(diagnostics::ModulePinReason::Bootstrap); | |
| 705 | 54 | if (worker_ref == nullptr) | |
| 706 | { | ||
| 707 | ✗ | const DWORD err = GetLastError(); | |
| 708 | ✗ | unwind_bootstrap(*instance_mutex); | |
| 709 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, "bootstrap", err}); | |
| 710 | } | ||
| 711 | |||
| 712 | // The worker publishes its own thread id as its first instruction, so this call does not collect it. A | ||
| 713 | // CREATE_SUSPENDED plus ResumeThread is not leaf-safe inside DllMain. | ||
| 714 | 54 | s_worker_thread = launch_bootstrap_worker(worker_ref); | |
| 715 | 54 | if (!s_worker_thread) | |
| 716 | { | ||
| 717 | 2 | const DWORD err = GetLastError(); | |
| 718 | 2 | detail::release_module_ref(worker_ref, diagnostics::ModulePinReason::Bootstrap); | |
| 719 | 2 | unwind_bootstrap(*instance_mutex); | |
| 720 | 2 | return std::unexpected(Error{ErrorCode::SystemCallFailed, "bootstrap", err}); | |
| 721 | } | ||
| 722 | |||
| 723 | // No fallible operation remains. Stage the Session and callback, then release the worker from its Starting | ||
| 724 | // wait. This ordering also keeps a launch failure from destroying consumer state or shutting subsystems | ||
| 725 | // down inside DllMain. | ||
| 726 | 52 | s_pending_session.emplace(detail::SessionBootstrapAccess::make(*instance_mutex)); | |
| 727 | 52 | if (rich_on_ready != nullptr) | |
| 728 | { | ||
| 729 | 36 | s_on_ready = std::move(*rich_on_ready); | |
| 730 | } | ||
| 731 | 52 | s_on_ready_fn = fn_on_ready; | |
| 732 | 52 | s_bootstrap_state.store(BootstrapState::Ready, std::memory_order_release); | |
| 733 | 52 | return {}; | |
| 734 | } | ||
| 735 | |||
| 736 | } // anonymous namespace | ||
| 737 | |||
| 738 | 1644 | Session::Session(void *instance_mutex) noexcept : m_instance_mutex(instance_mutex), m_active(true) {} | |
| 739 | |||
| 740 | 3274 | Session::Session(Session &&other) noexcept | |
| 741 | 6548 | : m_scope(std::move(other.m_scope)), m_instance_mutex(other.m_instance_mutex), m_active(other.m_active) | |
| 742 | { | ||
| 743 | // Leave the source inert so its destructor does nothing: exactly one Session ever carries the live teardown. | ||
| 744 | 3274 | other.m_instance_mutex = nullptr; | |
| 745 | 3274 | other.m_active = false; | |
| 746 | 3274 | } | |
| 747 | |||
| 748 | 1 | Session &Session::operator=(Session &&other) noexcept | |
| 749 | { | ||
| 750 | 1 | if (this != &other) | |
| 751 | { | ||
| 752 | // Assigning over a live Session ends it: run the full ordered teardown of THIS session first (which also | ||
| 753 | // clears the single-session guard), then adopt the source. A mod holds one session at a time, so the source | ||
| 754 | // is always inert here; a no-op release() when this Session is already inert makes the common | ||
| 755 | // reassign-a-moved-from-Session case free. | ||
| 756 | 1 | release(); | |
| 757 | 2 | m_scope = std::move(other.m_scope); | |
| 758 | 1 | m_instance_mutex = other.m_instance_mutex; | |
| 759 | 1 | m_active = other.m_active; | |
| 760 | 1 | other.m_instance_mutex = nullptr; | |
| 761 | 1 | other.m_active = false; | |
| 762 | } | ||
| 763 | 1 | return *this; | |
| 764 | } | ||
| 765 | |||
| 766 | 4913 | Session::~Session() noexcept | |
| 767 | { | ||
| 768 | // Moved-from or abandon()ed sessions are inert, so release() is a no-op and a double-drop never | ||
| 769 | // double-tears-down. | ||
| 770 | 4913 | release(); | |
| 771 | 4913 | } | |
| 772 | |||
| 773 | 4914 | void Session::release() noexcept | |
| 774 | { | ||
| 775 | // The Session class `[B-100]` warning owns the caller precondition. | ||
| 776 | 4914 | if (!m_active) | |
| 777 | { | ||
| 778 | 3277 | return; | |
| 779 | } | ||
| 780 | |||
| 781 | // Enter Stopping so a start racing this teardown stays rejected until the slot is fully released. | ||
| 782 | 1637 | detail::lifecycle().begin_stop(); | |
| 783 | 1637 | m_scope.clear(); | |
| 784 | 1637 | run_subsystem_teardown(); | |
| 785 | 1637 | if (m_instance_mutex) | |
| 786 | { | ||
| 787 | 11 | CloseHandle(static_cast<HANDLE>(m_instance_mutex)); | |
| 788 | 11 | m_instance_mutex = nullptr; | |
| 789 | } | ||
| 790 | 1637 | m_active = false; | |
| 791 | // The identity retires with the session, after every teardown callback that can still read it. | ||
| 792 | 1637 | detail::lifecycle().clear_module(); | |
| 793 | 1637 | detail::lifecycle().mark_stopped(); | |
| 794 | } | ||
| 795 | |||
| 796 | 1604 | Result<Session> Session::start(const ModInfo &info) noexcept | |
| 797 | { | ||
| 798 | 1604 | Result<HANDLE> instance_mutex = begin_session(info, "Session::start", detail::LoaderContext::Normal); | |
| 799 | 1604 | if (!instance_mutex) | |
| 800 | { | ||
| 801 | 10 | return std::unexpected(instance_mutex.error()); | |
| 802 | } | ||
| 803 | |||
| 804 | // Logger::configure can throw while it builds owned strings. A std::bad_alloc maps to OutOfMemory. | ||
| 805 | // Every other exception maps to Unknown because a retry cannot clear an arbitrary fault. | ||
| 806 | // enable_async_mode contains its failures, so this catch owns only configuration. | ||
| 807 | try | ||
| 808 | { | ||
| 809 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 810 | 1594 | if (const auto probe = detail::g_session_configure_test_hook) | |
| 811 | 1 | probe(); | |
| 812 | #endif | ||
| 813 | 1593 | Logger::configure( | |
| 814 | info.name, | ||
| 815 | info.log_file, | ||
| 816 | DEFAULT_TIMESTAMP_FORMAT, | ||
| 817 | 1593 | info.log_open_mode, | |
| 818 | info.log_source_stamp_mode | ||
| 819 | ); | ||
| 820 | // Qualified: inside this static member the free accessor is hidden by the non-static Session::log(). | ||
| 821 | 1592 | DetourModKit::log().enable_async_mode(info.log); | |
| 822 | } | ||
| 823 | 2 | catch (const std::bad_alloc &) | |
| 824 | { | ||
| 825 | 1 | if (*instance_mutex != nullptr) | |
| 826 | { | ||
| 827 | ✗ | CloseHandle(*instance_mutex); | |
| 828 | } | ||
| 829 | 1 | detail::lifecycle().clear_module(); | |
| 830 | 1 | detail::lifecycle().mark_stopped(); | |
| 831 | 1 | return std::unexpected(Error{ErrorCode::OutOfMemory, "Session::start"}); | |
| 832 | 1 | } | |
| 833 | 1 | catch (...) | |
| 834 | { | ||
| 835 | 1 | if (*instance_mutex != nullptr) | |
| 836 | { | ||
| 837 | ✗ | CloseHandle(*instance_mutex); | |
| 838 | } | ||
| 839 | 1 | detail::lifecycle().clear_module(); | |
| 840 | 1 | detail::lifecycle().mark_stopped(); | |
| 841 | 1 | return std::unexpected(Error{ErrorCode::Unknown, "Session::start"}); | |
| 842 | 1 | } | |
| 843 | |||
| 844 | // Setup succeeded. The session now owns the single-session slot and mutex handle until release(). | ||
| 845 | #if !defined(_MSC_VER) && defined(_WIN64) | ||
| 846 | 1592 | detail::reopen_guarded_engine(); | |
| 847 | #endif | ||
| 848 | 1592 | detail::lifecycle().mark_running(); | |
| 849 | 1592 | return Session(*instance_mutex); | |
| 850 | } | ||
| 851 | |||
| 852 | 14 | Logger &Session::log() const noexcept | |
| 853 | { | ||
| 854 | // Qualified: the member Session::log() would otherwise hide the free accessor and recurse. | ||
| 855 | 14 | return DetourModKit::log(); | |
| 856 | } | ||
| 857 | |||
| 858 | 29 | config::Ini Session::ini() const noexcept | |
| 859 | { | ||
| 860 | 29 | return config::Ini{}; | |
| 861 | } | ||
| 862 | |||
| 863 | 21 | input::Input &Session::input() const noexcept | |
| 864 | { | ||
| 865 | 21 | return input::Input::instance(); | |
| 866 | } | ||
| 867 | |||
| 868 | 301 | input::Scope &Session::scope() noexcept | |
| 869 | { | ||
| 870 | 301 | return m_scope; | |
| 871 | } | ||
| 872 | |||
| 873 | 4 | void Session::abandon() noexcept | |
| 874 | { | ||
| 875 | // abandon() in session.hpp owns the process-termination-only rule. m_scope is a member whose own destructor | ||
| 876 | // still runs after this Session, so abandon it explicitly. Scope::abandon retains the guard container, so | ||
| 877 | // neither release logic nor consumer callback destruction can run during process detach. | ||
| 878 | 4 | m_scope.abandon(); | |
| 879 | 4 | m_active = false; | |
| 880 | 4 | m_instance_mutex = nullptr; | |
| 881 | 4 | detail::lifecycle().clear_module(); | |
| 882 | 4 | detail::lifecycle().mark_stopped(); | |
| 883 | 4 | } | |
| 884 | |||
| 885 | // Bootstrap free functions | ||
| 886 | |||
| 887 | 23 | Result<void> bootstrap_attach(const ModInfo &info, BootstrapReadyFn on_ready) noexcept | |
| 888 | { | ||
| 889 | 23 | return bootstrap_core(info, nullptr, on_ready); | |
| 890 | } | ||
| 891 | |||
| 892 | 40 | Result<void> bootstrap(const ModInfo &info, std::move_only_function<Result<void>(Session &)> on_ready) noexcept | |
| 893 | { | ||
| 894 | 40 | return bootstrap_core(info, &on_ready, nullptr); | |
| 895 | } | ||
| 896 | |||
| 897 | 8 | void bootstrap_detach(void *reserved) noexcept | |
| 898 | { | ||
| 899 | // This entry point is called only from DllMain. lpReserved distinguishes process termination from an explicit | ||
| 900 | // unload. Publish that context even when a prior drain already retired the handles, so later CRT destructors | ||
| 901 | // cannot inherit ExplicitDrain and treat a false heuristic result as permission to block under the loader lock. | ||
| 902 | 8 | const detail::LoaderContext context = | |
| 903 | 8 | reserved != nullptr ? detail::LoaderContext::ProcessExit : detail::LoaderContext::LoaderDetach; | |
| 904 | 8 | detail::lifecycle().set_loader_context(context); | |
| 905 | |||
| 906 | 8 | if (context == detail::LoaderContext::ProcessExit) | |
| 907 | { | ||
| 908 | const BootstrapState previous = | ||
| 909 | 2 | s_bootstrap_state.exchange(BootstrapState::Detached, std::memory_order_acq_rel); | |
| 910 | 2 | if (previous == BootstrapState::Detached) | |
| 911 | { | ||
| 912 | 5 | return; | |
| 913 | } | ||
| 914 | |||
| 915 | // PROCESS TERMINATION (abandon). The OS has already killed the worker; its adopted Session lives in that | ||
| 916 | // dead frame and is leaked untouched. If the worker never adopted the pending Session, leave it engaged in | ||
| 917 | // the never-destroyed storage too. Neither path runs consumer destruction inside DllMain. | ||
| 918 | 2 | if (s_pending_session) | |
| 919 | { | ||
| 920 | ✗ | s_pending_session->abandon(); | |
| 921 | } | ||
| 922 | 2 | detail::lifecycle().clear_worker_thread(); | |
| 923 | 2 | if (s_worker_thread) | |
| 924 | { | ||
| 925 | 2 | CloseHandle(s_worker_thread); | |
| 926 | 2 | s_worker_thread = nullptr; | |
| 927 | } | ||
| 928 | // Process termination: the OS has already terminated every other thread before this DllMain notification, | ||
| 929 | // so no request_shutdown() can be in flight and closing the event is safe. | ||
| 930 | 2 | close_shutdown_event_at_process_exit(); | |
| 931 | 2 | detail::lifecycle().clear_module(); | |
| 932 | 2 | if (previous != BootstrapState::Drained) | |
| 933 | { | ||
| 934 | 2 | diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Bootstrap); | |
| 935 | } | ||
| 936 | 2 | return; | |
| 937 | } | ||
| 938 | |||
| 939 | 6 | BootstrapState expected = BootstrapState::Ready; | |
| 940 | 6 | if (!s_bootstrap_state.compare_exchange_strong(expected, BootstrapState::Detached, std::memory_order_acq_rel)) | |
| 941 | { | ||
| 942 | // A drained unload has no handles left. Only its terminal transition remains. | ||
| 943 | 3 | if (expected == BootstrapState::Drained) | |
| 944 | { | ||
| 945 | (void)s_bootstrap_state | ||
| 946 | 3 | .compare_exchange_strong(expected, BootstrapState::Detached, std::memory_order_acq_rel); | |
| 947 | } | ||
| 948 | 3 | return; | |
| 949 | } | ||
| 950 | |||
| 951 | // EXPLICIT FreeLibrary. Signal the worker, then stop admitting signalers and retain the event rather than | ||
| 952 | // waiting for an already-admitted request_shutdown() call. The worker's counted module reference keeps its code | ||
| 953 | // mapped until it exits; this path never waits or destroys callback state under the loader lock. | ||
| 954 | 3 | signal_shutdown_event(); | |
| 955 | 3 | if (abandon_shutdown_event()) | |
| 956 | { | ||
| 957 | 3 | diagnostics::record_intentional_leak(diagnostics::LeakSubsystem::Bootstrap); | |
| 958 | } | ||
| 959 | // The published Detached state is terminal, so no drain, attach, or later detach can still read the worker's | ||
| 960 | // thread handle. Closing it does not disturb the running worker and keeps a repeated load/unload cycle from | ||
| 961 | // leaking one kernel thread object per load. | ||
| 962 | 3 | if (s_worker_thread) | |
| 963 | { | ||
| 964 | 3 | CloseHandle(s_worker_thread); | |
| 965 | 3 | s_worker_thread = nullptr; | |
| 966 | } | ||
| 967 | 3 | detail::lifecycle().clear_module(); | |
| 968 | } | ||
| 969 | |||
| 970 | 9461768 | void request_shutdown() noexcept | |
| 971 | { | ||
| 972 | // The access word admits this caller before it loads the handle. A clean drain closes the handle only after | ||
| 973 | // closing admission and observing every admitted caller leave SetEvent. | ||
| 974 | 9461768 | signal_shutdown_event(); | |
| 975 | 9715042 | } | |
| 976 | |||
| 977 | 51 | Result<void> shutdown_and_wait() noexcept | |
| 978 | { | ||
| 979 | // Refuse a self-drain before touching the state machine, so a worker-thread caller perturbs nothing and an | ||
| 980 | // ordinary control thread can still drain afterwards. Waiting here would block on the calling thread's own | ||
| 981 | // exit, which only that wait prevents. | ||
| 982 | 51 | if (detail::lifecycle().is_worker_thread()) | |
| 983 | { | ||
| 984 | 1 | return std::unexpected(Error{ErrorCode::SessionShutdownWouldBlock, "shutdown_and_wait"}); | |
| 985 | } | ||
| 986 | |||
| 987 | 50 | BootstrapState expected = BootstrapState::Ready; | |
| 988 | 50 | if (!s_bootstrap_state.compare_exchange_strong(expected, BootstrapState::Draining, std::memory_order_acq_rel)) | |
| 989 | { | ||
| 990 | 3 | if (expected == BootstrapState::Drained) | |
| 991 | { | ||
| 992 | 1 | return {}; | |
| 993 | } | ||
| 994 | 2 | if (expected == BootstrapState::Detached) | |
| 995 | { | ||
| 996 | 1 | return std::unexpected(Error{ErrorCode::SessionShutdownUnavailable, "shutdown_and_wait"}); | |
| 997 | } | ||
| 998 | 1 | return std::unexpected(Error{ErrorCode::SessionShutdownInProgress, "shutdown_and_wait"}); | |
| 999 | } | ||
| 1000 | |||
| 1001 | // LoaderContext describes the phase, not the current thread's lock ownership. | ||
| 1002 | // Only the per-thread probe decides whether this caller can wait. | ||
| 1003 | 47 | if (detail::is_loader_lock_held()) | |
| 1004 | { | ||
| 1005 | 1 | s_bootstrap_state.store(BootstrapState::Ready, std::memory_order_release); | |
| 1006 | 1 | return std::unexpected(Error{ErrorCode::SessionShutdownWouldBlock, "shutdown_and_wait"}); | |
| 1007 | } | ||
| 1008 | |||
| 1009 | 46 | detail::lifecycle().set_loader_context(detail::LoaderContext::ExplicitDrain); | |
| 1010 | 46 | request_shutdown(); | |
| 1011 | |||
| 1012 | 46 | if (s_worker_thread != nullptr) | |
| 1013 | { | ||
| 1014 | 46 | const DWORD wait_result = WaitForSingleObject(s_worker_thread, INFINITE); | |
| 1015 | 46 | if (wait_result != WAIT_OBJECT_0) | |
| 1016 | { | ||
| 1017 | ✗ | const DWORD error = wait_result == WAIT_FAILED ? GetLastError() : ERROR_GEN_FAILURE; | |
| 1018 | // The shutdown request is already latched, so the worker drains regardless of this failure; only the | ||
| 1019 | // slot is restored, for a caller that wants to retry the wait. Retire the drain phase with the drain | ||
| 1020 | // this caller no longer owns. The worker's own authorization comes from its identity, not this word. | ||
| 1021 | ✗ | detail::lifecycle().set_loader_context(detail::LoaderContext::Normal); | |
| 1022 | ✗ | s_bootstrap_state.store(BootstrapState::Ready, std::memory_order_release); | |
| 1023 | ✗ | return std::unexpected(Error{ErrorCode::SystemCallFailed, "shutdown_and_wait", error}); | |
| 1024 | } | ||
| 1025 | 46 | CloseHandle(s_worker_thread); | |
| 1026 | 46 | s_worker_thread = nullptr; | |
| 1027 | } | ||
| 1028 | |||
| 1029 | 46 | retire_bootstrap_after_drain(); | |
| 1030 | 46 | s_bootstrap_state.store(BootstrapState::Drained, std::memory_order_release); | |
| 1031 | 46 | return {}; | |
| 1032 | } | ||
| 1033 | |||
| 1034 | 11112223 | ModuleHandle module_handle() noexcept | |
| 1035 | { | ||
| 1036 | 11112223 | return detail::lifecycle().module(); | |
| 1037 | } | ||
| 1038 | |||
| 1039 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 1040 | // Test-only accessor for the bootstrap shutdown event handle. A test captures it before a synchronous drain and | ||
| 1041 | // confirms the handle closes only after racing request_shutdown() callers have left SetEvent. Not declared in a | ||
| 1042 | // public header; the test extern-declares it like the loader-lock override seams. | ||
| 1043 | 5 | HANDLE bootstrap_shutdown_event_for_test() noexcept | |
| 1044 | { | ||
| 1045 | 5 | return s_shutdown_event.load(std::memory_order_acquire); | |
| 1046 | } | ||
| 1047 | |||
| 1048 | // Arms or disarms the worker-launch failure. Set it around one bootstrap entry call only. | ||
| 1049 | 4 | void bootstrap_fail_worker_launch_for_test(bool fail) noexcept | |
| 1050 | { | ||
| 1051 | 4 | s_fail_worker_launch.store(fail, std::memory_order_release); | |
| 1052 | 4 | } | |
| 1053 | |||
| 1054 | 3 | void bootstrap_pre_setup_probe_for_test(void (*probe)() noexcept) noexcept | |
| 1055 | { | ||
| 1056 | 3 | s_bootstrap_pre_setup_probe = probe; | |
| 1057 | 3 | } | |
| 1058 | |||
| 1059 | // How many signals reached SetEvent on an already-invalidated handle. Monotonic across the process, so a case | ||
| 1060 | // brackets its own window with two reads. | ||
| 1061 | 2 | std::uint64_t bootstrap_signals_on_invalid_event_for_test() noexcept | |
| 1062 | { | ||
| 1063 | 2 | return s_signal_on_invalid_event.load(std::memory_order_relaxed); | |
| 1064 | } | ||
| 1065 | #endif | ||
| 1066 | |||
| 1067 | namespace | ||
| 1068 | { | ||
| 1069 | /** | ||
| 1070 | * @brief Runs both halves of the [B-74] transaction under one deadline. | ||
| 1071 | */ | ||
| 1072 | template <typename DrainInput> | ||
| 1073 | requires std::is_nothrow_invocable_r_v<input::CallbackDrainStatus, DrainInput, std::chrono::milliseconds> | ||
| 1074 | [[nodiscard]] LogicDllUnloadStatus | ||
| 1075 | 1565 | run_logic_dll_unload(std::chrono::milliseconds timeout, DrainInput drain_input) noexcept | |
| 1076 | { | ||
| 1077 | 1565 | if (!detail::blocking_teardown_permitted()) | |
| 1078 | { | ||
| 1079 | 1 | return LogicDllUnloadStatus::LoaderLock; | |
| 1080 | } | ||
| 1081 | 1564 | if (detail::current_thread_in_delivery()) | |
| 1082 | { | ||
| 1083 | 1 | return LogicDllUnloadStatus::SelfDelivery; | |
| 1084 | } | ||
| 1085 | |||
| 1086 | 1563 | const auto deadline = detail::drain_deadline(timeout); | |
| 1087 | 1563 | const config::detail::ReloadDrainStatus begin_status = config::detail::begin_reload_drain(); | |
| 1088 | 1563 | if (begin_status == config::detail::ReloadDrainStatus::SelfDelivery) | |
| 1089 | { | ||
| 1090 | ✗ | return LogicDllUnloadStatus::SelfDelivery; | |
| 1091 | } | ||
| 1092 | 1563 | if (begin_status == config::detail::ReloadDrainStatus::InProgress) | |
| 1093 | { | ||
| 1094 | 1 | return LogicDllUnloadStatus::InProgress; | |
| 1095 | } | ||
| 1096 | |||
| 1097 | 1562 | const auto now = std::chrono::steady_clock::now(); | |
| 1098 | 1562 | const auto input_timeout = now < deadline | |
| 1099 | 1562 | ? std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now) | |
| 1100 | ✗ | : std::chrono::milliseconds{0}; | |
| 1101 | 1562 | const input::CallbackDrainStatus input_status = drain_input(input_timeout); | |
| 1102 | |||
| 1103 | 1562 | const config::detail::ReloadDrainStatus config_status = config::detail::finish_reload_drain(deadline); | |
| 1104 | 1562 | LogicDllUnloadStatus status = LogicDllUnloadStatus::TimedOut; | |
| 1105 | 1562 | if (input_status == input::CallbackDrainStatus::SelfDelivery || | |
| 1106 | config_status == config::detail::ReloadDrainStatus::SelfDelivery) | ||
| 1107 | { | ||
| 1108 | ✗ | status = LogicDllUnloadStatus::SelfDelivery; | |
| 1109 | } | ||
| 1110 | 1562 | else if (input_status == input::CallbackDrainStatus::InProgress || | |
| 1111 | config_status == config::detail::ReloadDrainStatus::InProgress) | ||
| 1112 | { | ||
| 1113 | ✗ | status = LogicDllUnloadStatus::InProgress; | |
| 1114 | } | ||
| 1115 | 1562 | else if (input_status == input::CallbackDrainStatus::RetireFailed) | |
| 1116 | { | ||
| 1117 | ✗ | status = LogicDllUnloadStatus::RetireFailed; | |
| 1118 | } | ||
| 1119 | 1562 | else if (input_status == input::CallbackDrainStatus::Drained && | |
| 1120 | config_status == config::detail::ReloadDrainStatus::Ready) | ||
| 1121 | { | ||
| 1122 | 1554 | if (detail::open_input_callback_admission()) | |
| 1123 | { | ||
| 1124 | 1554 | return LogicDllUnloadStatus::SafeToUnload; | |
| 1125 | } | ||
| 1126 | ✗ | status = LogicDllUnloadStatus::InProgress; | |
| 1127 | } | ||
| 1128 | |||
| 1129 | // The unresolved drain closes admission until a later transaction proves the old callbacks gone. | ||
| 1130 | 8 | detail::mark_input_callback_drain_pending(); | |
| 1131 | 8 | return status; | |
| 1132 | } | ||
| 1133 | } // namespace | ||
| 1134 | |||
| 1135 | 18 | LogicDllUnloadStatus prepare_logic_dll_unload( | |
| 1136 | std::span<const std::string_view> binding_names, | ||
| 1137 | std::chrono::milliseconds timeout | ||
| 1138 | ) noexcept | ||
| 1139 | { | ||
| 1140 | 18 | return run_logic_dll_unload( | |
| 1141 | timeout, | ||
| 1142 | 15 | [binding_names](std::chrono::milliseconds input_timeout) noexcept -> input::CallbackDrainStatus | |
| 1143 | 15 | { return input::Input::instance().prepare_logic_dll_unload(binding_names, input_timeout); } | |
| 1144 | 18 | ); | |
| 1145 | } | ||
| 1146 | |||
| 1147 | 1547 | LogicDllUnloadStatus prepare_logic_dll_unload_all(std::chrono::milliseconds timeout) noexcept | |
| 1148 | { | ||
| 1149 | 1547 | return run_logic_dll_unload( | |
| 1150 | timeout, | ||
| 1151 | 1547 | [](std::chrono::milliseconds input_timeout) noexcept -> input::CallbackDrainStatus | |
| 1152 | 1547 | { return input::Input::instance().prepare_logic_dll_unload_all(input_timeout); } | |
| 1153 | 1547 | ); | |
| 1154 | } | ||
| 1155 | |||
| 1156 | 6 | void on_logic_dll_unload(std::span<const std::string_view> binding_names) noexcept | |
| 1157 | { | ||
| 1158 | 6 | if (!detail::blocking_teardown_permitted()) | |
| 1159 | { | ||
| 1160 | ✗ | detail::close_input_callback_admission(); | |
| 1161 | ✗ | config::detail::disable_reloads_for_unload(); | |
| 1162 | ✗ | return; | |
| 1163 | } | ||
| 1164 | 6 | (void)prepare_logic_dll_unload(binding_names); | |
| 1165 | } | ||
| 1166 | |||
| 1167 | 7 | void on_logic_dll_unload_all() noexcept | |
| 1168 | { | ||
| 1169 | 7 | if (!detail::blocking_teardown_permitted()) | |
| 1170 | { | ||
| 1171 | ✗ | detail::close_input_callback_admission(); | |
| 1172 | ✗ | config::detail::disable_reloads_for_unload(); | |
| 1173 | ✗ | return; | |
| 1174 | } | ||
| 1175 | 7 | (void)prepare_logic_dll_unload_all(); | |
| 1176 | } | ||
| 1177 | } // namespace DetourModKit | ||
| 1178 |