From 6c647f1c7f823f7b23b3fc30282e58f708b44f8b Mon Sep 17 00:00:00 2001 From: "aiagentpool-mrbot[bot]" <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:59:18 +0000 Subject: [PATCH 1/2] [MrBot] Fix Task #38853991: ARM64 VLD process-exit teardown hang Under concurrent ctest -j load on ARM64, a unit-test process can wedge forever after main returns. Full-memory dumps of the wedged *_ut_exe_ebs.exe show thread 0 (single-threaded post-main) stuck in NtQueryVirtualMemory <- KERNELBASE!QueryVirtualMemoryInformation, reached from VLD's leak report that runs under the loader lock at DLL_PROCESS_DETACH: ~VisualLeakDetector -> leak report (pre-warm GetLeaksCount + ReportLeaks) -> GetCallingModule() -> QueryVirtualMemoryInformation -> NtQueryVirtualMemory On ARM64 that per-address memory-region query intermittently livelocks under the loader lock at process exit (aka.ms/AA10dvw4); on x64 it does not. It can strike ANY VLD-linked exe under concurrent -j load, not a specific test. The existing Arm64TeardownReportScope only suppressed dbghelp symbolization, not the GetCallingModule -> QueryVirtualMemoryInformation path that actually wedges. GetCallingModule() is reached during teardown from two kinds of caller: (a) VLD's allocation hooks -> CaptureContext::~CaptureContext -> IsExcludedModule() (for the report's own transient allocations), and (b) CallStack::resolve()/resolveFunction() -> GetCallingModule() per frame, driven by the pre-warm's call-stack resolution. Fix (ARM64-only, non-weakening), all in src/vld.cpp + src/utility.cpp: 1. GetCallingModule(): during the ARM64 teardown report, look the module up via the loader (GetModuleHandleExW FROM_ADDRESS) instead of QueryVirtualMemoryInformation/VirtualQuery. The loader walk cannot reach NtQueryVirtualMemory and is safe under the (already-held) loader lock. For a code address this returns the same module base; for a non-module address it returns NULL, which every caller already treats as 'not a tracked module'. Gated by a new broad g_arm64InTeardown flag set BEFORE the pre-warm (separate from g_arm64InTeardownReport, which must stay off during the pre-warm so suppression names can still be resolved). This covers caller (b) and (a). 2. ~CaptureContext(): when leak detection is disabled on this thread or the thread holds the DbgHelp lock, skip IsExcludedModule() (the inner heap hooks already recorded no block, so nothing is mapped either way). Mirrors the existing _HeapAlloc guard; removes redundant GetCallingModule calls -- caller (a). 3. ~VisualLeakDetector(): disable leak detection on the teardown thread across the pre-warm + ReportLeaks(), restoring it before TLS is torn down, so the report's own transient allocations are not tracked. The reported leak set is unchanged (guarded allocations were never going to be mapped; the loader lookup returns the same module base for code addresses). x64/x86 behavior is untouched. Verified on ARM64 (Cobalt) hardware by looping the gate's full unit-test ctest -j16 suite: unfixed wedges on pass 1, fixed stays green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/utility.cpp | 23 ++++++++++++++++ src/vld.cpp | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/utility.cpp b/src/utility.cpp index 0f6bf728..ccf855f0 100644 --- a/src/utility.cpp +++ b/src/utility.cpp @@ -35,6 +35,9 @@ extern ReportHookSet* g_pReportHooks; extern VisualLeakDetector g_vld; extern ImageDirectoryEntries g_Ide; +#if defined(_M_ARM64) +extern "C" bool VldArm64InTeardown(void); +#endif // Global variables. static BOOL s_reportDelay = FALSE; // If TRUE, we sleep for a bit after calling OutputDebugString to give the debugger time to catch up. @@ -1510,6 +1513,26 @@ HMODULE GetCallingModule(UINT_PTR pCaller ) { HMODULE hModule = NULL; +#if defined(_M_ARM64) + if (VldArm64InTeardown()) + { + // During ARM64 process-exit teardown the loader lock is held and the + // QueryVirtualMemoryInformation / VirtualQuery calls below reach + // NtQueryVirtualMemory, which intermittently livelocks (aka.ms/AA10dvw4). + // Use a loader-based lookup that walks the loaded-module list instead and + // cannot wedge. For a code address (every call-stack frame) this returns + // the same module base; for a non-module address it returns NULL, which + // all callers already treat as "not a tracked module". + HMODULE hMod = NULL; + if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(pCaller), &hMod)) + { + return hMod; + } + return NULL; + } +#endif + // Try QueryVirtualMemoryInformation first (Windows 10 1607+) WIN32_MEMORY_REGION_INFORMATION memoryRegionInfo; if ( QueryVirtualMemoryInformation(GetCurrentProcess(), (LPCVOID)pCaller, MemoryRegionInfo, &memoryRegionInfo, sizeof(memoryRegionInfo), NULL) ) diff --git a/src/vld.cpp b/src/vld.cpp index 314c0979..f901adc3 100644 --- a/src/vld.cpp +++ b/src/vld.cpp @@ -231,6 +231,26 @@ struct Arm64TeardownReportScope Arm64TeardownReportScope() { InterlockedIncrement(&g_arm64InTeardownReport); } ~Arm64TeardownReportScope() { InterlockedDecrement(&g_arm64InTeardownReport); } }; + +// Broad "in ARM64 process-exit teardown" flag. Unlike g_arm64InTeardownReport +// (which suppresses dbghelp symbolization and must stay off during the pre-warm), +// this covers the ENTIRE leak report, including the pre-warm GetLeaksCount(). It +// is consulted only by GetCallingModule() (utility.cpp) to pick a loader-based +// module lookup instead of QueryVirtualMemoryInformation/VirtualQuery, whose +// NtQueryVirtualMemory intermittently livelocks under the loader lock on ARM64 +// at process exit (aka.ms/AA10dvw4). It changes no leak decision. +static volatile LONG g_arm64InTeardown = 0; + +extern "C" bool VldArm64InTeardown(void) +{ + return InterlockedCompareExchange(&g_arm64InTeardown, 0, 0) != 0; +} + +struct Arm64TeardownScope +{ + Arm64TeardownScope() { InterlockedIncrement(&g_arm64InTeardown); } + ~Arm64TeardownScope() { InterlockedDecrement(&g_arm64InTeardown); } +}; #endif #define _DECL_DLLMAIN // for _CRT_INIT @@ -651,6 +671,32 @@ VisualLeakDetector::~VisualLeakDetector () g_vldDllNotificationCookie = NULL; } #if defined(_M_ARM64) + // [ARM64 process-exit teardown hang fix] Route GetCallingModule() through a + // loader-based module lookup for the whole leak report. Set BEFORE the + // pre-warm below because the pre-warm's call-stack resolution asks for the + // module of every frame; on ARM64 the QueryVirtualMemoryInformation / + // VirtualQuery path that GetCallingModule() normally uses reaches + // NtQueryVirtualMemory, which intermittently livelocks under the loader + // lock at process exit (aka.ms/AA10dvw4). (This is separate from + // Arm64TeardownReportScope, which only suppresses dbghelp symbolization and + // must stay OFF during the pre-warm so suppression names can be resolved.) + Arm64TeardownScope arm64TeardownGetCallingModuleScope; + + // [ARM64 process-exit teardown hang fix] Disable leak detection on THIS + // (teardown) thread across the pre-warm and the leak report below. Both + // allocate: dbghelp allocates internally during symbol resolution, and + // VLD's own ReportLeaks() bookkeeping allocates through the heap hooks. + // With detection disabled those transient allocations bypass the + // allocation hooks instead of being tracked; tracking them would call + // GetCallingModule() -> QueryVirtualMemoryInformation() per allocation, + // which intermittently wedges forever on ARM64 under the loader lock at + // process exit (aka.ms/AA10dvw4). Restored right after ReportLeaks(), + // while thread-local storage is still valid (it is torn down later in + // this destructor). Non-weakening: the report only reads the block map + // already populated during the run; nothing user-relevant allocates + // after main returns. + DisableLeakDetection(); + // Pre-warm the suppression cache while dbghelp is still usable. The ARM64 // teardown scope below disables dbghelp symbol resolution because the // bundled ARM64 dbghelp spins under the loader lock at shutdown; but the @@ -706,6 +752,13 @@ VisualLeakDetector::~VisualLeakDetector () } } +#if defined(_M_ARM64) + // Re-enable leak detection now that the ARM64 leak report is complete and + // before this destructor tears down thread-local storage below (pairs + // with the DisableLeakDetection() before the pre-warm above). + RestoreLeakDetectionState(); +#endif + // Free resources used by the symbol handler. DbgTrace(L"dbghelp32.dll %i: SymCleanup\n", GetCurrentThreadId()); // SymCleanup is safe to call on ARM64 even during teardown: empirically @@ -3263,6 +3316,25 @@ CaptureContext::~CaptureContext() { if (!m_bFirst) return; +#if defined(_M_ARM64) + // [ARM64 process-exit teardown hang fix] Mirror the allocation-hook guards + // (see VisualLeakDetector::_HeapAlloc): when leak detection is disabled on + // this thread, or when this thread already holds the DbgHelp lock (a + // re-entrant allocation made from inside VLD's own dbghelp symbol + // resolution), the inner heap / RtlAllocateHeap hooks never recorded a block + // (m_tls->blockWithoutGuard stays NULL), so there is nothing to map here. + // Skip IsExcludedModule() as well: it calls GetCallingModule() -> + // QueryVirtualMemoryInformation(), a per-allocation memory-region query that + // intermittently wedges forever on ARM64 under the loader lock at process + // teardown (aka.ms/AA10dvw4; see the ARM64 report handling in + // ~VisualLeakDetector). Such allocations are never user leaks, so this + // reports no different leak -- it only avoids the wedge-prone query. + if (!g_vld.enabled() || g_DbgHelp.IsLockedByCurrentThread()) { + Reset(); + return; + } +#endif + BOOL excluded = IsExcludedModule(); if ((m_tls->blockWithoutGuard) && (!excluded)) { blockinfo_t* pblockInfo = NULL; From 80003f506438072a821b5b8fa2cb6490f6fc313c Mon Sep 17 00:00:00 2001 From: "aiagentpool-mrbot[bot]" <223556219+Copilot@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:40:01 +0000 Subject: [PATCH 2/2] Scope ARM64 teardown lookup to teardown thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vld.cpp | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/vld.cpp b/src/vld.cpp index f901adc3..4f5dc9a7 100644 --- a/src/vld.cpp +++ b/src/vld.cpp @@ -232,24 +232,29 @@ struct Arm64TeardownReportScope ~Arm64TeardownReportScope() { InterlockedDecrement(&g_arm64InTeardownReport); } }; -// Broad "in ARM64 process-exit teardown" flag. Unlike g_arm64InTeardownReport -// (which suppresses dbghelp symbolization and must stay off during the pre-warm), -// this covers the ENTIRE leak report, including the pre-warm GetLeaksCount(). It -// is consulted only by GetCallingModule() (utility.cpp) to pick a loader-based -// module lookup instead of QueryVirtualMemoryInformation/VirtualQuery, whose -// NtQueryVirtualMemory intermittently livelocks under the loader lock on ARM64 -// at process exit (aka.ms/AA10dvw4). It changes no leak decision. -static volatile LONG g_arm64InTeardown = 0; +// The ARM64 teardown thread uses a loader-based module lookup during the entire +// leak report, including the pre-warm GetLeaksCount(). Other threads must keep +// using the normal lookup because they do not hold the loader lock. +static volatile LONG g_arm64TeardownThreadId = 0; extern "C" bool VldArm64InTeardown(void) { - return InterlockedCompareExchange(&g_arm64InTeardown, 0, 0) != 0; + DWORD teardownThreadId = static_cast( + InterlockedCompareExchange(&g_arm64TeardownThreadId, 0, 0)); + return teardownThreadId != 0 && teardownThreadId == GetCurrentThreadId(); } struct Arm64TeardownScope { - Arm64TeardownScope() { InterlockedIncrement(&g_arm64InTeardown); } - ~Arm64TeardownScope() { InterlockedDecrement(&g_arm64InTeardown); } + Arm64TeardownScope() + { + InterlockedExchange(&g_arm64TeardownThreadId, static_cast(GetCurrentThreadId())); + } + + ~Arm64TeardownScope() + { + InterlockedExchange(&g_arm64TeardownThreadId, 0); + } }; #endif