GCC Code Coverage Report


Directory: ./
Coverage: low: ≥ 0% medium: ≥ 75.0% high: ≥ 90.0%
Coverage Exec / Excl / Total
Lines: 100.0% 1 / 0 / 1
Functions: 100.0% 1 / 0 / 1
Branches: -% 0 / 0 / 0

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