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