GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 81.5% 172 / 0 / 211
Functions: 100.0% 17 / 0 / 17
Branches: 50.4% 134 / 0 / 266

src/config_watcher.cpp
Line Branch Exec Source
1 /**
2 * @file config_watcher.cpp
3 * @brief Implementation of ConfigWatcher (ReadDirectoryChangesW-based).
4 */
5
6 #include "DetourModKit/config_watcher.hpp"
7
8 #include "DetourModKit/logger.hpp"
9 #include "DetourModKit/worker.hpp"
10 #include "platform.hpp"
11
12 #include <windows.h>
13
14 #include <algorithm>
15 #include <array>
16 #include <atomic>
17 #include <chrono>
18 #include <cstring>
19 #include <filesystem>
20 #include <future>
21 #include <memory>
22 #include <mutex>
23 #include <new>
24 #include <optional>
25 #include <string>
26 #include <string_view>
27 #include <type_traits>
28 #include <utility>
29 #include <vector>
30
31 namespace DetourModKit
32 {
33 namespace detail
34 {
35 // Test-only override for is_loader_lock_held(). When non-null the
36 // ConfigWatcher destructor consults this hook instead of the real
37 // PEB-based detection, letting the test suite exercise the
38 // detach-and-leak branch from user code. Defined as a plain
39 // function pointer because the override is set/cleared on a single
40 // thread inside a test fixture.
41 bool (*g_config_watcher_loader_lock_override)() noexcept = nullptr;
42 } // namespace detail
43
44 namespace
45 {
46 constexpr DWORD kNotifyFilter =
47 FILE_NOTIFY_CHANGE_LAST_WRITE |
48 FILE_NOTIFY_CHANGE_FILE_NAME |
49 FILE_NOTIFY_CHANGE_SIZE;
50
51 129 bool loader_lock_held_for_watcher() noexcept
52 {
53
2/2
✓ Branch 2 → 3 taken 4 times.
✓ Branch 2 → 4 taken 125 times.
129 if (auto *override_fn = detail::g_config_watcher_loader_lock_override)
54 {
55 4 return override_fn();
56 }
57 125 return detail::is_loader_lock_held();
58 }
59
60 // Sized so bursty editor saves do not overflow a single call while
61 // still fitting comfortably on the worker's stack.
62 constexpr DWORD kBufferBytes = 16 * 1024;
63
64 // Pumping timeout for GetOverlappedResultEx. Bounds how long a
65 // pending stop() must wait for the worker to observe its
66 // stop_token; idle cost is ~10 syscalls/s per watcher (not zero).
67 constexpr DWORD kPumpTimeoutMs = 100;
68
69 1234 bool iequals_w(std::wstring_view lhs, std::wstring_view rhs) noexcept
70 {
71
2/2
✓ Branch 4 → 5 taken 1203 times.
✓ Branch 4 → 6 taken 31 times.
1234 if (lhs.size() != rhs.size())
72 {
73 1203 return false;
74 }
75
2/2
✓ Branch 13 → 7 taken 449 times.
✓ Branch 13 → 14 taken 31 times.
480 for (size_t i = 0; i < lhs.size(); ++i)
76 {
77 449 const wchar_t a = static_cast<wchar_t>(::towupper(lhs[i]));
78 449 const wchar_t b = static_cast<wchar_t>(::towupper(rhs[i]));
79
1/2
✗ Branch 9 → 10 not taken.
✓ Branch 9 → 11 taken 449 times.
449 if (a != b)
80 {
81 return false;
82 }
83 }
84 31 return true;
85 }
86
87 struct OwnedHandle
88 {
89 HANDLE h{INVALID_HANDLE_VALUE};
90
91 OwnedHandle() = default;
92 245 explicit OwnedHandle(HANDLE raw) noexcept : h(raw) {}
93
94 OwnedHandle(const OwnedHandle &) = delete;
95 OwnedHandle &operator=(const OwnedHandle &) = delete;
96
97 OwnedHandle(OwnedHandle &&other) noexcept
98 : h(std::exchange(other.h, INVALID_HANDLE_VALUE)) {}
99
100 OwnedHandle &operator=(OwnedHandle &&other) noexcept
101 {
102 if (this != &other)
103 {
104 reset();
105 h = std::exchange(other.h, INVALID_HANDLE_VALUE);
106 }
107 return *this;
108 }
109
110 245 ~OwnedHandle() noexcept { reset(); }
111
112 490 [[nodiscard]] bool valid() const noexcept
113 {
114
3/4
✓ Branch 2 → 3 taken 484 times.
✓ Branch 2 → 5 taken 6 times.
✓ Branch 3 → 4 taken 484 times.
✗ Branch 3 → 5 not taken.
490 return h != INVALID_HANDLE_VALUE && h != nullptr;
115 }
116
117 245 void reset() noexcept
118 {
119
2/2
✓ Branch 3 → 4 taken 242 times.
✓ Branch 3 → 5 taken 3 times.
245 if (valid())
120 {
121 242 ::CloseHandle(h);
122 }
123 245 h = INVALID_HANDLE_VALUE;
124 245 }
125 };
126 } // namespace
127
128 struct ConfigWatcher::Impl
129 {
130 std::string ini_path_utf8;
131 std::wstring directory_wide;
132 std::wstring filename_wide;
133 std::chrono::milliseconds debounce;
134 std::function<void()> on_reload;
135
136 std::mutex start_mutex;
137 std::unique_ptr<StoppableWorker> worker;
138 std::atomic<std::thread::id> worker_thread_id{};
139
140 129 Impl(std::string_view path,
141 std::chrono::milliseconds deb,
142 std::function<void()> cb)
143
1/2
✓ Branch 4 → 5 taken 129 times.
✗ Branch 4 → 33 not taken.
258 : ini_path_utf8(path),
144 129 debounce(deb),
145 258 on_reload(std::move(cb))
146 {
147 // Resolve into directory + filename components up-front.
148 // weakly_canonical is avoided because the file may not exist yet;
149 // absolute() is enough for ReadDirectoryChangesW.
150 129 std::error_code ec;
151
1/2
✓ Branch 15 → 16 taken 129 times.
✗ Branch 15 → 48 not taken.
129 std::filesystem::path p(ini_path_utf8);
152
1/2
✓ Branch 16 → 17 taken 129 times.
✗ Branch 16 → 46 not taken.
129 std::filesystem::path abs = std::filesystem::absolute(p, ec);
153
2/2
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 128 times.
129 if (ec)
154 {
155
1/2
✓ Branch 19 → 20 taken 1 time.
✗ Branch 19 → 44 not taken.
1 abs = p;
156 }
157
158
2/4
✓ Branch 20 → 21 taken 129 times.
✗ Branch 20 → 38 not taken.
✓ Branch 21 → 22 taken 129 times.
✗ Branch 21 → 36 not taken.
129 directory_wide = abs.parent_path().wstring();
159
2/4
✓ Branch 25 → 26 taken 129 times.
✗ Branch 25 → 42 not taken.
✓ Branch 26 → 27 taken 129 times.
✗ Branch 26 → 40 not taken.
129 filename_wide = abs.filename().wstring();
160 129 }
161 };
162
163 129 ConfigWatcher::ConfigWatcher(std::string_view ini_path,
164 std::chrono::milliseconds debounce_window,
165 129 std::function<void()> on_reload)
166 129 : m_impl(std::make_unique<Impl>(ini_path, debounce_window, std::move(on_reload)))
167 {
168 129 }
169
170 254 ConfigWatcher::~ConfigWatcher() noexcept
171 {
172
5/6
✓ Branch 3 → 4 taken 129 times.
✗ Branch 3 → 7 not taken.
✓ Branch 5 → 6 taken 4 times.
✓ Branch 5 → 7 taken 125 times.
✓ Branch 8 → 9 taken 4 times.
✓ Branch 8 → 29 taken 125 times.
129 if (m_impl && loader_lock_held_for_watcher())
173 {
174 // Under loader lock (FreeLibrary path): joining the watcher
175 // would deadlock against ReadDirectoryChangesW's I/O completion,
176 // and tearing down Impl would invalidate the worker_thread_id
177 // pointer the detached lambda still references. Pin the module
178 // so trampoline and worker code pages remain mapped, request
179 // stop, then leak the entire Impl onto the heap so it outlives
180 // the destructor. The same discipline as HookManager::~HookManager
181 // and Logger::shutdown_internal.
182 4 detail::pin_current_module();
183
184
1/2
✓ Branch 12 → 13 taken 4 times.
✗ Branch 12 → 16 not taken.
4 if (m_impl->worker)
185 {
186 // shutdown() takes its own loader-lock branch: it requests
187 // stop and detaches the std::jthread (no join), then sets
188 // joined_ so the eventual ~StoppableWorker run during
189 // static teardown short-circuits without trying to join a
190 // detached handle.
191 4 m_impl->worker->shutdown();
192 }
193
194 // Per-call heap leak: each invocation allocates its own cell,
195 // so prior leaked Impls are never overwritten and the leak is
196 // bounded by one cell per ~ConfigWatcher-under-loader-lock
197 // call. The detached worker thread holds raw pointers and
198 // references into Impl members (worker_thread_id, captured
199 // strings); they must stay valid until the OS thread either
200 // observes the stop_token and exits or the process tears down.
201 //
202 // new (std::nothrow) keeps this noexcept destructor honest by
203 // returning nullptr on OOM rather than turning a container
204 // emplace_back bad_alloc into std::terminate. On allocation
205 // failure, fall back to releasing the unique_ptr so the Impl
206 // storage is leaked directly without invoking ~Impl (which
207 // would tear down the detached StoppableWorker -- safe under
208 // a normal join, but not under loader lock).
209 static_assert(std::is_nothrow_move_constructible_v<std::unique_ptr<Impl>>,
210 "Leak cell must be nothrow-move-constructible to keep ~ConfigWatcher noexcept honest.");
211
212
1/2
✗ Branch 26 → 27 not taken.
✓ Branch 26 → 28 taken 4 times.
4 if (auto *leaked = new (std::nothrow)
213
3/6
✓ Branch 17 → 18 taken 4 times.
✗ Branch 17 → 22 not taken.
✓ Branch 23 → 24 taken 4 times.
✗ Branch 23 → 26 not taken.
✗ Branch 24 → 25 not taken.
✓ Branch 24 → 26 taken 4 times.
8 std::unique_ptr<Impl>(std::move(m_impl)))
214 {
215 static_cast<void>(leaked);
216 }
217 else
218 {
219 static_cast<void>(m_impl.release());
220 }
221 4 return;
222 }
223
224 125 stop();
225
2/2
✓ Branch 32 → 33 taken 125 times.
✓ Branch 32 → 34 taken 4 times.
129 }
226
227 123 bool ConfigWatcher::is_running() const noexcept
228 {
229
3/4
✓ Branch 4 → 5 taken 17 times.
✓ Branch 4 → 10 taken 106 times.
✓ Branch 8 → 9 taken 17 times.
✗ Branch 8 → 10 not taken.
123 return m_impl->worker && m_impl->worker->is_running();
230 }
231
232 1 const std::string &ConfigWatcher::ini_path() const noexcept
233 {
234 1 return m_impl->ini_path_utf8;
235 }
236
237 1 std::chrono::milliseconds ConfigWatcher::debounce() const noexcept
238 {
239 1 return m_impl->debounce;
240 }
241
242 9 bool ConfigWatcher::is_worker_thread(std::thread::id id) const noexcept
243 {
244 9 return m_impl->worker_thread_id.load(std::memory_order_acquire) == id;
245 }
246
247 126 bool ConfigWatcher::start()
248 {
249
1/2
✓ Branch 3 → 4 taken 126 times.
✗ Branch 3 → 129 not taken.
126 std::lock_guard<std::mutex> lock(m_impl->start_mutex);
250
251 // Guard on existence, not is_running(): there is a window between
252 // make_unique<StoppableWorker> and the worker body flipping the
253 // running flag. Checking is_running() here would let a second
254 // caller in that window overwrite the still-starting worker.
255
2/2
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 125 times.
126 if (m_impl->worker)
256 {
257 1 return true;
258 }
259
260
5/6
✓ Branch 10 → 11 taken 124 times.
✓ Branch 10 → 14 taken 1 time.
✗ Branch 13 → 14 not taken.
✓ Branch 13 → 15 taken 124 times.
✓ Branch 16 → 17 taken 1 time.
✓ Branch 16 → 21 taken 124 times.
125 if (m_impl->directory_wide.empty() || m_impl->filename_wide.empty())
261 {
262
1/2
✓ Branch 17 → 18 taken 1 time.
✗ Branch 17 → 127 not taken.
1 Logger::get_instance().error(
263 "ConfigWatcher: invalid INI path '{}'; cannot start.",
264
1/2
✓ Branch 19 → 20 taken 1 time.
✗ Branch 19 → 87 not taken.
1 m_impl->ini_path_utf8);
265 1 return false;
266 }
267
268 // Capture everything the worker needs by value so the body can
269 // outlive the captured Impl members only in the loader-lock detach
270 // path; under normal teardown stop() joins before m_impl unwinds.
271
1/2
✓ Branch 22 → 23 taken 124 times.
✗ Branch 22 → 127 not taken.
124 auto directory = m_impl->directory_wide;
272
1/2
✓ Branch 24 → 25 taken 124 times.
✗ Branch 24 → 125 not taken.
124 auto filename = m_impl->filename_wide;
273 124 auto debounce_ms = m_impl->debounce;
274
1/2
✓ Branch 27 → 28 taken 124 times.
✗ Branch 27 → 123 not taken.
124 auto callback = m_impl->on_reload;
275
1/2
✓ Branch 29 → 30 taken 124 times.
✗ Branch 29 → 121 not taken.
124 auto label = m_impl->ini_path_utf8;
276
277 // The StoppableWorker body is stored in std::function, so the
278 // lambda must stay copyable; we cannot move a non-copyable
279 // OwnedHandle into it. Instead, open the directory handle on the
280 // worker thread and synchronously report success/failure back to
281 // this thread via a shared promise. start() can then return the
282 // real status without polling is_running() in a race.
283
1/2
✓ Branch 30 → 31 taken 124 times.
✗ Branch 30 → 119 not taken.
124 auto open_result = std::make_shared<std::promise<bool>>();
284
1/2
✓ Branch 32 → 33 taken 124 times.
✗ Branch 32 → 117 not taken.
124 std::future<bool> open_future = open_result->get_future();
285
286 // Pointer to the Impl's atomic thread-id slot. Using the raw
287 // pointer rather than capturing m_impl by reference: the lambda
288 // may outlive this stack frame via the StoppableWorker detach
289 // path, but ConfigWatcher (and therefore Impl) cannot be
290 // destroyed before the worker joins -- the destructor calls
291 // stop() which joins first. The atomic slot is always valid for
292 // as long as the worker exists.
293 124 auto *worker_id_slot = &m_impl->worker_thread_id;
294
295 248 m_impl->worker = std::make_unique<StoppableWorker>(
296 "ConfigWatcher",
297
6/22
✓ Branch 47 → 48 taken 124 times.
✗ Branch 47 → 88 not taken.
✗ Branch 52 → 53 not taken.
✓ Branch 52 → 54 taken 124 times.
✗ Branch 54 → 55 not taken.
✓ Branch 54 → 56 taken 124 times.
✗ Branch 56 → 57 not taken.
✓ Branch 56 → 58 taken 124 times.
✗ Branch 58 → 59 not taken.
✓ Branch 58 → 60 taken 124 times.
✗ Branch 60 → 61 not taken.
✓ Branch 60 → 62 taken 124 times.
✗ Branch 90 → 91 not taken.
✗ Branch 90 → 92 not taken.
✗ Branch 93 → 94 not taken.
✗ Branch 93 → 95 not taken.
✗ Branch 96 → 97 not taken.
✗ Branch 96 → 98 not taken.
✗ Branch 99 → 100 not taken.
✗ Branch 99 → 101 not taken.
✗ Branch 102 → 103 not taken.
✗ Branch 102 → 104 not taken.
620 [directory = std::move(directory),
298 124 filename = std::move(filename),
299 debounce_ms,
300 124 callback = std::move(callback),
301 124 label = std::move(label),
302 open_result,
303 worker_id_slot](std::stop_token st)
304 {
305 // Publish our thread id so is_worker_thread() can detect
306 // setter-invoked self-calls into disable_auto_reload().
307 124 worker_id_slot->store(std::this_thread::get_id(),
308 std::memory_order_release);
309 OwnedHandle dir_handle(::CreateFileW(
310 directory.c_str(),
311 FILE_LIST_DIRECTORY,
312 FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
313 nullptr,
314 OPEN_EXISTING,
315 FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
316
1/2
✓ Branch 5 → 6 taken 124 times.
✗ Branch 5 → 164 not taken.
124 nullptr));
317
318
2/2
✓ Branch 8 → 9 taken 3 times.
✓ Branch 8 → 15 taken 121 times.
124 if (!dir_handle.valid())
319 {
320
1/2
✓ Branch 9 → 10 taken 3 times.
✗ Branch 9 → 162 not taken.
3 Logger::get_instance().error(
321 "ConfigWatcher '{}': CreateFileW failed (GLE={}).",
322
2/4
✓ Branch 10 → 11 taken 3 times.
✗ Branch 10 → 135 not taken.
✓ Branch 11 → 12 taken 3 times.
✗ Branch 11 → 134 not taken.
3 label, ::GetLastError());
323
1/2
✓ Branch 13 → 14 taken 3 times.
✗ Branch 13 → 136 not taken.
3 open_result->set_value(false);
324 3 return;
325 }
326
327
1/2
✓ Branch 15 → 16 taken 121 times.
✗ Branch 15 → 162 not taken.
121 OwnedHandle event_handle(::CreateEventW(nullptr, TRUE, FALSE, nullptr));
328
1/2
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 25 taken 121 times.
121 if (!event_handle.valid())
329 {
330 Logger::get_instance().error(
331 "ConfigWatcher '{}': CreateEventW failed (GLE={}).",
332 label, ::GetLastError());
333 open_result->set_value(false);
334 return;
335 }
336
337
1/2
✓ Branch 27 → 28 taken 121 times.
✗ Branch 27 → 140 not taken.
121 std::vector<BYTE> buffer(kBufferBytes);
338 121 OVERLAPPED overlapped{};
339 121 overlapped.hEvent = event_handle.h;
340
341 // Debounce bookkeeping: once we observe a matching change,
342 // mark it pending and defer the callback until no matching
343 // change has arrived for `debounce_ms`. Using steady_clock
344 // to survive wall-clock adjustments.
345 121 bool pending = false;
346 121 std::chrono::steady_clock::time_point last_event{};
347
348 // Track whether an overflow/coalesced-events completion has
349 // already been logged once per instance; subsequent hits
350 // stay silent at DEBUG level to avoid log spam.
351 121 bool overflow_logged = false;
352
353 1354 auto issue_read = [&]() -> bool
354 {
355
1/2
✓ Branch 2 → 3 taken 1354 times.
✗ Branch 2 → 16 not taken.
1354 ::ResetEvent(event_handle.h);
356 1354 DWORD bytes_returned = 0;
357 5416 const BOOL ok = ::ReadDirectoryChangesW(
358
1/2
✓ Branch 5 → 6 taken 1354 times.
✗ Branch 5 → 16 not taken.
1354 dir_handle.h,
359 1354 buffer.data(),
360 1354 static_cast<DWORD>(buffer.size()),
361 FALSE, // no recursion
362 kNotifyFilter,
363 &bytes_returned,
364 &overlapped,
365 nullptr);
366
1/2
✗ Branch 6 → 7 not taken.
✓ Branch 6 → 11 taken 1354 times.
1354 if (!ok)
367 {
368 Logger::get_instance().error(
369 "ConfigWatcher '{}': ReadDirectoryChangesW failed (GLE={}).",
370 label, ::GetLastError());
371 return false;
372 }
373 1354 return true;
374 121 };
375
376
2/4
✓ Branch 29 → 30 taken 121 times.
✗ Branch 29 → 158 not taken.
✗ Branch 30 → 31 not taken.
✓ Branch 30 → 34 taken 121 times.
121 if (!issue_read())
377 {
378 open_result->set_value(false);
379 return;
380 }
381
382 // First overlapped read is queued successfully; signal
383 // start() that the watcher is ready. From here on any
384 // failure is post-startup and reported only via the log.
385
1/2
✓ Branch 35 → 36 taken 121 times.
✗ Branch 35 → 144 not taken.
121 open_result->set_value(true);
386
387
2/2
✓ Branch 100 → 37 taken 1385 times.
✓ Branch 100 → 101 taken 120 times.
1505 while (!st.stop_requested())
388 {
389 1385 DWORD bytes_transferred = 0;
390
1/2
✓ Branch 37 → 38 taken 1385 times.
✗ Branch 37 → 155 not taken.
1385 const BOOL overlapped_ok = ::GetOverlappedResultEx(
391 dir_handle.h, &overlapped, &bytes_transferred,
392 kPumpTimeoutMs, FALSE);
393
394
2/2
✓ Branch 38 → 39 taken 152 times.
✓ Branch 38 → 74 taken 1233 times.
1385 if (!overlapped_ok)
395 {
396
1/2
✓ Branch 39 → 40 taken 152 times.
✗ Branch 39 → 153 not taken.
152 const DWORD err = ::GetLastError();
397
398
3/4
✓ Branch 40 → 41 taken 1 time.
✓ Branch 40 → 42 taken 151 times.
✗ Branch 41 → 42 not taken.
✓ Branch 41 → 53 taken 1 time.
152 if (err == WAIT_TIMEOUT || err == WAIT_IO_COMPLETION)
399 {
400 // No I/O completed this tick. If a prior event
401 // is pending and the quiet window has elapsed,
402 // fire the debounced callback.
403
2/2
✓ Branch 42 → 43 taken 15 times.
✓ Branch 42 → 52 taken 136 times.
151 if (pending)
404 {
405 15 const auto now = std::chrono::steady_clock::now();
406
4/6
✓ Branch 44 → 45 taken 15 times.
✗ Branch 44 → 145 not taken.
✓ Branch 45 → 46 taken 15 times.
✗ Branch 45 → 145 not taken.
✓ Branch 47 → 48 taken 10 times.
✓ Branch 47 → 51 taken 5 times.
15 if (now - last_event >= debounce_ms)
407 {
408 10 pending = false;
409
2/2
✓ Branch 49 → 50 taken 9 times.
✓ Branch 49 → 51 taken 1 time.
10 if (callback)
410 {
411
1/2
✓ Branch 50 → 51 taken 9 times.
✗ Branch 50 → 147 not taken.
9 callback();
412 }
413 }
414 }
415 151 continue;
416 151 }
417
418
1/2
✗ Branch 53 → 54 not taken.
✓ Branch 53 → 57 taken 1 time.
1 if (err == ERROR_OPERATION_ABORTED)
419 {
420 // Directory handle closed or I/O cancelled
421 // externally (e.g. the watched parent
422 // directory was removed or renamed). We
423 // cannot recover a handle to a vanished
424 // directory here; surface the event at
425 // warning level so users notice.
426 Logger::get_instance().warning(
427 "ConfigWatcher '{}': directory handle "
428 "invalidated (parent removed/renamed); "
429 "watcher thread exiting.",
430 label);
431 1 break;
432 }
433
434
1/2
✗ Branch 57 → 58 not taken.
✓ Branch 57 → 69 taken 1 time.
1 if (err == ERROR_NOTIFY_ENUM_DIR)
435 {
436 // Kernel/redirector path for buffer overflow:
437 // events were dropped because they arrived
438 // faster than we could drain them. Treat as
439 // a coalesced match, re-issue the read, and
440 // let debounce deduplicate.
441 if (!overflow_logged)
442 {
443 Logger::get_instance().debug(
444 "ConfigWatcher '{}': notification "
445 "buffer overflowed (ERROR_NOTIFY_ENUM_DIR); "
446 "coalescing dropped events.",
447 label);
448 overflow_logged = true;
449 }
450 pending = true;
451 last_event = std::chrono::steady_clock::now();
452 if (!issue_read())
453 {
454 break;
455 }
456 // Some redirectors raise ERROR_NOTIFY_ENUM_DIR
457 // continuously under sustained event storms.
458 // Without a sleep the worker would spin at
459 // 100% CPU re-issuing reads. Capping at ~20
460 // Hz keeps debounce semantics intact while
461 // bounding CPU.
462 std::this_thread::sleep_for(
463 std::chrono::milliseconds(50));
464 continue;
465 }
466
467
1/2
✓ Branch 69 → 70 taken 1 time.
✗ Branch 69 → 153 not taken.
1 Logger::get_instance().error(
468 "ConfigWatcher '{}': GetOverlappedResultEx failed (GLE={}).",
469
1/2
✓ Branch 70 → 71 taken 1 time.
✗ Branch 70 → 152 not taken.
1 label, err);
470 1 break;
471 }
472
473 1233 bool matched = false;
474
475
1/2
✗ Branch 74 → 75 not taken.
✓ Branch 74 → 80 taken 1233 times.
1233 if (bytes_transferred == 0)
476 {
477 // Successful-completion path for buffer overflow:
478 // the kernel signals "events coalesced" by
479 // returning zero bytes. Same handling as
480 // ERROR_NOTIFY_ENUM_DIR above: mark pending,
481 // re-issue, let debounce deduplicate.
482 if (!overflow_logged)
483 {
484 Logger::get_instance().debug(
485 "ConfigWatcher '{}': notification buffer "
486 "overflowed (zero-byte completion); "
487 "coalescing dropped events.",
488 label);
489 overflow_logged = true;
490 }
491 matched = true;
492 }
493 else
494 {
495 // Real event batch received. Reset the overflow
496 // latch so a later recurrence logs again at the
497 // DEBUG edge rather than staying silent forever.
498 1233 overflow_logged = false;
499
500 // Walk the FILE_NOTIFY_INFORMATION chain.
501 1233 const BYTE *cursor = buffer.data();
502 1233 const BYTE *const end_ptr = cursor + bytes_transferred;
503
504
1/2
✓ Branch 90 → 82 taken 1234 times.
✗ Branch 90 → 91 not taken.
1234 while (cursor + sizeof(FILE_NOTIFY_INFORMATION) <= end_ptr)
505 {
506 1234 const auto *info =
507 reinterpret_cast<const FILE_NOTIFY_INFORMATION *>(cursor);
508
509 1234 const size_t name_len =
510 1234 info->FileNameLength / sizeof(WCHAR);
511 1234 const std::wstring_view changed_name(info->FileName, name_len);
512
513 // Match against target filename (case-insensitive).
514 // Rename-swap-save (temp -> target) surfaces the
515 // target filename in the RENAMED_NEW_NAME entry.
516
2/2
✓ Branch 85 → 86 taken 31 times.
✓ Branch 85 → 87 taken 1203 times.
1234 if (iequals_w(changed_name, filename))
517 {
518 31 matched = true;
519 }
520
521
2/2
✓ Branch 87 → 88 taken 1233 times.
✓ Branch 87 → 89 taken 1 time.
1234 if (info->NextEntryOffset == 0)
522 {
523 1233 break;
524 }
525 1 cursor += info->NextEntryOffset;
526 }
527 }
528
529
2/2
✓ Branch 91 → 92 taken 31 times.
✓ Branch 91 → 93 taken 1202 times.
1233 if (matched)
530 {
531 31 pending = true;
532 31 last_event = std::chrono::steady_clock::now();
533 }
534
535
2/4
✓ Branch 93 → 94 taken 1233 times.
✗ Branch 93 → 155 not taken.
✗ Branch 94 → 95 not taken.
✓ Branch 94 → 96 taken 1233 times.
1233 if (!issue_read())
536 {
537 break;
538 }
539 }
540
541 // Cancel any in-flight I/O and then WAIT for the kernel
542 // to finish with our OVERLAPPED and buffer. Per MSDN the
543 // OVERLAPPED structure and the backing buffer must remain
544 // valid until the cancelled I/O has actually completed;
545 // skipping the drain would let the kernel write into
546 // stack memory after it had been released. Infinite wait
547 // is safe because CancelIoEx guarantees completion.
548
1/2
✓ Branch 101 → 102 taken 121 times.
✗ Branch 101 → 158 not taken.
121 ::CancelIoEx(dir_handle.h, &overlapped);
549 121 DWORD drain_bytes = 0;
550
1/2
✓ Branch 102 → 103 taken 121 times.
✗ Branch 102 → 158 not taken.
121 const BOOL drained = ::GetOverlappedResult(
551 dir_handle.h, &overlapped, &drain_bytes, TRUE);
552
1/2
✓ Branch 103 → 104 taken 121 times.
✗ Branch 103 → 112 not taken.
121 if (!drained)
553 {
554
1/2
✓ Branch 104 → 105 taken 121 times.
✗ Branch 104 → 157 not taken.
121 const DWORD drain_err = ::GetLastError();
555
2/2
✓ Branch 105 → 106 taken 1 time.
✓ Branch 105 → 111 taken 120 times.
121 if (drain_err != ERROR_OPERATION_ABORTED &&
556
1/2
✓ Branch 106 → 107 taken 1 time.
✗ Branch 106 → 111 not taken.
1 drain_err != ERROR_NOTIFY_ENUM_DIR &&
557
1/2
✓ Branch 107 → 108 taken 1 time.
✗ Branch 107 → 111 not taken.
1 drain_err != ERROR_INVALID_HANDLE)
558 {
559
1/2
✓ Branch 108 → 109 taken 1 time.
✗ Branch 108 → 157 not taken.
2 Logger::get_instance().warning(
560 "ConfigWatcher '{}': drain GetOverlappedResult "
561 "returned unexpected error (GLE={}).",
562
1/2
✓ Branch 109 → 110 taken 1 time.
✗ Branch 109 → 156 not taken.
1 label, drain_err);
563 }
564 }
565
566 // Flush a final debounced callback if we are exiting
567 // with a pending change. This intentionally fires during
568 // stop() as well -- an edit that arrived inside the
569 // debounce window would otherwise be silently dropped.
570
5/6
✓ Branch 112 → 113 taken 2 times.
✓ Branch 112 → 116 taken 119 times.
✓ Branch 114 → 115 taken 2 times.
✗ Branch 114 → 116 not taken.
✓ Branch 117 → 118 taken 2 times.
✓ Branch 117 → 119 taken 119 times.
121 if (pending && callback)
571 {
572
1/2
✓ Branch 118 → 119 taken 2 times.
✗ Branch 118 → 158 not taken.
2 callback();
573 }
574
4/6
✓ Branch 121 → 122 taken 121 times.
✗ Branch 121 → 123 not taken.
✓ Branch 125 → 126 taken 121 times.
✗ Branch 125 → 127 not taken.
✓ Branch 129 → 130 taken 121 times.
✓ Branch 129 → 132 taken 3 times.
248 });
575
576 // Wait for the worker to finish its startup handshake with a
577 // bounded wait. Three failure modes to handle:
578 // 1. Handshake timeout -- worker is stuck somewhere (hostile
579 // AntiCheat hook on CreateFileW, flaky redirector). Callers
580 // hold higher-level mutexes across start(); an unbounded
581 // wait would DoS the whole hot-reload subsystem.
582 // 2. Worker threw before set_value() -- promise destroys,
583 // future.get() throws std::future_error(broken_promise).
584 // start() is documented to return false on failure, not
585 // throw.
586 // 3. Any other exception out of the future -- treat as failed.
587 // On failure we drop the StoppableWorker so a subsequent
588 // start() call can retry rather than staring at a stale worker.
589 // The worker's stop_token fires on StoppableWorker destruction,
590 // so we do not need a separate cancel path for the timeout
591 // branch -- the destructor does it cleanly.
592 124 bool started = false;
593 try
594 {
595 const auto wait_status =
596
1/2
✓ Branch 63 → 64 taken 124 times.
✗ Branch 63 → 107 not taken.
124 open_future.wait_for(std::chrono::seconds(5));
597
1/2
✓ Branch 64 → 65 taken 124 times.
✗ Branch 64 → 67 not taken.
124 if (wait_status == std::future_status::ready)
598 {
599
1/2
✓ Branch 65 → 66 taken 124 times.
✗ Branch 65 → 110 not taken.
124 started = open_future.get();
600 }
601 else
602 {
603 Logger::get_instance().warning(
604 "ConfigWatcher '{}': start handshake timed out after 5s; treating as failed.",
605 m_impl->ini_path_utf8);
606 started = false;
607 }
608 }
609 catch (const std::future_error &)
610 {
611 // Worker threw before set_value() -- treat as startup failure.
612 started = false;
613 }
614 catch (...)
615 {
616 started = false;
617 }
618
619
2/2
✓ Branch 71 → 72 taken 3 times.
✓ Branch 71 → 78 taken 121 times.
124 if (!started)
620 {
621 6 auto stale = std::move(m_impl->worker);
622 // stale's destructor triggers the stop_token and joins. If the
623 // worker is still genuinely hung (case 1 above), the join
624 // itself will block here, but that matches the semantics a
625 // caller expects from RAII cleanup; they asked to start()
626 // under a stuck CreateFileW, the destructor is the logical
627 // place to wait for it to come back.
628 3 }
629 124 return started;
630 126 }
631
632 238 void ConfigWatcher::stop() noexcept
633 {
634 238 std::unique_ptr<StoppableWorker> to_drop;
635 {
636 238 std::lock_guard<std::mutex> lock(m_impl->start_mutex);
637 476 to_drop = std::move(m_impl->worker);
638 238 }
639
640
2/2
✓ Branch 10 → 11 taken 117 times.
✓ Branch 10 → 13 taken 121 times.
238 if (to_drop)
641 {
642 117 to_drop->shutdown();
643 }
644 238 }
645 } // namespace DetourModKit
646