Skip to content

Commit 9d023e6

Browse files
authored
fix(logger,config_watcher): align loader-lock leak paths (#76)
* fix(logger,config_watcher): align loader-lock leak paths with HookManager nothrow pattern Replace static-vector emplace_back leaks with per-call new (std::nothrow) heap cells in Logger::shutdown_internal and ~ConfigWatcher so the noexcept destructor contract is honestly upheld under OOM rather than turning a vector growth bad_alloc into std::terminate. Add the previously missing pin_current_module() call to Logger's leak branch. ConfigWatcher falls back to m_impl.release() if the heap cell allocation fails, leaking the raw Impl pointer without invoking ~Impl. Refs #75 * test(version): update expectations to 3.2.1 CMakeLists.txt project version bumped to 3.2.1; bring the version macro asserts in lockstep so the suite stays green.
1 parent 6945ea1 commit 9d023e6

7 files changed

Lines changed: 99 additions & 38 deletions

File tree

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,14 @@ PATH="/c/msys64/mingw64/bin:$PATH" ./build/mingw-debug/tests/DetourModKit_tests.
256256
| Module | Thread safety | Hot-path mechanism |
257257
|--------|--------------|-------------------|
258258
| Scanner | Stateless -- inherently safe | N/A (startup only) |
259-
| HookManager | `shared_mutex` (readers) / `unique_lock` (writers); two-phase shutdown (disable under shared lock, clear under exclusive lock); `m_mutator_gate` (shared_mutex) blocks new mutators (including all VMT operations) during teardown; CAS on `m_shutdown_called` serializes shutdown/remove_all_hooks; double-checked fast-fail on `m_shutdown_called` in all mutators; destructor fallback (when `DMK_Shutdown()` was not called) acquires `m_mutator_gate` exclusively, flips `m_shutdown_called`, drains readers via exclusive `m_hooks_mutex`, then clears the maps -- under loader lock it pins the module and swaps each map's contents into heap storage allocated via `new (std::nothrow)` so the storage outlives the destructor without ever draining, mirroring the leak-on-loader-lock discipline used in `Logger::shutdown_internal` | `shared_lock` for `with_inline_hook()` |
260-
| Logger | `atomic<shared_ptr>` for lock-free async reads; `shutdown_internal` is safe across repeated shutdown / enable_async_mode cycles: when the writer thread has to be detached under loader lock, the `shared_ptr<AsyncLogger>` is appended to a static `std::vector` rather than overwriting a single static slot, so prior handles are never dropped while their writer threads may still be running | Single atomic load on log level check |
259+
| HookManager | `shared_mutex` (readers) / `unique_lock` (writers); two-phase shutdown (disable under shared lock, clear under exclusive lock); `m_mutator_gate` (shared_mutex) blocks new mutators (including all VMT operations) during teardown; CAS on `m_shutdown_called` serializes shutdown/remove_all_hooks; double-checked fast-fail on `m_shutdown_called` in all mutators; destructor fallback (when `DMK_Shutdown()` was not called) acquires `m_mutator_gate` exclusively, flips `m_shutdown_called`, drains readers via exclusive `m_hooks_mutex`, then clears the maps -- under loader lock it pins the module and swaps each map's contents into heap storage allocated via `new (std::nothrow)` so the storage outlives the destructor without ever draining, mirroring the leak-on-loader-lock discipline used in `Logger::shutdown_internal` and `ConfigWatcher::~ConfigWatcher` | `shared_lock` for `with_inline_hook()` |
260+
| Logger | `atomic<shared_ptr>` for lock-free async reads; `shutdown_internal` is safe across repeated shutdown / enable_async_mode cycles: when the writer thread has to be detached under loader lock, the module is pinned and the `shared_ptr<AsyncLogger>` is moved into a per-call heap cell allocated via `new (std::nothrow)` rather than appended to a static `std::vector`, so the leak path keeps the noexcept destructor honest under OOM (returns nullptr instead of throwing `bad_alloc`) and prior handles are never dropped while their writer threads may still be running | Single atomic load on log level check |
261261
| AsyncLogger | Lock-free MPMC queue (Vyukov-style); post-join drain on shutdown (at most one message per producer can be lost in the nanosecond race between drain and force-zero -- accepted trade-off to avoid atomic overhead on every enqueue); timestamp caching in write batches | Atomic sequence numbers per slot |
262262
| InputPoller | Atomic `active_states_[]` array | `memory_order_relaxed` load per binding |
263263
| InputManager | `mutex` for lifecycle, `atomic<InputPoller*>` for reads | Lock-free `is_binding_active()` |
264264
| Memory cache | Sharded `SRWLOCK` + epoch-based shutdown | Shared reader locks per shard |
265265
| Config | `mutex` for registration; deferred setter invocation outside lock (no reentrancy guard needed -- setters may call back into Config); `reload()` re-runs the registered items against the stashed INI path using the same deferred pattern and short-circuits on FNV-1a 64 hash match of the on-disk bytes to skip no-op reloads; bytes are read once per load/reload and fed to `CSimpleIniA::LoadData`, so the cached hash and the parsed INI state are guaranteed to reflect the same file snapshot (no TOCTOU between hash and parse); `enable_auto_reload()` owns a `ConfigWatcher` behind a separate `std::mutex` so start/stop transitions do not contend with registration traffic; setters invoked by the watcher run on the watcher thread, setters invoked by the reload hotkey run on a dedicated `ReloadServicer` thread (lazily started on first `register_reload_hotkey`, torn down in `clear_registered_items()`) so the `InputManager` poll thread never blocks on INI parsing; the servicer's press-request path takes its internal `m_mutex` around the predicate store before `cv.notify_one` to close the lost-wakeup window; all setters must be reentrant and thread-safe | N/A (startup only) |
266-
| ConfigWatcher | One `StoppableWorker` per instance; worker opens the parent directory with `FILE_FLAG_BACKUP_SEMANTICS` and `FILE_FLAG_OVERLAPPED`, then pumps `ReadDirectoryChangesW` via `GetOverlappedResultEx` with a 100 ms timeout so `stop_token` is observed promptly; debounce uses `steady_clock`; filename match is case-insensitive; `start()` and `stop()` are idempotent and serialized by an internal `std::mutex` | 100 ms `GetOverlappedResultEx` pump; idle CPU ~0 |
266+
| ConfigWatcher | One `StoppableWorker` per instance; worker opens the parent directory with `FILE_FLAG_BACKUP_SEMANTICS` and `FILE_FLAG_OVERLAPPED`, then pumps `ReadDirectoryChangesW` via `GetOverlappedResultEx` with a 100 ms timeout so `stop_token` is observed promptly; debounce uses `steady_clock`; filename match is case-insensitive; `start()` and `stop()` are idempotent and serialized by an internal `std::mutex`; under loader lock the destructor pins the module, requests stop on the worker, and moves `Impl` into a per-call heap cell allocated via `new (std::nothrow)` (with a `release()` fallback on OOM that leaks the raw pointer instead of running `~Impl`) so the noexcept destructor stays honest, mirroring the `Logger::shutdown_internal` discipline | 100 ms `GetOverlappedResultEx` pump; idle CPU ~0 |
267267
| EventDispatcher | Lock-free `emit()` / `emit_safe()` via `std::atomic<std::shared_ptr<const std::vector<Entry>>>` snapshot (copy-on-write publish, acquire-load on read); zero-subscriber fast path skips the snapshot load via an atomic handler counter; writers serialize on a small `std::mutex` that never touches the emit hot path; thread-local reentrancy guard rejects subscribe/unsubscribe from within handlers so the no-mutation-during-emit invariant holds; `emit()` propagates handler exceptions, `emit_safe()` catches and skips them | Atomic acquire-load of a `shared_ptr` snapshot plus linear iteration over a contiguous vector; no reader lock |
268268
| Profiler | Lock-free ring buffer via atomic `fetch_add` on write position; odd/even sequence counter per sample slot prevents torn reads during concurrent export -- the sequence is opened and closed with unconditional `fetch_add` (never a load-then-store) so concurrent producers racing on the same slot cannot roll the counter backwards; `DMK_PROFILE_SCOPE(name)` requires `name` to be a string literal, enforced at compile time by a `ScopedProfile` constructor that only binds to `const char (&)[N]` | Single atomic increment + sequence-guarded field writes per sample |
269269

CMakeLists.txt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
cmake_minimum_required(VERSION 3.25)
22

3-
project(DetourModKit VERSION 3.2.0 LANGUAGES CXX)
3+
project(DetourModKit VERSION 3.2.1 LANGUAGES CXX)
44

55
# --- Standard and Compiler Options ---
66
set(CMAKE_CXX_STANDARD 23)
@@ -146,9 +146,9 @@ configure_file(
146146
# For brand new files, a manual CMake re-run might still be needed.
147147
#
148148
# Notable modules picked up here (alphabetical, not exhaustive):
149-
# async_logger, bootstrap, config, config_watcher, event_dispatcher,
150-
# filesystem, hook_manager, input, logger, memory, profiler,
151-
# scanner, win_file_stream, worker.
149+
# async_logger, bootstrap, config, config_watcher, event_dispatcher,
150+
# filesystem, hook_manager, input, logger, memory, profiler,
151+
# scanner, win_file_stream, worker.
152152
file(GLOB DETOURMODKIT_SOURCE_FILES
153153
CONFIGURE_DEPENDS
154154
"src/*.cpp"
@@ -201,6 +201,7 @@ else()
201201
# after -O3 on the scanner's compile line is the effective flag because
202202
# GCC and Clang resolve repeated -O levels by taking the last one.
203203
set(_dmk_scanner_src "${CMAKE_CURRENT_SOURCE_DIR}/src/scanner.cpp")
204+
204205
if(EXISTS "${_dmk_scanner_src}")
205206
set_source_files_properties("${_dmk_scanner_src}"
206207
TARGET_DIRECTORY DetourModKit
@@ -483,8 +484,10 @@ if(DMK_BUILD_TESTS OR DMK_BUILD_BENCHMARKS)
483484
message(STATUS "Building unit tests...")
484485
enable_testing()
485486
endif()
487+
486488
if(DMK_BUILD_BENCHMARKS)
487489
message(STATUS "Building benchmarks...")
488490
endif()
491+
489492
add_subdirectory(tests)
490493
endif()

src/config_watcher.cpp

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@
2020
#include <future>
2121
#include <memory>
2222
#include <mutex>
23+
#include <new>
2324
#include <optional>
2425
#include <string>
2526
#include <string_view>
27+
#include <type_traits>
2628
#include <utility>
2729
#include <vector>
2830

@@ -174,9 +176,9 @@ namespace DetourModKit
174176
// and tearing down Impl would invalidate the worker_thread_id
175177
// pointer the detached lambda still references. Pin the module
176178
// so trampoline and worker code pages remain mapped, request
177-
// stop, then leak the entire Impl into a static vector that
178-
// outlives the destructor. The same discipline as
179-
// HookManager::~HookManager and Logger::shutdown_internal.
179+
// stop, then leak the entire Impl onto the heap so it outlives
180+
// the destructor. The same discipline as HookManager::~HookManager
181+
// and Logger::shutdown_internal.
180182
detail::pin_current_module();
181183

182184
if (m_impl->worker)
@@ -189,16 +191,33 @@ namespace DetourModKit
189191
m_impl->worker->shutdown();
190192
}
191193

192-
// Releasing into the leak list keeps Impl alive for the rest
193-
// of the process. The detached worker thread holds raw
194-
// pointers and references into Impl members (worker_thread_id,
195-
// captured strings); they must stay valid until the OS thread
196-
// either observes the stop_token and exits or the process
197-
// tears down. Either way the leaked storage is bounded:
198-
// ~ConfigWatcher under loader lock runs at most once per
199-
// Config-owned watcher per process.
200-
static std::vector<std::unique_ptr<Impl>> s_leaked_impls;
201-
s_leaked_impls.emplace_back(std::move(m_impl));
194+
// Per-call heap leak: each invocation allocates its own cell,
195+
// so prior leaked Impls are never overwritten and the leak is
196+
// bounded by one cell per ~ConfigWatcher-under-loader-lock
197+
// call. The detached worker thread holds raw pointers and
198+
// references into Impl members (worker_thread_id, captured
199+
// strings); they must stay valid until the OS thread either
200+
// observes the stop_token and exits or the process tears down.
201+
//
202+
// new (std::nothrow) keeps this noexcept destructor honest by
203+
// returning nullptr on OOM rather than turning a container
204+
// emplace_back bad_alloc into std::terminate. On allocation
205+
// failure, fall back to releasing the unique_ptr so the Impl
206+
// storage is leaked directly without invoking ~Impl (which
207+
// would tear down the detached StoppableWorker -- safe under
208+
// a normal join, but not under loader lock).
209+
static_assert(std::is_nothrow_move_constructible_v<std::unique_ptr<Impl>>,
210+
"Leak cell must be nothrow-move-constructible to keep ~ConfigWatcher noexcept honest.");
211+
212+
if (auto *leaked = new (std::nothrow)
213+
std::unique_ptr<Impl>(std::move(m_impl)))
214+
{
215+
static_cast<void>(leaked);
216+
}
217+
else
218+
{
219+
static_cast<void>(m_impl.release());
220+
}
202221
return;
203222
}
204223

src/logger.cpp

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
#include <chrono>
1212
#include <iomanip>
1313
#include <iostream>
14+
#include <new>
1415
#include <stdexcept>
1516
#include <array>
16-
#include <vector>
17+
#include <type_traits>
1718

1819
namespace DetourModKit
1920
{
@@ -200,9 +201,9 @@ namespace DetourModKit
200201

201202
// If the writer thread was detached under loader lock, it may still
202203
// be accessing AsyncLogger members (queue_, flush_mutex_, etc.).
203-
// Transfer ownership to a static so the object outlives the detached
204-
// thread. The pinned module keeps code pages valid; this keeps the
205-
// heap-allocated state valid.
204+
// Transfer ownership to permanent heap storage so the object
205+
// outlives the detached thread; pin the module so the code pages
206+
// it executes from also remain mapped.
206207
//
207208
// The transfer is unconditional when loader lock is held: concurrent
208209
// log() callers may still own temporary shared_ptrs obtained from
@@ -211,14 +212,24 @@ namespace DetourModKit
211212
// a temporary outlives us would let the last temporary's destructor
212213
// race the detached writer thread.
213214
//
214-
// The storage is append-only: a process that re-attaches after a
215-
// shutdown (e.g. hot-reload) and hits loader lock again must not
216-
// drop the prior handle, because its writer thread may still be
217-
// accessing the old AsyncLogger state.
215+
// The leak is per-call and append-only: each invocation allocates
216+
// its own heap cell, so a process that re-attaches after shutdown
217+
// (e.g. hot-reload) and hits loader lock again cannot drop a prior
218+
// handle whose writer thread may still be running. Mirrors the
219+
// HookManager loader-lock discipline: new (std::nothrow) keeps the
220+
// noexcept destructor honest by returning nullptr on OOM rather
221+
// than turning a std::vector::emplace_back bad_alloc into
222+
// std::terminate inside this noexcept context.
218223
if (local_logger && detail::is_loader_lock_held())
219224
{
220-
static std::vector<std::shared_ptr<AsyncLogger>> s_leaked_loggers;
221-
s_leaked_loggers.emplace_back(std::move(local_logger));
225+
static_assert(std::is_nothrow_move_constructible_v<std::shared_ptr<AsyncLogger>>,
226+
"Leak cell must be nothrow-move-constructible to keep ~Logger noexcept honest.");
227+
228+
detail::pin_current_module();
229+
230+
auto *leaked = new (std::nothrow)
231+
std::shared_ptr<AsyncLogger>(std::move(local_logger));
232+
static_cast<void>(leaked);
222233
}
223234

224235
{

tests/test_config_watcher.cpp

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
#include <chrono>
55
#include <filesystem>
66
#include <fstream>
7+
#include <memory>
78
#include <process.h>
89
#include <string>
910
#include <thread>
11+
#include <type_traits>
1012

1113
#include <windows.h>
1214

@@ -15,6 +17,17 @@
1517
using namespace DetourModKit;
1618
using namespace std::chrono_literals;
1719

20+
// The loader-lock fallback in ~ConfigWatcher heap-allocates a
21+
// std::unique_ptr<Impl> via new (std::nothrow) and leaks the cell to
22+
// outlive the destructor. Impl is a private nested type, so this
23+
// assertion guards the unique_ptr leak-cell pattern at the type level
24+
// rather than referencing Impl directly: if std::unique_ptr ever ceased
25+
// to be nothrow-move-constructible, the leak cell could no longer be
26+
// constructed from a noexcept context without risking std::terminate.
27+
static_assert(std::is_nothrow_move_constructible_v<std::unique_ptr<int>>,
28+
"std::unique_ptr must remain nothrow-move-constructible for the "
29+
"ConfigWatcher loader-lock leak path to keep ~ConfigWatcher noexcept honest.");
30+
1831
namespace
1932
{
2033
class ConfigWatcherTest : public ::testing::Test
@@ -427,8 +440,9 @@ namespace
427440
// process, so the runtime exposes a test-only function pointer override that
428441
// reports "loader lock held" on demand. These tests exercise the leak-on-
429442
// loader-lock branch in ~ConfigWatcher: the worker is detached instead of
430-
// joined, the Impl is moved into a static vector that outlives the
431-
// destructor, and the watcher does not deadlock.
443+
// joined, the Impl is moved into a per-call heap cell allocated via
444+
// new (std::nothrow) that outlives the destructor, and the watcher does not
445+
// deadlock.
432446
namespace DetourModKit::detail
433447
{
434448
extern bool (*g_config_watcher_loader_lock_override)() noexcept;
@@ -505,9 +519,11 @@ namespace
505519

506520
TEST_F(ConfigWatcherLoaderLockTest, MultipleLoaderLockTeardownsAreSafe)
507521
{
508-
// Confirms the static leak vector accepts multiple entries without
509-
// tripping any single-slot overwrite hazards (the bug pattern that
510-
// motivated #69's Logger::shutdown_internal fix).
522+
// Confirms the per-call heap leak path accepts multiple invocations
523+
// without tripping any single-slot overwrite hazards (the bug pattern
524+
// that motivated #69's Logger::shutdown_internal fix). Each
525+
// teardown allocates its own cell via new (std::nothrow), so prior
526+
// leaked Impls are never overwritten.
511527
for (int i = 0; i < 3; ++i)
512528
{
513529
ConfigWatcher watcher(m_ini_path.string(), 50ms, []() {});

tests/test_logger.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#include <gtest/gtest.h>
22
#include <fstream>
33
#include <filesystem>
4+
#include <memory>
45
#include <thread>
56
#include <chrono>
7+
#include <type_traits>
68
#include <windows.h>
79
#include <atomic>
810

@@ -11,6 +13,15 @@
1113

1214
using namespace DetourModKit;
1315

16+
// The loader-lock fallback in Logger::shutdown_internal heap-allocates a
17+
// std::shared_ptr<AsyncLogger> via new (std::nothrow) and leaks the cell
18+
// to outlive the destructor. Guard the leak cell's move constructor stays
19+
// noexcept so the call cannot turn the noexcept ~Logger contract into
20+
// std::terminate via a thrown move.
21+
static_assert(std::is_nothrow_move_constructible_v<std::shared_ptr<AsyncLogger>>,
22+
"std::shared_ptr<AsyncLogger> must be nothrow-move-constructible "
23+
"for the Logger loader-lock leak path to keep ~Logger noexcept honest.");
24+
1425
class LoggerTest : public ::testing::Test
1526
{
1627
protected:

tests/test_version.cpp

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,21 @@ namespace
1010
{
1111
EXPECT_EQ(DMK_VERSION_MAJOR, 3);
1212
EXPECT_EQ(DMK_VERSION_MINOR, 2);
13-
EXPECT_EQ(DMK_VERSION_PATCH, 0);
13+
EXPECT_EQ(DMK_VERSION_PATCH, 1);
1414
}
1515

1616
TEST(VersionTest, VersionStringMatchesMacros)
1717
{
18-
EXPECT_STREQ(DMK_VERSION_STRING, "3.2.0");
18+
EXPECT_STREQ(DMK_VERSION_STRING, "3.2.1");
1919
}
2020

2121
TEST(VersionTest, AtLeastComparisonsAreCorrect)
2222
{
23+
EXPECT_TRUE(DMK_VERSION_AT_LEAST(3, 2, 1));
2324
EXPECT_TRUE(DMK_VERSION_AT_LEAST(3, 2, 0));
2425
EXPECT_TRUE(DMK_VERSION_AT_LEAST(3, 1, 0));
2526
EXPECT_TRUE(DMK_VERSION_AT_LEAST(2, 0, 0));
26-
EXPECT_FALSE(DMK_VERSION_AT_LEAST(3, 2, 1));
27+
EXPECT_FALSE(DMK_VERSION_AT_LEAST(3, 2, 2));
2728
EXPECT_FALSE(DMK_VERSION_AT_LEAST(3, 3, 0));
2829
EXPECT_FALSE(DMK_VERSION_AT_LEAST(4, 0, 0));
2930
}

0 commit comments

Comments
 (0)