./
Coverage:
low: ≥ 0%
medium: ≥ 75.0%
high: ≥ 90.0%
Lines:
377 / 486
77.6%
Branches:
220 / 360
61.1%
Functions:
52 / 54
96.3%

src/internal/config_watcher.cpp
Line Branch Exec Source
1 /**
2 * @file config_watcher.cpp
3 * @brief Implementation of the internal ConfigWatcher engine (ReadDirectoryChangesW-based), not installed.
4 */
5
6 #include "config_watcher.hpp"
7 #include "DetourModKit/diagnostics.hpp"
8
9 #include "DetourModKit/logger.hpp"
10 #include "DetourModKit/detail/worker.hpp"
11 #include "lifecycle_context.hpp"
12 #include "utf8_conversion.hpp"
13 #include "worker_start_log.hpp"
14
15 #include <windows.h>
16
17 #include <algorithm>
18 #include <atomic>
19 #include <chrono>
20 #include <cstddef>
21 #include <cstdint>
22 #include <cstring>
23 #include <filesystem>
24 #include <future>
25 #include <memory>
26 #include <mutex>
27 #include <new>
28 #include <optional>
29 #include <stop_token>
30 #include <string>
31 #include <string_view>
32 #include <type_traits>
33 #include <utility>
34 #include <vector>
35
36 namespace DetourModKit
37 {
38 namespace detail
39 {
40 enum class ConfigWatcherStartWait : std::uint8_t
41 {
42 Pending,
43 Released,
44 Cancelled,
45 };
46
47 struct ConfigWatcherStartGate
48 {
49 218 explicit ConfigWatcherStartGate(LogLevel threshold)
50 436 : diags{
51 .threshold = threshold,
52 .records = {},
53 },
54 218 late_diags{
55 .threshold = threshold,
56 .records = {},
57 218 }
58 {
59 218 }
60
61 std::mutex mutex;
62 config::detail::DeferredDiagnostics diags;
63 config::detail::DeferredDiagnostics late_diags;
64 bool complete{false};
65 bool emitted{false};
66 std::atomic<ConfigWatcherStartWait> wait{ConfigWatcherStartWait::Pending};
67 };
68
69 namespace
70 {
71 1935953 void cancel_start_wait(const std::shared_ptr<ConfigWatcherStartGate> &gate) noexcept
72 {
73
2/2
✓ Branch 3 → 4 taken 9 times.
✓ Branch 3 → 5 taken 1935944 times.
1935953 if (!gate)
74 {
75 9 return;
76 }
77 1935944 auto expected = ConfigWatcherStartWait::Pending;
78
2/2
✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 10 taken 1935938 times.
1935944 if (gate->wait.compare_exchange_strong(
79 expected,
80 ConfigWatcherStartWait::Cancelled,
81 std::memory_order_acq_rel,
82 std::memory_order_acquire
83 ))
84 {
85 6 gate->wait.notify_all();
86 }
87 }
88 } // anonymous namespace
89
90 #if defined(DMK_ENABLE_TEST_SEAMS)
91 // Overrides loader-lock detection so teardown retention can be exercised from a normal test thread.
92 bool (*g_config_watcher_loader_lock_override)() noexcept = nullptr;
93
94 // A throwing probe exercises failure before the startup promise has been settled.
95 void (*g_config_watcher_prehandshake_seam)() = nullptr;
96
97 // These probes inject CreateEventW and initial ReadDirectoryChangesW failures.
98 bool (*g_config_watcher_create_event_failure_seam)() noexcept = nullptr;
99 bool (*g_config_watcher_initial_read_failure_seam)() noexcept = nullptr;
100
101 // Published from the pump body's exit guard. A husked watcher's Impl is leaked, so worker_exited is no longer
102 // reachable through the ConfigWatcher shell; this is the only channel that can observe a detached pump
103 // terminating after teardown abandoned it.
104 std::atomic<std::atomic<bool> *> g_config_watcher_worker_exited_probe{nullptr};
105 #endif
106
107 namespace
108 {
109 constexpr DWORD NOTIFY_FILTER =
110 FILE_NOTIFY_CHANGE_LAST_WRITE | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_SIZE;
111
112 201 bool watcher_must_not_block() noexcept
113 {
114 #if defined(DMK_ENABLE_TEST_SEAMS)
115 201 return !blocking_teardown_permitted(g_config_watcher_loader_lock_override);
116 #else
117 return !blocking_teardown_permitted();
118 #endif
119 }
120
121 // Sized so bursty editor saves do not overflow a single ReadDirectoryChangesW call. The notification
122 // buffer is heap-resident (a std::vector inside the WatchIoState bundle), so its size is not bounded by the
123 // worker's stack.
124 constexpr DWORD BUFFER_BYTES = 16 * 1024;
125
126 // Pumping timeout for GetOverlappedResultEx. Bounds how long a pending stop() must wait for the worker to
127 // observe its stop_token; idle cost is ~10 syscalls/s per watcher (not zero).
128 constexpr DWORD PUMP_TIMEOUT_MS = 100;
129
130 // Per-wait bound for the stop-path drain. Only bites when a notify IRP is genuinely stuck (a
131 // deleted/orphaned watched directory); in the normal case the cancelled read completes in microseconds and
132 // the wait returns immediately. Two waits (cancel, then handle-close) cap worst-case teardown at ~2 * this
133 // value instead of an infinite hang.
134 constexpr DWORD DRAIN_TIMEOUT_MS = 1000;
135
136 // Case-insensitive filename comparison using ordinal (locale-independent) Unicode folding.
137 // CompareStringOrdinal with bIgnoreCase == TRUE is the Microsoft-recommended primitive for matching file
138 // names: it applies the same simple uppercase fold NTFS/exFAT use for case-insensitivity, and (unlike
139 // ::towupper) it does not consult the process locale. A watcher running under a Turkish (or any
140 // non-invariant) locale must still match "Config.ini" against "config.ini"; a locale-sensitive fold could
141 // map the ASCII 'I'/'i' pair differently and silently stop firing reloads. The length pre-check keeps the
142 // common mismatch cheap; the empty short-circuit avoids passing a null data()/zero count to the API.
143 1345 bool iequals_w(std::wstring_view lhs, std::wstring_view rhs) noexcept
144 {
145
2/2
✓ Branch 4 → 5 taken 1245 times.
✓ Branch 4 → 6 taken 100 times.
1345 if (lhs.size() != rhs.size())
146 {
147 1245 return false;
148 }
149
1/2
✗ Branch 7 → 8 not taken.
✓ Branch 7 → 9 taken 100 times.
100 if (lhs.empty())
150 {
151 ✗ return true;
152 }
153 200 return ::CompareStringOrdinal(
154 lhs.data(),
155 100 static_cast<int>(lhs.size()),
156 rhs.data(),
157 100 static_cast<int>(rhs.size()),
158 TRUE
159 100 ) == CSTR_EQUAL;
160 }
161
162 struct OwnedHandle
163 {
164 HANDLE h{INVALID_HANDLE_VALUE};
165
166 428 OwnedHandle() = default;
167 418 explicit OwnedHandle(HANDLE raw) noexcept : h(raw) {}
168
169 OwnedHandle(const OwnedHandle &) = delete;
170 OwnedHandle &operator=(const OwnedHandle &) = delete;
171
172 OwnedHandle(OwnedHandle &&other) noexcept : h(std::exchange(other.h, INVALID_HANDLE_VALUE)) {}
173
174 418 OwnedHandle &operator=(OwnedHandle &&other) noexcept
175 {
176
1/2
✓ Branch 2 → 3 taken 418 times.
✗ Branch 2 → 6 not taken.
418 if (this != &other)
177 {
178 418 reset();
179 418 h = std::exchange(other.h, INVALID_HANDLE_VALUE);
180 }
181 418 return *this;
182 }
183
184 844 ~OwnedHandle() noexcept { reset(); }
185
186
3/4
✓ Branch 2 → 3 taken 820 times.
✓ Branch 2 → 5 taken 860 times.
✓ Branch 3 → 4 taken 820 times.
✗ Branch 3 → 5 not taken.
1680 [[nodiscard]] bool valid() const noexcept { return h != INVALID_HANDLE_VALUE && h != nullptr; }
187
188 1262 void reset() noexcept
189 {
190
2/2
✓ Branch 3 → 4 taken 409 times.
✓ Branch 3 → 5 taken 853 times.
1262 if (valid())
191 {
192 409 ::CloseHandle(h);
193 }
194 1262 h = INVALID_HANDLE_VALUE;
195 1262 }
196 };
197
198 // Heap-resident I/O state for the ReadDirectoryChangesW pump. Bundled so the stop-path drain can leak the
199 // entire set (directory handle, completion event, OVERLAPPED, and notification buffer) in one move when a
200 // pending notify IRP cannot be confirmed complete. The kernel may still write into the OVERLAPPED and the
201 // buffer after a cancellation that the filesystem never finishes (e.g. the watched directory was deleted),
202 // so those structures must outlive the worker rather than be freed while an IRP still references them.
203 struct WatchIoState
204 {
205 OwnedHandle dir_handle;
206 OwnedHandle event_handle;
207 std::vector<BYTE> buffer;
208 OVERLAPPED overlapped{};
209 };
210
211 // Resets an atomic thread-id slot to the default (no-thread) id when the worker leaves its body, covering
212 // every exit path uniformly: a requested stop, a self-induced error exit, and the early
213 // CreateFileW/CreateEventW failures that return after the id was already published. The worker publishes
214 // its own id on entry so is_worker_thread() can detect setter-induced self-calls; clearing it as the worker
215 // exits keeps a later OS-recycled thread id from matching this dead worker and suppressing a real stop
216 // request. The store happens-before thread termination, so the slot is already cleared before the id can be
217 // reused.
218 class WorkerThreadIdGuard
219 {
220 public:
221 218 explicit WorkerThreadIdGuard(std::atomic<std::thread::id> &id_slot) noexcept : m_slot(id_slot) {}
222 217 ~WorkerThreadIdGuard() noexcept { m_slot.store(std::thread::id{}, std::memory_order_release); }
223
224 WorkerThreadIdGuard(const WorkerThreadIdGuard &) = delete;
225 WorkerThreadIdGuard &operator=(const WorkerThreadIdGuard &) = delete;
226
227 private:
228 std::atomic<std::thread::id> &m_slot;
229 };
230
231 class WorkerExitGuard
232 {
233 public:
234 218 explicit WorkerExitGuard(std::atomic<bool> &exited) noexcept : m_exited(exited) {}
235 217 ~WorkerExitGuard() noexcept
236 {
237 217 m_exited.store(true, std::memory_order_release);
238 #if defined(DMK_ENABLE_TEST_SEAMS)
239
2/2
✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 215 times.
217 if (auto *observed = g_config_watcher_worker_exited_probe.load(std::memory_order_acquire))
240 {
241 2 observed->store(true, std::memory_order_release);
242 }
243 #endif
244 217 }
245
246 WorkerExitGuard(const WorkerExitGuard &) = delete;
247 WorkerExitGuard &operator=(const WorkerExitGuard &) = delete;
248
249 private:
250 std::atomic<bool> &m_exited;
251 };
252 } // namespace
253
254 struct ConfigWatcher::Impl
255 {
256 std::wstring ini_path;
257 /// Holds the UTF-8 rendering of ini_path for diagnostics only.
258 std::string ini_path_text;
259 std::wstring directory_wide;
260 std::wstring filename_wide;
261 std::chrono::milliseconds debounce;
262 std::function<void()> on_reload;
263
264 std::mutex start_mutex;
265 std::unique_ptr<StoppableWorker> worker;
266 std::atomic<StartGate> start_gate;
267 std::atomic<std::thread::id> worker_thread_id{};
268 std::atomic<bool> worker_exited{true};
269 std::atomic<bool> stop_requested{false};
270
271 202 Impl(std::wstring_view path, std::chrono::milliseconds deb, std::function<void()> cb)
272
2/4
✓ Branch 4 → 5 taken 202 times.
✗ Branch 4 → 37 not taken.
✓ Branch 6 → 7 taken 202 times.
✗ Branch 6 → 67 not taken.
606 : ini_path(path), ini_path_text(DetourModKit::detail::utf8_from_wide(path)), debounce(deb),
273 404 on_reload(std::move(cb))
274 {
275 // Resolve into directory + filename components up-front.
276 // weakly_canonical is avoided because the file may not exist yet;
277 // absolute() is enough for ReadDirectoryChangesW.
278 202 std::error_code ec;
279
1/2
✓ Branch 19 → 20 taken 202 times.
✗ Branch 19 → 52 not taken.
202 std::filesystem::path input_path(ini_path);
280
1/2
✓ Branch 20 → 21 taken 202 times.
✗ Branch 20 → 50 not taken.
202 std::filesystem::path absolute_path = std::filesystem::absolute(input_path, ec);
281
2/2
✓ Branch 22 → 23 taken 2 times.
✓ Branch 22 → 24 taken 200 times.
202 if (ec)
282 {
283
1/2
✓ Branch 23 → 24 taken 2 times.
✗ Branch 23 → 48 not taken.
2 absolute_path = input_path;
284 }
285
286
2/4
✓ Branch 24 → 25 taken 202 times.
✗ Branch 24 → 42 not taken.
✓ Branch 25 → 26 taken 202 times.
✗ Branch 25 → 40 not taken.
202 directory_wide = absolute_path.parent_path().wstring();
287
2/4
✓ Branch 29 → 30 taken 202 times.
✗ Branch 29 → 46 not taken.
✓ Branch 30 → 31 taken 202 times.
✗ Branch 30 → 44 not taken.
202 filename_wide = absolute_path.filename().wstring();
288 202 }
289 };
290
291 7 void ConfigWatcher::leak_impl_storage(std::unique_ptr<Impl> &impl) noexcept
292 {
293 // new (std::nothrow) keeps the caller's noexcept teardown honest by returning nullptr on OOM rather than
294 // turning a bad_alloc into std::terminate. On allocation failure, release the unique_ptr so the Impl
295 // storage is leaked directly without invoking ~Impl, which would join the worker. Callers use this helper
296 // only when joining is unsafe: during loader-lock teardown or after a startup handshake timed out while the
297 // worker may still be blocked in a hooked system call.
298 // Each invocation allocates its own cell, so prior leaked Impls are never overwritten; the leak is bounded
299 // to one cell per husking call and the detached worker's raw pointers into Impl members stay valid until it
300 // exits or the process tears down.
301 static_assert(
302 std::is_nothrow_move_constructible_v<std::unique_ptr<Impl>>,
303 "Leak cell must be nothrow-move-constructible to keep the noexcept husk paths honest."
304 );
305
306
4/8
✓ Branch 3 → 4 taken 7 times.
✗ Branch 3 → 8 not taken.
✓ Branch 9 → 10 taken 7 times.
✗ Branch 9 → 12 not taken.
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 7 times.
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 7 times.
14 if (auto *leaked = new (std::nothrow) std::unique_ptr<Impl>(std::move(impl)))
307 {
308 (void)leaked;
309 }
310 else
311 {
312 ✗ (void)impl.release();
313 }
314 7 DetourModKit::diagnostics::record_intentional_leak(DetourModKit::diagnostics::LeakSubsystem::ConfigWatcher);
315 7 }
316
317 202 ConfigWatcher::ConfigWatcher(
318 std::wstring_view ini_path,
319 std::chrono::milliseconds debounce_window,
320 std::function<void()> on_reload
321 202 )
322 202 : m_impl(std::make_unique<Impl>(ini_path, debounce_window, std::move(on_reload)))
323 {
324 202 }
325
326 396 ConfigWatcher::~ConfigWatcher() noexcept
327 {
328
5/6
✓ Branch 3 → 4 taken 201 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 6 times.
✓ Branch 5 → 7 taken 195 times.
✓ Branch 8 → 9 taken 6 times.
✓ Branch 8 → 23 taken 195 times.
201 if (m_impl && watcher_must_not_block())
329 {
330 // Blocking is not authorized (an unload phase, or the loader-lock veto): joining the watcher would
331 // deadlock against ReadDirectoryChangesW's I/O completion, and tearing down Impl would invalidate the
332 // worker_thread_id pointer the detached lambda still references. Publish the watcher's independent
333 // cancellation flag and leak the entire Impl onto the heap so it outlives the destructor. The owned
334 // StoppableWorker keeps the worker's code pages mapped by leaking its own module reference on its
335 // unauthorized branch, so no module reference is taken here. The same leaf discipline is used by the
336 // hook handle teardown and Logger::shutdown_internal.
337
338
2/2
✓ Branch 11 → 12 taken 5 times.
✓ Branch 11 → 21 taken 1 time.
6 if (m_impl->worker)
339 {
340 // StoppableWorker cannot invoke stop callbacks once blocking teardown is vetoed. Publish this
341 // watcher's independent lock-free cancellation flag first; its I/O pump observes it on a bounded
342 // cadence after any in-flight call returns. shutdown() then detaches without joining.
343 5 m_impl->stop_requested.store(true, std::memory_order_release);
344 5 cancel_start_wait(m_impl->start_gate.load(std::memory_order_acquire));
345 5 m_impl->worker->shutdown();
346 }
347
348 // Husk this ConfigWatcher: move Impl into a never-freed heap cell instead of running ~Impl under the
349 // loader lock, where tearing down the detached worker would deadlock against ReadDirectoryChangesW's
350 // I/O completion.
351 6 leak_impl_storage(m_impl);
352 6 return;
353 }
354
355 195 stop();
356
357 // stop() reaches StoppableWorker::shutdown(), which re-queries the process-global blocking-teardown
358 // predicate for itself. Another thread can narrow that predicate between the check above and shutdown()'s
359 // own, so an arm entered as a join can still finish as a detach. The pump then keeps reading stop_requested
360 // / worker_exited / worker_thread_id through raw slots into Impl, and running ~Impl here would
361 // write-after-free them from a thread that is still executing. Observe the body's own exit publication
362 // instead of re-querying the predicate (which would TOCTOU against the same decision) and husk the watcher
363 // when it has not exited, the self-safe-destructor discipline AsyncLogger applies to its detached writer.
364 // Leaking is the safe direction: a body that exits immediately after this load only costs one bounded Impl.
365
5/6
✓ Branch 25 → 26 taken 195 times.
✗ Branch 25 → 30 not taken.
✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 30 taken 194 times.
✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 33 taken 194 times.
195 if (m_impl && !m_impl->worker_exited.load(std::memory_order_acquire))
366 {
367 1 leak_impl_storage(m_impl);
368 }
369
2/2
✓ Branch 35 → 36 taken 195 times.
✓ Branch 35 → 37 taken 6 times.
201 }
370
371 181373 bool ConfigWatcher::is_running() const noexcept
372 {
373
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 181373 times.
181373 if (!m_impl)
374 {
375 ✗ return false;
376 }
377 // Avoid reading m_impl->worker here: start() assigns it and stop() moves it out under start_mutex, so an
378 // unlocked status query would race the unique_ptr. The worker publishes this atomic id before issuing the
379 // first overlapped read and clears it on exit, which gives this noexcept accessor a race-free running
380 // signal.
381 181373 return m_impl->worker_thread_id.load(std::memory_order_acquire) != std::thread::id{};
382 }
383
384 19 const std::wstring &ConfigWatcher::ini_path() const noexcept
385 {
386
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 10 taken 19 times.
19 if (!m_impl)
387 {
388
0/4
✗ Branch 4 → 5 not taken.
✗ Branch 4 → 9 not taken.
✗ Branch 6 → 7 not taken.
✗ Branch 6 → 9 not taken.
✗ static const std::wstring s_empty;
389 ✗ return s_empty;
390 }
391 19 return m_impl->ini_path;
392 }
393
394 9 std::chrono::milliseconds ConfigWatcher::debounce() const noexcept
395 {
396
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 7 taken 9 times.
9 if (!m_impl)
397 {
398 ✗ return std::chrono::milliseconds{0};
399 }
400 9 return m_impl->debounce;
401 }
402
403 1935461 bool ConfigWatcher::is_worker_thread(std::thread::id id) const noexcept
404 {
405
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1935461 times.
1935461 if (!m_impl)
406 {
407 ✗ return false;
408 }
409 1935461 const std::thread::id worker = m_impl->worker_thread_id.load(std::memory_order_acquire);
410 // The default (no-thread) id means no worker is currently published. That holds before start() posts the
411 // first read, and after the worker reset the slot on exit. Never report that state as a match, even when
412 // the caller passes a default-constructed id, so a reset slot can never alias a real stop request.
413
4/4
✓ Branch 9 → 10 taken 1935438 times.
✓ Branch 9 → 13 taken 23 times.
✓ Branch 11 → 12 taken 3 times.
✓ Branch 11 → 13 taken 1935435 times.
1935461 return worker != std::thread::id{} && worker == id;
414 }
415
416 160 bool ConfigWatcher::start()
417 {
418 160 config::detail::DeferredDiagnostics diags = config::detail::open_deferred_diagnostics();
419 160 StartGate gate;
420 160 bool started = false;
421 try
422 {
423
1/2
✓ Branch 3 → 4 taken 160 times.
✗ Branch 3 → 10 not taken.
160 started = start(diags, gate);
424 }
425 ✗ catch (...)
426 {
427 ✗ release_start_gate(gate);
428 ✗ throw;
429 ✗ }
430 160 config::detail::emit_deferred_diagnostics(diags);
431 160 release_start_gate(gate);
432 160 return started;
433 160 }
434
435 228 void ConfigWatcher::release_start_gate(const StartGate &gate) noexcept
436 {
437
2/2
✓ Branch 3 → 4 taken 8 times.
✓ Branch 3 → 5 taken 220 times.
228 if (!gate)
438 {
439 8 return;
440 }
441 220 config::detail::DeferredDiagnostics to_emit;
442 220 config::detail::DeferredDiagnostics late_to_emit;
443 {
444 220 std::lock_guard<std::mutex> lock(gate->mutex);
445 220 gate->wait.store(ConfigWatcherStartWait::Released, std::memory_order_release);
446
5/6
✓ Branch 10 → 11 taken 220 times.
✗ Branch 10 → 14 not taken.
✓ Branch 12 → 13 taken 218 times.
✓ Branch 12 → 14 taken 2 times.
✓ Branch 15 → 16 taken 218 times.
✓ Branch 15 → 25 taken 2 times.
220 if (gate->complete && !gate->emitted)
447 {
448 218 gate->emitted = true;
449 436 to_emit = std::move(gate->diags);
450 436 late_to_emit = std::move(gate->late_diags);
451 }
452 220 }
453 220 gate->wait.notify_all();
454 220 config::detail::emit_deferred_diagnostics(to_emit);
455 220 config::detail::emit_deferred_diagnostics(late_to_emit);
456 220 }
457
458 namespace
459 {
460 218 void settle_startup_handshake(
461 const std::shared_ptr<std::promise<bool>> &open_result,
462 bool &handshake_settled,
463 bool ok
464 ) noexcept
465 {
466
1/2
✓ Branch 2 → 3 taken 218 times.
✗ Branch 2 → 5 not taken.
218 if (!handshake_settled)
467 {
468 218 handshake_settled = true;
469 try
470 {
471
1/2
✓ Branch 4 → 5 taken 218 times.
✗ Branch 4 → 6 not taken.
218 open_result->set_value(ok);
472 }
473 ✗ catch (...)
474 {
475 ✗ }
476 }
477 218 }
478
479 203 void wait_for_start_release(const std::shared_ptr<ConfigWatcherStartGate> &startup_gate) noexcept
480 {
481 203 auto state = startup_gate->wait.load(std::memory_order_acquire);
482
2/2
✓ Branch 9 → 5 taken 200 times.
✓ Branch 9 → 10 taken 203 times.
403 while (state == ConfigWatcherStartWait::Pending)
483 {
484 200 startup_gate->wait.wait(ConfigWatcherStartWait::Pending, std::memory_order_acquire);
485 200 state = startup_gate->wait.load(std::memory_order_acquire);
486 }
487 203 }
488
489 218 void complete_startup_handshake(
490 const std::shared_ptr<ConfigWatcherStartGate> &startup_gate,
491 config::detail::DeferredDiagnostics &startup_diags,
492 const std::shared_ptr<std::promise<bool>> &open_result,
493 bool &handshake_settled,
494 bool ok
495 ) noexcept
496 {
497 218 config::detail::DeferredDiagnostics to_emit;
498 {
499 218 std::lock_guard<std::mutex> channel_lock(startup_gate->mutex);
500 218 startup_gate->diags = std::move(startup_diags);
501 218 startup_gate->complete = true;
502
2/4
✗ Branch 11 → 12 not taken.
✓ Branch 11 → 15 taken 218 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 22 taken 218 times.
218 if (startup_gate->wait.load(std::memory_order_acquire) == ConfigWatcherStartWait::Released &&
503
0/2
✗ Branch 13 → 14 not taken.
✗ Branch 13 → 15 not taken.
✗ !startup_gate->emitted)
504 {
505 ✗ startup_gate->emitted = true;
506 ✗ to_emit = std::move(startup_gate->diags);
507 }
508 218 }
509 218 settle_startup_handshake(open_result, handshake_settled, ok);
510 218 config::detail::emit_deferred_diagnostics(to_emit);
511 218 }
512
513 2 void defer_startup_exception(
514 config::detail::DeferredDiagnostics &startup_diags,
515 std::string_view message
516 ) noexcept
517 {
518 try
519 {
520
1/2
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 5 not taken.
2 config::detail::defer_diagnostic(
521 startup_diags,
522 LogLevel::Error,
523 "StoppableWorker '{}': unhandled exception: {}",
524 "ConfigWatcher",
525 message
526 );
527 }
528 ✗ catch (...)
529 {
530 ✗ DetourModKit::detail::LoggerDropAccess::record(log());
531 ✗ }
532 2 }
533
534 ✗ void defer_unknown_startup_exception(config::detail::DeferredDiagnostics &startup_diags) noexcept
535 {
536 try
537 {
538
0/2
✗ Branch 2 → 3 not taken.
✗ Branch 2 → 5 not taken.
✗ config::detail::defer_diagnostic(
539 startup_diags,
540 LogLevel::Error,
541 "StoppableWorker '{}': unknown exception escaped body.",
542 "ConfigWatcher"
543 );
544 }
545 ✗ catch (...)
546 {
547 ✗ DetourModKit::detail::LoggerDropAccess::record(log());
548 ✗ }
549 ✗ }
550
551 /// Returns false after it defers the diagnostic of an open failure.
552 216 [[nodiscard]] bool open_watch_io_state(
553 std::unique_ptr<WatchIoState> &io,
554 const std::wstring &directory,
555 const std::string &label,
556 config::detail::DeferredDiagnostics &startup_diags
557 )
558 {
559 #if defined(DMK_ENABLE_TEST_SEAMS)
560
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 212 times.
216 if (auto *seam = g_config_watcher_prehandshake_seam)
561 {
562 4 seam();
563 }
564 #endif
565
566
1/2
✓ Branch 4 → 5 taken 214 times.
✗ Branch 4 → 50 not taken.
214 io = std::make_unique<WatchIoState>();
567 214 io->buffer.resize(BUFFER_BYTES);
568
569 428 io->dir_handle = OwnedHandle(
570 ::CreateFileW(
571 directory.c_str(),
572 FILE_LIST_DIRECTORY,
573 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
574 nullptr,
575 OPEN_EXISTING,
576 FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
577 nullptr
578 )
579
1/2
✓ Branch 10 → 11 taken 214 times.
✗ Branch 10 → 51 not taken.
428 );
580
581
2/2
✓ Branch 17 → 18 taken 7 times.
✓ Branch 17 → 21 taken 207 times.
214 if (!io->dir_handle.valid())
582 {
583 ✗ config::detail::defer_diagnostic(
584 startup_diags,
585 LogLevel::Error,
586 "ConfigWatcher '{}': CreateFileW failed (GLE={}).",
587 label,
588
2/4
✓ Branch 18 → 19 taken 7 times.
✗ Branch 18 → 53 not taken.
✓ Branch 19 → 20 taken 7 times.
✗ Branch 19 → 52 not taken.
7 ::GetLastError()
589 );
590 7 return false;
591 }
592
593 #if defined(DMK_ENABLE_TEST_SEAMS)
594
3/4
✓ Branch 21 → 22 taken 3 times.
✓ Branch 21 → 25 taken 204 times.
✓ Branch 23 → 24 taken 3 times.
✗ Branch 23 → 25 not taken.
210 const bool force_event_failure = g_config_watcher_create_event_failure_seam != nullptr &&
595 3 g_config_watcher_create_event_failure_seam();
596 #else
597 constexpr bool force_event_failure = false;
598 #endif
599
2/2
✓ Branch 26 → 27 taken 204 times.
✓ Branch 26 → 33 taken 3 times.
207 if (!force_event_failure)
600 {
601
1/2
✓ Branch 27 → 28 taken 204 times.
✗ Branch 27 → 54 not taken.
204 io->event_handle = OwnedHandle(::CreateEventW(nullptr, TRUE, FALSE, nullptr));
602 }
603
5/6
✓ Branch 33 → 34 taken 204 times.
✓ Branch 33 → 37 taken 3 times.
✗ Branch 36 → 37 not taken.
✓ Branch 36 → 38 taken 204 times.
✓ Branch 39 → 40 taken 3 times.
✓ Branch 39 → 46 taken 204 times.
207 if (force_event_failure || !io->event_handle.valid())
604 {
605 ✗ config::detail::defer_diagnostic(
606 startup_diags,
607 LogLevel::Error,
608 "ConfigWatcher '{}': CreateEventW failed (GLE={}).",
609 label,
610
2/6
✓ Branch 40 → 41 taken 3 times.
✗ Branch 40 → 42 not taken.
✗ Branch 42 → 43 not taken.
✗ Branch 42 → 56 not taken.
✓ Branch 44 → 45 taken 3 times.
✗ Branch 44 → 55 not taken.
3 force_event_failure ? ERROR_GEN_FAILURE : ::GetLastError()
611 );
612 3 return false;
613 }
614
615 204 io->overlapped.hEvent = io->event_handle.h;
616 204 return true;
617 }
618
619 1540 [[nodiscard]] bool issue_directory_read(
620 WatchIoState &io,
621 bool handshake_settled,
622 config::detail::DeferredDiagnostics &startup_diags,
623 const std::string &label
624 )
625 {
626 #if defined(DMK_ENABLE_TEST_SEAMS)
627
7/8
✓ Branch 2 → 3 taken 204 times.
✓ Branch 2 → 7 taken 1336 times.
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 7 taken 203 times.
✓ Branch 5 → 6 taken 1 time.
✗ Branch 5 → 7 not taken.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 11 taken 1539 times.
1541 if (!handshake_settled && g_config_watcher_initial_read_failure_seam != nullptr &&
628 1 g_config_watcher_initial_read_failure_seam())
629 {
630 ✗ config::detail::defer_diagnostic(
631 startup_diags,
632 LogLevel::Error,
633 "ConfigWatcher '{}': ReadDirectoryChangesW failed (GLE={}).",
634 label,
635
1/2
✓ Branch 9 → 10 taken 1 time.
✗ Branch 9 → 28 not taken.
1 ERROR_GEN_FAILURE
636 );
637 1 return false;
638 }
639 #endif
640
1/2
✓ Branch 11 → 12 taken 1539 times.
✗ Branch 11 → 33 not taken.
1539 ::ResetEvent(io.event_handle.h);
641 1539 DWORD bytes_returned = 0;
642
1/2
✓ Branch 14 → 15 taken 1539 times.
✗ Branch 14 → 33 not taken.
1539 const BOOL ok = ::ReadDirectoryChangesW(
643 io.dir_handle.h,
644 1539 io.buffer.data(),
645 1539 static_cast<DWORD>(io.buffer.size()),
646 FALSE, // no recursion
647 NOTIFY_FILTER,
648 &bytes_returned,
649 &io.overlapped,
650 nullptr
651 );
652
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 25 taken 1539 times.
1539 if (!ok)
653 {
654
0/2
✗ Branch 16 → 17 not taken.
✗ Branch 16 → 20 not taken.
✗ if (!handshake_settled)
655 {
656 ✗ config::detail::defer_diagnostic(
657 startup_diags,
658 LogLevel::Error,
659 "ConfigWatcher '{}': ReadDirectoryChangesW failed (GLE={}).",
660 label,
661
0/4
✗ Branch 17 → 18 not taken.
✗ Branch 17 → 31 not taken.
✗ Branch 18 → 19 not taken.
✗ Branch 18 → 30 not taken.
✗ ::GetLastError()
662 );
663 }
664 else
665 {
666 ✗ (void)log().try_log(
667 LogLevel::Error,
668 "ConfigWatcher '{}': ReadDirectoryChangesW failed (GLE={}).",
669 label,
670
0/2
✗ Branch 21 → 22 not taken.
✗ Branch 21 → 32 not taken.
✗ ::GetLastError()
671 );
672 }
673 ✗ return false;
674 }
675 1539 return true;
676 }
677
678 /**
679 * @brief Invokes @p callback and logs an exception that it throws.
680 * @details The callback boundary is noexcept because a thrown callback before the drain can free storage
681 * that the pending I/O still uses. try_log keeps both catch handlers within that boundary.
682 */
683 47 void fire_reload(const std::function<void()> &callback, const std::string &label) noexcept
684 {
685
2/2
✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 5 taken 46 times.
47 if (!callback)
686 {
687 1 return;
688 }
689 try
690 {
691
2/2
✓ Branch 5 → 6 taken 43 times.
✓ Branch 5 → 7 taken 3 times.
46 callback();
692 }
693
1/2
✓ Branch 7 → 8 taken 3 times.
✗ Branch 7 → 13 not taken.
3 catch (const std::exception &e)
694 {
695 3 (void)log()
696 3 .try_log(LogLevel::Error, "ConfigWatcher '{}': reload callback threw: {}", label, e.what());
697 3 }
698 ✗ catch (...)
699 {
700 ✗ (void)log().try_log(
701 LogLevel::Error,
702 "ConfigWatcher '{}': reload callback threw a non-std exception.",
703 label
704 );
705 ✗ }
706 }
707
708 /// Returns true when the completed batch names the target file or reports coalesced events.
709 1336 [[nodiscard]] bool scan_completed_batch(
710 DWORD bytes_transferred,
711 const std::vector<BYTE> &buffer,
712 const std::wstring &filename,
713 const std::string &label,
714 bool &overflow_logged
715 ) noexcept
716 {
717 1336 bool matched = false;
718
719
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 8 taken 1336 times.
1336 if (bytes_transferred == 0)
720 {
721 // A successful zero-byte completion means that the notification buffer overflowed and the
722 // kernel coalesced the events. The batch counts as a match, as ERROR_NOTIFY_ENUM_DIR does in
723 // pump_directory_changes, so the caller marks a change pending and the debounce absorbs duplicates.
724
0/2
✗ Branch 3 → 4 not taken.
✗ Branch 3 → 7 not taken.
✗ if (!overflow_logged)
725 {
726 ✗ (void)log().try_log(
727 LogLevel::Debug,
728 "ConfigWatcher '{}': notification buffer "
729 "overflowed (zero-byte completion); "
730 "coalescing dropped events.",
731 label
732 );
733 ✗ overflow_logged = true;
734 }
735 ✗ matched = true;
736 }
737 else
738 {
739 // A real event batch resets the overflow latch, so the next overflow logs again at DEBUG level.
740 1336 overflow_logged = false;
741
742 // The walk trusts the kernel, but it checks every kernel-supplied length and offset against
743 // the buffer before a read or an advance. Without that check, a corrupt or malicious
744 // FileNameLength or NextEntryOffset causes an out-of-bounds read of the heap buffer. On any
745 // inconsistency the walk stops, so it never reads past the bytes that the kernel returned.
746 1336 const BYTE *cursor = buffer.data();
747 1336 const BYTE *const end_ptr = cursor + bytes_transferred;
748
749 // The fixed header occupies the bytes before the variable-length FileName[] member. This offset
750 // bounds both the header and the filename extent against end_ptr.
751 1336 constexpr size_t name_field_offset = offsetof(FILE_NOTIFY_INFORMATION, FileName);
752
753 // (a) The entry header must fit before the walk reads any of its fields. The check compares the
754 // remaining span before the walk forms cursor + name_field_offset, so malformed trailing bytes
755 // cannot move the check outside the buffer.
756
1/2
✓ Branch 25 → 10 taken 1345 times.
✗ Branch 25 → 26 not taken.
1345 while (static_cast<size_t>(end_ptr - cursor) >= name_field_offset)
757 {
758 1345 const auto *info = reinterpret_cast<const FILE_NOTIFY_INFORMATION *>(cursor);
759
760 1345 const DWORD name_bytes = info->FileNameLength;
761
762 // (b) FileNameLength must be a whole number of WCHARs. An odd byte count is malformed.
763
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 12 taken 1345 times.
1345 if (name_bytes % sizeof(WCHAR) != 0)
764 {
765 1336 break;
766 }
767
768 // (c) FileName + FileNameLength must not run past the buffer end. The check compares the
769 // available span (end_ptr - name_start), so no pointer addition can overflow.
770 1345 const BYTE *const name_start = cursor + name_field_offset;
771
1/2
✗ Branch 12 → 13 not taken.
✓ Branch 12 → 14 taken 1345 times.
1345 if (name_bytes > static_cast<size_t>(end_ptr - name_start))
772 {
773 ✗ break;
774 }
775
776 1345 const size_t name_len = name_bytes / sizeof(WCHAR);
777 1345 const std::wstring_view changed_name(info->FileName, name_len);
778
779 // A rename-swap-save (temp -> target) surfaces the target filename in the RENAMED_NEW_NAME
780 // entry.
781
2/2
✓ Branch 17 → 18 taken 100 times.
✓ Branch 17 → 19 taken 1245 times.
1345 if (iequals_w(changed_name, filename))
782 {
783 100 matched = true;
784 }
785
786 // A zero NextEntryOffset terminates the walk (the spec's end-of-chain marker).
787 1345 const DWORD next = info->NextEntryOffset;
788
2/2
✓ Branch 19 → 20 taken 1336 times.
✓ Branch 19 → 21 taken 9 times.
1345 if (next == 0)
789 {
790 1336 break;
791 }
792
793 // (d) NextEntryOffset must advance at least past this entry's header, so a bogus small value
794 // cannot loop or alias the current entry. It must also keep the next entry's start at or
795 // before the buffer end. The loop condition then checks that the next header fits. The
796 // comparison uses the available span, so no pointer addition can overflow.
797
2/4
✓ Branch 21 → 22 taken 9 times.
✗ Branch 21 → 24 not taken.
✓ Branch 22 → 23 taken 9 times.
✗ Branch 22 → 24 not taken.
9 if (next < name_field_offset || next > static_cast<size_t>(end_ptr - cursor))
798 {
799 break;
800 }
801 9 cursor += next;
802 }
803 }
804 1336 return matched;
805 }
806
807 /**
808 * @brief Runs the notification pump until a stop request or a pump failure.
809 * @details The caller settled the handshake before this call, so a read failure goes only to the log.
810 * @return true when a matching change is still pending.
811 */
812 203 [[nodiscard]] bool pump_directory_changes(
813 const std::stop_token &st,
814 const std::atomic<bool> *stop_requested_slot,
815 WatchIoState &io,
816 const std::wstring &filename,
817 std::chrono::milliseconds debounce_ms,
818 const std::function<void()> &callback,
819 const std::string &label,
820 bool handshake_settled,
821 config::detail::DeferredDiagnostics &startup_diags
822 )
823 {
824 // Debounce bookkeeping: once we observe a matching change, mark it pending and defer the callback
825 // until no matching change has arrived for `debounce_ms`. Using steady_clock to survive wall-clock
826 // adjustments.
827 203 bool pending = false;
828 203 std::chrono::steady_clock::time_point last_event{};
829
830 // Track whether an overflow/coalesced-events completion has already been logged once per instance;
831 // subsequent hits stay silent at DEBUG level to avoid log spam.
832 203 bool overflow_logged = false;
833
834 // Check the debounce deadline before every wait. Foreign file events can prevent WAIT_TIMEOUT while
835 // they leave last_event unchanged. This placement fires the reload after the target quiet window.
836 1485 auto maybe_fire_debounced = [&]() noexcept
837 {
838
6/6
✓ Branch 2 → 3 taken 160 times.
✓ Branch 2 → 9 taken 1325 times.
✓ Branch 7 → 8 taken 40 times.
✓ Branch 7 → 9 taken 120 times.
✓ Branch 10 → 11 taken 40 times.
✓ Branch 10 → 12 taken 1445 times.
1485 if (pending && std::chrono::steady_clock::now() - last_event >= debounce_ms)
839 {
840 40 pending = false;
841 40 fire_reload(callback, label);
842 }
843 1688 };
844
845
6/6
✓ Branch 41 → 42 taken 1525 times.
✓ Branch 41 → 45 taken 160 times.
✓ Branch 43 → 44 taken 1485 times.
✓ Branch 43 → 45 taken 40 times.
✓ Branch 46 → 3 taken 1485 times.
✓ Branch 46 → 47 taken 200 times.
1685 while (!st.stop_requested() && !stop_requested_slot->load(std::memory_order_acquire))
846 {
847 // Check the debounce deadline before every wait. Foreign file traffic can prevent WAIT_TIMEOUT.
848 1485 maybe_fire_debounced();
849
850 1485 DWORD bytes_transferred = 0;
851
1/2
✓ Branch 4 → 5 taken 1484 times.
✗ Branch 4 → 52 not taken.
1485 const BOOL overlapped_ok = ::GetOverlappedResultEx(
852 io.dir_handle.h,
853 &io.overlapped,
854 &bytes_transferred,
855 PUMP_TIMEOUT_MS,
856 FALSE
857 );
858
859
2/2
✓ Branch 5 → 6 taken 148 times.
✓ Branch 5 → 31 taken 1336 times.
1484 if (!overlapped_ok)
860 {
861
1/2
✓ Branch 6 → 7 taken 148 times.
✗ Branch 6 → 51 not taken.
148 const DWORD err = ::GetLastError();
862
863
3/4
✓ Branch 7 → 8 taken 2 times.
✓ Branch 7 → 9 taken 146 times.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 2 times.
148 if (err == WAIT_TIMEOUT || err == WAIT_IO_COMPLETION)
864 {
865 // No I/O completed this tick. The loop already checked the debounce deadline.
866 146 continue;
867 }
868
869
1/2
✗ Branch 10 → 11 not taken.
✓ Branch 10 → 14 taken 2 times.
2 if (err == ERROR_OPERATION_ABORTED)
870 {
871 // Directory handle closed or I/O cancelled externally (e.g. the watched parent
872 // directory was removed or renamed). We cannot recover a handle to a vanished directory
873 // here; surface the event at warning level so users notice.
874 ✗ (void)log().try_log(
875 LogLevel::Warning,
876 "ConfigWatcher '{}': directory handle "
877 "invalidated (parent removed/renamed); "
878 "watcher thread exiting.",
879 label
880 );
881 2 break;
882 }
883
884
1/2
✗ Branch 14 → 15 not taken.
✓ Branch 14 → 26 taken 2 times.
2 if (err == ERROR_NOTIFY_ENUM_DIR)
885 {
886 // Kernel/redirector path for buffer overflow:
887 // events were dropped because they arrived faster than we could drain them. Treat as a
888 // coalesced match, re-issue the read, and let debounce deduplicate.
889
0/2
✗ Branch 15 → 16 not taken.
✗ Branch 15 → 19 not taken.
✗ if (!overflow_logged)
890 {
891 ✗ (void)log().try_log(
892 LogLevel::Debug,
893 "ConfigWatcher '{}': notification "
894 "buffer overflowed (ERROR_NOTIFY_ENUM_DIR); "
895 "coalescing dropped events.",
896 label
897 );
898 ✗ overflow_logged = true;
899 }
900 ✗ pending = true;
901 ✗ last_event = std::chrono::steady_clock::now();
902
0/4
✗ Branch 20 → 21 not taken.
✗ Branch 20 → 51 not taken.
✗ Branch 21 → 22 not taken.
✗ Branch 21 → 23 not taken.
✗ if (!issue_directory_read(io, handshake_settled, startup_diags, label))
903 {
904 ✗ break;
905 }
906 // Some redirectors raise ERROR_NOTIFY_ENUM_DIR continuously under sustained event
907 // storms. Without the sleep, the worker spins at 100% CPU as it re-issues reads. The
908 // ~20 Hz cap bounds CPU and keeps the debounce semantics intact.
909
0/2
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 49 not taken.
✗ std::this_thread::sleep_for(std::chrono::milliseconds(50));
910 ✗ continue;
911 }
912
913 2 (void)log().try_log(
914 LogLevel::Error,
915 "ConfigWatcher '{}': GetOverlappedResultEx failed (GLE={}).",
916 label,
917 err
918 );
919 2 break;
920 }
921
922 const bool matched =
923 1336 scan_completed_batch(bytes_transferred, io.buffer, filename, label, overflow_logged);
924
925
2/2
✓ Branch 32 → 33 taken 100 times.
✓ Branch 32 → 34 taken 1236 times.
1336 if (matched)
926 {
927 100 pending = true;
928 100 last_event = std::chrono::steady_clock::now();
929 }
930
931
2/4
✓ Branch 34 → 35 taken 1336 times.
✗ Branch 34 → 52 not taken.
✗ Branch 35 → 36 not taken.
✓ Branch 35 → 37 taken 1336 times.
1336 if (!issue_directory_read(io, handshake_settled, startup_diags, label))
932 {
933 ✗ break;
934 }
935 }
936 202 return pending;
937 }
938
939 /**
940 * @brief Cancels the in-flight read and waits for the kernel to release the OVERLAPPED and the
941 * notification buffer.
942 * @details Per MSDN, both must stay valid until the cancelled I/O completes. An earlier free lets the
943 * kernel write into released memory.
944 *
945 * If the watched directory was deleted, the notify IRP can be orphaned: CancelIoEx reports
946 * success, but no completion arrives. An unbounded GetOverlappedResult wait then hangs the
947 * StoppableWorker join and the whole teardown. Every wait here is therefore bounded, and the
948 * drain escalates in three steps:
949 * 1. CancelIoEx and a bounded wait cover the normal case.
950 * 2. On a timeout, the drain closes the directory handle. The I/O Manager then cancels and
951 * completes the outstanding IRP, and the completion signals the event.
952 * 3. If the IRP still does not complete in time, the drain leaks the I/O bundle, so a late
953 * completion never writes into freed memory.
954 */
955 202 void drain_watch_io(
956 std::unique_ptr<WatchIoState> &io,
957 LogLevel startup_threshold,
958 const std::string &label,
959 const std::shared_ptr<ConfigWatcherStartGate> &startup_gate
960 ) noexcept
961 {
962 202 ::CancelIoEx(io->dir_handle.h, &io->overlapped);
963
964 202 DWORD drain_bytes = 0;
965 const BOOL drain_ok =
966 202 ::GetOverlappedResultEx(io->dir_handle.h, &io->overlapped, &drain_bytes, DRAIN_TIMEOUT_MS, FALSE);
967
968 // Only WAIT_TIMEOUT and WAIT_IO_COMPLETION mean that the IRP is still pending. Any other status,
969 // ERROR_OPERATION_ABORTED among them, means that the kernel is done with the OVERLAPPED and the buffer.
970 202 bool drained = drain_ok != FALSE;
971
1/2
✓ Branch 8 → 9 taken 202 times.
✗ Branch 8 → 15 not taken.
202 if (!drained)
972 {
973 202 const DWORD drain_err = ::GetLastError();
974
2/4
✓ Branch 10 → 11 taken 202 times.
✗ Branch 10 → 13 not taken.
✓ Branch 11 → 12 taken 202 times.
✗ Branch 11 → 13 not taken.
202 drained = drain_err != WAIT_TIMEOUT && drain_err != WAIT_IO_COMPLETION;
975 }
976
977
1/2
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 21 taken 202 times.
202 if (!drained)
978 {
979 ✗ io->dir_handle.reset();
980 ✗ drained = ::WaitForSingleObject(io->event_handle.h, DRAIN_TIMEOUT_MS) == WAIT_OBJECT_0;
981 }
982
983
1/2
✗ Branch 21 → 22 not taken.
✓ Branch 21 → 42 taken 202 times.
202 if (!drained)
984 {
985 ✗ config::detail::DeferredDiagnostics late_diags{
986 .threshold = startup_threshold,
987 .records = {},
988 ✗ };
989 try
990 {
991
0/2
✗ Branch 22 → 23 not taken.
✗ Branch 22 → 43 not taken.
✗ config::detail::defer_diagnostic(
992 late_diags,
993 LogLevel::Warning,
994 "ConfigWatcher '{}': pending directory notification did not drain after cancel + "
995 "handle close; leaking the watch buffer to stay memory-safe.",
996 label
997 );
998 ✗ config::detail::DeferredDiagnostics to_emit;
999 {
1000
0/2
✗ Branch 24 → 25 not taken.
✗ Branch 24 → 44 not taken.
✗ std::lock_guard<std::mutex> channel_lock(startup_gate->mutex);
1001
0/2
✗ Branch 27 → 28 not taken.
✗ Branch 27 → 31 not taken.
✗ if (startup_gate->wait.load(std::memory_order_acquire) == ConfigWatcherStartWait::Released)
1002 {
1003 ✗ to_emit = std::move(late_diags);
1004 }
1005 else
1006 {
1007 ✗ startup_gate->late_diags = std::move(late_diags);
1008 }
1009 ✗ }
1010 ✗ config::detail::emit_deferred_diagnostics(to_emit);
1011 ✗ }
1012 ✗ catch (...)
1013 {
1014 ✗ DetourModKit::detail::LoggerDropAccess::record(log());
1015 ✗ }
1016 ✗ (void)io.release();
1017 ✗ }
1018 202 }
1019
1020 /**
1021 * @brief Opens the watch I/O state and queues the first read.
1022 * @return false after this helper settles the handshake with a failure.
1023 */
1024 216 [[nodiscard]] bool open_watch_and_queue_first_read(
1025 std::unique_ptr<WatchIoState> &io,
1026 const std::wstring &directory,
1027 const std::string &label,
1028 const std::shared_ptr<ConfigWatcherStartGate> &startup_gate,
1029 config::detail::DeferredDiagnostics &startup_diags,
1030 const std::shared_ptr<std::promise<bool>> &open_result,
1031 bool &handshake_settled
1032 ) noexcept
1033 {
1034 13 const auto complete_startup = [&](bool ok) noexcept
1035 13 { complete_startup_handshake(startup_gate, startup_diags, open_result, handshake_settled, ok); };
1036
1037 2 const auto fail_startup = [&](std::string_view message) noexcept
1038 {
1039 2 defer_startup_exception(startup_diags, message);
1040 2 complete_startup(false);
1041 218 };
1042
1043 ✗ const auto fail_startup_unknown = [&]() noexcept
1044 {
1045 ✗ defer_unknown_startup_exception(startup_diags);
1046 ✗ complete_startup(false);
1047 216 };
1048
1049 try
1050 {
1051
4/4
✓ Branch 2 → 3 taken 214 times.
✓ Branch 2 → 14 taken 2 times.
✓ Branch 3 → 4 taken 10 times.
✓ Branch 3 → 6 taken 204 times.
216 if (!open_watch_io_state(io, directory, label, startup_diags))
1052 {
1053 10 complete_startup(false);
1054 10 return false;
1055 }
1056 }
1057
1/2
✓ Branch 14 → 15 taken 2 times.
✗ Branch 14 → 20 not taken.
2 catch (const std::exception &e)
1058 {
1059 2 fail_startup(e.what());
1060 2 return false;
1061 2 }
1062 ✗ catch (...)
1063 {
1064 ✗ fail_startup_unknown();
1065 ✗ return false;
1066 ✗ }
1067
1068 try
1069 {
1070
3/4
✓ Branch 7 → 8 taken 204 times.
✗ Branch 7 → 23 not taken.
✓ Branch 8 → 9 taken 1 time.
✓ Branch 8 → 11 taken 203 times.
204 if (!issue_directory_read(*io, handshake_settled, startup_diags, label))
1071 {
1072 1 complete_startup(false);
1073 1 return false;
1074 }
1075 }
1076
0/2
✗ Branch 23 → 24 not taken.
✗ Branch 23 → 29 not taken.
✗ catch (const std::exception &e)
1077 {
1078 ✗ fail_startup(e.what());
1079 ✗ return false;
1080 ✗ }
1081 ✗ catch (...)
1082 {
1083 ✗ fail_startup_unknown();
1084 ✗ return false;
1085 ✗ }
1086 203 return true;
1087 }
1088
1089 /**
1090 * @brief Runs the StoppableWorker body of one start().
1091 * @details Each parameter after @p st is one member of the body lambda's closure.
1092 */
1093 218 void run_watch_worker(
1094 const std::stop_token &st,
1095 const std::wstring &directory,
1096 const std::wstring &filename,
1097 std::chrono::milliseconds debounce_ms,
1098 const std::function<void()> &callback,
1099 const std::string &label,
1100 const std::shared_ptr<std::promise<bool>> &open_result,
1101 const std::shared_ptr<ConfigWatcherStartGate> &startup_gate,
1102 LogLevel startup_threshold,
1103 std::atomic<std::thread::id> *worker_id_slot,
1104 std::atomic<bool> *worker_exited_slot,
1105 const std::atomic<bool> *stop_requested_slot
1106 )
1107 {
1108 218 const WorkerExitGuard worker_exit_guard{*worker_exited_slot};
1109 218 config::detail::DeferredDiagnostics startup_diags{
1110 .threshold = startup_threshold,
1111 .records = {},
1112 218 };
1113 // Publish the thread id so that is_worker_thread() detects a setter-invoked self-call into
1114 // disable_auto_reload(). The guard clears the slot on every exit path after the final flush callback
1115 // (see WorkerThreadIdGuard).
1116 218 worker_id_slot->store(std::this_thread::get_id(), std::memory_order_release);
1117 218 const WorkerThreadIdGuard worker_id_guard{*worker_id_slot};
1118
1119 // start() co-owns open_result for the whole bounded wait, so a dropped body copy cannot wake the
1120 // waiter through broken_promise. The body must publish the result on every exit itself.
1121 // settle_startup_handshake() records success or failure exactly once. SettleGuard publishes a failure
1122 // on any exit before the handshake settles. start() therefore returns a failure promptly after a
1123 // pre-handshake exit, not after the full 5s handshake timeout.
1124 218 bool handshake_settled = false;
1125 class SettleGuard
1126 {
1127 public:
1128 218 SettleGuard(std::shared_ptr<std::promise<bool>> promise, bool &settled) noexcept
1129 436 : m_promise(std::move(promise)), m_settled(settled)
1130 {
1131 218 }
1132
1133 217 ~SettleGuard() noexcept
1134 {
1135
1/2
✗ Branch 2 → 3 not taken.
✓ Branch 2 → 6 taken 217 times.
217 if (!m_settled)
1136 {
1137 ✗ m_settled = true;
1138 try
1139 {
1140
0/2
✗ Branch 4 → 5 not taken.
✗ Branch 4 → 8 not taken.
✗ m_promise->set_value(false);
1141 }
1142 ✗ catch (...)
1143 {
1144 ✗ }
1145 }
1146 217 }
1147
1148 SettleGuard(const SettleGuard &) = delete;
1149 SettleGuard &operator=(const SettleGuard &) = delete;
1150
1151 private:
1152 std::shared_ptr<std::promise<bool>> m_promise;
1153 bool &m_settled;
1154 218 } settle_guard{open_result, handshake_settled};
1155
1156 205 const auto complete_startup = [&](bool ok) noexcept
1157 205 { complete_startup_handshake(startup_gate, startup_diags, open_result, handshake_settled, ok); };
1158
1159 const std::stop_callback stop_wait_callback(
1160 st,
1161 630 [startup_gate]() noexcept { cancel_start_wait(startup_gate); }
1162 218 );
1163
1164
5/6
✓ Branch 13 → 14 taken 216 times.
✓ Branch 13 → 16 taken 2 times.
✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 216 times.
✓ Branch 18 → 19 taken 2 times.
✓ Branch 18 → 21 taken 216 times.
218 if (st.stop_requested() || stop_requested_slot->load(std::memory_order_acquire))
1165 {
1166 2 complete_startup(false);
1167 2 return;
1168 }
1169
1170 216 std::unique_ptr<WatchIoState> io;
1171
2/2
✓ Branch 22 → 23 taken 13 times.
✓ Branch 22 → 24 taken 203 times.
216 if (!open_watch_and_queue_first_read(
1172 io,
1173 directory,
1174 label,
1175 startup_gate,
1176 startup_diags,
1177 open_result,
1178 handshake_settled
1179 ))
1180 {
1181 13 return;
1182 }
1183
1184 // The first overlapped read is queued, so the watcher reports ready. A later failure does not change
1185 // the result of start().
1186 203 complete_startup(true);
1187 203 wait_for_start_release(startup_gate);
1188
1189
1/2
✓ Branch 27 → 28 taken 202 times.
✗ Branch 27 → 58 not taken.
203 const bool pending = pump_directory_changes(
1190 st,
1191 stop_requested_slot,
1192 203 *io,
1193 filename,
1194 debounce_ms,
1195 callback,
1196 label,
1197 handshake_settled,
1198 startup_diags
1199 );
1200 202 drain_watch_io(io, startup_threshold, label, startup_gate);
1201
1202 // A final pending change fires before the worker exits, so the debounce window does not discard it.
1203 // fire_reload contains callback exceptions after the I/O drain.
1204
2/2
✓ Branch 29 → 30 taken 7 times.
✓ Branch 29 → 31 taken 195 times.
202 if (pending)
1205 {
1206 7 fire_reload(callback, label);
1207 }
1208
12/12
✓ Branch 33 → 34 taken 202 times.
✓ Branch 33 → 35 taken 13 times.
✓ Branch 37 → 38 taken 202 times.
✓ Branch 37 → 39 taken 15 times.
✓ Branch 41 → 42 taken 202 times.
✓ Branch 41 → 43 taken 15 times.
✓ Branch 45 → 46 taken 202 times.
✓ Branch 45 → 47 taken 15 times.
✓ Branch 49 → 50 taken 202 times.
✓ Branch 49 → 51 taken 15 times.
✓ Branch 53 → 54 taken 202 times.
✓ Branch 53 → 56 taken 15 times.
290 }
1209
1210 2 void defer_invalid_path_error(config::detail::DeferredDiagnostics &diags, const std::string &ini_path_text)
1211 {
1212
1/2
✓ Branch 2 → 3 taken 2 times.
✗ Branch 2 → 4 not taken.
2 config::detail::defer_diagnostic(
1213 diags,
1214 LogLevel::Error,
1215 "ConfigWatcher: invalid INI path '{}'; cannot start.",
1216 ini_path_text
1217 );
1218 2 }
1219
1220 /**
1221 * @brief Waits a bounded time for the startup handshake and sets @p handshake_timed_out on a timeout.
1222 * @details The worker settles the promise on every exit path (see SettleGuard in run_watch_worker), so a
1223 * real result arrives promptly even after a pre-handshake throw. Only a wedged worker reaches the
1224 * 5s bound. A hostile hook on CreateFileW or CreateEventW that never returns wedges it. Callers
1225 * hold higher-level mutexes across start(), so an unbounded wait can stall the whole hot-reload
1226 * subsystem. On a timeout, start() must not join the worker inline (see clean_up_failed_start()).
1227 */
1228 216 [[nodiscard]] bool wait_for_start_handshake(
1229 std::future<bool> &open_future,
1230 config::detail::DeferredDiagnostics &diags,
1231 const std::string &ini_path_text,
1232 bool &handshake_timed_out
1233 ) noexcept
1234 {
1235 216 bool started = false;
1236 try
1237 {
1238
1/2
✓ Branch 3 → 4 taken 216 times.
✗ Branch 3 → 11 not taken.
216 const auto wait_status = open_future.wait_for(std::chrono::seconds(5));
1239
1/2
✓ Branch 4 → 5 taken 216 times.
✗ Branch 4 → 7 not taken.
216 if (wait_status == std::future_status::ready)
1240 {
1241
1/2
✓ Branch 5 → 6 taken 216 times.
✗ Branch 5 → 14 not taken.
216 started = open_future.get();
1242 }
1243 else
1244 {
1245 ✗ handshake_timed_out = true;
1246
0/2
✗ Branch 7 → 8 not taken.
✗ Branch 7 → 13 not taken.
✗ config::detail::defer_diagnostic(
1247 diags,
1248 LogLevel::Warning,
1249 "ConfigWatcher '{}': start handshake timed out after 5s; treating as failed.",
1250 ini_path_text
1251 );
1252 ✗ started = false;
1253 }
1254 }
1255 ✗ catch (...)
1256 {
1257 ✗ started = false;
1258 ✗ }
1259 216 return started;
1260 }
1261 } // anonymous namespace
1262
1263 222 bool ConfigWatcher::keep_live_worker_or_drop_exited(StartGate &gate) noexcept
1264 {
1265 // A post-handshake runtime failure makes the body return on its own while the StoppableWorker keeps a
1266 // finished thread. Such failures include a removed parent directory, a GetOverlappedResultEx error, and a
1267 // failed re-issue. A restart must not report that exited worker as success, so this helper joins and drops
1268 // it. start() then launches a fresh worker.
1269 //
1270 // Liveness comes from the worker_thread_id slot that is_running() reads, not from
1271 // StoppableWorker::is_running(). WorkerThreadIdGuard clears the slot before the StoppableWorker wrapper
1272 // publishes its Exited state. A check on StoppableWorker::is_running() can therefore report success to a
1273 // caller that already saw is_running() == false, with the exited worker still installed.
1274 //
1275 // A non-null worker under start_mutex implies a settled successful handshake, because every failure path
1276 // resets the worker or husks the watcher. The body stores its id before it settles. An empty slot with a
1277 // worker object therefore means that the body ended, and reset() joins a thread that is about to exit.
1278 //
1279 // The slot test also covers a null worker handle, because a null handle does not imply a finished body. A
1280 // stop() whose StoppableWorker::shutdown() hits the blocking-teardown veto detaches the body and drops the
1281 // handle. Without the slot test, a restart then clears stop_requested, the only signal that the detached
1282 // body observes, and leaves two pumps on one Impl. Whichever exits first publishes worker_exited and lets
1283 // ~ConfigWatcher free storage that the other still reads.
1284
2/2
✓ Branch 6 → 7 taken 2 times.
✓ Branch 6 → 12 taken 220 times.
222 if (m_impl->worker_thread_id.load(std::memory_order_acquire) != std::thread::id{})
1285 {
1286 2 gate = m_impl->start_gate.load(std::memory_order_acquire);
1287 2 return true;
1288 }
1289 220 m_impl->worker.reset();
1290 220 return false;
1291 }
1292
1293 222 bool ConfigWatcher::start(config::detail::DeferredDiagnostics &diags, StartGate &gate)
1294 {
1295 222 gate.reset();
1296
1/2
✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 222 times.
222 if (!m_impl)
1297 {
1298 // A prior start() timed out and leaked the Impl (see clean_up_failed_start()). The instance is inert,
1299 // and the caller must drop it.
1300 ✗ return false;
1301 }
1302
1/2
✓ Branch 7 → 8 taken 222 times.
✗ Branch 7 → 140 not taken.
222 std::lock_guard<std::mutex> lock(m_impl->start_mutex);
1303
1304
2/2
✓ Branch 9 → 10 taken 2 times.
✓ Branch 9 → 11 taken 220 times.
222 if (keep_live_worker_or_drop_exited(gate))
1305 {
1306 2 return true;
1307 }
1308
1309
5/6
✓ Branch 13 → 14 taken 218 times.
✓ Branch 13 → 17 taken 2 times.
✗ Branch 16 → 17 not taken.
✓ Branch 16 → 18 taken 218 times.
✓ Branch 19 → 20 taken 2 times.
✓ Branch 19 → 23 taken 218 times.
220 if (m_impl->directory_wide.empty() || m_impl->filename_wide.empty())
1310 {
1311
1/2
✓ Branch 21 → 22 taken 2 times.
✗ Branch 21 → 138 not taken.
2 defer_invalid_path_error(diags, m_impl->ini_path_text);
1312 2 return false;
1313 }
1314 218 m_impl->stop_requested.store(false, std::memory_order_release);
1315
1316 // Capture the worker inputs by value, so the body reads no Impl member except the three atomic slots below.
1317 // Under normal teardown, stop() joins before m_impl unwinds.
1318
1/2
✓ Branch 26 → 27 taken 218 times.
✗ Branch 26 → 138 not taken.
218 auto directory = m_impl->directory_wide;
1319
1/2
✓ Branch 28 → 29 taken 218 times.
✗ Branch 28 → 136 not taken.
218 auto filename = m_impl->filename_wide;
1320 218 auto debounce_ms = m_impl->debounce;
1321
1/2
✓ Branch 31 → 32 taken 218 times.
✗ Branch 31 → 134 not taken.
218 auto callback = m_impl->on_reload;
1322
1/2
✓ Branch 33 → 34 taken 218 times.
✗ Branch 33 → 132 not taken.
218 auto label = m_impl->ini_path_text;
1323 218 const LogLevel startup_threshold = diags.threshold;
1324
1325 // StoppableWorker stores its body in std::function, so the lambda must stay copyable and cannot own a
1326 // non-copyable OwnedHandle. The worker thread opens the directory handle and reports the result through a
1327 // shared promise. start() then returns the real status without a racy is_running() poll.
1328
1/2
✓ Branch 34 → 35 taken 218 times.
✗ Branch 34 → 130 not taken.
218 auto open_result = std::make_shared<std::promise<bool>>();
1329
1/2
✓ Branch 36 → 37 taken 218 times.
✗ Branch 36 → 128 not taken.
218 std::future<bool> open_future = open_result->get_future();
1330
1/2
✓ Branch 37 → 38 taken 218 times.
✗ Branch 37 → 126 not taken.
218 auto startup_gate = std::make_shared<ConfigWatcherStartGate>(diags.threshold);
1331 218 m_impl->start_gate.store(startup_gate, std::memory_order_release);
1332 218 gate = startup_gate;
1333
1334 // The lambda captures raw pointers to the Impl atomic slots, not an m_impl reference. The lambda can
1335 // outlive this stack frame through the StoppableWorker detach path, and a detached body still accesses all
1336 // three slots. The slots stay valid because Impl is never freed under a live body. Every teardown that
1337 // cannot join (the veto branch, and the authorized branch whose worker detached anyway) husks the watcher
1338 // and leaks Impl.
1339 218 auto *worker_id_slot = &m_impl->worker_thread_id;
1340 218 auto *worker_exited_slot = &m_impl->worker_exited;
1341 218 auto *stop_requested_slot = &m_impl->stop_requested;
1342
1343 436 auto worker_body = [directory = std::move(directory),
1344 218 filename = std::move(filename),
1345 debounce_ms,
1346 218 callback = std::move(callback),
1347 218 label = std::move(label),
1348 open_result,
1349 startup_gate,
1350 startup_threshold,
1351 worker_id_slot,
1352 worker_exited_slot,
1353 stop_requested_slot](const std::stop_token &st) -> void
1354 {
1355 218 run_watch_worker(
1356 st,
1357 218 directory,
1358 218 filename,
1359 debounce_ms,
1360 218 callback,
1361 218 label,
1362 218 open_result,
1363 218 startup_gate,
1364 startup_threshold,
1365 worker_id_slot,
1366 worker_exited_slot,
1367 stop_requested_slot
1368 );
1369
6/12
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 62 taken 218 times.
✗ Branch 62 → 63 not taken.
✓ Branch 62 → 64 taken 218 times.
✗ Branch 64 → 65 not taken.
✓ Branch 64 → 66 taken 218 times.
✗ Branch 66 → 67 not taken.
✓ Branch 66 → 68 taken 218 times.
✗ Branch 68 → 69 not taken.
✓ Branch 68 → 70 taken 218 times.
✗ Branch 70 → 71 not taken.
✓ Branch 70 → 72 taken 218 times.
872 };
1370
1371 try
1372 {
1373 218 m_impl->worker_exited.store(false, std::memory_order_release);
1374 const WorkerStartLogDeferral start_log_deferral{
1375 &diags,
1376 &config::detail::defer_worker_start_diagnostic,
1377 218 };
1378
2/2
✓ Branch 77 → 78 taken 216 times.
✓ Branch 77 → 97 taken 2 times.
218 m_impl->worker = std::make_unique<StoppableWorker>("ConfigWatcher", std::move(worker_body));
1379 218 }
1380
1/2
✓ Branch 101 → 102 taken 2 times.
✗ Branch 101 → 109 not taken.
2 catch (const std::exception &e)
1381 {
1382 2 m_impl->worker_exited.store(true, std::memory_order_release);
1383 ✗ config::detail::defer_diagnostic(
1384 diags,
1385 LogLevel::Error,
1386 "ConfigWatcher '{}': failed to start worker: {}",
1387
1/2
✓ Branch 107 → 108 taken 2 times.
✗ Branch 107 → 115 not taken.
2 m_impl->ini_path_text,
1388 2 e.what()
1389 );
1390 2 return false;
1391 2 }
1392 ✗ catch (...)
1393 {
1394 ✗ m_impl->worker_exited.store(true, std::memory_order_release);
1395 ✗ config::detail::defer_diagnostic(
1396 diags,
1397 LogLevel::Error,
1398 "ConfigWatcher '{}': failed to start worker: unknown exception.",
1399
0/2
✗ Branch 113 → 114 not taken.
✗ Branch 113 → 119 not taken.
✗ m_impl->ini_path_text
1400 );
1401 ✗ return false;
1402
0/2
✗ Branch 114 → 86 not taken.
✗ Branch 114 → 122 not taken.
✗ }
1403
1404 // Distinguishes the hung-worker case (handshake never completed) from a worker that reported failure and
1405 // is already returning. Only the former makes a join block; the two paths clean up differently.
1406 216 bool handshake_timed_out = false;
1407 const bool started =
1408 216 wait_for_start_handshake(open_future, diags, m_impl->ini_path_text, handshake_timed_out);
1409 216 clean_up_failed_start(started, handshake_timed_out);
1410 216 return started;
1411 222 }
1412
1413 216 void ConfigWatcher::clean_up_failed_start(bool started, bool handshake_timed_out) noexcept
1414 {
1415
3/4
✓ Branch 2 → 3 taken 13 times.
✓ Branch 2 → 11 taken 203 times.
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 11 taken 13 times.
216 if (!started && handshake_timed_out)
1416 {
1417 // Leak-on-timeout, never block-on-timeout. The worker never completed its startup handshake, so it can
1418 // be wedged. A hostile-hooked CreateFileW/CreateEventW that never returns is one such case. A join (the
1419 // naive cleanup, via a local unique_ptr whose destructor joins) blocks for the process lifetime while
1420 // this thread holds start_mutex. A call through enable_auto_reload() also holds get_watcher_mutex, so
1421 // the join wedges every later start(), stop(), and disable_auto_reload().
1422 //
1423 // Instead, this branch requests stop and leaks the whole Impl, as the loader-lock branch of
1424 // ~ConfigWatcher does. The worker exits once its blocked system call returns. The jthread and the three
1425 // atomic slots that the body reads live inside Impl, so Impl must outlive the worker thread. The leak
1426 // skips ~Impl and ~StoppableWorker, so no join runs, and the module reference of the worker keeps its
1427 // code pages mapped. A husked (null-Impl) ConfigWatcher is inert: the caller drops it immediately
1428 // (enable_auto_reload calls failed_watcher.reset()), and stop()/start() null-guard against it. The leak
1429 // is bounded to one Impl per hostile start timeout, an exceptional path.
1430
0/2
✗ Branch 6 → 7 not taken.
✗ Branch 6 → 10 not taken.
✗ if (m_impl->worker)
1431 {
1432 ✗ m_impl->worker->request_stop();
1433 }
1434 ✗ leak_impl_storage(m_impl);
1435 }
1436
2/2
✓ Branch 11 → 12 taken 13 times.
✓ Branch 11 → 14 taken 203 times.
216 else if (!started)
1437 {
1438 // The worker reported a startup failure (CreateFileW/CreateEventW failed) or threw before the
1439 // handshake: either way it is already returning, so joining it does not block. Drop it the normal way,
1440 // which joins the exiting worker and releases its module reference. m_impl stays intact, so this
1441 // ConfigWatcher is reusable for a retry rather than husked, and nothing is leaked on a benign start
1442 // failure.
1443 13 m_impl->worker.reset();
1444 }
1445 216 }
1446
1447 346 void ConfigWatcher::stop() noexcept
1448 {
1449
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 346 times.
346 if (!m_impl)
1450 {
1451 // Spent watcher (a start() timeout leaked the Impl); there is nothing left to stop.
1452 ✗ return;
1453 }
1454 // Publish before shutdown(), which re-queries blocking_teardown_permitted() for itself. That predicate is
1455 // process-global and can go false between ~ConfigWatcher's check and this one, so shutdown() may take its
1456 // unauthorized branch and detach without requesting stop. Without this flag the pump would then have no
1457 // exit signal at all while ~Impl frees the members it keeps reading. Harmless on the join path: the worker
1458 // is being stopped either way.
1459 346 m_impl->stop_requested.store(true, std::memory_order_release);
1460 346 cancel_start_wait(m_impl->start_gate.load(std::memory_order_acquire));
1461
1462 346 std::unique_ptr<StoppableWorker> to_drop;
1463 {
1464 346 std::lock_guard<std::mutex> lock(m_impl->start_mutex);
1465 692 to_drop = std::move(m_impl->worker);
1466 346 }
1467
1468
2/2
✓ Branch 19 → 20 taken 196 times.
✓ Branch 19 → 22 taken 150 times.
346 if (to_drop)
1469 {
1470 196 to_drop->shutdown();
1471 }
1472 346 }
1473
1474 1935408 void ConfigWatcher::request_stop() noexcept
1475 {
1476
1/2
✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 1935408 times.
1935408 if (!m_impl)
1477 {
1478 ✗ return;
1479 }
1480 // The worker observes this within its 100 ms I/O pump interval. This must not take start_mutex: start()
1481 // may be inside its 5 s hostile-call handshake, while safe-unload preparation owns a shorter deadline.
1482 1935408 m_impl->stop_requested.store(true, std::memory_order_release);
1483 1935408 cancel_start_wait(m_impl->start_gate.load(std::memory_order_acquire));
1484 }
1485
1486 1935403 bool ConfigWatcher::has_exited() const noexcept
1487 {
1488
3/4
✓ Branch 3 → 4 taken 1935403 times.
✗ Branch 3 → 8 not taken.
✓ Branch 6 → 7 taken 8 times.
✓ Branch 6 → 8 taken 1935395 times.
1935403 return m_impl != nullptr && m_impl->worker_exited.load(std::memory_order_acquire);
1489 }
1490 } // namespace detail
1491 } // namespace DetourModKit
1492