GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 81.9% 388 / 0 / 474
Functions: 97.7% 43 / 0 / 44
Branches: 66.8% 155 / 0 / 232

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