src/config_watch.cpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | /** | ||
| 2 | * @file config_watch.cpp | ||
| 3 | * @brief This TU owns the watcher control plane: the auto-reload watcher slot, the reload-hotkey servicer, and the | ||
| 4 | * persisted reload callback. | ||
| 5 | * | ||
| 6 | * The data-plane pass lives in config.cpp and the reload lifecycle gate in src/internal/config_reload.cpp. The other | ||
| 7 | * planes reach this state through internal/config_watch_control.hpp. | ||
| 8 | */ | ||
| 9 | |||
| 10 | #include "DetourModKit/config.hpp" | ||
| 11 | #include "DetourModKit/diagnostics.hpp" | ||
| 12 | #include "DetourModKit/input.hpp" | ||
| 13 | #include "DetourModKit/logger.hpp" | ||
| 14 | #include "DetourModKit/detail/worker.hpp" | ||
| 15 | |||
| 16 | #include "internal/config_pass.hpp" | ||
| 17 | #include "internal/config_reload_lifecycle.hpp" | ||
| 18 | #include "internal/config_watch_control.hpp" | ||
| 19 | #include "internal/config_watcher.hpp" | ||
| 20 | #include "internal/lifecycle_context.hpp" | ||
| 21 | #include "internal/lifecycle_reaper.hpp" | ||
| 22 | #include "internal/worker_start_log.hpp" | ||
| 23 | |||
| 24 | #include <atomic> | ||
| 25 | #include <chrono> | ||
| 26 | #include <condition_variable> | ||
| 27 | #include <cstdint> | ||
| 28 | #include <filesystem> | ||
| 29 | #include <functional> | ||
| 30 | #include <memory> | ||
| 31 | #include <mutex> | ||
| 32 | #include <new> | ||
| 33 | #include <string> | ||
| 34 | #include <string_view> | ||
| 35 | #include <thread> | ||
| 36 | #include <utility> | ||
| 37 | #include <vector> | ||
| 38 | |||
| 39 | namespace DetourModKit::detail | ||
| 40 | { | ||
| 41 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 42 | // Test-only override for the loader-lock probe inside ~ReloadServicer's teardown gate. | ||
| 43 | // It replaces only the veto result. | ||
| 44 | // The explicit loader context remains the sole authorization. | ||
| 45 | // One fixture thread sets and clears this plain function pointer. | ||
| 46 | bool (*g_config_reload_loader_lock_override)() noexcept = nullptr; | ||
| 47 | |||
| 48 | // ~ReloadServicer sets this flag on the off-thread reaper branch. A proof can observe self-retirement. | ||
| 49 | std::atomic<bool> g_servicer_reaped_on_worker{false}; | ||
| 50 | |||
| 51 | // Parks the reload worker while it owns Channel::mutex. A subprocess can drive process-exit teardown after | ||
| 52 | // Windows terminates the mutex owner. | ||
| 53 | std::atomic<std::atomic<bool> *> g_config_reload_worker_mutex_gate_probe{nullptr}; | ||
| 54 | std::atomic<bool> g_config_reload_worker_mutex_waiting_probe{false}; | ||
| 55 | |||
| 56 | // Parks the reload worker after its last mutex use and before exit-guard publication. A test can keep the body | ||
| 57 | // live but unexited across teardown. | ||
| 58 | std::atomic<std::atomic<bool> *> g_config_reload_worker_exit_gate_probe{nullptr}; | ||
| 59 | |||
| 60 | // Fired immediately before config disposes of an internally retained reload-hotkey BindingGuard. | ||
| 61 | void (*g_config_reload_hotkey_guard_disposal_probe)() noexcept = nullptr; | ||
| 62 | #endif | ||
| 63 | } // namespace DetourModKit::detail | ||
| 64 | |||
| 65 | namespace DetourModKit | ||
| 66 | { | ||
| 67 | namespace config | ||
| 68 | { | ||
| 69 | namespace | ||
| 70 | { | ||
| 71 | // A separate mutex keeps watcher start/stop apart from registration traffic. It also serializes the reload | ||
| 72 | // servicer and reload-hotkey guard vector. | ||
| 73 | 1932116 | std::mutex &get_watcher_mutex() | |
| 74 | { | ||
| 75 |
3/4✓ Branch 2 → 3 taken 353 times.
✓ Branch 2 → 8 taken 1931763 times.
✓ Branch 4 → 5 taken 353 times.
✗ Branch 4 → 8 not taken.
|
1932116 | static std::mutex s_mtx; |
| 76 | 1932116 | return s_mtx; | |
| 77 | } | ||
| 78 | |||
| 79 | 3610076 | std::unique_ptr<DetourModKit::detail::ConfigWatcher> &get_config_watcher() | |
| 80 | { | ||
| 81 | // `[B-47]` requires never-destroyed storage. Static destruction can run after libstdc++ tears down | ||
| 82 | // worker synchronization state and can fault the process. Explicit disable and drain paths still | ||
| 83 | // reset this owner. Lifecycle.FullLifecycleExit pins the process exit path. | ||
| 84 | using WatcherOwner = std::unique_ptr<DetourModKit::detail::ConfigWatcher>; | ||
| 85 | alignas(WatcherOwner) static unsigned char s_watcher_storage[sizeof(WatcherOwner)]; | ||
| 86 |
4/6✓ Branch 2 → 3 taken 309 times.
✓ Branch 2 → 10 taken 3609767 times.
✓ Branch 4 → 5 taken 309 times.
✗ Branch 4 → 10 not taken.
✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 309 times.
|
3610076 | static WatcherOwner *const s_watcher = ::new (static_cast<void *>(s_watcher_storage)) WatcherOwner(); |
| 87 | 3610076 | return *s_watcher; | |
| 88 | } | ||
| 89 | |||
| 90 | // Stores a copy of the user on_reload callback. ConfigWatcher swallows it with no getter, so only this | ||
| 91 | // copy lets load()'s re-point reconstruct an equivalent watcher. get_watcher_mutex() guards it. | ||
| 92 | 456 | std::function<void(bool)> &get_reload_user_callback() noexcept | |
| 93 | { | ||
| 94 |
3/4✓ Branch 2 → 3 taken 196 times.
✓ Branch 2 → 8 taken 260 times.
✓ Branch 4 → 5 taken 196 times.
✗ Branch 4 → 8 not taken.
|
456 | static std::function<void(bool)> s_callback; |
| 95 | 456 | return s_callback; | |
| 96 | } | ||
| 97 | |||
| 98 | // This counter advances on each real disable_auto_reload() teardown. load() captures it before a stale- | ||
| 99 | // watcher join and checks it before restart. A changed value prevents watcher resurrection after a | ||
| 100 | // concurrent disable. An empty callback slot still represents a valid enabled state. get_watcher_mutex() | ||
| 101 | // guards the counter. | ||
| 102 | 367 | [[nodiscard]] std::uint64_t &get_watcher_disable_generation() noexcept | |
| 103 | { | ||
| 104 | static std::uint64_t s_generation = 0; | ||
| 105 | 367 | return s_generation; | |
| 106 | } | ||
| 107 | |||
| 108 | // Compares two resolved INI paths without case sensitivity. Separators and normalization already match. An | ||
| 109 | // ordinal ASCII fold is correct for case-insensitive Windows paths. A locale fold is deliberately avoided, | ||
| 110 | // per the watcher's ordinal filename match. | ||
| 111 | 13 | [[nodiscard]] bool resolved_paths_equivalent(std::string_view a, std::string_view b) noexcept | |
| 112 | { | ||
| 113 |
2/2✓ Branch 4 → 5 taken 3 times.
✓ Branch 4 → 6 taken 10 times.
|
13 | if (a.size() != b.size()) |
| 114 | { | ||
| 115 | 3 | return false; | |
| 116 | } | ||
| 117 | 1224 | const auto ascii_lower = [](char c) noexcept -> unsigned char | |
| 118 | { | ||
| 119 | 1224 | const auto u = static_cast<unsigned char>(c); | |
| 120 |
4/4✓ Branch 2 → 3 taken 1068 times.
✓ Branch 2 → 5 taken 156 times.
✓ Branch 3 → 4 taken 240 times.
✓ Branch 3 → 5 taken 828 times.
|
1224 | return (u >= 'A' && u <= 'Z') ? static_cast<unsigned char>(u + ('a' - 'A')) : u; |
| 121 | }; | ||
| 122 |
2/2✓ Branch 15 → 7 taken 612 times.
✓ Branch 15 → 16 taken 8 times.
|
620 | for (size_t i = 0; i < a.size(); ++i) |
| 123 | { | ||
| 124 |
2/2✓ Branch 11 → 12 taken 2 times.
✓ Branch 11 → 13 taken 610 times.
|
612 | if (ascii_lower(a[i]) != ascii_lower(b[i])) |
| 125 | { | ||
| 126 | 2 | return false; | |
| 127 | } | ||
| 128 | } | ||
| 129 | 8 | return true; | |
| 130 | } | ||
| 131 | |||
| 132 | // Keeps reload-hotkey BindingGuards alive for the process lifetime. ~BindingGuard disables the binding, so | ||
| 133 | // a dropped returned guard makes the hotkey a silent no-op forever. Guarded by get_watcher_mutex(). | ||
| 134 | 836 | std::vector<input::BindingGuard> &get_reload_hotkey_guards() noexcept | |
| 135 | { | ||
| 136 |
3/4✓ Branch 2 → 3 taken 351 times.
✓ Branch 2 → 7 taken 485 times.
✓ Branch 4 → 5 taken 351 times.
✗ Branch 4 → 7 not taken.
|
836 | static std::vector<input::BindingGuard> s_guards; |
| 137 | 836 | return s_guards; | |
| 138 | } | ||
| 139 | |||
| 140 | 12 | void run_reload_hotkey_guard_disposal_probe() noexcept | |
| 141 | { | ||
| 142 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 143 |
2/2✓ Branch 2 → 3 taken 2 times.
✓ Branch 2 → 4 taken 10 times.
|
12 | if (const auto probe = DetourModKit::detail::g_config_reload_hotkey_guard_disposal_probe) |
| 144 | { | ||
| 145 | 2 | probe(); | |
| 146 | } | ||
| 147 | #endif | ||
| 148 | 12 | } | |
| 149 | |||
| 150 | // ~ReloadServicer uses this to choose join versus detach-and-leak. This matches the ConfigWatcher | ||
| 151 | // destructor's watcher_must_not_block(). | ||
| 152 | 10 | bool reload_servicer_must_not_block() noexcept | |
| 153 | { | ||
| 154 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 155 | 10 | return !DetourModKit::detail::blocking_teardown_permitted( | |
| 156 | DetourModKit::detail::g_config_reload_loader_lock_override | ||
| 157 | 10 | ); | |
| 158 | #else | ||
| 159 | return !DetourModKit::detail::blocking_teardown_permitted(); | ||
| 160 | #endif | ||
| 161 | } | ||
| 162 | |||
| 163 | /** | ||
| 164 | * @class ReloadServicer | ||
| 165 | * @brief Owns a background thread that coalesces reload-hotkey presses and invokes reload() off the input | ||
| 166 | * poll thread at most once per press batch. | ||
| 167 | * @details All state the worker touches lives in a heap-owned @ref Channel. | ||
| 168 | * It is separate from the servicer shell. The loader-lock teardown branch can detach the worker | ||
| 169 | * and leak the Channel under the ConfigWatcher discipline. It starts on the first reload_hotkey | ||
| 170 | * call. | ||
| 171 | * A std::shared_ptr prevents a press callback concurrent with shutdown from access to a freed | ||
| 172 | * servicer. | ||
| 173 | * The worker contains exceptions from reload(), so the service remains alive. | ||
| 174 | */ | ||
| 175 | class ReloadServicer | ||
| 176 | { | ||
| 177 | // Channel stores every field that the worker reads. The worker member appears last, so ~Channel | ||
| 178 | // destroys it first and joins before the mutex or condition variable dies. | ||
| 179 | struct Channel | ||
| 180 | { | ||
| 181 | std::mutex mutex; | ||
| 182 | std::condition_variable cv; | ||
| 183 | std::atomic<bool> reload_requested{false}; | ||
| 184 | std::atomic<bool> shutdown{false}; | ||
| 185 | std::atomic<bool> worker_exited{false}; | ||
| 186 | // service_loop publishes this value on entry and clears it on exit. ~ReloadServicer can then detect | ||
| 187 | // a self-join. config::clear() from a reload setter runs on this worker thread. | ||
| 188 | std::atomic<std::thread::id> worker_tid{}; | ||
| 189 | // Lifecycle epoch captured at construction so superseded servicers cannot enter consumer code. | ||
| 190 | std::uint64_t birth_epoch{0}; | ||
| 191 | std::unique_ptr<DetourModKit::StoppableWorker> worker; | ||
| 192 | }; | ||
| 193 | |||
| 194 | public: | ||
| 195 | /** | ||
| 196 | * @brief Starts the reload service and records its canonical worker start line. | ||
| 197 | * @param diags Receives the worker start line for later emission. | ||
| 198 | */ | ||
| 199 | 12 | explicit ReloadServicer(detail::DeferredDiagnostics &diags) : m_channel(std::make_unique<Channel>()) | |
| 200 | { | ||
| 201 | // Launch the worker against the heap-owned Channel, NOT `this`. The loader-lock teardown branch | ||
| 202 | // leaks the Channel, so the body must use storage that outlives the shell. | ||
| 203 | 12 | m_channel->birth_epoch = detail::current_reload_lifecycle_epoch(); | |
| 204 | 12 | Channel *channel = m_channel.get(); | |
| 205 | const DetourModKit::detail::WorkerStartLogDeferral start_log_deferral{ | ||
| 206 | &diags, | ||
| 207 | &detail::defer_worker_start_diagnostic, | ||
| 208 | 12 | }; | |
| 209 | 23 | m_channel->worker = std::make_unique<DetourModKit::StoppableWorker>( | |
| 210 | "ConfigReloadServicer", | ||
| 211 |
2/2✓ Branch 7 → 8 taken 11 times.
✓ Branch 7 → 13 taken 1 time.
|
36 | [channel](std::stop_token st) { service_loop(*channel, std::move(st)); } |
| 212 | 11 | ); | |
| 213 | 13 | } | |
| 214 | |||
| 215 | 10 | ~ReloadServicer() noexcept | |
| 216 | 7 | { | |
| 217 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 10 times.
|
10 | if (!m_channel) |
| 218 | { | ||
| 219 | ✗ | return; | |
| 220 | } | ||
| 221 |
2/2✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 20 taken 9 times.
|
10 | if (reload_servicer_must_not_block()) |
| 222 | { | ||
| 223 | // The worker can own the Channel mutex when process-exit teardown begins, so publish only the | ||
| 224 | // lock-free shutdown hint and detach without callback invocation. The wake is best-effort by | ||
| 225 | // construction. A servicer parked in cv.wait can stay parked for process lifetime. | ||
| 226 | // This does not strand resources because this branch retains the Channel and module reference. | ||
| 227 | 1 | m_channel->shutdown.store(true, std::memory_order_release); | |
| 228 | 1 | m_channel->cv.notify_all(); | |
| 229 |
1/2✓ Branch 13 → 14 taken 1 time.
✗ Branch 13 → 17 not taken.
|
1 | if (m_channel->worker) |
| 230 | { | ||
| 231 | 1 | m_channel->worker->shutdown(); | |
| 232 | } | ||
| 233 | |||
| 234 | // The detached service_loop can still read the Channel, so retain it for process lifetime. | ||
| 235 | 1 | (void)m_channel.release(); | |
| 236 | 1 | DetourModKit::diagnostics::record_intentional_leak( | |
| 237 | DetourModKit::diagnostics::LeakSubsystem::Worker | ||
| 238 | ); | ||
| 239 | 1 | return; | |
| 240 | } | ||
| 241 | |||
| 242 | // Synchronous teardown is authorized. Serialize the shutdown predicate with the CV wait so the | ||
| 243 | // notification cannot land in its lost-wakeup window. | ||
| 244 | { | ||
| 245 | 9 | std::lock_guard<std::mutex> lock(m_channel->mutex); | |
| 246 | 9 | m_channel->shutdown.store(true, std::memory_order_release); | |
| 247 | 9 | } | |
| 248 | 9 | m_channel->cv.notify_all(); | |
| 249 | |||
| 250 | const bool on_worker = | ||
| 251 | 9 | m_channel->worker_tid.load(std::memory_order_acquire) == std::this_thread::get_id(); | |
| 252 | |||
| 253 |
2/2✓ Branch 31 → 32 taken 1 time.
✓ Branch 31 → 39 taken 8 times.
|
9 | if (on_worker) |
| 254 | { | ||
| 255 | // Self-shutdown off the loader lock cannot join this worker from itself because | ||
| 256 | // std::system_error results. Inline Channel destruction frees storage that service_loop uses. | ||
| 257 | // Hand the Channel to the off-thread reaper. It joins the worker, then destroys the Channel. | ||
| 258 | // No permanent leak remains. | ||
| 259 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 260 | 1 | DetourModKit::detail::g_servicer_reaped_on_worker.store(true, std::memory_order_release); | |
| 261 | #endif | ||
| 262 | 2 | DetourModKit::detail::reap_owner(std::move(m_channel)); | |
| 263 | 1 | return; | |
| 264 | } | ||
| 265 | |||
| 266 | // Off the loader lock and off the worker thread. shutdown() rechecks the teardown veto, so a join | ||
| 267 | // path can finish as a detach. Observe the body's exit publication, not another TOCTOU-prone veto | ||
| 268 | // check. Retain the Channel while the body remains active, as ~ConfigWatcher does. A leak is the | ||
| 269 | // safe direction. | ||
| 270 |
1/2✓ Branch 41 → 42 taken 8 times.
✗ Branch 41 → 45 not taken.
|
8 | if (m_channel->worker) |
| 271 | { | ||
| 272 | 8 | m_channel->worker->shutdown(); | |
| 273 | } | ||
| 274 |
2/2✓ Branch 47 → 48 taken 1 time.
✓ Branch 47 → 51 taken 7 times.
|
8 | if (!m_channel->worker_exited.load(std::memory_order_acquire)) |
| 275 | { | ||
| 276 | 1 | (void)m_channel.release(); | |
| 277 | 1 | DetourModKit::diagnostics::record_intentional_leak( | |
| 278 | DetourModKit::diagnostics::LeakSubsystem::Worker | ||
| 279 | ); | ||
| 280 | 1 | return; | |
| 281 | } | ||
| 282 | 7 | m_channel.reset(); | |
| 283 |
2/2✓ Branch 54 → 55 taken 7 times.
✓ Branch 54 → 56 taken 3 times.
|
10 | } |
| 284 | |||
| 285 | ReloadServicer(const ReloadServicer &) = delete; | ||
| 286 | ReloadServicer &operator=(const ReloadServicer &) = delete; | ||
| 287 | ReloadServicer(ReloadServicer &&) = delete; | ||
| 288 | ReloadServicer &operator=(ReloadServicer &&) = delete; | ||
| 289 | |||
| 290 | /// Requests a reload without exceptions or allocations. The press callback must not throw. | ||
| 291 | 3 | void request_reload() noexcept | |
| 292 | { | ||
| 293 | // Mutate the predicate under the channel mutex to close the waiter-side lost-wakeup window. | ||
| 294 | { | ||
| 295 | 3 | std::lock_guard<std::mutex> lock(m_channel->mutex); | |
| 296 | 3 | m_channel->reload_requested.store(true, std::memory_order_release); | |
| 297 | 3 | } | |
| 298 | 3 | m_channel->cv.notify_one(); | |
| 299 | 3 | } | |
| 300 | |||
| 301 | /// Requests worker stop without a join or callback-storage destruction. | ||
| 302 | 104930 | void request_stop() noexcept | |
| 303 | { | ||
| 304 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 104930 times.
|
104930 | if (!m_channel) |
| 305 | { | ||
| 306 | ✗ | return; | |
| 307 | } | ||
| 308 | { | ||
| 309 | 104930 | std::lock_guard<std::mutex> lock(m_channel->mutex); | |
| 310 | 104930 | m_channel->shutdown.store(true, std::memory_order_release); | |
| 311 | 104930 | } | |
| 312 | 104930 | m_channel->cv.notify_all(); | |
| 313 |
1/2✓ Branch 14 → 15 taken 104930 times.
✗ Branch 14 → 18 not taken.
|
104930 | if (m_channel->worker) |
| 314 | { | ||
| 315 | 104930 | m_channel->worker->request_stop(); | |
| 316 | } | ||
| 317 | } | ||
| 318 | |||
| 319 | /// Returns true after worker body exit. | ||
| 320 | 104928 | [[nodiscard]] bool has_exited() const noexcept | |
| 321 | { | ||
| 322 |
3/4✓ Branch 3 → 4 taken 104928 times.
✗ Branch 3 → 8 not taken.
✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 8 taken 104927 times.
|
104928 | return m_channel != nullptr && m_channel->worker_exited.load(std::memory_order_acquire); |
| 323 | } | ||
| 324 | |||
| 325 | /** | ||
| 326 | * @brief Reports whether @p id is the servicer worker thread's id. | ||
| 327 | * @details Any teardown that can join this worker must query this first and skip. Otherwise it | ||
| 328 | * self-joins or deadlocks. The default id never matches, so a reset slot cannot alias a live | ||
| 329 | * query. | ||
| 330 | */ | ||
| 331 | 104931 | [[nodiscard]] bool is_worker_thread(std::thread::id id) const noexcept | |
| 332 | { | ||
| 333 |
1/2✗ Branch 3 → 4 not taken.
✓ Branch 3 → 5 taken 104931 times.
|
104931 | if (!m_channel) |
| 334 | { | ||
| 335 | ✗ | return false; | |
| 336 | } | ||
| 337 | 104931 | const std::thread::id worker = m_channel->worker_tid.load(std::memory_order_acquire); | |
| 338 |
3/4✓ Branch 9 → 10 taken 104931 times.
✗ Branch 9 → 13 not taken.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 104930 times.
|
104931 | return worker != std::thread::id{} && worker == id; |
| 339 | } | ||
| 340 | |||
| 341 | private: | ||
| 342 | 12 | static void service_loop(Channel &channel, std::stop_token st) noexcept | |
| 343 | { | ||
| 344 | class ExitGuard | ||
| 345 | { | ||
| 346 | public: | ||
| 347 | 12 | explicit ExitGuard(Channel &owned_channel) noexcept : m_channel(owned_channel) {} | |
| 348 | 11 | ~ExitGuard() noexcept | |
| 349 | { | ||
| 350 | 11 | m_channel.worker_tid.store(std::thread::id{}, std::memory_order_release); | |
| 351 | 11 | m_channel.worker_exited.store(true, std::memory_order_release); | |
| 352 | 11 | } | |
| 353 | |||
| 354 | ExitGuard(const ExitGuard &) = delete; | ||
| 355 | ExitGuard &operator=(const ExitGuard &) = delete; | ||
| 356 | |||
| 357 | private: | ||
| 358 | Channel &m_channel; | ||
| 359 | }; | ||
| 360 | |||
| 361 | 12 | const ExitGuard exit_guard{channel}; | |
| 362 | 12 | DetourModKit::Logger &logger = DetourModKit::log(); | |
| 363 | |||
| 364 | // Publish our thread id for ~ReloadServicer's self-join detection. Clear it on exit so a later | ||
| 365 | // OS-recycled id cannot alias a dead worker. | ||
| 366 | 12 | channel.worker_tid.store(std::this_thread::get_id(), std::memory_order_release); | |
| 367 | |||
| 368 | // Wake the CV on a stop request so the blocked wait exits promptly. | ||
| 369 | std::stop_callback stop_cb( | ||
| 370 | st, | ||
| 371 | 33 | [&channel]() -> void | |
| 372 | { | ||
| 373 | { | ||
| 374 |
1/2✓ Branch 2 → 3 taken 9 times.
✗ Branch 2 → 7 not taken.
|
9 | std::lock_guard<std::mutex> lock(channel.mutex); |
| 375 | 9 | channel.shutdown.store(true, std::memory_order_release); | |
| 376 | 9 | } | |
| 377 | 9 | channel.cv.notify_all(); | |
| 378 | 9 | } | |
| 379 | 12 | ); | |
| 380 | |||
| 381 |
6/6✓ Branch 44 → 45 taken 12 times.
✓ Branch 44 → 48 taken 3 times.
✓ Branch 46 → 47 taken 10 times.
✓ Branch 46 → 48 taken 2 times.
✓ Branch 49 → 8 taken 10 times.
✓ Branch 49 → 50 taken 5 times.
|
27 | while (!st.stop_requested() && !channel.shutdown.load(std::memory_order_acquire)) |
| 382 | { | ||
| 383 | { | ||
| 384 | 10 | std::unique_lock<std::mutex> lock(channel.mutex); | |
| 385 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 386 |
2/2✓ Branch 10 → 11 taken 1 time.
✓ Branch 10 → 17 taken 9 times.
|
10 | if (auto *gate = DetourModKit::detail::g_config_reload_worker_mutex_gate_probe.load( |
| 387 | std::memory_order_acquire | ||
| 388 | )) | ||
| 389 | { | ||
| 390 | 1 | DetourModKit::detail::g_config_reload_worker_mutex_waiting_probe.store( | |
| 391 | true, | ||
| 392 | std::memory_order_release | ||
| 393 | ); | ||
| 394 |
1/2✓ Branch 15 → 13 taken 21714 times.
✗ Branch 15 → 16 not taken.
|
21714 | while (gate->load(std::memory_order_acquire)) |
| 395 | { | ||
| 396 | 21714 | std::this_thread::yield(); | |
| 397 | } | ||
| 398 | ✗ | DetourModKit::detail::g_config_reload_worker_mutex_waiting_probe.store( | |
| 399 | false, | ||
| 400 | std::memory_order_release | ||
| 401 | ); | ||
| 402 | } | ||
| 403 | #endif | ||
| 404 | 9 | channel.cv.wait( | |
| 405 | lock, | ||
| 406 | 18 | [&]() noexcept | |
| 407 | { | ||
| 408 |
5/6✓ Branch 3 → 4 taken 12 times.
✓ Branch 3 → 8 taken 6 times.
✓ Branch 5 → 6 taken 12 times.
✗ Branch 5 → 8 not taken.
✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 9 times.
|
30 | return st.stop_requested() || channel.shutdown.load(std::memory_order_acquire) || |
| 409 | 30 | channel.reload_requested.load(std::memory_order_acquire); | |
| 410 | } | ||
| 411 | ); | ||
| 412 | 9 | } | |
| 413 | |||
| 414 |
5/6✓ Branch 20 → 21 taken 3 times.
✓ Branch 20 → 23 taken 6 times.
✗ Branch 22 → 23 not taken.
✓ Branch 22 → 24 taken 3 times.
✓ Branch 25 → 26 taken 6 times.
✓ Branch 25 → 27 taken 3 times.
|
9 | if (st.stop_requested() || channel.shutdown.load(std::memory_order_acquire)) |
| 415 | { | ||
| 416 | 6 | break; | |
| 417 | } | ||
| 418 | |||
| 419 | // Coalesce: a burst of presses during the reload collapses into at most one follow-up pass. | ||
| 420 |
2/2✓ Branch 41 → 28 taken 3 times.
✓ Branch 41 → 43 taken 3 times.
|
6 | while (channel.reload_requested.exchange(false, std::memory_order_acq_rel)) |
| 421 | { | ||
| 422 | // Gate on the unload latch and this servicer's lifecycle epoch. Do not run setters into a | ||
| 423 | // Logic DLL under unload or a re-armed registry that belongs to a newer one. | ||
| 424 | 3 | detail::BackgroundReloadGuard reload_guard{channel.birth_epoch}; | |
| 425 |
1/2✗ Branch 30 → 31 not taken.
✓ Branch 30 → 32 taken 3 times.
|
3 | if (!reload_guard.engaged()) |
| 426 | { | ||
| 427 | ✗ | break; | |
| 428 | } | ||
| 429 | try | ||
| 430 | { | ||
| 431 | 3 | bool setters_ran = false; | |
| 432 |
1/2✓ Branch 32 → 33 taken 3 times.
✗ Branch 32 → 59 not taken.
|
3 | (void)detail::reload_impl(setters_ran, &reload_guard); |
| 433 | } | ||
| 434 | ✗ | catch (const std::exception &e) | |
| 435 | { | ||
| 436 | (void)logger | ||
| 437 | ✗ | .try_log(LogLevel::Error, "Config: reload servicer caught exception: {}", e.what()); | |
| 438 | ✗ | } | |
| 439 | ✗ | catch (...) | |
| 440 | { | ||
| 441 | ( | ||
| 442 | void | ||
| 443 | ✗ | )logger.try_log(LogLevel::Error, "Config: reload servicer caught unknown exception."); | |
| 444 | ✗ | } | |
| 445 |
1/2✓ Branch 36 → 37 taken 3 times.
✗ Branch 36 → 39 not taken.
|
3 | } |
| 446 | } | ||
| 447 | |||
| 448 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 449 | // Holds the body between its last channel.mutex use and the exit guard below. A concurrent teardown | ||
| 450 | // observes a worker that is provably live and lacks an exit publication. | ||
| 451 |
1/2✗ Branch 51 → 52 not taken.
✓ Branch 51 → 56 taken 11 times.
|
11 | if (auto *gate = DetourModKit::detail::g_config_reload_worker_exit_gate_probe.load( |
| 452 | std::memory_order_acquire | ||
| 453 | )) | ||
| 454 | { | ||
| 455 | ✗ | while (gate->load(std::memory_order_acquire)) | |
| 456 | { | ||
| 457 | ✗ | std::this_thread::yield(); | |
| 458 | } | ||
| 459 | } | ||
| 460 | #endif | ||
| 461 | 11 | } | |
| 462 | |||
| 463 | std::unique_ptr<Channel> m_channel; | ||
| 464 | }; | ||
| 465 | |||
| 466 | // A shared_ptr lets a press callback keep its own strong reference when clear() resets the slot. | ||
| 467 | 3610281 | std::shared_ptr<ReloadServicer> &get_reload_servicer() noexcept | |
| 468 | { | ||
| 469 |
3/4✓ Branch 2 → 3 taken 352 times.
✓ Branch 2 → 7 taken 3609929 times.
✓ Branch 4 → 5 taken 352 times.
✗ Branch 4 → 7 not taken.
|
3610281 | static std::shared_ptr<ReloadServicer> s_servicer; |
| 470 | 3610281 | return s_servicer; | |
| 471 | } | ||
| 472 | |||
| 473 | // start_watcher_locked creates an auto-reload watcher on a resolved path, then connects the persisted user | ||
| 474 | // callback. The caller must hold get_watcher_mutex(). enable_auto_reload() and load()'s re-point use this | ||
| 475 | // single construction site, so the presence guard and construction are atomic. | ||
| 476 | 48 | [[nodiscard]] AutoReloadStatus start_watcher_locked( | |
| 477 | const std::string &resolved_path, | ||
| 478 | std::chrono::milliseconds debounce, | ||
| 479 | detail::DeferredDiagnostics &diags, | ||
| 480 | DetourModKit::detail::ConfigWatcher::StartGate &start_gate, | ||
| 481 | std::unique_ptr<DetourModKit::detail::ConfigWatcher> &failed_watcher | ||
| 482 | ) | ||
| 483 | { | ||
| 484 | 48 | auto &watcher = get_config_watcher(); | |
| 485 |
1/2✗ Branch 4 → 5 not taken.
✓ Branch 4 → 6 taken 48 times.
|
48 | if (detail::background_reloads_disabled()) |
| 486 | { | ||
| 487 | ✗ | return AutoReloadStatus::StartFailed; | |
| 488 | } | ||
| 489 | // Guard on existence, not is_running(). A second caller otherwise can overwrite the unique_ptr before | ||
| 490 | // the worker publishes its active state. | ||
| 491 |
1/2✗ Branch 7 → 8 not taken.
✓ Branch 7 → 10 taken 48 times.
|
48 | if (watcher) |
| 492 | { | ||
| 493 | ✗ | detail::defer_diagnostic( | |
| 494 | diags, | ||
| 495 | LogLevel::Warning, | ||
| 496 | "Config: Auto-reload watcher start skipped because a watcher is already present; " | ||
| 497 | "call disable_auto_reload() first." | ||
| 498 | ); | ||
| 499 | ✗ | return AutoReloadStatus::AlreadyRunning; | |
| 500 | } | ||
| 501 | |||
| 502 | // Copy the persisted user callback into the reload lambda. The persisted slot must survive. A later | ||
| 503 | // load()-driven re-point can then reconstruct an equivalent watcher. | ||
| 504 | 48 | watcher = std::make_unique<DetourModKit::detail::ConfigWatcher>( | |
| 505 | resolved_path, | ||
| 506 | debounce, | ||
| 507 |
3/8✓ Branch 11 → 12 taken 48 times.
✗ Branch 11 → 38 not taken.
✓ Branch 13 → 14 taken 48 times.
✗ Branch 13 → 33 not taken.
✗ Branch 17 → 18 not taken.
✓ Branch 17 → 19 taken 48 times.
✗ Branch 35 → 36 not taken.
✗ Branch 35 → 37 not taken.
|
96 | [user_cb = get_reload_user_callback(), birth_epoch = detail::current_reload_lifecycle_epoch()]() |
| 508 | { | ||
| 509 | // Gate the whole pass on the unload latch and this watcher's lifecycle epoch. The guard holds | ||
| 510 | // the in-flight count across BOTH the setter pass and the user callback. | ||
| 511 | 26 | detail::BackgroundReloadGuard reload_guard{birth_epoch}; | |
| 512 |
2/2✓ Branch 4 → 5 taken 2 times.
✓ Branch 4 → 6 taken 24 times.
|
26 | if (!reload_guard.engaged()) |
| 513 | { | ||
| 514 | 2 | return; | |
| 515 | } | ||
| 516 | // Reload first so the user callback observes the refreshed values. setters_ran lets it | ||
| 517 | // distinguish a real reload from a skipped setter pass. | ||
| 518 | 24 | bool setters_ran = false; | |
| 519 |
1/2✓ Branch 6 → 7 taken 24 times.
✗ Branch 6 → 22 not taken.
|
24 | (void)detail::reload_impl(setters_ran, &reload_guard); |
| 520 | // Re-check the latch. An unload can set it during the pass. | ||
| 521 |
6/6✓ Branch 8 → 9 taken 9 times.
✓ Branch 8 → 12 taken 15 times.
✓ Branch 10 → 11 taken 8 times.
✓ Branch 10 → 12 taken 1 time.
✓ Branch 13 → 14 taken 8 times.
✓ Branch 13 → 15 taken 16 times.
|
24 | if (user_cb && reload_guard.current()) |
| 522 | { | ||
| 523 |
1/2✓ Branch 14 → 15 taken 8 times.
✗ Branch 14 → 22 not taken.
|
8 | user_cb(setters_ran); |
| 524 | } | ||
| 525 |
2/2✓ Branch 17 → 18 taken 24 times.
✓ Branch 17 → 20 taken 2 times.
|
26 | } |
| 526 | 48 | ); | |
| 527 | |||
| 528 | 48 | bool started = false; | |
| 529 | try | ||
| 530 | { | ||
| 531 |
1/2✓ Branch 20 → 21 taken 48 times.
✗ Branch 20 → 40 not taken.
|
48 | started = watcher->start(diags, start_gate); |
| 532 | } | ||
| 533 | ✗ | catch (...) | |
| 534 | { | ||
| 535 | ✗ | failed_watcher = std::move(watcher); | |
| 536 | ✗ | get_reload_user_callback() = nullptr; | |
| 537 | ✗ | throw; | |
| 538 | ✗ | } | |
| 539 |
2/2✓ Branch 21 → 22 taken 7 times.
✓ Branch 21 → 30 taken 41 times.
|
48 | if (!started) |
| 540 | { | ||
| 541 | 7 | failed_watcher = std::move(watcher); | |
| 542 | // Drop the persisted callback with the failed watcher so it cannot pin Logic DLL references. | ||
| 543 | 7 | get_reload_user_callback() = nullptr; | |
| 544 | try | ||
| 545 | { | ||
| 546 |
1/2✓ Branch 27 → 28 taken 7 times.
✗ Branch 27 → 50 not taken.
|
7 | detail::defer_diagnostic( |
| 547 | diags, | ||
| 548 | LogLevel::Error, | ||
| 549 | "Config: Auto-reload watcher failed to start for {}", | ||
| 550 | resolved_path | ||
| 551 | ); | ||
| 552 | } | ||
| 553 | ✗ | catch (...) | |
| 554 | { | ||
| 555 | ✗ | DetourModKit::detail::LoggerDropAccess::record(log()); | |
| 556 | ✗ | } | |
| 557 | 7 | return AutoReloadStatus::StartFailed; | |
| 558 | } | ||
| 559 | 41 | return AutoReloadStatus::Started; | |
| 560 | } | ||
| 561 | } // anonymous namespace | ||
| 562 | |||
| 563 | namespace detail | ||
| 564 | { | ||
| 565 | 823 | void dispose_reload_hotkey_guards(std::vector<input::BindingGuard> &guards) noexcept | |
| 566 | { | ||
| 567 |
2/2✓ Branch 3 → 4 taken 813 times.
✓ Branch 3 → 5 taken 10 times.
|
823 | if (guards.empty()) |
| 568 | { | ||
| 569 | 813 | return; | |
| 570 | } | ||
| 571 | 10 | run_reload_hotkey_guard_disposal_probe(); | |
| 572 | 10 | guards.clear(); | |
| 573 | } | ||
| 574 | |||
| 575 | 183 | WatchRepoint detach_watcher_if_repointed(std::string_view loaded_resolved_path) | |
| 576 | { | ||
| 577 | 183 | WatchRepoint result; | |
| 578 | 183 | DeferredDiagnostics diags = open_deferred_diagnostics(); | |
| 579 | { | ||
| 580 |
1/2✓ Branch 4 → 5 taken 183 times.
✗ Branch 4 → 34 not taken.
|
183 | std::lock_guard<std::mutex> wlock(get_watcher_mutex()); |
| 581 | 183 | auto &watcher = get_config_watcher(); | |
| 582 |
2/2✓ Branch 7 → 8 taken 13 times.
✓ Branch 7 → 26 taken 170 times.
|
183 | if (watcher) |
| 583 | { | ||
| 584 |
2/2✓ Branch 12 → 13 taken 5 times.
✓ Branch 12 → 26 taken 8 times.
|
13 | if (!resolved_paths_equivalent(watcher->ini_path(), loaded_resolved_path)) |
| 585 | { | ||
| 586 |
1/2✗ Branch 16 → 17 not taken.
✓ Branch 16 → 19 taken 5 times.
|
5 | if (watcher->is_worker_thread(std::this_thread::get_id())) |
| 587 | { | ||
| 588 | // Inline watcher destruction self-joins the worker. Report and skip the re-point under | ||
| 589 | // disable_auto_reload()'s self-join rule. | ||
| 590 | ✗ | defer_diagnostic( | |
| 591 | diags, | ||
| 592 | LogLevel::Error, | ||
| 593 | "Config: load() switched the config file on the watcher thread; not " | ||
| 594 | "re-pointing auto-reload to avoid a self-join. Re-point from another " | ||
| 595 | "thread via disable_auto_reload()/enable_auto_reload()." | ||
| 596 | ); | ||
| 597 | } | ||
| 598 | else | ||
| 599 | { | ||
| 600 | // Move the stale watcher out and preserve the persisted user callback for restart. | ||
| 601 | // Snapshot the disable generation under this lock for the lost-disable window check. | ||
| 602 | 5 | result.debounce = watcher->debounce(); | |
| 603 | 5 | result.generation_at_move = get_watcher_disable_generation(); | |
| 604 | 5 | result.stale = std::move(watcher); | |
| 605 | 5 | result.repoint = true; | |
| 606 | } | ||
| 607 | } | ||
| 608 | } | ||
| 609 | 183 | } | |
| 610 | 183 | emit_deferred_diagnostics(diags); | |
| 611 | 183 | return result; | |
| 612 | 183 | } | |
| 613 | |||
| 614 | 5 | void restart_watcher_after_repoint(std::chrono::milliseconds debounce, std::uint64_t generation_at_move) | |
| 615 | { | ||
| 616 | // Re-snapshot the latest remembered path and re-start under get_watcher_mutex(). A disable | ||
| 617 | // generation bump since the move-out means a disable raced into the join window: honor it and | ||
| 618 | // leave auto-reload OFF. The re-check and construction are one atomic step under the held lock. | ||
| 619 |
1/2✓ Branch 2 → 3 taken 5 times.
✗ Branch 2 → 52 not taken.
|
5 | const std::string repoint_filename = snapshot_last_loaded_ini_path(); |
| 620 | 5 | DeferredDiagnostics diags = open_deferred_diagnostics(); | |
| 621 | 5 | DetourModKit::detail::ConfigWatcher::StartGate start_gate; | |
| 622 | 5 | std::unique_ptr<DetourModKit::detail::ConfigWatcher> failed_watcher; | |
| 623 | try | ||
| 624 | { | ||
| 625 | { | ||
| 626 |
1/2✓ Branch 5 → 6 taken 5 times.
✗ Branch 5 → 36 not taken.
|
5 | std::lock_guard<std::mutex> wlock(get_watcher_mutex()); |
| 627 |
5/6✓ Branch 7 → 8 taken 5 times.
✗ Branch 7 → 11 not taken.
✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 11 taken 1 time.
✓ Branch 12 → 13 taken 4 times.
✓ Branch 12 → 19 taken 1 time.
|
5 | if (!repoint_filename.empty() && get_watcher_disable_generation() == generation_at_move) |
| 628 | { | ||
| 629 |
1/2✓ Branch 13 → 14 taken 4 times.
✗ Branch 13 → 33 not taken.
|
4 | const std::filesystem::path repoint_path = get_ini_file_path(repoint_filename, diags); |
| 630 | ( | ||
| 631 | void | ||
| 632 |
2/4✓ Branch 14 → 15 taken 4 times.
✗ Branch 14 → 30 not taken.
✓ Branch 15 → 16 taken 4 times.
✗ Branch 15 → 28 not taken.
|
4 | )start_watcher_locked(repoint_path.string(), debounce, diags, start_gate, failed_watcher); |
| 633 | 4 | } | |
| 634 | 5 | } | |
| 635 | 5 | emit_deferred_diagnostics(diags); | |
| 636 | } | ||
| 637 | ✗ | catch (...) | |
| 638 | { | ||
| 639 | ✗ | DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate); | |
| 640 | ✗ | failed_watcher.reset(); | |
| 641 | ✗ | throw; | |
| 642 | ✗ | } | |
| 643 | 5 | DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate); | |
| 644 | 5 | failed_watcher.reset(); | |
| 645 | 5 | } | |
| 646 | |||
| 647 | 2 | bool on_reload_servicer_thread() noexcept | |
| 648 | { | ||
| 649 | 2 | std::lock_guard<std::mutex> lock(get_watcher_mutex()); | |
| 650 | 2 | const std::shared_ptr<ReloadServicer> &servicer = get_reload_servicer(); | |
| 651 |
3/4✓ Branch 6 → 7 taken 1 time.
✓ Branch 6 → 12 taken 1 time.
✓ Branch 10 → 11 taken 1 time.
✗ Branch 10 → 12 not taken.
|
2 | return servicer && servicer->is_worker_thread(std::this_thread::get_id()); |
| 652 | 2 | } | |
| 653 | |||
| 654 | 693 | WatchHotkeyControl detach_hotkey_control() noexcept | |
| 655 | { | ||
| 656 | 693 | WatchHotkeyControl control; | |
| 657 | 693 | std::lock_guard<std::mutex> wlock(get_watcher_mutex()); | |
| 658 | 1386 | control.guards = std::move(get_reload_hotkey_guards()); | |
| 659 | 1386 | control.servicer = std::move(get_reload_servicer()); | |
| 660 | 1386 | return control; | |
| 661 | 693 | } | |
| 662 | |||
| 663 | // Shared stop poke for both drain verbs. The caller holds get_watcher_mutex(). Returns false when the | ||
| 664 | // caller is the watcher or servicer worker thread, without requesting any stop. | ||
| 665 | 1804786 | [[nodiscard]] bool poke_stops_locked() noexcept | |
| 666 | { | ||
| 667 | 1804786 | const auto &watcher = get_config_watcher(); | |
| 668 | 1804786 | const auto &servicer = get_reload_servicer(); | |
| 669 |
6/8✓ Branch 5 → 6 taken 1699608 times.
✓ Branch 5 → 10 taken 105178 times.
✓ Branch 9 → 10 taken 1699608 times.
✗ Branch 9 → 16 not taken.
✓ Branch 11 → 12 taken 104930 times.
✓ Branch 11 → 17 taken 1699856 times.
✗ Branch 18 → 19 not taken.
✓ Branch 18 → 20 taken 1804786 times.
|
3714502 | if ((watcher && watcher->is_worker_thread(std::this_thread::get_id())) || |
| 670 |
1/2✗ Branch 15 → 16 not taken.
✓ Branch 15 → 17 taken 104930 times.
|
1909716 | (servicer && servicer->is_worker_thread(std::this_thread::get_id()))) |
| 671 | { | ||
| 672 | ✗ | return false; | |
| 673 | } | ||
| 674 | |||
| 675 |
2/2✓ Branch 21 → 22 taken 1699608 times.
✓ Branch 21 → 24 taken 105178 times.
|
1804786 | if (watcher) |
| 676 | { | ||
| 677 | 1699608 | watcher->request_stop(); | |
| 678 | } | ||
| 679 |
2/2✓ Branch 25 → 26 taken 104930 times.
✓ Branch 25 → 28 taken 1699856 times.
|
1804786 | if (servicer) |
| 680 | { | ||
| 681 | 104930 | servicer->request_stop(); | |
| 682 | } | ||
| 683 | 1804786 | return true; | |
| 684 | } | ||
| 685 | |||
| 686 | 134 | WatchStopPoke request_watch_stops_for_drain() noexcept | |
| 687 | { | ||
| 688 | 134 | std::unique_lock<std::mutex> lock(get_watcher_mutex(), std::try_to_lock); | |
| 689 |
2/2✓ Branch 5 → 6 taken 1 time.
✓ Branch 5 → 7 taken 133 times.
|
134 | if (!lock.owns_lock()) |
| 690 | { | ||
| 691 | 1 | return WatchStopPoke::LockBusy; | |
| 692 | } | ||
| 693 |
1/2✓ Branch 8 → 9 taken 133 times.
✗ Branch 8 → 10 not taken.
|
133 | return poke_stops_locked() ? WatchStopPoke::Requested : WatchStopPoke::SelfDelivery; |
| 694 | 134 | } | |
| 695 | |||
| 696 | 1930745 | WatchDrainState try_detach_watch_control(bool (*reloads_quiesced)() noexcept, WatchTeardown &out) noexcept | |
| 697 | { | ||
| 698 | 1930745 | std::unique_lock<std::mutex> lock(get_watcher_mutex(), std::try_to_lock); | |
| 699 |
2/2✓ Branch 5 → 6 taken 126092 times.
✓ Branch 5 → 7 taken 1804653 times.
|
1930745 | if (!lock.owns_lock()) |
| 700 | { | ||
| 701 | 126092 | return WatchDrainState::LockBusy; | |
| 702 | } | ||
| 703 |
1/2✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 1804653 times.
|
1804653 | if (!poke_stops_locked()) |
| 704 | { | ||
| 705 | ✗ | return WatchDrainState::SelfDelivery; | |
| 706 | } | ||
| 707 | 1804653 | const auto &watcher = get_config_watcher(); | |
| 708 | 1804653 | const auto &servicer = get_reload_servicer(); | |
| 709 | const bool workers_exited = | ||
| 710 |
8/8✓ Branch 13 → 14 taken 1699601 times.
✓ Branch 13 → 17 taken 105052 times.
✓ Branch 16 → 17 taken 5 times.
✓ Branch 16 → 23 taken 1699596 times.
✓ Branch 18 → 19 taken 104928 times.
✓ Branch 18 → 22 taken 129 times.
✓ Branch 21 → 22 taken 1 time.
✓ Branch 21 → 23 taken 104927 times.
|
1804653 | (!watcher || watcher->has_exited()) && (!servicer || servicer->has_exited()); |
| 711 |
5/6✓ Branch 24 → 25 taken 130 times.
✓ Branch 24 → 28 taken 1804523 times.
✓ Branch 26 → 27 taken 130 times.
✗ Branch 26 → 28 not taken.
✓ Branch 29 → 30 taken 130 times.
✓ Branch 29 → 50 taken 1804523 times.
|
1804653 | if (workers_exited && reloads_quiesced()) |
| 712 | { | ||
| 713 | 260 | out.watcher = std::move(get_config_watcher()); | |
| 714 | 260 | out.servicer = std::move(get_reload_servicer()); | |
| 715 | // std::function move assignment has no standard noexcept guarantee. Stage through the noexcept | ||
| 716 | // move constructor, then commit with the noexcept member swap. | ||
| 717 | 260 | std::function<void(bool)> detached_callback(std::move(get_reload_user_callback())); | |
| 718 | 130 | out.callback.swap(detached_callback); | |
| 719 | 260 | out.guards = std::move(get_reload_hotkey_guards()); | |
| 720 | 130 | ++get_watcher_disable_generation(); | |
| 721 | 130 | return WatchDrainState::Detached; | |
| 722 | 130 | } | |
| 723 | 1804523 | return WatchDrainState::Draining; | |
| 724 | 1930745 | } | |
| 725 | } // namespace detail | ||
| 726 | |||
| 727 | 50 | AutoReloadStatus enable_auto_reload(std::chrono::milliseconds debounce, std::function<void(bool)> on_reload) | |
| 728 | { | ||
| 729 |
1/2✓ Branch 2 → 3 taken 50 times.
✗ Branch 2 → 55 not taken.
|
50 | const std::string ini_filename = detail::snapshot_last_loaded_ini_path(); |
| 730 | |||
| 731 | 50 | Logger &logger = log(); | |
| 732 | |||
| 733 |
2/2✓ Branch 5 → 6 taken 2 times.
✓ Branch 5 → 8 taken 48 times.
|
50 | if (ini_filename.empty()) |
| 734 | { | ||
| 735 |
1/2✓ Branch 6 → 7 taken 2 times.
✗ Branch 6 → 27 not taken.
|
2 | logger.warning("Config: enable_auto_reload() called before load(); watcher not started."); |
| 736 | 2 | return AutoReloadStatus::NoPriorLoad; | |
| 737 | } | ||
| 738 | |||
| 739 | // The path resolution runs before the watcher mutex, so it reaches the same absolute path load() uses. | ||
| 740 | 48 | detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics(); | |
| 741 |
1/2✓ Branch 9 → 10 taken 48 times.
✗ Branch 9 → 51 not taken.
|
48 | std::filesystem::path ini_path = detail::get_ini_file_path(ini_filename, diags); |
| 742 |
1/2✓ Branch 10 → 11 taken 48 times.
✗ Branch 10 → 49 not taken.
|
48 | std::string resolved_path = ini_path.string(); |
| 743 | 48 | DetourModKit::detail::ConfigWatcher::StartGate start_gate; | |
| 744 | 48 | std::unique_ptr<DetourModKit::detail::ConfigWatcher> failed_watcher; | |
| 745 | |||
| 746 | // Hold get_watcher_mutex() across publish-callback-then-start: a bounded start() stall is preferable to | ||
| 747 | // a use-after-free if disable_auto_reload() destroyed the watcher mid-start(). | ||
| 748 | 48 | AutoReloadStatus status{AutoReloadStatus::StartFailed}; | |
| 749 | try | ||
| 750 | { | ||
| 751 | ✗ | status = [&]() -> AutoReloadStatus | |
| 752 | { | ||
| 753 |
1/2✓ Branch 3 → 4 taken 48 times.
✗ Branch 3 → 24 not taken.
|
48 | std::lock_guard<std::mutex> wlock(get_watcher_mutex()); |
| 754 | |||
| 755 |
1/2✗ Branch 5 → 6 not taken.
✓ Branch 5 → 7 taken 48 times.
|
48 | if (detail::background_reloads_disabled()) |
| 756 | { | ||
| 757 | ✗ | return AutoReloadStatus::StartFailed; | |
| 758 | } | ||
| 759 | |||
| 760 | // On a duplicate enable attempt, preserve the live watcher's callback. A new callback publication | ||
| 761 | // before this check makes a later re-point switch callbacks silently. | ||
| 762 |
2/2✓ Branch 9 → 10 taken 4 times.
✓ Branch 9 → 12 taken 44 times.
|
48 | if (get_config_watcher()) |
| 763 | { | ||
| 764 |
1/2✓ Branch 10 → 11 taken 4 times.
✗ Branch 10 → 21 not taken.
|
4 | detail::defer_diagnostic( |
| 765 | diags, | ||
| 766 | LogLevel::Warning, | ||
| 767 | "Config: enable_auto_reload() called while a watcher is already present; " | ||
| 768 | "call disable_auto_reload() first." | ||
| 769 | ); | ||
| 770 | 4 | return AutoReloadStatus::AlreadyRunning; | |
| 771 | } | ||
| 772 | |||
| 773 | // Persist a copy of the user callback for load()'s re-point, published under the watcher mutex | ||
| 774 | // before the construction helper reads it. | ||
| 775 | 88 | get_reload_user_callback() = std::move(on_reload); | |
| 776 | |||
| 777 |
1/2✓ Branch 16 → 17 taken 44 times.
✗ Branch 16 → 22 not taken.
|
44 | return start_watcher_locked(resolved_path, debounce, diags, start_gate, failed_watcher); |
| 778 |
1/2✓ Branch 11 → 12 taken 48 times.
✗ Branch 11 → 28 not taken.
|
96 | }(); |
| 779 | |||
| 780 |
2/2✓ Branch 12 → 13 taken 37 times.
✓ Branch 12 → 16 taken 11 times.
|
48 | if (status == AutoReloadStatus::Started) |
| 781 | { | ||
| 782 | try | ||
| 783 | { | ||
| 784 | ✗ | detail::defer_diagnostic( | |
| 785 | diags, | ||
| 786 | LogLevel::Info, | ||
| 787 | "Config: Auto-reload enabled for {} (debounce {} ms)", | ||
| 788 | resolved_path, | ||
| 789 |
1/2✓ Branch 14 → 15 taken 37 times.
✗ Branch 14 → 29 not taken.
|
37 | static_cast<long long>(debounce.count()) |
| 790 | ); | ||
| 791 | } | ||
| 792 | ✗ | catch (...) | |
| 793 | { | ||
| 794 | ✗ | DetourModKit::detail::LoggerDropAccess::record(log()); | |
| 795 | ✗ | } | |
| 796 | } | ||
| 797 | 48 | detail::emit_deferred_diagnostics(diags); | |
| 798 | } | ||
| 799 | ✗ | catch (...) | |
| 800 | { | ||
| 801 | ✗ | DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate); | |
| 802 | ✗ | failed_watcher.reset(); | |
| 803 | ✗ | throw; | |
| 804 | ✗ | } | |
| 805 | 48 | DetourModKit::detail::ConfigWatcher::release_start_gate(start_gate); | |
| 806 | 48 | failed_watcher.reset(); | |
| 807 | 48 | return status; | |
| 808 | 50 | } | |
| 809 | |||
| 810 | 230 | void disable_auto_reload() noexcept | |
| 811 | { | ||
| 812 | // A watcher join from a bound setter blocks on its final flush, which re-enters reload_impl and waits for | ||
| 813 | // the pass lock this thread holds. Refuse to avoid deadlock. | ||
| 814 |
2/2✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 7 taken 228 times.
|
230 | if (detail::reload_apply_lock_held_by_current_thread()) |
| 815 | { | ||
| 816 | 2 | (void)log().try_log( | |
| 817 | LogLevel::Error, | ||
| 818 | "Config: disable_auto_reload() called from a bound setter; ignoring to avoid " | ||
| 819 | "joining a watcher that may be waiting for the active reload pass." | ||
| 820 | ); | ||
| 821 | 3 | return; | |
| 822 | } | ||
| 823 | |||
| 824 | 228 | std::unique_ptr<DetourModKit::detail::ConfigWatcher> to_drop; | |
| 825 | 228 | bool self_join_refused = false; | |
| 826 | { | ||
| 827 | 228 | std::lock_guard<std::mutex> wlock(get_watcher_mutex()); | |
| 828 | 228 | auto &watcher = get_config_watcher(); | |
| 829 | // Inline unique_ptr destruction on the watcher thread forces the worker to join itself. Report after | ||
| 830 | // the unlock and return. To cancel inside a reload, release the binding guard or flip a caller-owned | ||
| 831 | // flag. | ||
| 832 |
6/6✓ Branch 11 → 12 taken 31 times.
✓ Branch 11 → 17 taken 197 times.
✓ Branch 15 → 16 taken 1 time.
✓ Branch 15 → 17 taken 30 times.
✓ Branch 18 → 19 taken 1 time.
✓ Branch 18 → 20 taken 227 times.
|
228 | if (watcher && watcher->is_worker_thread(std::this_thread::get_id())) |
| 833 | { | ||
| 834 | 1 | self_join_refused = true; | |
| 835 | } | ||
| 836 | else | ||
| 837 | { | ||
| 838 | 227 | to_drop = std::move(watcher); | |
| 839 | // Drop the persisted re-point callback with its watcher so it cannot pin Logic DLL references. | ||
| 840 | 227 | get_reload_user_callback() = nullptr; | |
| 841 | // Signal a load() re-point in its lost-disable window so it does not resurrect the watcher. | ||
| 842 | 227 | ++get_watcher_disable_generation(); | |
| 843 | } | ||
| 844 | 228 | } | |
| 845 |
2/2✓ Branch 28 → 29 taken 1 time.
✓ Branch 28 → 32 taken 227 times.
|
228 | if (self_join_refused) |
| 846 | { | ||
| 847 | 1 | (void)log().try_log( | |
| 848 | LogLevel::Error, | ||
| 849 | "Config: disable_auto_reload() called from the watcher thread; ignoring to avoid self-join " | ||
| 850 | "deadlock. Call from a different thread or disable the hotkey binding instead." | ||
| 851 | ); | ||
| 852 | 1 | return; | |
| 853 | } | ||
| 854 | // ~ConfigWatcher joins its worker outside our mutex. | ||
| 855 |
2/2✓ Branch 34 → 35 taken 227 times.
✓ Branch 34 → 37 taken 1 time.
|
228 | } |
| 856 | |||
| 857 | 16 | bool reload_hotkey(std::string_view ini_key, std::string_view default_combo) | |
| 858 | { | ||
| 859 | // An empty or opt-out default leaves the hotkey inert. Return false to expose that state. | ||
| 860 |
2/2✓ Branch 3 → 4 taken 1 time.
✓ Branch 3 → 12 taken 15 times.
|
16 | if (default_combo.empty()) |
| 861 | { | ||
| 862 |
1/2✓ Branch 8 → 9 taken 1 time.
✗ Branch 8 → 114 not taken.
|
2 | log().warning( |
| 863 | "Config: reload_hotkey('{}', '<empty>') rejected; provide a non-empty default combo.", | ||
| 864 |
1/2✓ Branch 7 → 8 taken 1 time.
✗ Branch 7 → 117 not taken.
|
2 | std::string(ini_key) |
| 865 | ); | ||
| 866 | 1 | return false; | |
| 867 | } | ||
| 868 | |||
| 869 | // Pre-parse the default. The parser defers its own typo WARNING, and a NONE opt-out still returns false. | ||
| 870 | 15 | detail::DeferredDiagnostics diags = detail::open_deferred_diagnostics(); | |
| 871 | const input::KeyComboList parsed = | ||
| 872 |
2/4✓ Branch 16 → 17 taken 15 times.
✗ Branch 16 → 123 not taken.
✓ Branch 17 → 18 taken 15 times.
✗ Branch 17 → 121 not taken.
|
30 | detail::parse_key_combo_list(std::string(default_combo), diags, "Config reload hotkey"); |
| 873 | 15 | detail::emit_deferred_diagnostics(diags); | |
| 874 |
2/2✓ Branch 22 → 23 taken 1 time.
✓ Branch 22 → 24 taken 14 times.
|
15 | if (parsed.empty()) |
| 875 | { | ||
| 876 | 1 | return false; | |
| 877 | } | ||
| 878 | |||
| 879 | // The INI key supplies a stable binding name, so repeat registrations update in place. | ||
| 880 |
2/4✓ Branch 26 → 27 taken 14 times.
✗ Branch 26 → 130 not taken.
✓ Branch 27 → 28 taken 14 times.
✗ Branch 27 → 128 not taken.
|
14 | std::string binding_name = "config_reload:" + std::string(ini_key); |
| 881 | |||
| 882 | // Lazily spin up the reload servicer on the first hotkey registration, under get_watcher_mutex(). | ||
| 883 | 14 | std::shared_ptr<ReloadServicer> servicer; | |
| 884 | 14 | bool servicer_created = false; | |
| 885 | { | ||
| 886 |
1/2✓ Branch 31 → 32 taken 14 times.
✗ Branch 31 → 137 not taken.
|
14 | std::lock_guard<std::mutex> lock(get_watcher_mutex()); |
| 887 |
1/2✗ Branch 33 → 34 not taken.
✓ Branch 33 → 35 taken 14 times.
|
14 | if (detail::background_reloads_disabled()) |
| 888 | { | ||
| 889 | ✗ | return false; | |
| 890 | } | ||
| 891 | 14 | auto &slot = get_reload_servicer(); | |
| 892 |
2/2✓ Branch 37 → 38 taken 12 times.
✓ Branch 37 → 42 taken 2 times.
|
14 | if (!slot) |
| 893 | { | ||
| 894 |
2/2✓ Branch 38 → 39 taken 11 times.
✓ Branch 38 → 134 taken 1 time.
|
12 | slot = std::make_shared<ReloadServicer>(diags); |
| 895 | 11 | servicer_created = true; | |
| 896 | } | ||
| 897 | 13 | servicer = slot; | |
| 898 |
1/2✓ Branch 45 → 46 taken 13 times.
✗ Branch 45 → 48 not taken.
|
14 | } |
| 899 | |||
| 900 |
2/2✓ Branch 47 → 49 taken 11 times.
✓ Branch 47 → 50 taken 2 times.
|
13 | if (servicer_created) |
| 901 | { | ||
| 902 | 11 | detail::emit_deferred_diagnostics(diags); | |
| 903 | } | ||
| 904 | |||
| 905 | input::BindingGuard guard = press_combo( | ||
| 906 | 13 | "Input", | |
| 907 | ini_key, | ||
| 908 | 13 | "Config reload hotkey", | |
| 909 | binding_name, | ||
| 910 |
1/2✓ Branch 52 → 53 taken 13 times.
✗ Branch 52 → 142 not taken.
|
26 | [servicer]() noexcept |
| 911 | { | ||
| 912 | // Press callbacks run on the poll thread and must return promptly. Defer the reload to the | ||
| 913 | // servicer thread. The shared_ptr capture keeps the servicer alive. | ||
| 914 | ✗ | if (servicer) | |
| 915 | { | ||
| 916 | ✗ | servicer->request_reload(); | |
| 917 | } | ||
| 918 | ✗ | }, | |
| 919 | default_combo, | ||
| 920 | 13 | std::nullopt | |
| 921 |
1/2✓ Branch 56 → 57 taken 13 times.
✗ Branch 56 → 138 not taken.
|
26 | ); |
| 922 | 13 | input::BindingGuard replaced_guard; | |
| 923 | 13 | bool replaced_existing = false; | |
| 924 | |||
| 925 | // Store the guard under the watcher mutex so its destructor does not disable the binding. Replace any | ||
| 926 | // prior guard for the same INI key. Release the replaced guard outside the mutex. A release under this | ||
| 927 | // mutex can wait on an unload drain whose callable disposal joins a worker that needs the same mutex. | ||
| 928 | { | ||
| 929 |
1/2✓ Branch 61 → 62 taken 13 times.
✗ Branch 61 → 151 not taken.
|
13 | std::lock_guard<std::mutex> lock(get_watcher_mutex()); |
| 930 |
1/2✗ Branch 63 → 64 not taken.
✓ Branch 63 → 65 taken 13 times.
|
13 | if (detail::background_reloads_disabled()) |
| 931 | { | ||
| 932 | ✗ | return false; | |
| 933 | } | ||
| 934 | 13 | auto &guards = get_reload_hotkey_guards(); | |
| 935 |
2/2✓ Branch 93 → 67 taken 2 times.
✓ Branch 93 → 94 taken 11 times.
|
26 | for (auto it = guards.begin(); it != guards.end(); ++it) |
| 936 | { | ||
| 937 |
1/2✓ Branch 72 → 73 taken 2 times.
✗ Branch 72 → 83 not taken.
|
4 | if (it->name() == binding_name) |
| 938 | { | ||
| 939 | 4 | replaced_guard = std::move(*it); | |
| 940 | 2 | replaced_existing = true; | |
| 941 |
1/2✓ Branch 81 → 82 taken 2 times.
✗ Branch 81 → 147 not taken.
|
2 | guards.erase(it); |
| 942 | 2 | break; | |
| 943 | } | ||
| 944 | } | ||
| 945 |
1/2✓ Branch 96 → 97 taken 13 times.
✗ Branch 96 → 149 not taken.
|
13 | guards.emplace_back(std::move(guard)); |
| 946 |
1/2✓ Branch 99 → 100 taken 13 times.
✗ Branch 99 → 102 not taken.
|
13 | } |
| 947 |
2/2✓ Branch 101 → 103 taken 2 times.
✓ Branch 101 → 105 taken 11 times.
|
13 | if (replaced_existing) |
| 948 | { | ||
| 949 | 2 | run_reload_hotkey_guard_disposal_probe(); | |
| 950 | 2 | replaced_guard.release(); | |
| 951 | } | ||
| 952 | |||
| 953 | 13 | return true; | |
| 954 | 18 | } | |
| 955 | } // namespace config | ||
| 956 | |||
| 957 | #if defined(DMK_ENABLE_TEST_SEAMS) | ||
| 958 | namespace detail | ||
| 959 | { | ||
| 960 | // Requests one servicer-thread reload without synthetic key input. Returns false if no servicer exists. | ||
| 961 | 3 | bool request_servicer_reload_for_test() noexcept | |
| 962 | { | ||
| 963 | 3 | std::shared_ptr<config::ReloadServicer> servicer; | |
| 964 | { | ||
| 965 | 3 | std::lock_guard<std::mutex> lock(config::get_watcher_mutex()); | |
| 966 | 3 | servicer = config::get_reload_servicer(); | |
| 967 | 3 | } | |
| 968 |
1/2✗ Branch 8 → 9 not taken.
✓ Branch 8 → 10 taken 3 times.
|
3 | if (!servicer) |
| 969 | { | ||
| 970 | ✗ | return false; | |
| 971 | } | ||
| 972 | 3 | servicer->request_reload(); | |
| 973 | 3 | return true; | |
| 974 | 3 | } | |
| 975 | |||
| 976 | 2 | void lock_config_watcher_mutex_for_test() noexcept | |
| 977 | { | ||
| 978 | 2 | std::lock_guard<std::mutex> lock(config::get_watcher_mutex()); | |
| 979 | 2 | } | |
| 980 | |||
| 981 | // Reports whether the watcher control mutex is free right now. A record producer cannot pass this probe under | ||
| 982 | // the same non-recursive mutex. | ||
| 983 | 46 | bool config_watcher_mutex_free_for_test() noexcept | |
| 984 | { | ||
| 985 | 46 | std::unique_lock<std::mutex> probe(config::get_watcher_mutex(), std::try_to_lock); | |
| 986 | 46 | return probe.owns_lock(); | |
| 987 | 46 | } | |
| 988 | } // namespace detail | ||
| 989 | #endif | ||
| 990 | } // namespace DetourModKit | ||
| 991 |