GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 80.7% 184 / 0 / 228
Functions: 100.0% 21 / 0 / 21
Branches: 50.0% 142 / 0 / 284

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