Skip to content

Commit 6945ea1

Browse files
authored
chore: bump version to 3.2.0 (#74)
1 parent 0dd0948 commit 6945ea1

7 files changed

Lines changed: 211 additions & 5 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
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.1.0 LANGUAGES CXX)
3+
project(DetourModKit VERSION 3.2.0 LANGUAGES CXX)
44

55
# --- Standard and Compiler Options ---
66
set(CMAKE_CXX_STANDARD 23)

include/DetourModKit/config.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,13 @@ namespace DetourModKit
313313
* @brief Stops the filesystem watcher started by enable_auto_reload().
314314
* @details Idempotent. Returns only once the watcher thread has exited
315315
* (or been detached under the Windows loader lock).
316+
* @note When invoked from inside an on_reload callback (i.e. on the
317+
* watcher thread itself) this is a no-op: joining the worker
318+
* from its own thread would raise
319+
* std::system_error(resource_deadlock_would_occur). The error
320+
* is logged and the watcher remains running. Tear the watcher
321+
* down from a different thread, e.g. by posting the disable
322+
* request to a deferred shutdown hook.
316323
*/
317324
void disable_auto_reload() noexcept;
318325

include/DetourModKit/event_dispatcher.hpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,8 @@ namespace DetourModKit
478478
// the class-level doc for details.
479479
[[nodiscard]] int &emitting_depth() const noexcept
480480
{
481+
// Shared across all dispatcher instances on the same thread.
482+
// The reentrancy guard is per-thread (intentional), not per-dispatcher.
481483
thread_local int depth{0};
482484
return depth;
483485
}

include/DetourModKit/memory.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ namespace DetourModKit
143143
* zero overhead on the success path. On MinGW, falls back to a single
144144
* VirtualQuery guard before dereferencing (no cache interaction).
145145
* Suitable for hot paths that already manage their own error recovery.
146+
* @note On MinGW the implementation additionally probes the cache via a
147+
* non-blocking try_lock_shared before falling back to VirtualQuery,
148+
* so cache hits avoid a syscall. This is invisible to callers; the
149+
* function still exposes no caller-observable cache state.
146150
* @param base The base address to read from.
147151
* @param offset Byte offset added to base before dereferencing.
148152
* @return The pointer-sized value at the address, or 0 if the read faults.

src/config_watcher.cpp

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
#include "DetourModKit/logger.hpp"
99
#include "DetourModKit/worker.hpp"
10+
#include "platform.hpp"
1011

1112
#include <windows.h>
1213

@@ -27,13 +28,33 @@
2728

2829
namespace DetourModKit
2930
{
31+
namespace detail
32+
{
33+
// Test-only override for is_loader_lock_held(). When non-null the
34+
// ConfigWatcher destructor consults this hook instead of the real
35+
// PEB-based detection, letting the test suite exercise the
36+
// detach-and-leak branch from user code. Defined as a plain
37+
// function pointer because the override is set/cleared on a single
38+
// thread inside a test fixture.
39+
bool (*g_config_watcher_loader_lock_override)() noexcept = nullptr;
40+
} // namespace detail
41+
3042
namespace
3143
{
3244
constexpr DWORD kNotifyFilter =
3345
FILE_NOTIFY_CHANGE_LAST_WRITE |
3446
FILE_NOTIFY_CHANGE_FILE_NAME |
3547
FILE_NOTIFY_CHANGE_SIZE;
3648

49+
bool loader_lock_held_for_watcher() noexcept
50+
{
51+
if (auto *override_fn = detail::g_config_watcher_loader_lock_override)
52+
{
53+
return override_fn();
54+
}
55+
return detail::is_loader_lock_held();
56+
}
57+
3758
// Sized so bursty editor saves do not overflow a single call while
3859
// still fitting comfortably on the worker's stack.
3960
constexpr DWORD kBufferBytes = 16 * 1024;
@@ -146,6 +167,41 @@ namespace DetourModKit
146167

147168
ConfigWatcher::~ConfigWatcher() noexcept
148169
{
170+
if (m_impl && loader_lock_held_for_watcher())
171+
{
172+
// Under loader lock (FreeLibrary path): joining the watcher
173+
// would deadlock against ReadDirectoryChangesW's I/O completion,
174+
// and tearing down Impl would invalidate the worker_thread_id
175+
// pointer the detached lambda still references. Pin the module
176+
// 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.
180+
detail::pin_current_module();
181+
182+
if (m_impl->worker)
183+
{
184+
// shutdown() takes its own loader-lock branch: it requests
185+
// stop and detaches the std::jthread (no join), then sets
186+
// joined_ so the eventual ~StoppableWorker run during
187+
// static teardown short-circuits without trying to join a
188+
// detached handle.
189+
m_impl->worker->shutdown();
190+
}
191+
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));
202+
return;
203+
}
204+
149205
stop();
150206
}
151207

tests/test_config_watcher.cpp

Lines changed: 103 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,110 @@ namespace
414414

415415
TEST_F(ConfigWatcherTest, Construct_InvalidPath_StartReturnsFalse)
416416
{
417-
const std::filesystem::path missing =
418-
m_temp_dir / "nonexistent_subdir" / "file.ini";
419-
420-
ConfigWatcher watcher(missing.string(), 100ms, []() {});
417+
ConfigWatcher watcher(
418+
(m_temp_dir / "nonexistent_subdir" / "file.ini").string(),
419+
100ms, []() {});
421420
EXPECT_FALSE(watcher.start());
422421
EXPECT_FALSE(watcher.is_running());
423422
}
424423
} // namespace
424+
425+
// Loader-lock detach tests. The real loader-lock branch (detected by reading
426+
// the PEB inside DllMain) cannot be reached from user code in a normal test
427+
// process, so the runtime exposes a test-only function pointer override that
428+
// reports "loader lock held" on demand. These tests exercise the leak-on-
429+
// 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.
432+
namespace DetourModKit::detail
433+
{
434+
extern bool (*g_config_watcher_loader_lock_override)() noexcept;
435+
} // namespace DetourModKit::detail
436+
437+
namespace
438+
{
439+
using DetourModKit::detail::g_config_watcher_loader_lock_override;
440+
441+
bool always_true_loader_lock() noexcept
442+
{
443+
return true;
444+
}
445+
446+
class ConfigWatcherLoaderLockTest : public ConfigWatcherTest
447+
{
448+
protected:
449+
void TearDown() override
450+
{
451+
g_config_watcher_loader_lock_override = nullptr;
452+
ConfigWatcherTest::TearDown();
453+
}
454+
};
455+
456+
TEST_F(ConfigWatcherLoaderLockTest, DestructorWithoutLoaderLockJoinsCleanly)
457+
{
458+
std::atomic<int> hits{0};
459+
const auto t_start = std::chrono::steady_clock::now();
460+
{
461+
ConfigWatcher watcher(m_ini_path.string(), 50ms,
462+
[&hits]()
463+
{ hits.fetch_add(1); });
464+
ASSERT_TRUE(watcher.start());
465+
ASSERT_TRUE(wait_until([&]()
466+
{ return watcher.is_running(); },
467+
1s));
468+
}
469+
const auto elapsed = std::chrono::steady_clock::now() - t_start;
470+
471+
// Without the loader-lock override the destructor takes the normal
472+
// join path. A clean join completes well under a second; a hang
473+
// (e.g. a regression that joined under loader lock) would blow past
474+
// the GetOverlappedResultEx pump timeout repeatedly.
475+
EXPECT_LT(elapsed, std::chrono::seconds(3));
476+
}
477+
478+
TEST_F(ConfigWatcherLoaderLockTest, DestructorUnderLoaderLockDoesNotHang)
479+
{
480+
std::atomic<int> hits{0};
481+
const auto t_start = std::chrono::steady_clock::now();
482+
{
483+
ConfigWatcher watcher(m_ini_path.string(), 50ms,
484+
[&hits]()
485+
{ hits.fetch_add(1); });
486+
ASSERT_TRUE(watcher.start());
487+
ASSERT_TRUE(wait_until([&]()
488+
{ return watcher.is_running(); },
489+
1s));
490+
491+
// Flip the override on so ~ConfigWatcher takes the leak branch.
492+
// The detach path must not block, must not call join(), and
493+
// must keep the worker's captured pointers valid by leaking
494+
// the Impl into a static vector.
495+
g_config_watcher_loader_lock_override = &always_true_loader_lock;
496+
}
497+
const auto elapsed = std::chrono::steady_clock::now() - t_start;
498+
499+
// Under loader lock the destructor returns essentially immediately:
500+
// request_stop on the worker, detach, leak. The OS thread continues
501+
// running but no longer blocks the destructor.
502+
EXPECT_LT(elapsed, std::chrono::seconds(2))
503+
<< "Loader-lock detach branch must not join the worker";
504+
}
505+
506+
TEST_F(ConfigWatcherLoaderLockTest, MultipleLoaderLockTeardownsAreSafe)
507+
{
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).
511+
for (int i = 0; i < 3; ++i)
512+
{
513+
ConfigWatcher watcher(m_ini_path.string(), 50ms, []() {});
514+
ASSERT_TRUE(watcher.start());
515+
ASSERT_TRUE(wait_until([&]()
516+
{ return watcher.is_running(); },
517+
1s));
518+
g_config_watcher_loader_lock_override = &always_true_loader_lock;
519+
// Watcher destructor on scope exit takes the leak path.
520+
}
521+
SUCCEED();
522+
}
523+
} // namespace

tests/test_version.cpp

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#include <gtest/gtest.h>
2+
3+
#include "DetourModKit/version.hpp"
4+
5+
#include <cstring>
6+
7+
namespace
8+
{
9+
TEST(VersionTest, MacrosMatchProjectVersion)
10+
{
11+
EXPECT_EQ(DMK_VERSION_MAJOR, 3);
12+
EXPECT_EQ(DMK_VERSION_MINOR, 2);
13+
EXPECT_EQ(DMK_VERSION_PATCH, 0);
14+
}
15+
16+
TEST(VersionTest, VersionStringMatchesMacros)
17+
{
18+
EXPECT_STREQ(DMK_VERSION_STRING, "3.2.0");
19+
}
20+
21+
TEST(VersionTest, AtLeastComparisonsAreCorrect)
22+
{
23+
EXPECT_TRUE(DMK_VERSION_AT_LEAST(3, 2, 0));
24+
EXPECT_TRUE(DMK_VERSION_AT_LEAST(3, 1, 0));
25+
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, 3, 0));
28+
EXPECT_FALSE(DMK_VERSION_AT_LEAST(4, 0, 0));
29+
}
30+
31+
TEST(VersionTest, EncodedVersionMatchesComponents)
32+
{
33+
EXPECT_EQ(DMK_VERSION,
34+
DMK_MAKE_VERSION(DMK_VERSION_MAJOR,
35+
DMK_VERSION_MINOR,
36+
DMK_VERSION_PATCH));
37+
}
38+
} // namespace

0 commit comments

Comments
 (0)