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 |