GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 87.4% 340 / 0 / 389
Functions: 97.5% 39 / 0 / 40
Branches: 70.3% 208 / 0 / 296

src/config_watch.cpp
Line Branch Exec Source
1 /**
2 * @file config_watch.cpp
3 * @brief This TU owns the watcher control plane: the auto-reload watcher slot, the reload-hotkey servicer, and the
4 * persisted reload callback.
5 *
6 * The data-plane pass lives in config.cpp and the reload lifecycle gate in src/internal/config_reload.cpp. The other
7 * planes reach this state through internal/config_watch_control.hpp.
8 */
9
10 #include "DetourModKit/config.hpp"
11 #include "DetourModKit/diagnostics.hpp"
12 #include "DetourModKit/input.hpp"
13 #include "DetourModKit/logger.hpp"
14 #include "DetourModKit/detail/worker.hpp"
15
16 #include "internal/config_pass.hpp"
17 #include "internal/config_reload_lifecycle.hpp"
18 #include "internal/config_watch_control.hpp"
19 #include "internal/config_watcher.hpp"
20 #include "internal/lifecycle_context.hpp"
21 #include "internal/lifecycle_reaper.hpp"
22 #include "internal/worker_start_log.hpp"
23
24 #include <atomic>
25 #include <chrono>
26 #include <condition_variable>
27 #include <cstdint>
28 #include <filesystem>
29 #include <functional>
30 #include <memory>
31 #include <mutex>
32 #include <new>
33 #include <string>
34 #include <string_view>
35 #include <thread>
36 #include <utility>
37 #include <vector>
38
39 namespace DetourModKit::detail
40 {
41 #if defined(DMK_ENABLE_TEST_SEAMS)
42 // Test-only override for the loader-lock probe inside ~ReloadServicer's teardown gate.
43 // It replaces only the veto result.
44 // The explicit loader context remains the sole authorization.
45 // One fixture thread sets and clears this plain function pointer.
46 bool (*g_config_reload_loader_lock_override)() noexcept = nullptr;
47
48 // ~ReloadServicer sets this flag on the off-thread reaper branch. A proof can observe self-retirement.
49 std::atomic<bool> g_servicer_reaped_on_worker{false};
50
51 // Parks the reload worker while it owns Channel::mutex. A subprocess can drive process-exit teardown after
52 // Windows terminates the mutex owner.
53 std::atomic<std::atomic<bool> *> g_config_reload_worker_mutex_gate_probe{nullptr};
54 std::atomic<bool> g_config_reload_worker_mutex_waiting_probe{false};
55
56 // Parks the reload worker after its last mutex use and before exit-guard publication. A test can keep the body
57 // live but unexited across teardown.
58 std::atomic<std::atomic<bool> *> g_config_reload_worker_exit_gate_probe{nullptr};
59
60 // Fired immediately before config disposes of an internally retained reload-hotkey BindingGuard.
61 void (*g_config_reload_hotkey_guard_disposal_probe)() noexcept = nullptr;
62 #endif
63 } // namespace DetourModKit::detail
64
65 namespace DetourModKit
66 {
67 namespace config
68 {
69 namespace
70 {
71 // A separate mutex keeps watcher start/stop apart from registration traffic. It also serializes the reload
72 // servicer and reload-hotkey guard vector.
73 1932116 std::mutex &get_watcher_mutex()
74 {
75
3/4
✓ Branch 2 → 3 taken 353 times.
✓ Branch 2 → 8 taken 1931763 times.
✓ Branch 4 → 5 taken 353 times.
✗ Branch 4 → 8 not taken.
1932116 static std::mutex s_mtx;
76 1932116 return s_mtx;
77 }
78
79 3610076 std::unique_ptr<DetourModKit::detail::ConfigWatcher> &get_config_watcher()
80 {
81 // `[B-47]` requires never-destroyed storage. Static destruction can run after libstdc++ tears down
82 // worker synchronization state and can fault the process. Explicit disable and drain paths still
83 // reset this owner. Lifecycle.FullLifecycleExit pins the process exit path.
84 using WatcherOwner = std::unique_ptr<DetourModKit::detail::ConfigWatcher>;
85 alignas(WatcherOwner) static unsigned char s_watcher_storage[sizeof(WatcherOwner)];
86
4/6
✓ Branch 2 → 3 taken 309 times.
✓ Branch 2 → 10 taken 3609767 times.
✓ Branch 4 → 5 taken 309 times.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 309 times.
3610076 static WatcherOwner *const s_watcher = ::new (static_cast<void *>(s_watcher_storage)) WatcherOwner();
87 3610076 return *s_watcher;
88 }
89
90 // Stores a copy of the user on_reload callback. ConfigWatcher swallows it with no getter, so only this
91 // copy lets load()'s re-point reconstruct an equivalent watcher. get_watcher_mutex() guards it.
92 456 std::function<void(bool)> &get_reload_user_callback() noexcept
93 {
94
3/4
✓ Branch 2 → 3 taken 196 times.
✓ Branch 2 → 8 taken 260 times.
✓ Branch 4 → 5 taken 196 times.
✗ Branch 4 → 8 not taken.
456 static std::function<void(bool)> s_callback;
95 456 return s_callback;
96 }
97
98 // This counter advances on each real disable_auto_reload() teardown. load() captures it before a stale-
99 // watcher join and checks it before restart. A changed value prevents watcher resurrection after a
100 // concurrent disable. An empty callback slot still represents a valid enabled state. get_watcher_mutex()
101 // guards the counter.
102 367 [[nodiscard]] std::uint64_t &get_watcher_disable_generation() noexcept
103 {
104 static std::uint64_t s_generation = 0;
105 367 return s_generation;
106 }
107
108 // Compares two resolved INI paths without case sensitivity. Separators and normalization already match. An
109 // ordinal ASCII fold is correct for case-insensitive Windows paths. A locale fold is deliberately avoided,
110 // per the watcher's ordinal filename match.
111 13 [[nodiscard]] bool resolved_paths_equivalent(std::string_view a, std::string_view b) noexcept
112 {
113
2/2
✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 6 taken 10 times.
13 if (a.size() != b.size())
114 {
115 3 return false;
116 }
117 1224 const auto ascii_lower = [](char c) noexcept -> unsigned char
118 {
119 1224 const auto u = static_cast<unsigned char>(c);
120
4/4
✓ Branch 2 → 3 taken 1068 times.
✓ Branch 2 → 5 taken 156 times.
✓ Branch 3 → 4 taken 240 times.
✓ Branch 3 → 5 taken 828 times.
1224 return (u >= 'A' && u <= 'Z') ? static_cast<unsigned char>(u + ('a' - 'A')) : u;
121 };
122
2/2
✓ Branch 15 → 7 taken 612 times.
✓ Branch 15 → 16 taken 8 times.
620 for (size_t i = 0; i < a.size(); ++i)
123 {
124
2/2
✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 610 times.
612 if (ascii_lower(a[i]) != ascii_lower(b[i]))
125 {
126 2 return false;
127 }
128 }
129 8 return true;
130 }
131
132 // Keeps reload-hotkey BindingGuards alive for the process lifetime. ~BindingGuard disables the binding, so
133 // a dropped returned guard makes the hotkey a silent no-op forever. Guarded by get_watcher_mutex().
134 836 std::vector<input::BindingGuard> &get_reload_hotkey_guards() noexcept
135 {
136
3/4
✓ Branch 2 → 3 taken 351 times.
✓ Branch 2 → 7 taken 485 times.
✓ Branch 4 → 5 taken 351 times.
✗ Branch 4 → 7 not taken.
836 static std::vector<input::BindingGuard> s_guards;
137 836 return s_guards;
138 }
139
140 12 void run_reload_hotkey_guard_disposal_probe() noexcept
141 {
142 #if defined(DMK_ENABLE_TEST_SEAMS)
143
2/2
✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 10 times.
12 if (const auto probe = DetourModKit::detail::g_config_reload_hotkey_guard_disposal_probe)
144 {
145 2 probe();
146 }
147 #endif
148 12 }
149
150 // ~ReloadServicer uses this to choose join versus detach-and-leak. This matches the ConfigWatcher
151 // destructor's watcher_must_not_block().
152 10 bool reload_servicer_must_not_block() noexcept
153 {
154 #if defined(DMK_ENABLE_TEST_SEAMS)
155 10 return !DetourModKit::detail::blocking_teardown_permitted(
156 DetourModKit::detail::g_config_reload_loader_lock_override
157 10 );
158 #else
159 return !DetourModKit::detail::blocking_teardown_permitted();
160 #endif
161 }
162
163 /**
164 * @class ReloadServicer
165 * @brief Owns a background thread that coalesces reload-hotkey presses and invokes reload() off the input
166 * poll thread at most once per press batch.
167 * @details All state the worker touches lives in a heap-owned @ref Channel.
168 * It is separate from the servicer shell. The loader-lock teardown branch can detach the worker
169 * and leak the Channel under the ConfigWatcher discipline. It starts on the first reload_hotkey
170 * call.
171 * A std::shared_ptr prevents a press callback concurrent with shutdown from access to a freed
172 * servicer.
173 * The worker contains exceptions from reload(), so the service remains alive.
174 */
175 class ReloadServicer
176 {
177 // Channel stores every field that the worker reads. The worker member appears last, so ~Channel
178 // destroys it first and joins before the mutex or condition variable dies.
179 struct Channel
180 {
181 std::mutex mutex;
182 std::condition_variable cv;
183 std::atomic<bool> reload_requested{false};
184 std::atomic<bool> shutdown{false};
185 std::atomic<bool> worker_exited{false};
186 // service_loop publishes this value on entry and clears it on exit. ~ReloadServicer can then detect
187 // a self-join. config::clear() from a reload setter runs on this worker thread.
188 std::atomic<std::thread::id> worker_tid{};
189 // Lifecycle epoch captured at construction so superseded servicers cannot enter consumer code.
190 std::uint64_t birth_epoch{0};
191 std::unique_ptr<DetourModKit::StoppableWorker> worker;
192 };
193
194 public:
195 /**
196 * @brief Starts the reload service and records its canonical worker start line.
197 * @param diags Receives the worker start line for later emission.
198 */
199 12 explicit ReloadServicer(detail::DeferredDiagnostics &diags) : m_channel(std::make_unique<Channel>())
200 {
201 // Launch the worker against the heap-owned Channel, NOT `this`. The loader-lock teardown branch
202 // leaks the Channel, so the body must use storage that outlives the shell.
203 12 m_channel->birth_epoch = detail::current_reload_lifecycle_epoch();
204 12 Channel *channel = m_channel.get();
205 const DetourModKit::detail::WorkerStartLogDeferral start_log_deferral{
206 &diags,
207 &detail::defer_worker_start_diagnostic,
208 12 };
209 23 m_channel->worker = std::make_unique<DetourModKit::StoppableWorker>(
210 "ConfigReloadServicer",
211
2/2
✓ Branch 7 → 8 taken 11 times.
✓ Branch 7 → 13 taken 1 time.
36 [channel](std::stop_token st) { service_loop(*channel, std::move(st)); }
212 11 );
213 13 }
214
215 10 ~ReloadServicer() noexcept
216 7 {
217
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 10 times.
10 if (!m_channel)
218 {
219 return;
220 }
221
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 20 taken 9 times.
10 if (reload_servicer_must_not_block())
222 {
223 // The worker can own the Channel mutex when process-exit teardown begins, so publish only the
224 // lock-free shutdown hint and detach without callback invocation. The wake is best-effort by
225 // construction. A servicer parked in cv.wait can stay parked for process lifetime.
226 // This does not strand resources because this branch retains the Channel and module reference.
227 1 m_channel->shutdown.store(true, std::memory_order_release);
228 1 m_channel->cv.notify_all();
229
1/2
✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 17 not taken.
1 if (m_channel->worker)
230 {
231 1 m_channel->worker->shutdown();
232 }
233
234 // The detached service_loop can still read the Channel, so retain it for process lifetime.
235 1 (void)m_channel.release();
236 1 DetourModKit::diagnostics::record_intentional_leak(
237 DetourModKit::diagnostics::LeakSubsystem::Worker
238 );
239 1 return;
240 }
241
242 // Synchronous teardown is authorized. Serialize the shutdown predicate with the CV wait so the
243 // notification cannot land in its lost-wakeup window.
244 {
245 9 std::lock_guard<std::mutex> lock(m_channel->mutex);
246 9 m_channel->shutdown.store(true, std::memory_order_release);
247 9 }
248 9 m_channel->cv.notify_all();
249
250 const bool on_worker =
251 9 m_channel->worker_tid.load(std::memory_order_acquire) == std::this_thread::get_id();
252
253
2/2
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 39 taken 8 times.
9 if (on_worker)
254 {
255 // Self-shutdown off the loader lock cannot join this worker from itself because
256 // std::system_error results. Inline Channel destruction frees storage that service_loop uses.
257 // Hand the Channel to the off-thread reaper. It joins the worker, then destroys the Channel.
258 // No permanent leak remains.
259 #if defined(DMK_ENABLE_TEST_SEAMS)
260 1 DetourModKit::detail::g_servicer_reaped_on_worker.store(true, std::memory_order_release);
261 #endif
262 2 DetourModKit::detail::reap_owner(std::move(m_channel));
263 1 return;
264 }
265
266 // Off the loader lock and off the worker thread. shutdown() rechecks the teardown veto, so a join
267 // path can finish as a detach. Observe the body's exit publication, not another TOCTOU-prone veto
268 // check. Retain the Channel while the body remains active, as ~ConfigWatcher does. A leak is the
269 // safe direction.
270
1/2
✓ Branch 41 → 42 taken 8 times.
✗ Branch 41 → 45 not taken.
8 if (m_channel->worker)
271 {
272 8 m_channel->worker->shutdown();
273 }
274
2/2
✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 51 taken 7 times.
8 if (!m_channel->worker_exited.load(std::memory_order_acquire))
275 {
276 1 (void)m_channel.release();
277 1 DetourModKit::diagnostics::record_intentional_leak(
278 DetourModKit::diagnostics::LeakSubsystem::Worker
279 );
280 1 return;
281 }
282 7 m_channel.reset();
283
2/2
✓ Branch 54 → 55 taken 7 times.
✓ Branch 54 → 56 taken 3 times.
10 }
284
285 ReloadServicer(const ReloadServicer &) = delete;
286 ReloadServicer &operator=(const ReloadServicer &) = delete;
287 ReloadServicer(ReloadServicer &&) = delete;
288 ReloadServicer &operator=(ReloadServicer &&) = delete;
289
290 /// Requests a reload without exceptions or allocations. The press callback must not throw.
291 3 void request_reload() noexcept
292 {
293 // Mutate the predicate under the channel mutex to close the waiter-side lost-wakeup window.
294 {
295 3 std::lock_guard<std::mutex> lock(m_channel->mutex);
296 3 m_channel->reload_requested.store(true, std::memory_order_release);
297 3 }
298 3 m_channel->cv.notify_one();
299 3 }
300
301 /// Requests worker stop without a join or callback-storage destruction.
302 104930 void request_stop() noexcept
303 {
304
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 104930 times.
104930 if (!m_channel)
305 {
306 return;
307 }
308 {
309 104930 std::lock_guard<std::mutex> lock(m_channel->mutex);
310 104930 m_channel->shutdown.store(true, std::memory_order_release);
311 104930 }
312 104930 m_channel->cv.notify_all();
313
1/2
✓ Branch 14 → 15 taken 104930 times.
✗ Branch 14 → 18 not taken.
104930 if (m_channel->worker)
314 {
315 104930 m_channel->worker->request_stop();
316 }
317 }
318
319 /// Returns true after worker body exit.
320 104928 [[nodiscard]] bool has_exited() const noexcept
321 {
322
3/4
✓ Branch 3 → 4 taken 104928 times.
✗ Branch 3 → 8 not taken.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 104927 times.
104928 return m_channel != nullptr && m_channel->worker_exited.load(std::memory_order_acquire);
323 }
324
325 /**
326 * @brief Reports whether @p id is the servicer worker thread's id.
327 * @details Any teardown that can join this worker must query this first and skip. Otherwise it
328 * self-joins or deadlocks. The default id never matches, so a reset slot cannot alias a live
329 * query.
330 */
331 104931 [[nodiscard]] bool is_worker_thread(std::thread::id id) const noexcept
332 {
333
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 104931 times.
104931 if (!m_channel)
334 {
335 return false;
336 }
337 104931 const std::thread::id worker = m_channel->worker_tid.load(std::memory_order_acquire);
338
3/4
✓ Branch 9 → 10 taken 104931 times.
✗ Branch 9 → 13 not taken.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 104930 times.
104931 return worker != std::thread::id{} && worker == id;
339 }
340
341 private:
342 12 static void service_loop(Channel &channel, std::stop_token st) noexcept
343 {
344 class ExitGuard
345 {
346 public:
347 12 explicit ExitGuard(Channel &owned_channel) noexcept : m_channel(owned_channel) {}
348 11 ~ExitGuard() noexcept
349 {
350 11 m_channel.worker_tid.store(std::thread::id{}, std::memory_order_release);
351 11 m_channel.worker_exited.store(true, std::memory_order_release);
352 11 }
353
354 ExitGuard(const ExitGuard &) = delete;
355 ExitGuard &operator=(const ExitGuard &) = delete;
356
357 private:
358 Channel &m_channel;
359 };
360
361 12 const ExitGuard exit_guard{channel};
362 12 DetourModKit::Logger &logger = DetourModKit::log();
363
364 // Publish our thread id for ~ReloadServicer's self-join detection. Clear it on exit so a later
365 // OS-recycled id cannot alias a dead worker.
366 12 channel.worker_tid.store(std::this_thread::get_id(), std::memory_order_release);
367
368 // Wake the CV on a stop request so the blocked wait exits promptly.
369 std::stop_callback stop_cb(
370 st,
371 33 [&channel]() -> void
372 {
373 {
374
1/2
✓ Branch 2 → 3 taken 9 times.
✗ Branch 2 → 7 not taken.
9 std::lock_guard<std::mutex> lock(channel.mutex);
375 9 channel.shutdown.store(true, std::memory_order_release);
376 9 }
377 9 channel.cv.notify_all();
378 9 }
379 12 );
380
381
6/6
✓ Branch 44 → 45 taken 12 times.
✓ Branch 44 → 48 taken 3 times.
✓ Branch 46 → 47 taken 10 times.
✓ Branch 46 → 48 taken 2 times.
✓ Branch 49 → 8 taken 10 times.
✓ Branch 49 → 50 taken 5 times.
27 while (!st.stop_requested() && !channel.shutdown.load(std::memory_order_acquire))
382 {
383 {
384 10 std::unique_lock<std::mutex> lock(channel.mutex);
385 #if defined(DMK_ENABLE_TEST_SEAMS)
386
2/2
✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 17 taken 9 times.
10 if (auto *gate = DetourModKit::detail::g_config_reload_worker_mutex_gate_probe.load(
387 std::memory_order_acquire
388 ))
389 {
390 1 DetourModKit::detail::g_config_reload_worker_mutex_waiting_probe.store(
391 true,
392 std::memory_order_release
393 );
394
1/2
✓ Branch 15 → 13 taken 21714 times.
✗ Branch 15 → 16 not taken.
21714 while (gate->load(std::memory_order_acquire))
395 {
396 21714 std::this_thread::yield();
397 }
398 DetourModKit::detail::g_config_reload_worker_mutex_waiting_probe.store(
399 false,
400 std::memory_order_release
401 );
402 }
403 #endif
404 9 channel.cv.wait(
405 lock,
406 18 [&]() noexcept
407 {
408
5/6
✓ Branch 3 → 4 taken 12 times.
✓ Branch 3 → 8 taken 6 times.
✓ Branch 5 → 6 taken 12 times.
✗ Branch 5 → 8 not taken.
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 9 times.
30 return st.stop_requested() || channel.shutdown.load(std::memory_order_acquire) ||
409 30 channel.reload_requested.load(std::memory_order_acquire);
410 }
411 );
412 9 }
413
414
5/6
✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 23 taken 6 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 3 times.
✓ Branch 25 → 26 taken 6 times.
✓ Branch 25 → 27 taken 3 times.
9 if (st.stop_requested() || channel.shutdown.load(std::memory_order_acquire))
415 {
416 6 break;
417 }
418
419 // Coalesce: a burst of presses during the reload collapses into at most one follow-up pass.
420
2/2
✓ Branch 41 → 28 taken 3 times.
✓ Branch 41 → 43 taken 3 times.
6 while (channel.reload_requested.exchange(false, std::memory_order_acq_rel))
421 {
422 // Gate on the unload latch and this servicer's lifecycle epoch. Do not run setters into a
423 // Logic DLL under unload or a re-armed registry that belongs to a newer one.
424 3 detail::BackgroundReloadGuard reload_guard{channel.birth_epoch};
425
1/2
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 3 times.
3 if (!reload_guard.engaged())
426 {
427 break;
428 }
429 try
430 {
431 3 bool setters_ran = false;
432
1/2
✓ Branch 32 → 33 taken 3 times.
✗ Branch 32 → 59 not taken.
3 (void)detail::reload_impl(setters_ran, &reload_guard);
433 }
434 catch (const std::exception &e)
435 {
436 (void)logger
437 .try_log(LogLevel::Error, "Config: reload servicer caught exception: {}", e.what());
438 }
439 catch (...)
440 {
441 (
442 void
443 )logger.try_log(LogLevel::Error, "Config: reload servicer caught unknown exception.");
444 }
445
1/2
✓ Branch 36 → 37 taken 3 times.
✗ Branch 36 → 39 not taken.
3 }
446 }
447
448 #if defined(DMK_ENABLE_TEST_SEAMS)
449 // Holds the body between its last channel.mutex use and the exit guard below. A concurrent teardown
450 // observes a worker that is provably live and lacks an exit publication.
451
1/2
✗ Branch 51 → 52 not taken.
✓ Branch 51 → 56 taken 11 times.
11 if (auto *gate = DetourModKit::detail::g_config_reload_worker_exit_gate_probe.load(
452 std::memory_order_acquire
453 ))
454 {
455 while (gate->load(std::memory_order_acquire))
456 {
457 std::this_thread::yield();
458 }
459 }
460 #endif
461 11 }
462
463 std::unique_ptr<Channel> m_channel;
464 };
465
466 // A shared_ptr lets a press callback keep its own strong reference when clear() resets the slot.
467 3610281 std::shared_ptr<ReloadServicer> &get_reload_servicer() noexcept
468 {
469
3/4
✓ Branch 2 → 3 taken 352 times.
✓ Branch 2 → 7 taken 3609929 times.
✓ Branch 4 → 5 taken 352 times.
✗ Branch 4 → 7 not taken.
3610281 static std::shared_ptr<ReloadServicer> s_servicer;
470 3610281 return s_servicer;
471 }
472
473 // start_watcher_locked creates an auto-reload watcher on a resolved path, then connects the persisted user
474 // callback. The caller must hold get_watcher_mutex(). enable_auto_reload() and load()'s re-point use this
475 // single construction site, so the presence guard and construction are atomic.
476 48 [[nodiscard]] AutoReloadStatus start_watcher_locked(
477 const std::string &resolved_path,
478 std::chrono::milliseconds debounce,
479 detail::DeferredDiagnostics &diags,
480 DetourModKit::detail::ConfigWatcher::StartGate &start_gate,
481 std::unique_ptr<DetourModKit::detail::ConfigWatcher> &failed_watcher
482 )
483 {
484 48 auto &watcher = get_config_watcher();
485
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 48 times.
48 if (detail::background_reloads_disabled())
486 {
487 return AutoReloadStatus::StartFailed;
488 }
489 // Guard on existence, not is_running(). A second caller otherwise can overwrite the unique_ptr before
490 // the worker publishes its active state.
491
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 10 taken 48 times.
48 if (watcher)
492 {
493 detail::defer_diagnostic(
494 diags,
495 LogLevel::Warning,
496 "Config: Auto-reload watcher start skipped because a watcher is already present; "
497 "call disable_auto_reload() first."
498 );
499 return AutoReloadStatus::AlreadyRunning;
500 }
501
502 // Copy the persisted user callback into the reload lambda. The persisted slot must survive. A later
503 // load()-driven re-point can then reconstruct an equivalent watcher.
504 48 watcher = std::make_unique<DetourModKit::detail::ConfigWatcher>(
505 resolved_path,
506 debounce,
507
3/8
✓ Branch 11 → 12 taken 48 times.
✗ Branch 11 → 38 not taken.
✓ Branch 13 → 14 taken 48 times.
✗ Branch 13 → 33 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 48 times.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
96 [user_cb = get_reload_user_callback(), birth_epoch = detail::current_reload_lifecycle_epoch()]()
508 {
509 // Gate the whole pass on the unload latch and this watcher's lifecycle epoch. The guard holds
510 // the in-flight count across BOTH the setter pass and the user callback.
511 26 detail::BackgroundReloadGuard reload_guard{birth_epoch};
512
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 24 times.
26 if (!reload_guard.engaged())
513 {
514 2 return;
515 }
516 // Reload first so the user callback observes the refreshed values. setters_ran lets it
517 // distinguish a real reload from a skipped setter pass.
518 24 bool setters_ran = false;
519
1/2
✓ Branch 6 → 7 taken 24 times.
✗ Branch 6 → 22 not taken.
24 (void)detail::reload_impl(setters_ran, &reload_guard);
520 // Re-check the latch. An unload can set it during the pass.
521
6/6
✓ Branch 8 → 9 taken 9 times.
✓ Branch 8 → 12 taken 15 times.
✓ Branch 10 → 11 taken 8 times.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 13 → 14 taken 8 times.
✓ Branch 13 → 15 taken 16 times.
24 if (user_cb && reload_guard.current())
522 {
523
1/2
✓ Branch 14 → 15 taken 8 times.
✗ Branch 14 → 22 not taken.
8 user_cb(setters_ran);
524 }
525
2/2
✓ Branch 17 → 18 taken 24 times.
✓ Branch 17 → 20 taken 2 times.
26 }
526 48 );
527
528 48 bool started = false;
529 try
530 {
531
1/2
✓ Branch 20 → 21 taken 48 times.
✗ Branch 20 → 40 not taken.
48 started = watcher->start(diags, start_gate);
532 }
533 catch (...)
534 {
535 failed_watcher = std::move(watcher);
536 get_reload_user_callback() = nullptr;
537 throw;
538 }
539
2/2
✓ Branch 21 → 22 taken 7 times.
✓ Branch 21 → 30 taken 41 times.
48 if (!started)
540 {
541 7 failed_watcher = std::move(watcher);
542 // Drop the persisted callback with the failed watcher so it cannot pin Logic DLL references.
543 7 get_reload_user_callback() = nullptr;
544 try
545 {
546
1/2
✓ Branch 27 → 28 taken 7 times.
✗ Branch 27 → 50 not taken.
7 detail::defer_diagnostic(
547 diags,
548 LogLevel::Error,
549 "Config: Auto-reload watcher failed to start for {}",
550 resolved_path
551 );
552 }
553 catch (...)
554 {
555 DetourModKit::detail::LoggerDropAccess::record(log());
556 }
557 7 return AutoReloadStatus::StartFailed;
558 }
559 41 return AutoReloadStatus::Started;
560 }
561 } // anonymous namespace
562
563 namespace detail
564 {
565 823 void dispose_reload_hotkey_guards(std::vector<input::BindingGuard> &guards) noexcept
566 {
567
2/2
✓ Branch 3 → 4 taken 813 times.
✓ Branch 3 → 5 taken 10 times.
823 if (guards.empty())
568 {
569 813 return;
570 }
571 10 run_reload_hotkey_guard_disposal_probe();
572 10 guards.clear();
573 }
574
575 183 WatchRepoint detach_watcher_if_repointed(std::string_view loaded_resolved_path)
576 {
577 183 WatchRepoint result;
578 183 DeferredDiagnostics diags = open_deferred_diagnostics();
579 {
580
1/2
✓ Branch 4 → 5 taken 183 times.
✗ Branch 4 → 34 not taken.
183 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
581 183 auto &watcher = get_config_watcher();
582
2/2
✓ Branch 7 → 8 taken 13 times.
✓ Branch 7 → 26 taken 170 times.
183 if (watcher)
583 {
584
2/2
✓ Branch 12 → 13 taken 5 times.
✓ Branch 12 → 26 taken 8 times.
13 if (!resolved_paths_equivalent(watcher->ini_path(), loaded_resolved_path))
585 {
586
1/2
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 19 taken 5 times.
5 if (watcher->is_worker_thread(std::this_thread::get_id()))
587 {
588 // Inline watcher destruction self-joins the worker. Report and skip the re-point under
589 // disable_auto_reload()'s self-join rule.
590 defer_diagnostic(
591 diags,
592 LogLevel::Error,
593 "Config: load() switched the config file on the watcher thread; not "
594 "re-pointing auto-reload to avoid a self-join. Re-point from another "
595 "thread via disable_auto_reload()/enable_auto_reload()."
596 );
597 }
598 else
599 {
600 // Move the stale watcher out and preserve the persisted user callback for restart.
601 // Snapshot the disable generation under this lock for the lost-disable window check.
602 5 result.debounce = watcher->debounce();
603 5 result.generation_at_move = get_watcher_disable_generation();
604 5 result.stale = std::move(watcher);
605 5 result.repoint = true;
606 }
607 }
608 }
609 183 }
610 183 emit_deferred_diagnostics(diags);
611 183 return result;
612 183 }
613
614 5 void restart_watcher_after_repoint(std::chrono::milliseconds debounce, std::uint64_t generation_at_move)
615 {
616 // Re-snapshot the latest remembered path and re-start under get_watcher_mutex(). A disable
617 // generation bump since the move-out means a disable raced into the join window: honor it and
618 // leave auto-reload OFF. The re-check and construction are one atomic step under the held lock.
619
1/2
✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 52 not taken.
5 const std::string repoint_filename = snapshot_last_loaded_ini_path();
620 5 DeferredDiagnostics diags = open_deferred_diagnostics();
621 5 DetourModKit::detail::ConfigWatcher::StartGate start_gate;
622 5 std::unique_ptr<DetourModKit::detail::ConfigWatcher> failed_watcher;
623 try
624 {
625 {
626
1/2
✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 36 not taken.
5 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
627
5/6
✓ Branch 7 → 8 taken 5 times.
✗ Branch 7 → 11 not taken.
✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 11 taken 1 time.
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 19 taken 1 time.
5 if (!repoint_filename.empty() && get_watcher_disable_generation() == generation_at_move)
628 {
629
1/2
✓ Branch 13 → 14 taken 4 times.
✗ Branch 13 → 33 not taken.
4 const std::filesystem::path repoint_path = get_ini_file_path(repoint_filename, diags);
630 (
631 void
632
2/4
✓ Branch 14 → 15 taken 4 times.
✗ Branch 14 → 30 not taken.
✓ Branch 15 → 16 taken 4 times.
✗ Branch 15 → 28 not taken.
4 )start_watcher_locked(repoint_path.string(), debounce, diags, start_gate, failed_watcher);
633 4 }
634 5 }
635 5 emit_deferred_diagnostics(diags);
636 }
637 catch (...)
638 {
639 DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate);
640 failed_watcher.reset();
641 throw;
642 }
643 5 DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate);
644 5 failed_watcher.reset();
645 5 }
646
647 2 bool on_reload_servicer_thread() noexcept
648 {
649 2 std::lock_guard<std::mutex> lock(get_watcher_mutex());
650 2 const std::shared_ptr<ReloadServicer> &servicer = get_reload_servicer();
651
3/4
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 12 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 12 not taken.
2 return servicer && servicer->is_worker_thread(std::this_thread::get_id());
652 2 }
653
654 693 WatchHotkeyControl detach_hotkey_control() noexcept
655 {
656 693 WatchHotkeyControl control;
657 693 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
658 1386 control.guards = std::move(get_reload_hotkey_guards());
659 1386 control.servicer = std::move(get_reload_servicer());
660 1386 return control;
661 693 }
662
663 // Shared stop poke for both drain verbs. The caller holds get_watcher_mutex(). Returns false when the
664 // caller is the watcher or servicer worker thread, without requesting any stop.
665 1804786 [[nodiscard]] bool poke_stops_locked() noexcept
666 {
667 1804786 const auto &watcher = get_config_watcher();
668 1804786 const auto &servicer = get_reload_servicer();
669
6/8
✓ Branch 5 → 6 taken 1699608 times.
✓ Branch 5 → 10 taken 105178 times.
✓ Branch 9 → 10 taken 1699608 times.
✗ Branch 9 → 16 not taken.
✓ Branch 11 → 12 taken 104930 times.
✓ Branch 11 → 17 taken 1699856 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 1804786 times.
3714502 if ((watcher && watcher->is_worker_thread(std::this_thread::get_id())) ||
670
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 104930 times.
1909716 (servicer && servicer->is_worker_thread(std::this_thread::get_id())))
671 {
672 return false;
673 }
674
675
2/2
✓ Branch 21 → 22 taken 1699608 times.
✓ Branch 21 → 24 taken 105178 times.
1804786 if (watcher)
676 {
677 1699608 watcher->request_stop();
678 }
679
2/2
✓ Branch 25 → 26 taken 104930 times.
✓ Branch 25 → 28 taken 1699856 times.
1804786 if (servicer)
680 {
681 104930 servicer->request_stop();
682 }
683 1804786 return true;
684 }
685
686 134 WatchStopPoke request_watch_stops_for_drain() noexcept
687 {
688 134 std::unique_lock<std::mutex> lock(get_watcher_mutex(), std::try_to_lock);
689
2/2
✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 133 times.
134 if (!lock.owns_lock())
690 {
691 1 return WatchStopPoke::LockBusy;
692 }
693
1/2
✓ Branch 8 → 9 taken 133 times.
✗ Branch 8 → 10 not taken.
133 return poke_stops_locked() ? WatchStopPoke::Requested : WatchStopPoke::SelfDelivery;
694 134 }
695
696 1930745 WatchDrainState try_detach_watch_control(bool (*reloads_quiesced)() noexcept, WatchTeardown &out) noexcept
697 {
698 1930745 std::unique_lock<std::mutex> lock(get_watcher_mutex(), std::try_to_lock);
699
2/2
✓ Branch 5 → 6 taken 126092 times.
✓ Branch 5 → 7 taken 1804653 times.
1930745 if (!lock.owns_lock())
700 {
701 126092 return WatchDrainState::LockBusy;
702 }
703
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1804653 times.
1804653 if (!poke_stops_locked())
704 {
705 return WatchDrainState::SelfDelivery;
706 }
707 1804653 const auto &watcher = get_config_watcher();
708 1804653 const auto &servicer = get_reload_servicer();
709 const bool workers_exited =
710
8/8
✓ Branch 13 → 14 taken 1699601 times.
✓ Branch 13 → 17 taken 105052 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 16 → 23 taken 1699596 times.
✓ Branch 18 → 19 taken 104928 times.
✓ Branch 18 → 22 taken 129 times.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 104927 times.
1804653 (!watcher || watcher->has_exited()) && (!servicer || servicer->has_exited());
711
5/6
✓ Branch 24 → 25 taken 130 times.
✓ Branch 24 → 28 taken 1804523 times.
✓ Branch 26 → 27 taken 130 times.
✗ Branch 26 → 28 not taken.
✓ Branch 29 → 30 taken 130 times.
✓ Branch 29 → 50 taken 1804523 times.
1804653 if (workers_exited && reloads_quiesced())
712 {
713 260 out.watcher = std::move(get_config_watcher());
714 260 out.servicer = std::move(get_reload_servicer());
715 // std::function move assignment has no standard noexcept guarantee. Stage through the noexcept
716 // move constructor, then commit with the noexcept member swap.
717 260 std::function<void(bool)> detached_callback(std::move(get_reload_user_callback()));
718 130 out.callback.swap(detached_callback);
719 260 out.guards = std::move(get_reload_hotkey_guards());
720 130 ++get_watcher_disable_generation();
721 130 return WatchDrainState::Detached;
722 130 }
723 1804523 return WatchDrainState::Draining;
724 1930745 }
725 } // namespace detail
726
727 50 AutoReloadStatus enable_auto_reload(std::chrono::milliseconds debounce, std::function<void(bool)> on_reload)
728 {
729
1/2
✓ Branch 2 → 3 taken 50 times.
✗ Branch 2 → 55 not taken.
50 const std::string ini_filename = detail::snapshot_last_loaded_ini_path();
730
731 50 Logger &logger = log();
732
733
2/2
✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 8 taken 48 times.
50 if (ini_filename.empty())
734 {
735
1/2
✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 27 not taken.
2 logger.warning("Config: enable_auto_reload() called before load(); watcher not started.");
736 2 return AutoReloadStatus::NoPriorLoad;
737 }
738
739 // The path resolution runs before the watcher mutex, so it reaches the same absolute path load() uses.
740 48 detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics();
741
1/2
✓ Branch 9 → 10 taken 48 times.
✗ Branch 9 → 51 not taken.
48 std::filesystem::path ini_path = detail::get_ini_file_path(ini_filename, diags);
742
1/2
✓ Branch 10 → 11 taken 48 times.
✗ Branch 10 → 49 not taken.
48 std::string resolved_path = ini_path.string();
743 48 DetourModKit::detail::ConfigWatcher::StartGate start_gate;
744 48 std::unique_ptr<DetourModKit::detail::ConfigWatcher> failed_watcher;
745
746 // Hold get_watcher_mutex() across publish-callback-then-start: a bounded start() stall is preferable to
747 // a use-after-free if disable_auto_reload() destroyed the watcher mid-start().
748 48 AutoReloadStatus status{AutoReloadStatus::StartFailed};
749 try
750 {
751 status = [&]() -> AutoReloadStatus
752 {
753
1/2
✓ Branch 3 → 4 taken 48 times.
✗ Branch 3 → 24 not taken.
48 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
754
755
1/2
✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 48 times.
48 if (detail::background_reloads_disabled())
756 {
757 return AutoReloadStatus::StartFailed;
758 }
759
760 // On a duplicate enable attempt, preserve the live watcher's callback. A new callback publication
761 // before this check makes a later re-point switch callbacks silently.
762
2/2
✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 12 taken 44 times.
48 if (get_config_watcher())
763 {
764
1/2
✓ Branch 10 → 11 taken 4 times.
✗ Branch 10 → 21 not taken.
4 detail::defer_diagnostic(
765 diags,
766 LogLevel::Warning,
767 "Config: enable_auto_reload() called while a watcher is already present; "
768 "call disable_auto_reload() first."
769 );
770 4 return AutoReloadStatus::AlreadyRunning;
771 }
772
773 // Persist a copy of the user callback for load()'s re-point, published under the watcher mutex
774 // before the construction helper reads it.
775 88 get_reload_user_callback() = std::move(on_reload);
776
777
1/2
✓ Branch 16 → 17 taken 44 times.
✗ Branch 16 → 22 not taken.
44 return start_watcher_locked(resolved_path, debounce, diags, start_gate, failed_watcher);
778
1/2
✓ Branch 11 → 12 taken 48 times.
✗ Branch 11 → 28 not taken.
96 }();
779
780
2/2
✓ Branch 12 → 13 taken 37 times.
✓ Branch 12 → 16 taken 11 times.
48 if (status == AutoReloadStatus::Started)
781 {
782 try
783 {
784 detail::defer_diagnostic(
785 diags,
786 LogLevel::Info,
787 "Config: Auto-reload enabled for {} (debounce {} ms)",
788 resolved_path,
789
1/2
✓ Branch 14 → 15 taken 37 times.
✗ Branch 14 → 29 not taken.
37 static_cast<long long>(debounce.count())
790 );
791 }
792 catch (...)
793 {
794 DetourModKit::detail::LoggerDropAccess::record(log());
795 }
796 }
797 48 detail::emit_deferred_diagnostics(diags);
798 }
799 catch (...)
800 {
801 DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate);
802 failed_watcher.reset();
803 throw;
804 }
805 48 DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate);
806 48 failed_watcher.reset();
807 48 return status;
808 50 }
809
810 230 void disable_auto_reload() noexcept
811 {
812 // A watcher join from a bound setter blocks on its final flush, which re-enters reload_impl and waits for
813 // the pass lock this thread holds. Refuse to avoid deadlock.
814
2/2
✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 7 taken 228 times.
230 if (detail::reload_apply_lock_held_by_current_thread())
815 {
816 2 (void)log().try_log(
817 LogLevel::Error,
818 "Config: disable_auto_reload() called from a bound setter; ignoring to avoid "
819 "joining a watcher that may be waiting for the active reload pass."
820 );
821 3 return;
822 }
823
824 228 std::unique_ptr<DetourModKit::detail::ConfigWatcher> to_drop;
825 228 bool self_join_refused = false;
826 {
827 228 std::lock_guard<std::mutex> wlock(get_watcher_mutex());
828 228 auto &watcher = get_config_watcher();
829 // Inline unique_ptr destruction on the watcher thread forces the worker to join itself. Report after
830 // the unlock and return. To cancel inside a reload, release the binding guard or flip a caller-owned
831 // flag.
832
6/6
✓ Branch 11 → 12 taken 31 times.
✓ Branch 11 → 17 taken 197 times.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 30 times.
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 227 times.
228 if (watcher && watcher->is_worker_thread(std::this_thread::get_id()))
833 {
834 1 self_join_refused = true;
835 }
836 else
837 {
838 227 to_drop = std::move(watcher);
839 // Drop the persisted re-point callback with its watcher so it cannot pin Logic DLL references.
840 227 get_reload_user_callback() = nullptr;
841 // Signal a load() re-point in its lost-disable window so it does not resurrect the watcher.
842 227 ++get_watcher_disable_generation();
843 }
844 228 }
845
2/2
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 32 taken 227 times.
228 if (self_join_refused)
846 {
847 1 (void)log().try_log(
848 LogLevel::Error,
849 "Config: disable_auto_reload() called from the watcher thread; ignoring to avoid self-join "
850 "deadlock. Call from a different thread or disable the hotkey binding instead."
851 );
852 1 return;
853 }
854 // ~ConfigWatcher joins its worker outside our mutex.
855
2/2
✓ Branch 34 → 35 taken 227 times.
✓ Branch 34 → 37 taken 1 time.
228 }
856
857 16 bool reload_hotkey(std::string_view ini_key, std::string_view default_combo)
858 {
859 // An empty or opt-out default leaves the hotkey inert. Return false to expose that state.
860
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 12 taken 15 times.
16 if (default_combo.empty())
861 {
862
1/2
✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 114 not taken.
2 log().warning(
863 "Config: reload_hotkey('{}', '<empty>') rejected; provide a non-empty default combo.",
864
1/2
✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 117 not taken.
2 std::string(ini_key)
865 );
866 1 return false;
867 }
868
869 // Pre-parse the default. The parser defers its own typo WARNING, and a NONE opt-out still returns false.
870 15 detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics();
871 const input::KeyComboList parsed =
872
2/4
✓ Branch 16 → 17 taken 15 times.
✗ Branch 16 → 123 not taken.
✓ Branch 17 → 18 taken 15 times.
✗ Branch 17 → 121 not taken.
30 detail::parse_key_combo_list(std::string(default_combo), diags, "Config reload hotkey");
873 15 detail::emit_deferred_diagnostics(diags);
874
2/2
✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 14 times.
15 if (parsed.empty())
875 {
876 1 return false;
877 }
878
879 // The INI key supplies a stable binding name, so repeat registrations update in place.
880
2/4
✓ Branch 26 → 27 taken 14 times.
✗ Branch 26 → 130 not taken.
✓ Branch 27 → 28 taken 14 times.
✗ Branch 27 → 128 not taken.
14 std::string binding_name = "config_reload:" + std::string(ini_key);
881
882 // Lazily spin up the reload servicer on the first hotkey registration, under get_watcher_mutex().
883 14 std::shared_ptr<ReloadServicer> servicer;
884 14 bool servicer_created = false;
885 {
886
1/2
✓ Branch 31 → 32 taken 14 times.
✗ Branch 31 → 137 not taken.
14 std::lock_guard<std::mutex> lock(get_watcher_mutex());
887
1/2
✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 14 times.
14 if (detail::background_reloads_disabled())
888 {
889 return false;
890 }
891 14 auto &slot = get_reload_servicer();
892
2/2
✓ Branch 37 → 38 taken 12 times.
✓ Branch 37 → 42 taken 2 times.
14 if (!slot)
893 {
894
2/2
✓ Branch 38 → 39 taken 11 times.
✓ Branch 38 → 134 taken 1 time.
12 slot = std::make_shared<ReloadServicer>(diags);
895 11 servicer_created = true;
896 }
897 13 servicer = slot;
898
1/2
✓ Branch 45 → 46 taken 13 times.
✗ Branch 45 → 48 not taken.
14 }
899
900
2/2
✓ Branch 47 → 49 taken 11 times.
✓ Branch 47 → 50 taken 2 times.
13 if (servicer_created)
901 {
902 11 detail::emit_deferred_diagnostics(diags);
903 }
904
905 input::BindingGuard guard = press_combo(
906 13 "Input",
907 ini_key,
908 13 "Config reload hotkey",
909 binding_name,
910
1/2
✓ Branch 52 → 53 taken 13 times.
✗ Branch 52 → 142 not taken.
26 [servicer]() noexcept
911 {
912 // Press callbacks run on the poll thread and must return promptly. Defer the reload to the
913 // servicer thread. The shared_ptr capture keeps the servicer alive.
914 if (servicer)
915 {
916 servicer->request_reload();
917 }
918 },
919 default_combo,
920 13 std::nullopt
921
1/2
✓ Branch 56 → 57 taken 13 times.
✗ Branch 56 → 138 not taken.
26 );
922 13 input::BindingGuard replaced_guard;
923 13 bool replaced_existing = false;
924
925 // Store the guard under the watcher mutex so its destructor does not disable the binding. Replace any
926 // prior guard for the same INI key. Release the replaced guard outside the mutex. A release under this
927 // mutex can wait on an unload drain whose callable disposal joins a worker that needs the same mutex.
928 {
929
1/2
✓ Branch 61 → 62 taken 13 times.
✗ Branch 61 → 151 not taken.
13 std::lock_guard<std::mutex> lock(get_watcher_mutex());
930
1/2
✗ Branch 63 → 64 not taken.
✓ Branch 63 → 65 taken 13 times.
13 if (detail::background_reloads_disabled())
931 {
932 return false;
933 }
934 13 auto &guards = get_reload_hotkey_guards();
935
2/2
✓ Branch 93 → 67 taken 2 times.
✓ Branch 93 → 94 taken 11 times.
26 for (auto it = guards.begin(); it != guards.end(); ++it)
936 {
937
1/2
✓ Branch 72 → 73 taken 2 times.
✗ Branch 72 → 83 not taken.
4 if (it->name() == binding_name)
938 {
939 4 replaced_guard = std::move(*it);
940 2 replaced_existing = true;
941
1/2
✓ Branch 81 → 82 taken 2 times.
✗ Branch 81 → 147 not taken.
2 guards.erase(it);
942 2 break;
943 }
944 }
945
1/2
✓ Branch 96 → 97 taken 13 times.
✗ Branch 96 → 149 not taken.
13 guards.emplace_back(std::move(guard));
946
1/2
✓ Branch 99 → 100 taken 13 times.
✗ Branch 99 → 102 not taken.
13 }
947
2/2
✓ Branch 101 → 103 taken 2 times.
✓ Branch 101 → 105 taken 11 times.
13 if (replaced_existing)
948 {
949 2 run_reload_hotkey_guard_disposal_probe();
950 2 replaced_guard.release();
951 }
952
953 13 return true;
954 18 }
955 } // namespace config
956
957 #if defined(DMK_ENABLE_TEST_SEAMS)
958 namespace detail
959 {
960 // Requests one servicer-thread reload without synthetic key input. Returns false if no servicer exists.
961 3 bool request_servicer_reload_for_test() noexcept
962 {
963 3 std::shared_ptr<config::ReloadServicer> servicer;
964 {
965 3 std::lock_guard<std::mutex> lock(config::get_watcher_mutex());
966 3 servicer = config::get_reload_servicer();
967 3 }
968
1/2
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 3 times.
3 if (!servicer)
969 {
970 return false;
971 }
972 3 servicer->request_reload();
973 3 return true;
974 3 }
975
976 2 void lock_config_watcher_mutex_for_test() noexcept
977 {
978 2 std::lock_guard<std::mutex> lock(config::get_watcher_mutex());
979 2 }
980
981 // Reports whether the watcher control mutex is free right now. A record producer cannot pass this probe under
982 // the same non-recursive mutex.
983 46 bool config_watcher_mutex_free_for_test() noexcept
984 {
985 46 std::unique_lock<std::mutex> probe(config::get_watcher_mutex(), std::try_to_lock);
986 46 return probe.owns_lock();
987 46 }
988 } // namespace detail
989 #endif
990 } // namespace DetourModKit
991