include/DetourModKit/session.hpp
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #ifndef DETOURMODKIT_SESSION_HPP | ||
| 2 | #define DETOURMODKIT_SESSION_HPP | ||
| 3 | |||
| 4 | /** | ||
| 5 | * @file session.hpp | ||
| 6 | * @brief Process-lifecycle surface: the RAII Session, the ModInfo descriptor, and the DllMain bootstrap entry points. | ||
| 7 | */ | ||
| 8 | |||
| 9 | #include "DetourModKit/async_logger_config.hpp" | ||
| 10 | #include "DetourModKit/config.hpp" | ||
| 11 | #include "DetourModKit/error.hpp" | ||
| 12 | #include "DetourModKit/input.hpp" | ||
| 13 | #include "DetourModKit/logger.hpp" | ||
| 14 | |||
| 15 | #include <chrono> | ||
| 16 | #include <cstdint> | ||
| 17 | #include <functional> | ||
| 18 | #include <span> | ||
| 19 | #include <string_view> | ||
| 20 | |||
| 21 | // HMODULE is `struct HINSTANCE__ *`. The incomplete tag exposes the handle type without <windows.h>. | ||
| 22 | struct HINSTANCE__; | ||
| 23 | |||
| 24 | namespace DetourModKit | ||
| 25 | { | ||
| 26 | /** | ||
| 27 | * @brief Opaque Win32 module handle, identical to HMODULE. | ||
| 28 | * @details This header does not include <windows.h>, so a real HMODULE binds to this alias with no cast. A | ||
| 29 | * consumer translation unit that needs <windows.h> for its own DllMain must include it directly. | ||
| 30 | */ | ||
| 31 | using ModuleHandle = ::HINSTANCE__ *; | ||
| 32 | |||
| 33 | namespace detail | ||
| 34 | { | ||
| 35 | struct SessionBootstrapAccess; | ||
| 36 | } // namespace detail | ||
| 37 | |||
| 38 | /** | ||
| 39 | * @struct ModInfo | ||
| 40 | * @brief Identity, single-instance gating, process gating, and async-logger settings for a mod. | ||
| 41 | * @details Every entry copies or borrows each field before return, so string literals suffice. @p name also | ||
| 42 | * supplies the logger prefix and mod identity. A non-empty @p game_process_name must match the process | ||
| 43 | * executable's basename (case-insensitive) or start() returns ErrorCode::ProcessMismatch. A non-empty | ||
| 44 | * @p instance_mutex_prefix creates a per-PID named mutex, so a second load of the same mod fails with | ||
| 45 | * ErrorCode::InstanceAlreadyRunning. There is no INI path here: the config registry is bind-then-load, | ||
| 46 | * so load the INI from on_ready via session.ini().load(path) after the binds exist. | ||
| 47 | */ | ||
| 48 | struct ModInfo | ||
| 49 | { | ||
| 50 | std::string_view name{}; | ||
| 51 | std::string_view log_file{}; | ||
| 52 | std::string_view game_process_name{}; | ||
| 53 | std::string_view instance_mutex_prefix{}; | ||
| 54 | AsyncLoggerConfig log{}; | ||
| 55 | /** | ||
| 56 | * @brief Selects the process-default logger's first sink open. See @ref LogOpenMode. | ||
| 57 | * @details Append preserves the prior generation's records across a staged-generation reload. | ||
| 58 | */ | ||
| 59 | LogOpenMode log_open_mode{LogOpenMode::Truncate}; | ||
| 60 | /// The source-location stamp policy for formatted records. The default retains Trace and Debug stamps. | ||
| 61 | LogSourceStampMode log_source_stamp_mode{}; | ||
| 62 | }; | ||
| 63 | |||
| 64 | /** | ||
| 65 | * @class Session | ||
| 66 | * @brief RAII owner of a mod's process lifetime: single-instance guard, logger configuration, input binding scope, | ||
| 67 | * and the ordered teardown of every process-wide subsystem. | ||
| 68 | * @details Session::start(ModInfo) is the synchronous, directly-held path. bootstrap_attach(ModInfo, on_ready) is | ||
| 69 | * the hosted path. The private release() path owns the teardown order. ~Session and active move-assignment | ||
| 70 | * call it. scope().clear() releases this session's input bindings first, in reverse insertion order. The | ||
| 71 | * process-wide subsystems then tear down | ||
| 72 | * in reverse dependency order. The order is the config auto-reload watcher, the input poll thread, the | ||
| 73 | * memory cache, the config registry, and the logger. The logger stays last because every prior step can | ||
| 74 | * still log. Each subsystem shutdown applies | ||
| 75 | * its own teardown gate: join when the caller is authorized and the loader-lock probe does not | ||
| 76 | * veto, otherwise abandon and retain. Hooks are not owned by the Session: each hook lives in a | ||
| 77 | * caller-held Hook handle and unhooks when that handle drops. | ||
| 78 | * @note A Session is move-only. A moved-from or abandon()ed Session is inert: its destructor does nothing. One | ||
| 79 | * Session is active at a time. A second start() returns ErrorCode::SessionAlreadyActive, and a second | ||
| 80 | * bootstrap entry returns the code that identifies the current bootstrap slot owner. | ||
| 81 | * @note Session::start, on_ready, ~Session, and abandon() run single-threaded on the init/teardown thread. Do not | ||
| 82 | * call them from a hook, an input callback, or a config-reload callback. | ||
| 83 | * @warning `[B-100]` Run Session::start, ~Session, and active move-assignment off the loader lock. Teardown invokes | ||
| 84 | * consumer release callbacks and joins worker threads. A DllMain caller must route both phases through | ||
| 85 | * bootstrap_attach and bootstrap_detach. See abandon() for the process-termination-only escape. | ||
| 86 | */ | ||
| 87 | class Session | ||
| 88 | { | ||
| 89 | public: | ||
| 90 | /** | ||
| 91 | * @brief Synchronously builds a Session: process gate, single-instance mutex, and logger configuration. | ||
| 92 | * @param info Mod identity, gating, and async-logger settings. | ||
| 93 | * @return A live Session on success, or an ErrorCode-bearing failure: ProcessMismatch (wrong executable), | ||
| 94 | * InstanceAlreadyRunning (a duplicate load holds the mutex), SessionAlreadyActive (a session already | ||
| 95 | * exists in this process), SystemCallFailed (a Win32 lifecycle operation failed; Error::detail = | ||
| 96 | * GetLastError()), OutOfMemory (setup threw std::bad_alloc), or Unknown (setup threw anything else). | ||
| 97 | * @note Setup/control-plane only. Logger configuration exceptions map to Result failures. A refused async-mode | ||
| 98 | * activation is contained inside the logger and does not fail start: the session starts with synchronous | ||
| 99 | * logging. | ||
| 100 | * @warning See the class `[B-100]` loader-lock warning. | ||
| 101 | */ | ||
| 102 | [[nodiscard]] static Result<Session> start(const ModInfo &info) noexcept; | ||
| 103 | |||
| 104 | /** @brief Move-constructs, transferring the live teardown; the moved-from Session is left inert. */ | ||
| 105 | Session(Session &&other) noexcept; | ||
| 106 | /** | ||
| 107 | * @brief Move-assigns: ends this Session (ordered teardown) if it was active, then adopts @p other. | ||
| 108 | * @note Setup/control-plane only. An active overwrite runs ordered teardown. | ||
| 109 | * @warning See the class `[B-100]` loader-lock warning. | ||
| 110 | */ | ||
| 111 | Session &operator=(Session &&other) noexcept; | ||
| 112 | /** @brief Deleted: Session is move-only; its teardown and single-instance guard cannot be copied. */ | ||
| 113 | Session(const Session &) = delete; | ||
| 114 | Session &operator=(const Session &) = delete; | ||
| 115 | |||
| 116 | /** | ||
| 117 | * @brief Runs the ordered teardown if this Session is active; otherwise a no-op (moved-from / abandoned). | ||
| 118 | * @note Setup/control-plane only. Teardown clears the scope and shuts each subsystem down in order. | ||
| 119 | * @warning See the class `[B-100]` loader-lock warning. | ||
| 120 | */ | ||
| 121 | ~Session() noexcept; | ||
| 122 | |||
| 123 | /** | ||
| 124 | * @brief The process-default logger this session configured. Convenience for `DetourModKit::log()`. | ||
| 125 | */ | ||
| 126 | [[nodiscard]] Logger &log() const noexcept; | ||
| 127 | |||
| 128 | /** | ||
| 129 | * @brief A handle to the process configuration registry. Load the mod's INI here (after registering binds): | ||
| 130 | * `session.ini().load(path)`. | ||
| 131 | */ | ||
| 132 | [[nodiscard]] config::Ini ini() const noexcept; | ||
| 133 | |||
| 134 | /** | ||
| 135 | * @brief The process input manager. Convenience for `input::Input::instance()`. | ||
| 136 | */ | ||
| 137 | [[nodiscard]] input::Input &input() const noexcept; | ||
| 138 | |||
| 139 | /** | ||
| 140 | * @brief This session's input binding scope. Add BindingGuards here; ~Session clears it first (reverse order). | ||
| 141 | */ | ||
| 142 | [[nodiscard]] input::Scope &scope() noexcept; | ||
| 143 | |||
| 144 | /** | ||
| 145 | * @brief True while this Session owns a live teardown; false once moved-from or abandon()ed. | ||
| 146 | */ | ||
| 147 | 7 | [[nodiscard]] explicit operator bool() const noexcept { return m_active; } | |
| 148 | |||
| 149 | /** | ||
| 150 | * @brief Neutralizes the Session so its destructor does NOTHING: no scope clear, no subsystem teardown, no | ||
| 151 | * unhook, no logger flush, no thread join. | ||
| 152 | * @details For DLL_PROCESS_DETACH with `lpReserved != NULL` (process termination) ONLY. On that path the OS has | ||
| 153 | * already terminated every other thread and is reclaiming the address space, so touching patched | ||
| 154 | * pages, flushing the logger, or joining a dead thread is at best pointless and at worst a UAF. | ||
| 155 | * abandon() retains teardown-sensitive ownership untouched and lets the OS reclaim it at exit. Never | ||
| 156 | * call it for an explicit FreeLibrary (lpReserved == NULL), where a real ordered teardown must run. | ||
| 157 | * @note Setup/control-plane only: a process-termination detach path (see details). | ||
| 158 | */ | ||
| 159 | void abandon() noexcept; | ||
| 160 | |||
| 161 | private: | ||
| 162 | friend struct detail::SessionBootstrapAccess; | ||
| 163 | |||
| 164 | // start() and the bootstrap access bridge both build the Session here. The Session owns the single-instance | ||
| 165 | // mutex until release(). A null instance_mutex means ModInfo requested no guard. | ||
| 166 | explicit Session(void *instance_mutex) noexcept; | ||
| 167 | |||
| 168 | // The ordered teardown shared by ~Session and active move-assignment. It closes the owned mutex. Idempotent and | ||
| 169 | // inert-safe. | ||
| 170 | void release() noexcept; | ||
| 171 | |||
| 172 | // The mod's input bindings; cleared first in ~Session. Move-only, default-constructible: keeps Session movable. | ||
| 173 | input::Scope m_scope; | ||
| 174 | // The single-instance mutex handle (or null). release() closes it. The void pointer keeps this public header | ||
| 175 | // free of <windows.h>. | ||
| 176 | void *m_instance_mutex{nullptr}; | ||
| 177 | // Gates the destructor. Transferred on move (source becomes inert), cleared by abandon(). | ||
| 178 | bool m_active{false}; | ||
| 179 | }; | ||
| 180 | |||
| 181 | /// Defines the plain initialization callback accepted by bootstrap_attach(). | ||
| 182 | using BootstrapReadyFn = Result<void> (*)(Session &); | ||
| 183 | |||
| 184 | /** | ||
| 185 | * @brief DllMain DLL_PROCESS_ATTACH entry point: publishes a minimal attach, then starts the Session on a worker. | ||
| 186 | * @details Auto-captures the module that contains the call because DetourModKit links statically into the mod DLL. | ||
| 187 | * It calls DisableThreadLibraryCalls and performs the process and single-instance gates without heap | ||
| 188 | * allocation. It copies the logger inputs into fixed bootstrap storage and creates the shutdown event and | ||
| 189 | * worker. The worker configures the logger and runs @p on_ready(session) off the loader lock. There it may | ||
| 190 | * allocate, load INIs, install hooks, and register bindings into session.scope(). It then blocks on the | ||
| 191 | * event until bootstrap_detach(), request_shutdown(), or shutdown_and_wait() wakes it, and destroys the | ||
| 192 | * Session off the loader lock. The worker logs an @p on_ready failure as a value. | ||
| 193 | * @param info Mod identity, gating, and async-logger settings. | ||
| 194 | * @param on_ready Called once on the worker thread with the live Session. A null value registers no callback. | ||
| 195 | * @return An empty Result once the worker is published, or ProcessMismatch, InstanceAlreadyRunning, | ||
| 196 | * SessionAlreadyActive, InvalidArg, SessionShutdownInProgress, SessionShutdownUnavailable, or | ||
| 197 | * SystemCallFailed. Pre-publication failures roll back the mutex and lifecycle slot. | ||
| 198 | * @note The synchronous phase calls no logger, callback, or wait. No exception crosses the loader lock. | ||
| 199 | * @note Setup/control-plane only: the DllMain attach entry point. | ||
| 200 | */ | ||
| 201 | [[nodiscard]] Result<void> bootstrap_attach(const ModInfo &info, BootstrapReadyFn on_ready) noexcept; | ||
| 202 | |||
| 203 | /** | ||
| 204 | * @brief Provides the rich bootstrap callback form for callers outside DllMain. | ||
| 205 | * @details This entry uses bootstrap_attach() infrastructure. Its callback can own move-only setup state. | ||
| 206 | * @param info Mod identity, gates, and asynchronous logger settings. | ||
| 207 | * @param on_ready Called once on the worker thread with the live Session. See bootstrap_attach(). | ||
| 208 | * @return See bootstrap_attach(). | ||
| 209 | * @warning Do not call this entry from DllMain. Callable conversion can allocate at the call site. | ||
| 210 | * A pre-publication failure destroys the callable and its captures on the current thread. | ||
| 211 | * @note Setup/control-plane only: the off-DllMain attach entry point. | ||
| 212 | */ | ||
| 213 | [[nodiscard]] Result<void> | ||
| 214 | bootstrap(const ModInfo &info, std::move_only_function<Result<void>(Session &)> on_ready) noexcept; | ||
| 215 | |||
| 216 | /** | ||
| 217 | * @brief DllMain DLL_PROCESS_DETACH entry point. Routes by @p reserved (DllMain's lpvReserved). | ||
| 218 | * @details Two paths, both loader-lock-safe (neither waits nor joins): | ||
| 219 | * | ||
| 220 | * - @p reserved == NULL (explicit FreeLibrary): publishes LoaderDetach and returns without a wait, join, | ||
| 221 | * or callback-state destruction. The worker's counted module reference blocks this notification from | ||
| 222 | * a bare FreeLibrary while the worker is live. A mod that needs a guaranteed-drained | ||
| 223 | * unload must call shutdown_and_wait() before FreeLibrary. | ||
| 224 | * - @p reserved != NULL (process termination): the OS has already killed the worker, so this takes the | ||
| 225 | * abandon path - no teardown, no unhook, no flush, no join. | ||
| 226 | * | ||
| 227 | * Idempotent: subsequent calls are no-ops. | ||
| 228 | * @param reserved DllMain's lpvReserved (NULL for FreeLibrary, non-NULL for process exit). | ||
| 229 | * @note Setup/control-plane only: call it solely from DllMain's DLL_PROCESS_DETACH path. | ||
| 230 | */ | ||
| 231 | void bootstrap_detach(void *reserved) noexcept; | ||
| 232 | |||
| 233 | /** | ||
| 234 | * @brief Requests asynchronous teardown of the bootstrap worker. | ||
| 235 | * @details A no-op if bootstrap() never ran or teardown already completed. This function does not wait and | ||
| 236 | * therefore does not guarantee teardown has completed before a subsequent FreeLibrary; use | ||
| 237 | * shutdown_and_wait() when the module must be fully drained first. | ||
| 238 | * @note Callback-safe: safe from any thread (a hook, an input callback, or DllMain). It only signals an event and | ||
| 239 | * never allocates, waits, or joins. | ||
| 240 | */ | ||
| 241 | void request_shutdown() noexcept; | ||
| 242 | |||
| 243 | /** | ||
| 244 | * @brief Signals the bootstrap worker and waits for its complete off-loader-lock teardown. | ||
| 245 | * @details On success the worker has exited, released its counted module reference, and drained every Session-owned | ||
| 246 | * subsystem. The call is idempotent after a completed drain or when bootstrap() never started. A | ||
| 247 | * concurrent drain is reported instead of returning before the first caller has finished. | ||
| 248 | * @return Success after a complete drain; SessionShutdownInProgress when another control thread already owns the | ||
| 249 | * drain or a bootstrap attach is concurrently claiming the slot; SessionShutdownUnavailable after DllMain | ||
| 250 | * detach has claimed the state; SessionShutdownWouldBlock when the loader phase forbids waiting or the | ||
| 251 | * caller is the bootstrap worker itself; or SystemCallFailed when waiting on the worker handle fails | ||
| 252 | * (Error::detail = GetLastError()). | ||
| 253 | * @note Setup/control-plane only. Call before FreeLibrary, never from DllMain, a hook, or an input callback. | ||
| 254 | * @warning Called ON the bootstrap worker (from @p on_ready, or from a callback the worker's teardown reaches) this | ||
| 255 | * returns SessionShutdownWouldBlock rather than waiting, because the wait is for the calling thread's | ||
| 256 | * own exit. Use request_shutdown() to retire the session from that thread. | ||
| 257 | */ | ||
| 258 | [[nodiscard]] Result<void> shutdown_and_wait() noexcept; | ||
| 259 | |||
| 260 | /** | ||
| 261 | * @brief The module handle captured at bootstrap() time, or nullptr before bootstrap(), after bootstrap_detach(), | ||
| 262 | * after a successful shutdown_and_wait(), or when only the synchronous Session::start path was used. | ||
| 263 | * @note A completed drain retires the identity along with the rest of the generation, so capture the handle BEFORE | ||
| 264 | * shutdown_and_wait() if the unload sequence needs it afterwards. | ||
| 265 | * @note Callback-safe: published and read through a lock-free atomic, so a reader on any thread observes only the | ||
| 266 | * current identity or null and never races a concurrent detach-path clear. | ||
| 267 | */ | ||
| 268 | [[nodiscard]] ModuleHandle module_handle() noexcept; | ||
| 269 | |||
| 270 | /** | ||
| 271 | * @enum LogicDllUnloadStatus | ||
| 272 | * @brief Typed result of preparing consumer-owned callback state for a Logic DLL unmap. | ||
| 273 | */ | ||
| 274 | enum class LogicDllUnloadStatus : std::uint8_t | ||
| 275 | { | ||
| 276 | /// With the documented caller-owned preconditions met, DMK no longer blocks unmapping the Logic DLL. | ||
| 277 | SafeToUnload, | ||
| 278 | /// The Windows loader lock forbids the waits and joins required to certify safe unmapping. | ||
| 279 | LoaderLock, | ||
| 280 | /// The caller is executing inside an input or config callback and cannot drain itself. | ||
| 281 | SelfDelivery, | ||
| 282 | /// Another control thread owns the safe-drain transaction. | ||
| 283 | InProgress, | ||
| 284 | /// Selected input bindings were not retired. | ||
| 285 | RetireFailed, | ||
| 286 | /// The deadline expired while a callback or worker body remained alive. | ||
| 287 | TimedOut | ||
| 288 | }; | ||
| 289 | |||
| 290 | /// Default deadline for Logic DLL safe-unload preparation. | ||
| 291 | inline constexpr std::chrono::milliseconds DEFAULT_LOGIC_DLL_DRAIN_TIMEOUT{500}; | ||
| 292 | |||
| 293 | /** | ||
| 294 | * @brief Retires named input bindings and config callbacks before a Logic DLL is unmapped. | ||
| 295 | * @param binding_names Names registered by the Logic DLL. | ||
| 296 | * @param timeout Deadline for the rundown waits. It bounds how long the drain waits for in-flight callbacks and | ||
| 297 | * worker bodies, not the consumer code it then runs: a retired hold's balancing edge and the callable's | ||
| 298 | * capture destructors execute after the deadline is spent and are unbounded. | ||
| 299 | * @return SafeToUnload only after every callable copy DMK still owns for the named bindings, and every config | ||
| 300 | * setter from the old lifecycle, is gone. Retirement reaches the callback through the binding's delivery | ||
| 301 | * gate, so an outstanding BindingGuard does not keep one alive; a still-held Hold binding receives its | ||
| 302 | * balancing on_state_change(false) during the drain, while the DLL is still mapped. A balancing edge | ||
| 303 | * already running from a concurrent guard release is part of the same rundown. | ||
| 304 | * @note A guard retained across a successful drain stays valid and still lifts its binding's passthrough | ||
| 305 | * suppression when released, but no longer reaches the callback: the drain already delivered the hold's | ||
| 306 | * balancing edge and destroyed the callable. | ||
| 307 | * @note Setup/control-plane only. Call from an off-loader-lock shutdown thread after stopping consumer-owned | ||
| 308 | * workers and dropping dispatcher subscriptions and hook handles. | ||
| 309 | * @warning The drain runs your balancing callbacks and capture destructors on this thread, after the deadline is | ||
| 310 | * spent, and a BindingGuard release racing it blocks untimed until they finish. Neither wait is bounded, | ||
| 311 | * so hold no lock, and own no join, that any of that code can wait on. | ||
| 312 | */ | ||
| 313 | [[nodiscard]] LogicDllUnloadStatus prepare_logic_dll_unload( | ||
| 314 | std::span<const std::string_view> binding_names, | ||
| 315 | std::chrono::milliseconds timeout = DEFAULT_LOGIC_DLL_DRAIN_TIMEOUT | ||
| 316 | ) noexcept; | ||
| 317 | |||
| 318 | /** | ||
| 319 | * @brief Retires every input binding and all config callbacks before Logic DLLs are unmapped. | ||
| 320 | * @param timeout Deadline for the rundown waits, as prepare_logic_dll_unload documents. | ||
| 321 | * @return SafeToUnload only after every callable copy DMK still owns, and every config setter, is gone. Retirement | ||
| 322 | * reaches callbacks through their delivery gates, as prepare_logic_dll_unload documents. | ||
| 323 | * @note Setup/control-plane only. Call from an off-loader-lock shutdown thread, as prepare_logic_dll_unload | ||
| 324 | * documents. | ||
| 325 | * @warning The unbounded-consumer-code warning on prepare_logic_dll_unload applies here unchanged. | ||
| 326 | * @warning In a multi-Logic-DLL host this retires bindings belonging to every Logic DLL. | ||
| 327 | */ | ||
| 328 | [[nodiscard]] LogicDllUnloadStatus | ||
| 329 | prepare_logic_dll_unload_all(std::chrono::milliseconds timeout = DEFAULT_LOGIC_DLL_DRAIN_TIMEOUT) noexcept; | ||
| 330 | |||
| 331 | /** | ||
| 332 | * @brief Source-compatible best-effort abandon wrapper. | ||
| 333 | * @details Off the loader lock it attempts prepare_logic_dll_unload. Under the loader lock it only closes new | ||
| 334 | * callback admission and requests no blocking rundown. | ||
| 335 | * @warning This void result never authorizes FreeLibrary. Use prepare_logic_dll_unload and require SafeToUnload. | ||
| 336 | * @note Best-effort: the wrapper fails closed and reports nothing. | ||
| 337 | */ | ||
| 338 | void on_logic_dll_unload(std::span<const std::string_view> binding_names) noexcept; | ||
| 339 | |||
| 340 | /** | ||
| 341 | * @brief Source-compatible best-effort abandon wrapper for every binding. | ||
| 342 | * @warning This void result never authorizes FreeLibrary. Use prepare_logic_dll_unload_all and require | ||
| 343 | * SafeToUnload. | ||
| 344 | * @note Best-effort: the wrapper fails closed and reports nothing. | ||
| 345 | */ | ||
| 346 | void on_logic_dll_unload_all() noexcept; | ||
| 347 | } // namespace DetourModKit | ||
| 348 | |||
| 349 | #endif // DETOURMODKIT_SESSION_HPP | ||
| 350 |