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 |