GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 75.0% 339 / 0 / 452
Functions: 97.5% 39 / 0 / 40
Branches: 58.7% 215 / 0 / 366

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