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 | struct HINSTANCE__; | ||
| 22 | |||
| 23 | namespace DetourModKit | ||
| 24 | { | ||
| 25 | /** @brief Opaque Win32 module handle, identical to HMODULE. This header does not include <windows.h>. */ | ||
| 26 | using ModuleHandle = ::HINSTANCE__ *; | ||
| 27 | |||
| 28 | namespace detail | ||
| 29 | { | ||
| 30 | struct SessionBootstrapAccess; | ||
| 31 | } // namespace detail | ||
| 32 | |||
| 33 | /** | ||
| 34 | * @brief Identity, single-instance gating, process gating, and async-logger settings for a mod. | ||
| 35 | * @details Every entry point copies each field that it keeps before it returns, so a field needs to outlive only | ||
| 36 | * the call. @p name is the mod identity and the logger prefix. A non-empty @p game_process_name must match | ||
| 37 | * the executable basename, or start() returns ErrorCode::ProcessMismatch. The match folds case on UTF-8 | ||
| 38 | * through CompareStringOrdinal and ignores the C runtime locale. A non-empty @p instance_mutex_prefix | ||
| 39 | * creates a per-PID named mutex, so a second load of the mod fails with ErrorCode::InstanceAlreadyRunning. | ||
| 40 | */ | ||
| 41 | struct ModInfo | ||
| 42 | { | ||
| 43 | std::string_view name{}; | ||
| 44 | std::string_view log_file{}; | ||
| 45 | std::string_view game_process_name{}; | ||
| 46 | std::string_view instance_mutex_prefix{}; | ||
| 47 | AsyncLoggerConfig log{}; | ||
| 48 | /** @brief Selects the process-default logger's first sink open. See @ref LogOpenMode. */ | ||
| 49 | LogOpenMode log_open_mode{LogOpenMode::Truncate}; | ||
| 50 | /// The source-location stamp policy for formatted records. See @ref LogSourceStampMode. | ||
| 51 | LogSourceStampMode log_source_stamp_mode{}; | ||
| 52 | }; | ||
| 53 | |||
| 54 | /** | ||
| 55 | * @brief Owns a mod's process lifetime: single-instance guard, logger configuration, input scope, and teardown. | ||
| 56 | * @details ~Session and active move-assignment run the ordered teardown. First, scope().clear() releases this | ||
| 57 | * session's input bindings in reverse insertion order. The process-wide subsystems then tear down in | ||
| 58 | * reverse dependency order. The order is the config auto-reload watcher, the input poll thread, the memory | ||
| 59 | * cache, the config registry, the diagnostics TLS index, and the logger. Under the loader lock, or in an | ||
| 60 | * attach or unload phase off the bootstrap worker, no subsystem shutdown joins a thread. The Session owns | ||
| 61 | * no Hook: a Hook handle unhooks when the caller drops it. | ||
| 62 | * @note A moved-from or abandon()ed Session is inert: its destructor does nothing. One Session is active at a time. | ||
| 63 | * A second start() returns ErrorCode::SessionAlreadyActive, and a second bootstrap entry returns the code | ||
| 64 | * that identifies the current bootstrap slot owner. | ||
| 65 | * @note The init/teardown thread runs Session::start, on_ready, ~Session, and abandon() single-threaded. Do not | ||
| 66 | * call them from a hook, an input callback, or a config-reload callback. | ||
| 67 | * @warning `[B-100]` Run Session::start, ~Session, and active move-assignment off the loader lock. Teardown invokes | ||
| 68 | * consumer release callbacks and joins worker threads. A DllMain caller must route both phases through | ||
| 69 | * bootstrap_attach and bootstrap_detach. See abandon() for the process-termination-only escape. | ||
| 70 | */ | ||
| 71 | class Session | ||
| 72 | { | ||
| 73 | public: | ||
| 74 | /** | ||
| 75 | * @brief Synchronously builds a Session: process gate, single-instance mutex, and logger configuration. | ||
| 76 | * @return The live Session, or ProcessMismatch, InstanceAlreadyRunning, or SessionAlreadyActive (see ModInfo | ||
| 77 | * and Session). A failed Win32 lifecycle call, or an instance_mutex_prefix longer than 32757 bytes, | ||
| 78 | * returns SystemCallFailed with a Win32 error code in Error::detail. A thrown std::bad_alloc maps to | ||
| 79 | * OutOfMemory, and any other exception maps to Unknown. | ||
| 80 | * @note Setup/control-plane only. If the logger refuses async mode, start succeeds with synchronous logging. | ||
| 81 | * @warning See the class `[B-100]` loader-lock warning. | ||
| 82 | */ | ||
| 83 | [[nodiscard]] static Result<Session> start(const ModInfo &info) noexcept; | ||
| 84 | |||
| 85 | /** @brief Transfers the live teardown and leaves @p other inert. */ | ||
| 86 | Session(Session &&other) noexcept; | ||
| 87 | /** | ||
| 88 | * @brief Runs the ordered teardown if this Session is active, then adopts @p other. | ||
| 89 | * @note Setup/control-plane only. | ||
| 90 | * @warning See the class `[B-100]` loader-lock warning. | ||
| 91 | */ | ||
| 92 | Session &operator=(Session &&other) noexcept; | ||
| 93 | /** @brief Deleted: the teardown and the single-instance guard have one owner. */ | ||
| 94 | Session(const Session &) = delete; | ||
| 95 | Session &operator=(const Session &) = delete; | ||
| 96 | |||
| 97 | /** | ||
| 98 | * @brief Runs the ordered teardown if this Session is active. | ||
| 99 | * @note Setup/control-plane only. | ||
| 100 | * @warning See the class `[B-100]` loader-lock warning. | ||
| 101 | */ | ||
| 102 | ~Session() noexcept; | ||
| 103 | |||
| 104 | /** @brief The process-default logger that this session configured, the same as `DetourModKit::log()`. */ | ||
| 105 | [[nodiscard]] Logger &log() const noexcept; | ||
| 106 | |||
| 107 | /** @brief A handle to the process configuration registry. After the binds exist, call `load(path)` on it. */ | ||
| 108 | [[nodiscard]] config::Ini ini() const noexcept; | ||
| 109 | |||
| 110 | /** @brief The process input manager, the same as `input::Input::instance()`. */ | ||
| 111 | [[nodiscard]] input::Input &input() const noexcept; | ||
| 112 | |||
| 113 | /** @brief This session's input binding scope for BindingGuards. */ | ||
| 114 | [[nodiscard]] input::Scope &scope() noexcept; | ||
| 115 | |||
| 116 | /** @brief True while this Session is active, false once it is moved-from or abandon()ed. */ | ||
| 117 | 7 | [[nodiscard]] explicit operator bool() const noexcept { return m_active; } | |
| 118 | |||
| 119 | /** | ||
| 120 | * @brief Makes the Session inert: its destructor runs no scope clear, teardown, flush, or join. | ||
| 121 | * @details Call it only for DLL_PROCESS_DETACH with `lpReserved != NULL` (process termination), where a | ||
| 122 | * teardown risks a use after free. Never call it for an explicit FreeLibrary (`lpReserved == NULL`). | ||
| 123 | * @note Setup/control-plane only. | ||
| 124 | */ | ||
| 125 | void abandon() noexcept; | ||
| 126 | |||
| 127 | private: | ||
| 128 | friend struct detail::SessionBootstrapAccess; | ||
| 129 | |||
| 130 | // The Session owns instance_mutex until release(). Null means that ModInfo requested no guard. | ||
| 131 | explicit Session(void *instance_mutex) noexcept; | ||
| 132 | |||
| 133 | void release() noexcept; | ||
| 134 | |||
| 135 | input::Scope m_scope; | ||
| 136 | void *m_instance_mutex{nullptr}; | ||
| 137 | bool m_active{false}; | ||
| 138 | }; | ||
| 139 | |||
| 140 | /// The plain on_ready callback type of bootstrap_attach(). | ||
| 141 | using BootstrapReadyFn = Result<void> (*)(Session &); | ||
| 142 | |||
| 143 | /** | ||
| 144 | * @brief DllMain DLL_PROCESS_ATTACH entry point: runs the loader-safe gates, then starts the Session on a worker. | ||
| 145 | * @details It captures the handle of the mod DLL that links this DetourModKit copy. It calls | ||
| 146 | * DisableThreadLibraryCalls and runs the process and single-instance gates without heap allocation. The | ||
| 147 | * worker configures the logger and runs @p on_ready off the loader lock. There it can allocate, load INIs, | ||
| 148 | * install hooks, and register bindings into session.scope(). It then waits until bootstrap_detach(), | ||
| 149 | * request_shutdown(), or shutdown_and_wait() wakes it, and destroys the Session off the loader lock. If | ||
| 150 | * @p on_ready fails, the worker logs the error and still waits, with the Session live and the DLL mapped. | ||
| 151 | * @param on_ready Called once on the worker thread with the live Session. A null value registers no callback. | ||
| 152 | * @return An empty Result once the worker is published, or ProcessMismatch, InstanceAlreadyRunning, | ||
| 153 | * SessionAlreadyActive, InvalidArg, SessionShutdownInProgress, SessionShutdownUnavailable, or | ||
| 154 | * SystemCallFailed. InvalidArg means that ModInfo::name or ModInfo::log_file is longer than 32768 bytes. | ||
| 155 | * Pre-publication failures roll back the mutex and the bootstrap slot. | ||
| 156 | * @note Setup/control-plane only. The synchronous phase calls no logger, callback, or wait. | ||
| 157 | */ | ||
| 158 | [[nodiscard]] Result<void> bootstrap_attach(const ModInfo &info, BootstrapReadyFn on_ready) noexcept; | ||
| 159 | |||
| 160 | /** | ||
| 161 | * @brief The off-DllMain form of bootstrap_attach(), with the same results. Its callable can own move-only state. | ||
| 162 | * @note Setup/control-plane only. | ||
| 163 | * @warning Do not call it from DllMain. The callable conversion can allocate at the call site. A pre-publication | ||
| 164 | * failure destroys the callable and its captures on the current thread. | ||
| 165 | */ | ||
| 166 | [[nodiscard]] Result<void> | ||
| 167 | bootstrap(const ModInfo &info, std::move_only_function<Result<void>(Session &)> on_ready) noexcept; | ||
| 168 | |||
| 169 | /** | ||
| 170 | * @brief DllMain DLL_PROCESS_DETACH entry point. Pass DllMain's lpvReserved as @p reserved. | ||
| 171 | * @details If @p reserved is NULL (explicit FreeLibrary), it destroys no callback state. From a Ready slot, it | ||
| 172 | * also signals the worker and retires the slot permanently. While the worker is live, a bare | ||
| 173 | * FreeLibrary cannot reach this call, because the worker holds a counted module reference. For a drained | ||
| 174 | * unload, call shutdown_and_wait() before FreeLibrary. | ||
| 175 | * | ||
| 176 | * If @p reserved is not NULL (process termination), the OS already killed the worker, so this path skips | ||
| 177 | * the teardown, flush, and join. Neither path waits or joins, so both are loader-lock-safe. Both publish | ||
| 178 | * an unload phase, so a later subsystem shutdown off the bootstrap worker joins no thread. | ||
| 179 | * @note Setup/control-plane only. Call it only from DllMain's DLL_PROCESS_DETACH path. A later call only | ||
| 180 | * republishes the unload phase. | ||
| 181 | */ | ||
| 182 | void bootstrap_detach(void *reserved) noexcept; | ||
| 183 | |||
| 184 | /** | ||
| 185 | * @brief Requests asynchronous teardown of the bootstrap worker. | ||
| 186 | * @details It does nothing if no bootstrap entry ran or the teardown completed. To drain the module before | ||
| 187 | * FreeLibrary, use shutdown_and_wait(). | ||
| 188 | * @note Callback-safe: call it from any thread (a hook, an input callback, or DllMain). It only signals an event | ||
| 189 | * and never allocates, waits, or joins. | ||
| 190 | */ | ||
| 191 | void request_shutdown() noexcept; | ||
| 192 | |||
| 193 | /** | ||
| 194 | * @brief Signals the bootstrap worker and waits for its complete off-loader-lock teardown. | ||
| 195 | * @details If a bootstrap worker is live, success means that the worker exited, released its counted module | ||
| 196 | * reference, and drained every Session-owned subsystem. After a completed drain, or with no bootstrap | ||
| 197 | * entry, the call returns success and does nothing. | ||
| 198 | * @return Success after a complete drain. SessionShutdownInProgress if another control thread owns the drain or a | ||
| 199 | * bootstrap entry claims the slot. SessionShutdownUnavailable if DllMain detach claimed the slot. | ||
| 200 | * SessionShutdownWouldBlock if the calling thread holds the loader lock or is the bootstrap worker. | ||
| 201 | * SystemCallFailed with Error::detail = GetLastError() if the worker-handle wait fails. | ||
| 202 | * @note Setup/control-plane only. Call it before FreeLibrary, never from DllMain, a hook, or an input callback. | ||
| 203 | * @warning On the bootstrap worker (in on_ready or a callback that its teardown reaches), use request_shutdown(). | ||
| 204 | */ | ||
| 205 | [[nodiscard]] Result<void> shutdown_and_wait() noexcept; | ||
| 206 | |||
| 207 | /** | ||
| 208 | * @brief The HMODULE of the module that links this DetourModKit copy, or nullptr outside the session window. | ||
| 209 | * @details Session::start and a bootstrap entry publish it after the process and instance gates pass, before their | ||
| 210 | * fallible setup completes. A concurrent reader can observe it before the call returns, and a failure in | ||
| 211 | * that setup clears it. It is null before either call, after ~Session, after bootstrap_detach(), and after | ||
| 212 | * a successful shutdown_and_wait(). To use the handle after ~Session or a drain, capture it first. | ||
| 213 | * SessionStart.PublishesModuleIdentityForTheSessionLifetime proves the synchronous path. | ||
| 214 | * @note Callback-safe: a lock-free atomic load returns the current identity or null, even during a detach. | ||
| 215 | */ | ||
| 216 | [[nodiscard]] ModuleHandle module_handle() noexcept; | ||
| 217 | |||
| 218 | /** @brief The status of a Logic DLL unload preparation for consumer-owned callback state. */ | ||
| 219 | enum class LogicDllUnloadStatus : std::uint8_t | ||
| 220 | { | ||
| 221 | /// With the caller-owned preconditions met, DMK holds nothing that blocks the Logic DLL unmap. | ||
| 222 | SafeToUnload, | ||
| 223 | /// The loader lock, or an attach or unload phase off the bootstrap worker, forbids the drain waits and joins. | ||
| 224 | LoaderLock, | ||
| 225 | /// The caller runs inside an input or config callback and cannot drain itself. | ||
| 226 | SelfDelivery, | ||
| 227 | /// Another control thread owns the safe-drain transaction. | ||
| 228 | InProgress, | ||
| 229 | /// Selected input bindings were not retired. | ||
| 230 | RetireFailed, | ||
| 231 | /// The deadline expired while a callback or worker body remained alive. | ||
| 232 | TimedOut | ||
| 233 | }; | ||
| 234 | |||
| 235 | /// Default deadline for Logic DLL safe-unload preparation. | ||
| 236 | inline constexpr std::chrono::milliseconds DEFAULT_LOGIC_DLL_DRAIN_TIMEOUT{500}; | ||
| 237 | |||
| 238 | /** | ||
| 239 | * @brief Retires the named input bindings and all config callbacks before a Logic DLL is unmapped. | ||
| 240 | * @details A completed config drain also stops auto-reload and the reload hotkey, then runs config::clear(). | ||
| 241 | * @param timeout Bounds the waits for in-flight callbacks and worker bodies, not the consumer code in the warning. | ||
| 242 | * @return SafeToUnload only after every callable copy that DMK still owns for the named bindings, and every config | ||
| 243 | * setter from the old lifecycle, is gone. An outstanding BindingGuard does not keep a callback alive. A | ||
| 244 | * still-held Hold binding receives its balancing on_state_change(false) during the drain, while the DLL is | ||
| 245 | * still mapped. The drain includes a balancing edge that a concurrent guard release already started. | ||
| 246 | * @note A guard retained across a successful drain stays valid and still lifts its binding's passthrough | ||
| 247 | * suppression when released. | ||
| 248 | * @note Setup/control-plane only. Before you call it, stop consumer-owned workers. Retire each hook::HookStack | ||
| 249 | * through hook::HookStack::retire. Drop dispatcher subscriptions and the remaining hook handles. Call it from | ||
| 250 | * an off-loader-lock shutdown thread. | ||
| 251 | * @warning The drain runs your balancing callbacks and capture destructors on this thread, after the deadline is | ||
| 252 | * spent. A BindingGuard release that races the drain blocks until they finish. Neither wait is bounded, so | ||
| 253 | * hold no lock and own no join that any of that code can wait on. | ||
| 254 | */ | ||
| 255 | [[nodiscard]] LogicDllUnloadStatus prepare_logic_dll_unload( | ||
| 256 | std::span<const std::string_view> binding_names, | ||
| 257 | std::chrono::milliseconds timeout = DEFAULT_LOGIC_DLL_DRAIN_TIMEOUT | ||
| 258 | ) noexcept; | ||
| 259 | |||
| 260 | /** | ||
| 261 | * @brief Retires every input binding and all config callbacks before Logic DLLs are unmapped. | ||
| 262 | * @return SafeToUnload only after every callable copy that DMK still owns, and every config setter, is gone. | ||
| 263 | * @note Setup/control-plane only. Every precondition and rule of prepare_logic_dll_unload applies. | ||
| 264 | * @warning The prepare_logic_dll_unload warning applies. It retires the bindings of every Logic DLL in the host. | ||
| 265 | */ | ||
| 266 | [[nodiscard]] LogicDllUnloadStatus | ||
| 267 | prepare_logic_dll_unload_all(std::chrono::milliseconds timeout = DEFAULT_LOGIC_DLL_DRAIN_TIMEOUT) noexcept; | ||
| 268 | |||
| 269 | /** | ||
| 270 | * @brief Calls prepare_logic_dll_unload with the default deadline and discards the status. | ||
| 271 | * @details If blocking teardown is not permitted on this thread, it closes new callback admission, disables config | ||
| 272 | * reloads, and does not wait. | ||
| 273 | * @note Best-effort: the wrapper fails closed. | ||
| 274 | * @warning This void result never authorizes FreeLibrary. Require SafeToUnload from prepare_logic_dll_unload. | ||
| 275 | */ | ||
| 276 | void on_logic_dll_unload(std::span<const std::string_view> binding_names) noexcept; | ||
| 277 | |||
| 278 | /** | ||
| 279 | * @brief Acts as on_logic_dll_unload for every binding, through prepare_logic_dll_unload_all. | ||
| 280 | * @note Best-effort: the wrapper fails closed. | ||
| 281 | * @warning This void result never authorizes FreeLibrary. Require SafeToUnload from prepare_logic_dll_unload_all. | ||
| 282 | */ | ||
| 283 | void on_logic_dll_unload_all() noexcept; | ||
| 284 | } // namespace DetourModKit | ||
| 285 | |||
| 286 | #endif // DETOURMODKIT_SESSION_HPP | ||
| 287 |