GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 65.2% 122 / 0 / 187
Functions: 91.7% 11 / 0 / 12
Branches: 51.7% 61 / 0 / 118

src/bootstrap.cpp
Line Branch Exec Source
1 #include "DetourModKit/bootstrap.hpp"
2 #include "DetourModKit/diagnostics.hpp"
3
4 #include "DetourModKit/config.hpp"
5 #include "DetourModKit/hook_manager.hpp"
6 #include "DetourModKit/input.hpp"
7 #include "DetourModKit/logger.hpp"
8 #include "DetourModKit/async_logger.hpp"
9
10 #include <DetourModKit.hpp>
11
12 #include <windows.h>
13
14 #include <atomic>
15 #include <cwchar>
16 #include <exception>
17
18 namespace DetourModKit::Bootstrap
19 {
20 namespace
21 {
22 HANDLE s_shutdown_event = nullptr;
23 HANDLE s_worker_thread = nullptr;
24 HANDLE s_instance_mutex = nullptr;
25 HMODULE s_module = nullptr;
26 std::atomic<bool> s_detach_called{false};
27
28 std::function<bool()> s_init_fn;
29 std::function<void()> s_shutdown_fn;
30
31 9 bool is_target_process(std::string_view expected) noexcept
32 {
33
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 8 times.
9 if (expected.empty())
34 {
35 1 return true;
36 }
37
38 // Resolve the running executable path as wide rather than via GetModuleFileNameA: the ANSI form maps the
39 // path through the active code page, so a game whose EXE basename contains non-ASCII characters would be
40 // mangled and could miss (or false-match) the gate. The rest of bootstrap already works in wide for the
41 // instance-mutex name, so the wide module path keeps the process gate consistent with the kit's UTF-8
42 // stance.
43 8 wchar_t exe_path[MAX_PATH]{};
44 8 const DWORD len = GetModuleFileNameW(nullptr, exe_path, MAX_PATH);
45
2/4
✓ Branch 6 → 7 taken 8 times.
✗ Branch 6 → 8 not taken.
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 8 times.
8 if (len == 0 || len >= MAX_PATH)
46 {
47 return false;
48 }
49
50 8 const wchar_t *exe_name = std::wcsrchr(exe_path, L'\\');
51
1/2
✓ Branch 10 → 11 taken 8 times.
✗ Branch 10 → 12 not taken.
8 exe_name = exe_name ? exe_name + 1 : exe_path;
52
53 // Widen the caller-supplied UTF-8 name into a bounded stack buffer for a wide case-insensitive compare.
54 // This helper is noexcept and runs on the bootstrap path, so it avoids a throwing allocation; a name that
55 // cannot fit a module file name cannot match the running executable. MultiByteToWideChar with a fixed
56 // destination and MB_ERR_INVALID_CHARS fails closed (returns 0) on overflow or malformed UTF-8 rather than
57 // truncating into a false match.
58
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 16 taken 8 times.
8 if (expected.size() >= MAX_PATH)
59 {
60 return false;
61 }
62 wchar_t expected_buf[MAX_PATH];
63 8 const int wide_len = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, expected.data(),
64 8 static_cast<int>(expected.size()), expected_buf, MAX_PATH - 1);
65
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 8 times.
8 if (wide_len <= 0)
66 {
67 return false;
68 }
69 8 expected_buf[wide_len] = L'\0';
70 8 return _wcsicmp(exe_name, expected_buf) == 0;
71 }
72
73 7 bool acquire_instance_mutex(std::string_view prefix) noexcept
74 {
75
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 7 times.
7 if (prefix.empty())
76 {
77 return true;
78 }
79
80 // Build the name in a std::wstring rather than formatting into a fixed wchar_t buffer: the prefix is
81 // caller-supplied, so a bounded formatter would have to truncate or fail, and an unbounded one (wsprintfW)
82 // could overflow. CreateMutexW rejects an over-long name on its own, which the null-handle check below
83 // already handles. The allocation is wrapped because this helper is noexcept and runs on the bootstrap
84 // path; an out-of-memory failure fails closed (no single-instance guard) rather than terminating the
85 // process.
86 7 std::wstring mutex_name;
87 try
88 {
89
1/2
✓ Branch 7 → 8 taken 7 times.
✗ Branch 7 → 32 not taken.
7 mutex_name.reserve(prefix.size() + 10);
90
2/2
✓ Branch 12 → 10 taken 337 times.
✓ Branch 12 → 13 taken 7 times.
344 for (char c : prefix)
91 {
92
1/2
✓ Branch 10 → 11 taken 337 times.
✗ Branch 10 → 32 not taken.
337 mutex_name.push_back(static_cast<wchar_t>(static_cast<unsigned char>(c)));
93 }
94
3/6
✓ Branch 13 → 14 taken 7 times.
✗ Branch 13 → 31 not taken.
✓ Branch 14 → 15 taken 7 times.
✗ Branch 14 → 31 not taken.
✓ Branch 15 → 16 taken 7 times.
✗ Branch 15 → 29 not taken.
7 mutex_name += std::to_wstring(GetCurrentProcessId());
95 }
96 catch (...)
97 {
98 return false;
99 }
100
101 7 HANDLE mutex_handle = CreateMutexW(nullptr, FALSE, mutex_name.c_str());
102
1/2
✗ Branch 19 → 20 not taken.
✓ Branch 19 → 21 taken 7 times.
7 if (!mutex_handle)
103 {
104 return false;
105 }
106
2/2
✓ Branch 22 → 23 taken 3 times.
✓ Branch 22 → 25 taken 4 times.
7 if (GetLastError() == ERROR_ALREADY_EXISTS)
107 {
108 3 CloseHandle(mutex_handle);
109 3 return false;
110 }
111
112 4 s_instance_mutex = mutex_handle;
113 4 return true;
114 7 }
115
116 4 DWORD WINAPI lifecycle_thread(LPVOID) noexcept
117 {
118 4 Logger &logger = Logger::get_instance();
119
120 4 bool init_ok = false;
121
1/2
✓ Branch 4 → 5 taken 4 times.
✗ Branch 4 → 7 not taken.
4 if (s_init_fn)
122 {
123 try
124 {
125
2/2
✓ Branch 5 → 6 taken 3 times.
✓ Branch 5 → 19 taken 1 time.
4 init_ok = s_init_fn();
126 }
127
1/2
✓ Branch 19 → 20 taken 1 time.
✗ Branch 19 → 24 not taken.
1 catch (const std::exception &e)
128 {
129 1 logger.error("Bootstrap: init_fn threw: {}", e.what());
130 1 }
131 catch (...)
132 {
133 logger.error("Bootstrap: init_fn threw unknown exception.");
134 }
135 }
136 else
137 {
138 init_ok = true;
139 }
140
141
2/2
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 11 taken 3 times.
4 if (!init_ok)
142 {
143 1 logger.error("Bootstrap: init_fn returned failure; worker idling until detach.");
144 }
145
146
1/2
✓ Branch 11 → 12 taken 4 times.
✗ Branch 11 → 13 not taken.
4 if (s_shutdown_event)
147 {
148 4 WaitForSingleObject(s_shutdown_event, INFINITE);
149 }
150
151
1/2
✓ Branch 14 → 15 taken 4 times.
✗ Branch 14 → 16 not taken.
4 if (s_shutdown_fn)
152 {
153 try
154 {
155
2/2
✓ Branch 15 → 16 taken 3 times.
✓ Branch 15 → 27 taken 1 time.
4 s_shutdown_fn();
156 }
157
1/2
✓ Branch 27 → 28 taken 1 time.
✗ Branch 27 → 32 not taken.
1 catch (const std::exception &e)
158 {
159 1 logger.error("Bootstrap: shutdown_fn threw: {}", e.what());
160 1 }
161 catch (...)
162 {
163 logger.error("Bootstrap: shutdown_fn threw unknown exception.");
164 }
165 }
166
167 4 DMK_Shutdown();
168 4 return 0;
169 }
170
171 4 void release_instance_mutex() noexcept
172 {
173
1/2
✓ Branch 2 → 3 taken 4 times.
✗ Branch 2 → 5 not taken.
4 if (s_instance_mutex)
174 {
175 4 CloseHandle(s_instance_mutex);
176 4 s_instance_mutex = nullptr;
177 }
178 4 }
179
180 // Shared teardown path for early-attach failures. Ensures singletons initialized by Logger::configure / user
181 // init_fn are torn down before on_dll_attach returns FALSE, so a subsequent DLL load starts clean.
182 void unwind_early_attach_failure() noexcept
183 {
184 if (s_shutdown_event)
185 {
186 CloseHandle(s_shutdown_event);
187 s_shutdown_event = nullptr;
188 }
189 release_instance_mutex();
190 s_init_fn = nullptr;
191 s_shutdown_fn = nullptr;
192 s_module = nullptr;
193 try
194 {
195 DMK_Shutdown();
196 }
197 catch (...)
198 {
199 }
200 }
201
202 // The throwing core of on_dll_attach, separated so the public entry point can be noexcept. on_dll_attach is
203 // called from DllMain under the Windows loader lock, where an escaping exception is undefined behavior. Any
204 // throwing step here -- most concretely Logger::configure, which builds std::string / std::wstring for the log
205 // prefix and path and can raise std::bad_alloc -- propagates to the noexcept wrapper, which fails closed.
206 10 [[nodiscard]] bool32_t attach_core(module_handle_t hMod, const ModInfo &info, std::function<bool()> init_fn,
207 std::function<void()> shutdown_fn)
208 {
209
3/4
✓ Branch 2 → 3 taken 9 times.
✓ Branch 2 → 4 taken 1 time.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 9 times.
10 if (s_shutdown_event || s_worker_thread)
210 {
211 1 return FALSE;
212 }
213
214 9 s_module = hMod;
215
2/2
✓ Branch 5 → 6 taken 8 times.
✓ Branch 5 → 7 taken 1 time.
9 if (hMod)
216 {
217 8 DisableThreadLibraryCalls(hMod);
218 }
219
220
2/2
✓ Branch 8 → 9 taken 2 times.
✓ Branch 8 → 10 taken 7 times.
9 if (!is_target_process(info.game_process_name))
221 {
222 2 s_module = nullptr;
223 2 return FALSE;
224 }
225
226
2/2
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 4 times.
7 if (!acquire_instance_mutex(info.instance_mutex_prefix))
227 {
228 3 s_module = nullptr;
229 3 return FALSE;
230 }
231
232
1/2
✓ Branch 14 → 15 taken 4 times.
✗ Branch 14 → 34 not taken.
4 Logger::configure(info.prefix, info.log_file);
233 4 Logger::get_instance().enable_async_mode(info.async_cfg);
234
235 4 s_init_fn = std::move(init_fn);
236 4 s_shutdown_fn = std::move(shutdown_fn);
237
238 4 s_shutdown_event = CreateEventW(nullptr, TRUE, FALSE, nullptr);
239
1/2
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 27 taken 4 times.
4 if (!s_shutdown_event)
240 {
241 unwind_early_attach_failure();
242 return FALSE;
243 }
244
245 4 s_worker_thread = CreateThread(nullptr, 0, lifecycle_thread, nullptr, 0, nullptr);
246
1/2
✗ Branch 28 → 29 not taken.
✓ Branch 28 → 31 taken 4 times.
4 if (!s_worker_thread)
247 {
248 unwind_early_attach_failure();
249 return FALSE;
250 }
251
252 // Re-arm the detach gate now that a fresh attach has fully succeeded, so the matching on_dll_detach runs
253 // its teardown instead of no-opping. Without this reset s_detach_called stays true after the first detach
254 // and a second attach/detach cycle would leak the worker thread, shutdown event, and instance mutex (the
255 // header contract promises a subsequent attach starts from a clean slate). Reset only on the success path:
256 // early failures above never set the gate, so they must not clear it either. Release pairs with the acquire
257 // in on_dll_detach's compare_exchange. Both run serialized under the loader lock.
258 4 s_detach_called.store(false, std::memory_order_release);
259
260 4 return TRUE;
261 }
262 } // anonymous namespace
263
264 10 [[nodiscard]] bool32_t on_dll_attach(module_handle_t hMod, const ModInfo &info, std::function<bool()> init_fn,
265 std::function<void()> shutdown_fn) noexcept
266 {
267 // Fail closed on any exception so nothing unwinds across the loader lock (see attach_core). On a throw the
268 // partially-built attach state is rolled back through the same path the explicit early-failure returns use.
269 try
270 {
271
1/2
✓ Branch 8 → 9 taken 10 times.
✗ Branch 8 → 14 not taken.
20 return attach_core(hMod, info, std::move(init_fn), std::move(shutdown_fn));
272 }
273 catch (...)
274 {
275 unwind_early_attach_failure();
276 return FALSE;
277 }
278 }
279
280 8 void request_shutdown() noexcept
281 {
282
2/2
✓ Branch 2 → 3 taken 6 times.
✓ Branch 2 → 4 taken 2 times.
8 if (s_shutdown_event)
283 {
284 6 SetEvent(s_shutdown_event);
285 }
286 8 }
287
288 4 void on_dll_detach(bool32_t is_process_exit) noexcept
289 {
290 4 bool expected = false;
291
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 4 times.
4 if (!s_detach_called.compare_exchange_strong(expected, true, std::memory_order_acq_rel))
292 {
293 return;
294 }
295
296 // On process exit Windows has already terminated every other thread in the process before calling DllMain with
297 // DLL_PROCESS_DETACH. Waiting on s_worker_thread would block forever because the worker was abruptly killed
298 // mid-WaitForSingleObject. Skip the wait and run the explicit shutdown path directly.
299
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 10 taken 4 times.
4 if (is_process_exit)
300 {
301 if (s_shutdown_fn)
302 {
303 try
304 {
305 s_shutdown_fn();
306 }
307 catch (const std::exception &e)
308 {
309 try
310 {
311 Logger::get_instance().error("Bootstrap: shutdown_fn threw: {}", e.what());
312 }
313 catch (...)
314 {
315 }
316 }
317 catch (...)
318 {
319 try
320 {
321 Logger::get_instance().error("Bootstrap: shutdown_fn threw unknown exception.");
322 }
323 catch (...)
324 {
325 }
326 }
327 }
328 DMK_Shutdown();
329 }
330
1/2
✓ Branch 10 → 11 taken 4 times.
✗ Branch 10 → 13 not taken.
4 else if (s_shutdown_event)
331 {
332 // Dynamic unload under loader lock. Signal the worker but do NOT wait here: blocking under loader lock
333 // deadlocks any peer DllMain that touches Win32 APIs. The contract (see bootstrap.hpp) is that callers who
334 // need a clean unload call request_shutdown() before FreeLibrary and give the worker time to drain.
335 4 SetEvent(s_shutdown_event);
336 4 DetourModKit::Diagnostics::record_intentional_leak(DetourModKit::Diagnostics::LeakSubsystem::Bootstrap);
337 }
338
339
1/2
✓ Branch 13 → 14 taken 4 times.
✗ Branch 13 → 16 not taken.
4 if (s_shutdown_event)
340 {
341 4 CloseHandle(s_shutdown_event);
342 4 s_shutdown_event = nullptr;
343 }
344
345
1/2
✓ Branch 16 → 17 taken 4 times.
✗ Branch 16 → 19 not taken.
4 if (s_worker_thread)
346 {
347 4 CloseHandle(s_worker_thread);
348 4 s_worker_thread = nullptr;
349 }
350
351 4 release_instance_mutex();
352 4 s_module = nullptr;
353 }
354
355 6 module_handle_t module_handle() noexcept
356 {
357 6 return s_module;
358 }
359
360 9 void on_logic_dll_unload(std::span<const std::string_view> hook_names,
361 std::span<const std::string_view> binding_names) noexcept
362 {
363 9 Logger &logger = Logger::get_instance();
364 9 size_t hooks_removed = 0;
365 9 size_t bindings_removed = 0;
366
367
2/2
✓ Branch 26 → 5 taken 8 times.
✓ Branch 26 → 27 taken 9 times.
26 for (const auto name : hook_names)
368 {
369 try
370 {
371
2/4
✓ Branch 7 → 8 taken 8 times.
✗ Branch 7 → 49 not taken.
✓ Branch 8 → 9 taken 8 times.
✗ Branch 8 → 49 not taken.
8 auto result = HookManager::get_instance().remove_hook(name);
372
2/2
✓ Branch 10 → 11 taken 5 times.
✓ Branch 10 → 12 taken 3 times.
8 if (result)
373 {
374 5 ++hooks_removed;
375 }
376 else
377 {
378
1/2
✓ Branch 14 → 15 taken 3 times.
✗ Branch 14 → 47 not taken.
3 logger.debug("Bootstrap: on_logic_dll_unload: hook '{}' not removed ({}).", name,
379 6 Hook::error_to_string(result.error()));
380 }
381 }
382 catch (const std::exception &e)
383 {
384 logger.error("Bootstrap: on_logic_dll_unload caught exception removing hook '{}': {}", name, e.what());
385 }
386 catch (...)
387 {
388 logger.error("Bootstrap: on_logic_dll_unload caught unknown exception removing hook '{}'.", name);
389 }
390 }
391
392
2/2
✓ Branch 42 → 29 taken 9 times.
✓ Branch 42 → 43 taken 9 times.
27 for (const auto name : binding_names)
393 {
394 try
395 {
396 // Pass invoke_callbacks=false because this helper is documented as safe from DllMain detach paths. User
397 // on_state_change(false)
398 // callbacks for held bindings live in the unloading Logic DLL;
399 // running them under loader lock is the deadlock-or-crash vector that the leak-on-purpose discipline
400 // was set up to forbid.
401 9 bindings_removed += InputManager::get_instance().remove_binding_by_name(name, false);
402 }
403 catch (const std::exception &e)
404 {
405 logger.error("Bootstrap: on_logic_dll_unload caught exception removing binding '{}': {}", name,
406 e.what());
407 }
408 catch (...)
409 {
410 logger.error("Bootstrap: on_logic_dll_unload caught unknown exception removing binding '{}'.", name);
411 }
412 }
413
414 9 logger.info("Bootstrap: on_logic_dll_unload drained {} hook(s) and {} binding(s).", hooks_removed,
415 bindings_removed);
416
417 // Wipe the Config registry last because the prior hook and binding teardown may invoke a registered setter one
418 // final time (a setter that observes a binding-driven flag, for instance). Clearing first would orphan that
419 // final-fire path mid-call. The registered std::function setters' call operators, vtables, and destructors live
420 // in the Logic DLL's
421 // .text segment; once the loader unmaps that segment, every surviving entry becomes a use-after-unload hazard.
422 // The next attach's replace_or_append destroys the stale slot before installing the fresh one, which would
423 // invoke the old setter's destructor against freed pages.
424 //
425 // Stop the auto-reload watcher before wiping the registry: its worker can fire reload() (running the registered
426 // setters) at any time, and both the user on_reload callback and those setters live in the unloading Logic DLL.
427 // A survivor would call into unmapped pages after the DLL goes away, and would make the next attach's
428 // enable_auto_reload() report AlreadyRunning instead of starting fresh. disable_auto_reload() is noexcept and a
429 // no-op when no watcher is running.
430 9 Config::disable_auto_reload();
431 9 Config::clear_registered_items();
432 9 }
433
434 10 void on_logic_dll_unload_all() noexcept
435 {
436 10 Logger &logger = Logger::get_instance();
437
438 // Hooks first so the original prologue bytes are restored before the binding teardown can disturb any callback
439 // that still trampolines through SafetyHook. remove_all_hooks() resets m_shutdown_called at the end, leaving
440 // HookManager re-usable for the next attach.
441 try
442 {
443
1/2
✓ Branch 3 → 4 taken 10 times.
✗ Branch 3 → 12 not taken.
10 HookManager::get_instance().remove_all_hooks();
444 }
445 catch (const std::exception &e)
446 {
447 try
448 {
449 logger.error("Bootstrap: on_logic_dll_unload_all caught exception in remove_all_hooks: {}", e.what());
450 }
451 catch (...)
452 {
453 }
454 }
455 catch (...)
456 {
457 try
458 {
459 logger.error("Bootstrap: on_logic_dll_unload_all caught unknown exception in remove_all_hooks.");
460 }
461 catch (...)
462 {
463 }
464 }
465
466 // clear_bindings() leaves the poll thread running and ready to accept fresh bindings, matching the "tear down
467 // per-Logic-DLL state but keep the manager re-usable" contract that the named-list overload honours. Pass
468 // invoke_callbacks=false because this helper is documented as safe from DllMain detach paths: user release
469 // callbacks live in the unloading Logic DLL and must not be invoked under loader lock.
470 try
471 {
472 10 InputManager::get_instance().clear_bindings(false);
473 }
474 catch (const std::exception &e)
475 {
476 try
477 {
478 logger.error("Bootstrap: on_logic_dll_unload_all caught exception in clear_bindings: {}", e.what());
479 }
480 catch (...)
481 {
482 }
483 }
484 catch (...)
485 {
486 try
487 {
488 logger.error("Bootstrap: on_logic_dll_unload_all caught unknown exception in clear_bindings.");
489 }
490 catch (...)
491 {
492 }
493 }
494
495 try
496 {
497
1/2
✓ Branch 7 → 8 taken 10 times.
✗ Branch 7 → 31 not taken.
10 logger.info("Bootstrap: on_logic_dll_unload_all drained all hooks and bindings.");
498 }
499 catch (...)
500 {
501 }
502
503 // Wipe the Config registry last for the same reason as the named-list overload: the prior remove_all_hooks /
504 // clear_bindings calls may fire a registered setter one final time, and clearing first would orphan that path.
505 // The registered std::function setters' call operators, vtables,
506 // and destructors live in the unloading Logic DLL's .text;
507 // every surviving entry becomes a use-after-unload hazard the moment the loader reclaims those pages.
508 //
509 // Stop the auto-reload watcher before wiping the registry: its worker can fire reload() (running the registered
510 // setters) at any time, and both the user on_reload callback and those setters live in the unloading Logic DLL.
511 // A survivor would call into unmapped pages after the DLL goes away, and would make the next attach's
512 // enable_auto_reload() report AlreadyRunning instead of starting fresh. disable_auto_reload() is noexcept and a
513 // no-op when no watcher is running.
514 10 Config::disable_auto_reload();
515 10 Config::clear_registered_items();
516 10 }
517 } // namespace DetourModKit::Bootstrap
518