./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
386 / 433
89.1%
Branches:
237 / 328
72.3%
Functions:
42 / 42
100.0%

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