From a181b62a6af6f79d1c6cb3bcced17d2915d953ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Gr=C3=A9goire?= Date: Thu, 29 Oct 2026 14:14:20 +0100 Subject: [PATCH] Don't deadlock when joining threads from a DLL being unloaded. When Tracy is built into a DLL, the static `Profiler` is destroyed inside `DllMain(DLL_PROCESS_DETACH)` while the loader lock is held. `Thread::~Thread()` waited for the thread to exit, but a thread can only exit by taking the loader lock for `DLL_THREAD_DETACH`, so `FreeLibrary` hung forever (typically in `s_symbolThread->~Thread()`). The thread function now signals an event when it is done and parks in an alertable sleep instead of returning. The destructor waits for that event rather than for the thread itself. Inside a loader callout (detected with `RtlIsThreadWithinLoaderCallout`) the parked thread is terminated, which is safe as it holds no locks at that point. Everywhere else it is woken with an APC and exits normally, so `thread_local` destructors still run. If the ntdll export is unavailable, the previous behaviour is kept. Same approach as `asio::detail::win_thread`. Reproduced with a test DLL embedding Tracy (`TRACY_ON_DEMAND`) loaded and freed in a loop: `FreeLibrary` hung on the first unload before the change, and 100 cycles pass after it when the DLL stays loaded for at least a few ms. Unloading immediately after loading still hangs if the Tracy threads are blocked on the loader lock during their startup (e.g. in `gethostname` or `SymInitialize`), as they never reach the parking point. Startup and shutdown timings are unchanged. --- public/client/TracyThread.hpp | 38 +++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/public/client/TracyThread.hpp b/public/client/TracyThread.hpp index f95627d4..f22d6c5e 100644 --- a/public/client/TracyThread.hpp +++ b/public/client/TracyThread.hpp @@ -42,22 +42,56 @@ public: Thread( void(*func)( void* ptr ), void* ptr ) : m_func( func ) , m_ptr( ptr ) + , m_exit( CreateEventW( nullptr, TRUE, FALSE, nullptr ) ) , m_hnd( CreateThread( nullptr, 0, Launch, this, 0, nullptr ) ) {} ~Thread() { - WaitForSingleObject( m_hnd, INFINITE ); + // The thread signals m_exit when it is done and then parks, instead of exiting, so that + // it can be joined from DllMain (e.g. a static Profiler destroyed by FreeLibrary). + // A normal thread exit needs the loader lock, which the joining thread already holds. + HANDLE handles[2] = { m_exit, m_hnd }; + WaitForMultipleObjects( 2, handles, FALSE, INFINITE ); + CloseHandle( m_exit ); + if( InLoaderCallout() ) + { + TerminateThread( m_hnd, 0 ); + } + else + { + QueueUserAPC( WakeUp, m_hnd, 0 ); + WaitForSingleObject( m_hnd, INFINITE ); + } CloseHandle( m_hnd ); } HANDLE Handle() const { return m_hnd; } private: - static DWORD WINAPI Launch( void* ptr ) { ((Thread*)ptr)->m_func( ((Thread*)ptr)->m_ptr ); return 0; } + static bool InLoaderCallout() + { + typedef BOOLEAN(NTAPI* IsWithinCallout)(); + static const auto fn = (IsWithinCallout)GetProcAddress( GetModuleHandleW( L"ntdll.dll" ), "RtlIsThreadWithinLoaderCallout" ); + return fn && fn(); + } + + static void CALLBACK WakeUp( ULONG_PTR ) {} + + static DWORD WINAPI Launch( void* ptr ) + { + auto self = (Thread*)ptr; + self->m_func( self->m_ptr ); + // *self may be gone as soon as m_exit is signaled. + const HANDLE exit = self->m_exit; + SetEvent( exit ); + SleepEx( INFINITE, TRUE ); + return 0; + } void(*m_func)( void* ptr ); void* m_ptr; + HANDLE m_exit; HANDLE m_hnd; };