@@ -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
0 commit comments