From a9578fcd3ad3551a9bc61718fe1103767aea0f6c Mon Sep 17 00:00:00 2001 From: Lee Culver Date: Mon, 8 Jun 2026 16:58:42 -0400 Subject: [PATCH 1/4] [cdac] Report GCFrame and in-flight exception objects as stack references WalkStackReferences (GetStackReferences) reports the per-thread GC roots that the GC scans in gcenv.ee.cpp ScanStackRoots. Alongside the frame references it now also reports: - The thread's GCFrame (GCPROTECT) chain: each GCFrame keeps a set of object references alive across a runtime operation. New data descriptors expose the chain -- Thread.GCFrame (m_pGCFrame) plus a GCFrame type (Next/ObjRefs/NumObjRefs/GCFlags) in vm/frames.h, vm/threads.h, datadescriptor.inc, and the managed Data/GCFrame.cs + DataType. The managed Thread.GCFrame field is optional, so a target that does not describe it is skipped. Interior promotion is applied when GCFlags != 0, matching GCFrame::GcScanRoots. - The thread's exception-tracking (ExInfo) chain: the current in-flight exception plus any superseded/nested ones, via the existing IException.GetNestedExceptionInfo contract. Adds two dump-based StackReferenceDumpTests with dedicated debuggees. NestedException builds a superseded exception chain and verifies the nested exception (reachable only through the ExInfo chain) is reported. GCProtect crashes inside an AppDomain.AssemblyResolve handler the runtime invokes while holding a GCPROTECT frame, and verifies a GCFrame-protected object is reported. Both use Full dumps and pass against a locally built runtime. --- .../vm/datadescriptor/datadescriptor.inc | 9 ++ src/coreclr/vm/frames.h | 11 +++ src/coreclr/vm/threads.h | 1 + .../Contracts/StackWalk/StackWalk_1.cs | 62 +++++++++++++ .../Data/GCFrame.cs | 13 +++ .../Data/Thread.cs | 2 + .../DataType.cs | 1 + .../Debuggees/GCProtect/GCProtect.csproj | 5 ++ .../DumpTests/Debuggees/GCProtect/Program.cs | 45 ++++++++++ .../NestedException/NestedException.csproj | 5 ++ .../Debuggees/NestedException/Program.cs | 47 ++++++++++ .../DumpTests/StackReferenceDumpTests.cs | 86 +++++++++++++++++++ 12 files changed, 287 insertions(+) create mode 100644 src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/GCFrame.cs create mode 100644 src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/GCProtect.csproj create mode 100644 src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/Program.cs create mode 100644 src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/NestedException.csproj create mode 100644 src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/Program.cs diff --git a/src/coreclr/vm/datadescriptor/datadescriptor.inc b/src/coreclr/vm/datadescriptor/datadescriptor.inc index 813180a13e6b14..b16cd621ddd9f3 100644 --- a/src/coreclr/vm/datadescriptor/datadescriptor.inc +++ b/src/coreclr/vm/datadescriptor/datadescriptor.inc @@ -41,6 +41,7 @@ CDAC_TYPE_FIELD(Thread, T_UINT32, State, cdac_data::State) CDAC_TYPE_FIELD(Thread, T_UINT32, PreemptiveGCDisabled, cdac_data::PreemptiveGCDisabled) CDAC_TYPE_FIELD(Thread, T_POINTER, RuntimeThreadLocals, cdac_data::RuntimeThreadLocals) CDAC_TYPE_FIELD(Thread, T_POINTER, Frame, cdac_data::Frame) +CDAC_TYPE_FIELD(Thread, T_POINTER, GCFrame, cdac_data::GCFrame) CDAC_TYPE_FIELD(Thread, T_POINTER, CachedStackBase, cdac_data::CachedStackBase) CDAC_TYPE_FIELD(Thread, T_POINTER, CachedStackLimit, cdac_data::CachedStackLimit) CDAC_TYPE_FIELD(Thread, T_POINTER, ExceptionTracker, cdac_data::ExceptionTracker) @@ -70,6 +71,14 @@ CDAC_TYPE_FIELD(ThreadStore, T_INT32, PendingCount, cdac_data::Pend CDAC_TYPE_FIELD(ThreadStore, T_INT32, DeadCount, cdac_data::DeadCount) CDAC_TYPE_END(ThreadStore) +CDAC_TYPE_BEGIN(GCFrame) +CDAC_TYPE_INDETERMINATE(GCFrame) +CDAC_TYPE_FIELD(GCFrame, T_POINTER, Next, cdac_data::Next) +CDAC_TYPE_FIELD(GCFrame, T_POINTER, ObjRefs, cdac_data::ObjRefs) +CDAC_TYPE_FIELD(GCFrame, T_UINT32, NumObjRefs, cdac_data::NumObjRefs) +CDAC_TYPE_FIELD(GCFrame, T_UINT32, GCFlags, cdac_data::GCFlags) +CDAC_TYPE_END(GCFrame) + CDAC_TYPE_BEGIN(RuntimeThreadLocals) CDAC_TYPE_INDETERMINATE(RuntimeThreadLocals) CDAC_TYPE_FIELD(RuntimeThreadLocals, TYPE(EEAllocContext), AllocContext, offsetof(RuntimeThreadLocals, alloc_context)) diff --git a/src/coreclr/vm/frames.h b/src/coreclr/vm/frames.h index 74a3c131e8e2e7..898717d16cfc1a 100644 --- a/src/coreclr/vm/frames.h +++ b/src/coreclr/vm/frames.h @@ -1764,6 +1764,17 @@ class GCFrame #ifdef FEATURE_INTERPRETER PTR_VOID m_osStackLocation; #endif + + friend struct ::cdac_data; +}; + +template<> +struct cdac_data +{ + static constexpr size_t Next = offsetof(GCFrame, m_Next); + static constexpr size_t ObjRefs = offsetof(GCFrame, m_pObjRefs); + static constexpr size_t NumObjRefs = offsetof(GCFrame, m_numObjRefs); + static constexpr size_t GCFlags = offsetof(GCFrame, m_gcFlags); }; //----------------------------------------------------------------------------- diff --git a/src/coreclr/vm/threads.h b/src/coreclr/vm/threads.h index ddf01bf860e0e0..c4e605646c4cc8 100644 --- a/src/coreclr/vm/threads.h +++ b/src/coreclr/vm/threads.h @@ -3766,6 +3766,7 @@ struct cdac_data static constexpr size_t PreemptiveGCDisabled = offsetof(Thread, m_fPreemptiveGCDisabled); static constexpr size_t RuntimeThreadLocals = offsetof(Thread, m_pRuntimeThreadLocals); static constexpr size_t Frame = offsetof(Thread, m_pFrame); + static constexpr size_t GCFrame = offsetof(Thread, m_pGCFrame); static constexpr size_t CachedStackBase = offsetof(Thread, m_CacheStackBase); static constexpr size_t CachedStackLimit = offsetof(Thread, m_CacheStackLimit); static constexpr size_t ExposedObject = offsetof(Thread, m_ExposedObject); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index 5de2562d36461f..dee0533e5bd400 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -292,6 +292,14 @@ IReadOnlyList IStackWalk.WalkStackReferences(ThreadData thre } } + // Report the thread's GCFrame (GCPROTECT) chain: each GCFrame keeps a set of object + // references alive across a runtime operation, so report them as roots. + ReportGCFrameRoots(threadData, scanContext); + + // Report the thread's exception-tracking (ExInfo) chain: the current in-flight exception + // and any superseded/nested ones are kept alive by the runtime, so report them as roots. + ReportExceptionTrackerRoots(threadData, scanContext); + return scanContext.StackRefs.Select(r => new StackReferenceData { HasRegisterInformation = r.HasRegisterInformation, @@ -306,6 +314,60 @@ IReadOnlyList IStackWalk.WalkStackReferences(ThreadData thre }).ToList(); } + // Reports each in-flight exception object held on the thread's exception-tracking (ExInfo) + // chain: the current exception and any superseded/nested ones. The GC reports the same set in + // gcenv.ee.cpp ScanStackRoots. + private void ReportExceptionTrackerRoots(ThreadData threadData, GcScanContext scanContext) + { + Data.Thread thread = _target.ProcessedData.GetOrAdd(threadData.ThreadAddress); + TargetPointer pExInfo = _target.ReadPointer(thread.ExceptionTracker); + if (pExInfo == TargetPointer.Null) + return; + + IException exceptionContract = _target.Contracts.Exception; + HashSet seen = new(); + while (pExInfo != TargetPointer.Null && seen.Add(pExInfo)) + { + // GetNestedExceptionInfo yields the address of the thrown-object slot (ExInfo::m_exception) + // and the previous (nested) ExInfo; GCReportCallback reads the object through that slot. + exceptionContract.GetNestedExceptionInfo(pExInfo, out TargetPointer previous, out TargetPointer thrownObjectSlot); + scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pExInfo); + scanContext.GCReportCallback(thrownObjectSlot, GcScanFlags.None); + pExInfo = previous; + } + } + + // Reports each object reference protected by the thread's GCFrame (GCPROTECT) chain. + // GCFrame::GcScanRoots reports m_pObjRefs[0..m_numObjRefs), using an interior promotion when + // m_gcFlags != 0; the GC reports the same set in gcenv.ee.cpp ScanStackRoots. + private void ReportGCFrameRoots(ThreadData threadData, GcScanContext scanContext) + { + Data.Thread thread = _target.ProcessedData.GetOrAdd(threadData.ThreadAddress); + + // The GCFrame field is optional in the data contract; nothing to scan when the target + // does not describe it. + if (thread.GCFrame is not TargetPointer head) + return; + + // The chain is terminated by GCFRAME_TOP (FRAME_TOP_VALUE == ~0), sized to the pointer width. + TargetPointer terminator = TargetPointer.PlatformMaxValue(_target); + ulong pointerSize = (ulong)_target.PointerSize; + HashSet seen = new(); + TargetPointer pGCFrame = head; + while (pGCFrame != TargetPointer.Null && pGCFrame != terminator && seen.Add(pGCFrame)) + { + Data.GCFrame gcFrame = _target.ProcessedData.GetOrAdd(pGCFrame); + scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pGCFrame); + GcScanFlags flags = gcFrame.GCFlags != 0 ? GcScanFlags.GC_CALL_INTERIOR : GcScanFlags.None; + for (uint i = 0; i < gcFrame.NumObjRefs; i++) + { + TargetPointer slot = new(gcFrame.ObjRefs.Value + (ulong)i * pointerSize); + scanContext.GCReportCallback(slot, flags); + } + pGCFrame = gcFrame.Next; + } + } + private record GCFrameData { public GCFrameData(StackDataFrameHandle frame) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/GCFrame.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/GCFrame.cs new file mode 100644 index 00000000000000..ae436263ca1192 --- /dev/null +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/GCFrame.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Diagnostics.DataContractReader.Data; + +[CdacType(nameof(DataType.GCFrame))] +internal sealed partial class GCFrame : IData +{ + [Field] public TargetPointer Next { get; } + [Field] public TargetPointer ObjRefs { get; } + [Field] public uint NumObjRefs { get; } + [Field] public uint GCFlags { get; } +} diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Thread.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Thread.cs index 98558beab9c3f1..a7d3bd996efc66 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Thread.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Thread.cs @@ -12,6 +12,8 @@ internal sealed partial class Thread : IData [Field(Writable = true)] public uint DebuggerControlledThreadState { get; private set; } [Field] public uint PreemptiveGCDisabled { get; } [Field] public TargetPointer Frame { get; } + // Optional: only targets that describe the GCFrame chain carry this field. + [Field] public TargetPointer? GCFrame { get; } [Field] public TargetPointer CachedStackBase { get; } [Field] public TargetPointer CachedStackLimit { get; } [Field] public ObjectHandle ExposedObject { get; } diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs index 145a2278801878..d957db98fdfa31 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs @@ -34,6 +34,7 @@ public enum DataType EEAllocContext, Exception, ExceptionInfo, + GCFrame, EEExceptionClause, ExceptionLookupTableEntry, EEILException, diff --git a/src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/GCProtect.csproj b/src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/GCProtect.csproj new file mode 100644 index 00000000000000..bb776824769fe6 --- /dev/null +++ b/src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/GCProtect.csproj @@ -0,0 +1,5 @@ + + + Full + + diff --git a/src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/Program.cs b/src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/Program.cs new file mode 100644 index 00000000000000..db0cc0bc8a2b9a --- /dev/null +++ b/src/native/managed/cdac/tests/DumpTests/Debuggees/GCProtect/Program.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; +using System.Runtime.CompilerServices; + +/// +/// Debuggee for cDAC dump tests — exercises GCFrame (GCPROTECT) root reporting. +/// Triggers AppDomain.AssemblyResolve by loading a missing assembly. The native +/// AppDomain::RaiseAssemblyResolveEvent invokes the managed handler while holding a +/// GCPROTECT frame over the requesting Assembly reference. The handler FailFasts so the +/// dump captures the thread with that GCFrame still live. The GC reports the GCFrame's protected +/// objects via GCFrame::GcScanRoots; the test verifies WalkStackReferences reports them too. +/// +internal static class Program +{ + private static void Main() + { + AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; + TriggerResolve(); + Environment.FailFast("cDAC dump test: GCProtect debuggee did not hit the resolve handler"); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TriggerResolve() + { + try + { + Assembly.Load("cDAC_Missing_Assembly_GCFrameMarker, Version=9.9.9.9, Culture=neutral, PublicKeyToken=null"); + } + catch + { + // Resolution ultimately fails; the crash happens inside the handler below first. + } + } + + private static Assembly? OnAssemblyResolve(object? sender, ResolveEventArgs args) + { + // Runs inside AppDomain::RaiseAssemblyResolveEvent's GCPROTECT(gc) scope, so the + // requesting Assembly reference is held only by that native GCFrame at this point. + Environment.FailFast("cDAC dump test: GCProtect debuggee intentional crash"); + return null; + } +} diff --git a/src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/NestedException.csproj b/src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/NestedException.csproj new file mode 100644 index 00000000000000..bb776824769fe6 --- /dev/null +++ b/src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/NestedException.csproj @@ -0,0 +1,5 @@ + + + Full + + diff --git a/src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/Program.cs b/src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/Program.cs new file mode 100644 index 00000000000000..177645d1538127 --- /dev/null +++ b/src/native/managed/cdac/tests/DumpTests/Debuggees/NestedException/Program.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Runtime.CompilerServices; + +/// +/// Debuggee for cDAC dump tests — exercises in-flight exception root reporting. +/// Builds a nested exception chain: a superseded +/// (kept alive on the thread's ExInfo chain as the previous nested tracker) plus the +/// current , then crashes via FailFast while both +/// are still in flight. The GC reports these via gcenv.ee.cpp ScanStackRoots; the test verifies +/// WalkStackReferences reports them as stack references. +/// +internal static class Program +{ + private static void Main() + { + try + { + ThrowNested(); + } + catch (Exception current) + { + // 'current' is the InvalidOperationException; the superseded + // FileNotFoundException is still held on the thread's ExInfo chain. + GC.KeepAlive(current); + Environment.FailFast("cDAC dump test: NestedException debuggee intentional crash"); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ThrowNested() + { + try + { + throw new FileNotFoundException("cDAC-NestedException-inner"); + } + catch (FileNotFoundException inner) + { + // Throwing while handling 'inner' supersedes its ExInfo (kept as the + // previous nested tracker) and starts a new tracker for the outer exception. + throw new InvalidOperationException("cDAC-NestedException-outer", inner); + } + } +} diff --git a/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs b/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs index fe299495996aa4..5dfa06f3313ab0 100644 --- a/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs +++ b/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs @@ -103,6 +103,92 @@ public void GCRoots_RefsPointToValidObjects(TestConfiguration config) $"Expected at least one stack ref pointing to a valid object (total refs: {refs.Count})"); } + // --- NestedException debuggee: in-flight exception objects reported as roots --- + + [ConditionalTheory] + [MemberData(nameof(TestConfigurations))] + [SkipOnVersion("net10.0", "InlinedCallFrame.Datum was added after net10.0")] + [SkipOnArch("x86", "GCInfo decoder does not support x86")] + public void NestedException_InFlightExceptionsReportedAsRoots(TestConfiguration config) + { + InitializeDumpTest(config, "NestedException", "full"); + IStackWalk stackWalk = Target.Contracts.StackWalk; + IException exceptionContract = Target.Contracts.Exception; + + ThreadData crashingThread = DumpTestHelpers.FindFailFastThread(Target); + + // FirstNestedException is the previous tracker on the thread's ExInfo chain + // (Thread_1.GetThreadData sets it from currentExInfo.PreviousNestedInfo), so it + // enumerates the superseded / nested in-flight exceptions. These are held only by the + // runtime's exception-tracking chain (the nested FileNotFoundException lives on the heap + // as InvalidOperationException.InnerException, not as a stack local), so the ExInfo scan in + // WalkStackReferences is what surfaces them as roots. + HashSet expected = new(); + HashSet seenTrackers = new(); + TargetPointer exInfo = crashingThread.FirstNestedException; + while (exInfo != TargetPointer.Null && seenTrackers.Add(exInfo)) + { + exceptionContract.GetNestedExceptionInfo(exInfo, out TargetPointer next, out TargetPointer thrownObjectSlot); + TargetPointer obj = Target.ReadPointer(thrownObjectSlot); + if (obj != TargetPointer.Null) + expected.Add(obj); + exInfo = next; + } + + Assert.True(expected.Count >= 1, + $"NestedException debuggee should hold at least one superseded exception on its ExInfo chain; found {expected.Count}"); + + // WalkStackReferences must surface every in-flight exception object as a stack reference. + HashSet reported = new(); + foreach (StackReferenceData r in stackWalk.WalkStackReferences(crashingThread)) + { + if (r.Object != TargetPointer.Null) + reported.Add(r.Object); + } + + foreach (ulong exc in expected) + Assert.True(reported.Contains(exc), + $"Expected in-flight exception object 0x{exc:x} to be reported as a stack reference"); + } + + // --- GCProtect debuggee: GCFrame (GCPROTECT) protected objects reported as roots --- + + [ConditionalTheory] + [MemberData(nameof(TestConfigurations))] + [SkipOnVersion("net10.0", "InlinedCallFrame.Datum was added after net10.0")] + [SkipOnArch("x86", "GCInfo decoder does not support x86")] + public void GCProtect_GCFrameRootsAreReported(TestConfiguration config) + { + InitializeDumpTest(config, "GCProtect", "full"); + IStackWalk stackWalk = Target.Contracts.StackWalk; + + ThreadData crashingThread = DumpTestHelpers.FindFailFastThread(Target); + + // The debuggee crashes inside an AppDomain.AssemblyResolve handler, which the runtime + // (AppDomain::RaiseAssemblyResolveEvent) invokes while holding a GCPROTECT frame over the + // requesting Assembly reference. ReportGCFrameRoots walks that GCFrame chain and reports + // each protected object via UpdateScanContext(sp: null, ip: null, frame: pGCFrame), so a + // GCFrame-sourced root is identifiable as IsStackSourceFrame == true with a null + // StackPointer. Frameless GcInfo roots carry a non-null StackPointer, and this debuggee + // has no in-flight exception (the only other producer of that signature). + IReadOnlyList refs = stackWalk.WalkStackReferences(crashingThread); + + int gcFrameRoots = 0; + foreach (StackReferenceData r in refs) + { + if (!r.IsStackSourceFrame || r.StackPointer != TargetPointer.Null || r.Object == TargetPointer.Null) + continue; + + // A real heap object held alive by a GCFrame: its MethodTable must be readable. + TargetPointer methodTable = Target.ReadPointer(r.Object); + if (methodTable != TargetPointer.Null) + gcFrameRoots++; + } + + Assert.True(gcFrameRoots > 0, + $"Expected at least one GCFrame (GCPROTECT) protected object to be reported as a stack reference (total refs: {refs.Count})"); + } + // --- StackRefs debuggee: known objects on stack with verifiable content --- // These tests require Frame-based GC root scanning (ScanFrameRoots) which is not yet implemented. From 0d824abe21510c2fe23f520c9a7ea30dd2eeec25 Mon Sep 17 00:00:00 2001 From: Lee Culver Date: Mon, 8 Jun 2026 20:33:22 -0400 Subject: [PATCH 2/4] Address PR review: pass GCFrame flags through, guard new root reporting, match GCFrame roots by Source - ReportGCFrameRoots: forward the full GCFrame.GCFlags bitmask to the promote func via (GcScanFlags) cast, mirroring GCFrame::GcScanRoots, instead of remapping any nonzero value to GC_CALL_INTERIOR. - ReportGCFrameRoots/ReportExceptionTrackerRoots: wrap each helper body in try/catch so a bad read yields partial results instead of failing the whole WalkStackReferences walk, matching the per-frame loop. - GCProtect_GCFrameRootsAreReported: identify GCFrame roots by matching StackReferenceData.Source against the thread's actual GCFrame chain node addresses, instead of the SP-null heuristic that also matches ExInfo-sourced roots. --- .../Contracts/StackWalk/StackWalk_1.cs | 78 +++++++++++-------- .../DumpTests/StackReferenceDumpTests.cs | 26 +++++-- 2 files changed, 64 insertions(+), 40 deletions(-) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index dee0533e5bd400..7d524bb738e04d 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -319,21 +319,28 @@ IReadOnlyList IStackWalk.WalkStackReferences(ThreadData thre // gcenv.ee.cpp ScanStackRoots. private void ReportExceptionTrackerRoots(ThreadData threadData, GcScanContext scanContext) { - Data.Thread thread = _target.ProcessedData.GetOrAdd(threadData.ThreadAddress); - TargetPointer pExInfo = _target.ReadPointer(thread.ExceptionTracker); - if (pExInfo == TargetPointer.Null) - return; + try + { + Data.Thread thread = _target.ProcessedData.GetOrAdd(threadData.ThreadAddress); + TargetPointer pExInfo = _target.ReadPointer(thread.ExceptionTracker); + if (pExInfo == TargetPointer.Null) + return; - IException exceptionContract = _target.Contracts.Exception; - HashSet seen = new(); - while (pExInfo != TargetPointer.Null && seen.Add(pExInfo)) + IException exceptionContract = _target.Contracts.Exception; + HashSet seen = new(); + while (pExInfo != TargetPointer.Null && seen.Add(pExInfo)) + { + // GetNestedExceptionInfo yields the address of the thrown-object slot (ExInfo::m_exception) + // and the previous (nested) ExInfo; GCReportCallback reads the object through that slot. + exceptionContract.GetNestedExceptionInfo(pExInfo, out TargetPointer previous, out TargetPointer thrownObjectSlot); + scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pExInfo); + scanContext.GCReportCallback(thrownObjectSlot, GcScanFlags.None); + pExInfo = previous; + } + } + catch (System.Exception ex) { - // GetNestedExceptionInfo yields the address of the thrown-object slot (ExInfo::m_exception) - // and the previous (nested) ExInfo; GCReportCallback reads the object through that slot. - exceptionContract.GetNestedExceptionInfo(pExInfo, out TargetPointer previous, out TargetPointer thrownObjectSlot); - scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pExInfo); - scanContext.GCReportCallback(thrownObjectSlot, GcScanFlags.None); - pExInfo = previous; + Debug.WriteLine($"Exception during {nameof(ReportExceptionTrackerRoots)}: {ex.GetType().Name}: {ex.Message}"); } } @@ -342,29 +349,36 @@ private void ReportExceptionTrackerRoots(ThreadData threadData, GcScanContext sc // m_gcFlags != 0; the GC reports the same set in gcenv.ee.cpp ScanStackRoots. private void ReportGCFrameRoots(ThreadData threadData, GcScanContext scanContext) { - Data.Thread thread = _target.ProcessedData.GetOrAdd(threadData.ThreadAddress); + try + { + Data.Thread thread = _target.ProcessedData.GetOrAdd(threadData.ThreadAddress); - // The GCFrame field is optional in the data contract; nothing to scan when the target - // does not describe it. - if (thread.GCFrame is not TargetPointer head) - return; + // The GCFrame field is optional in the data contract; nothing to scan when the target + // does not describe it. + if (thread.GCFrame is not TargetPointer head) + return; - // The chain is terminated by GCFRAME_TOP (FRAME_TOP_VALUE == ~0), sized to the pointer width. - TargetPointer terminator = TargetPointer.PlatformMaxValue(_target); - ulong pointerSize = (ulong)_target.PointerSize; - HashSet seen = new(); - TargetPointer pGCFrame = head; - while (pGCFrame != TargetPointer.Null && pGCFrame != terminator && seen.Add(pGCFrame)) - { - Data.GCFrame gcFrame = _target.ProcessedData.GetOrAdd(pGCFrame); - scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pGCFrame); - GcScanFlags flags = gcFrame.GCFlags != 0 ? GcScanFlags.GC_CALL_INTERIOR : GcScanFlags.None; - for (uint i = 0; i < gcFrame.NumObjRefs; i++) + // The chain is terminated by GCFRAME_TOP (FRAME_TOP_VALUE == ~0), sized to the pointer width. + TargetPointer terminator = TargetPointer.PlatformMaxValue(_target); + ulong pointerSize = (ulong)_target.PointerSize; + HashSet seen = new(); + TargetPointer pGCFrame = head; + while (pGCFrame != TargetPointer.Null && pGCFrame != terminator && seen.Add(pGCFrame)) { - TargetPointer slot = new(gcFrame.ObjRefs.Value + (ulong)i * pointerSize); - scanContext.GCReportCallback(slot, flags); + Data.GCFrame gcFrame = _target.ProcessedData.GetOrAdd(pGCFrame); + scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pGCFrame); + GcScanFlags flags = (GcScanFlags)gcFrame.GCFlags; + for (uint i = 0; i < gcFrame.NumObjRefs; i++) + { + TargetPointer slot = new(gcFrame.ObjRefs.Value + (ulong)i * pointerSize); + scanContext.GCReportCallback(slot, flags); + } + pGCFrame = gcFrame.Next; } - pGCFrame = gcFrame.Next; + } + catch (System.Exception ex) + { + Debug.WriteLine($"Exception during {nameof(ReportGCFrameRoots)}: {ex.GetType().Name}: {ex.Message}"); } } diff --git a/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs b/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs index 5dfa06f3313ab0..4b645eb86800bc 100644 --- a/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs +++ b/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs @@ -164,19 +164,29 @@ public void GCProtect_GCFrameRootsAreReported(TestConfiguration config) ThreadData crashingThread = DumpTestHelpers.FindFailFastThread(Target); - // The debuggee crashes inside an AppDomain.AssemblyResolve handler, which the runtime - // (AppDomain::RaiseAssemblyResolveEvent) invokes while holding a GCPROTECT frame over the - // requesting Assembly reference. ReportGCFrameRoots walks that GCFrame chain and reports - // each protected object via UpdateScanContext(sp: null, ip: null, frame: pGCFrame), so a - // GCFrame-sourced root is identifiable as IsStackSourceFrame == true with a null - // StackPointer. Frameless GcInfo roots carry a non-null StackPointer, and this debuggee - // has no in-flight exception (the only other producer of that signature). + // The debuggee crashes inside an AppDomain.AssemblyResolve handler the runtime invokes while + // holding a GCPROTECT frame over the requesting Assembly reference. WalkStackReferences reports + // each GCFrame-protected object with the GCFrame node address as its Source; the test walks the + // thread's GCFrame chain and asserts a reported root's Source matches a node in that chain. IReadOnlyList refs = stackWalk.WalkStackReferences(crashingThread); + // Enumerate the thread's GCFrame chain node addresses; each reported GCFrame root carries the + // GCFrame node address as its Source (UpdateScanContext(frame: pGCFrame)). + Target.TypeInfo threadType = Target.GetTypeInfo("Thread"); + Target.TypeInfo gcFrameType = Target.GetTypeInfo("GCFrame"); + TargetPointer terminator = TargetPointer.PlatformMaxValue(Target); + + HashSet gcFrameNodes = new(); + TargetPointer node = Target.ReadPointerFieldOrNull(crashingThread.ThreadAddress, threadType, "GCFrame"); + while (node != TargetPointer.Null && node != terminator && gcFrameNodes.Add(node)) + node = Target.ReadPointerField(node, gcFrameType, "Next"); + + Assert.True(gcFrameNodes.Count > 0, "GCProtect debuggee should have at least one live GCFrame"); + int gcFrameRoots = 0; foreach (StackReferenceData r in refs) { - if (!r.IsStackSourceFrame || r.StackPointer != TargetPointer.Null || r.Object == TargetPointer.Null) + if (!gcFrameNodes.Contains(r.Source) || r.Object == TargetPointer.Null) continue; // A real heap object held alive by a GCFrame: its MethodTable must be readable. From 3665c479bec2f6c886fe4a83fe3daafa98bfa52f Mon Sep 17 00:00:00 2001 From: Lee Culver Date: Mon, 8 Jun 2026 21:09:35 -0400 Subject: [PATCH 3/4] Address second PR review round: SP, DataType placement, docs, test guard - Report GCFrame/ExInfo roots with a non-zero StackPointer (the on-stack node address) instead of 0, matching native DacStackReferenceWalker which gives frame-sourced roots a real SP that SOSDacImpl forwards. - Move DataType.GCFrame from the exception-type group into the Frame group where it belongs (GCFrame is a Frame subtype). DataType lookups are name-keyed, so ordering is not load-bearing. - Document Thread.GCFrame and the GCFrame type descriptors, plus the new GCFrame/ExInfo root reporting, in the Thread and StackWalk contract docs. - Guard the test's MethodTable read with try/catch (a reported GCFrame root may be an interior pointer), mirroring GCRoots_RefsPointToValidObjects. --- docs/design/datacontracts/StackWalk.md | 14 ++++++++++++++ docs/design/datacontracts/Thread.md | 1 + .../Contracts/StackWalk/StackWalk_1.cs | 10 ++++++++-- .../DataType.cs | 2 +- .../tests/DumpTests/StackReferenceDumpTests.cs | 16 ++++++++++++---- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 29e13c20a4be6c..acccceb768f246 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -118,6 +118,11 @@ This contract depends on the following descriptors: | `ExceptionInfo` | `PassNumber` | Exception handling pass (1 or 2) | | `ExceptionInfo` | `ClauseForCatchHandlerStartPC` | Start PC offset of the catch handler clause, used for interruptible offset override | | `ExceptionInfo` | `ClauseForCatchHandlerEndPC` | End PC offset of the catch handler clause, used for interruptible offset override | +| `Thread` | `GCFrame` | Pointer to the head of the thread's GCFrame (GCPROTECT) chain, scanned by `WalkStackReferences` (optional) | +| `GCFrame` | `Next` | Pointer to the next `GCFrame` toward the top of the chain (terminated by `GCFRAME_TOP`) | +| `GCFrame` | `ObjRefs` | Pointer to the array of protected object reference slots | +| `GCFrame` | `NumObjRefs` | Count of protected object reference slots starting at `ObjRefs` | +| `GCFrame` | `GCFlags` | `GC_CALL_*` promotion flags applied when reporting the protected slots | Global variables used: | Global Name | Type | Purpose | @@ -589,6 +594,15 @@ At each frame yielded by `Filter`, the walk determines whether to scan for GC re See [GCRefMap Format and Resolution](#gcrefmap-format-and-resolution) for the GCRefMap scanning path and [Signature-Based Scanning](#signature-based-scanning) for the signature decoding path. +#### GCFrame and Exception Tracker Roots + +After walking the thread's frames, `WalkStackReferences` reports two additional sets of roots that the GC keeps alive but that are not surfaced by per-frame GC info (matching native `gcenv.ee.cpp` `ScanStackRoots`): + +- **GCFrame (GCPROTECT) chain**: starting from `Thread.GCFrame`, each `GCFrame` is walked via its `Next` pointer until the `GCFRAME_TOP` terminator (`~0`, sized to the pointer width). For each node, the `NumObjRefs` slots starting at `ObjRefs` are reported, applying the node's `GCFlags` (`GC_CALL_INTERIOR` / `GC_CALL_PINNED`) as the promotion flags. This mirrors native `GCFrame::GcScanRoots`. +- **Exception tracker (ExInfo) chain**: starting from `Thread.ExceptionTracker`, each in-flight exception object (the current one and any superseded/nested ones reached via `PreviousNestedInfo`) is reported through its thrown-object slot. + +Both sets are reported as frame-sourced roots: `Source` is the GCFrame / ExInfo node address, and `StackPointer` is that same address (the node lives on the stack), so the roots carry a non-zero, stack-resident location consistent with the per-frame roots. Each helper is independently wrapped in a try/catch so a single unreadable node yields partial results rather than failing the whole walk. + ### Signature-Based Scanning When a transition frame's calling convention is not described by a precomputed GCRefMap (`PrestubMethodFrame`, `CallCountingHelperFrame`, and the fallback path for `StubDispatchFrame`/`ExternalMethodFrame`), the GC reference walk classifies caller-stack arguments by decoding the callee's method signature. This corresponds to native `TransitionFrame::PromoteCallerStack` (`src/coreclr/vm/frames.cpp`). diff --git a/docs/design/datacontracts/Thread.md b/docs/design/datacontracts/Thread.md index b781ebfc7ea77e..9ce690983e2c13 100644 --- a/docs/design/datacontracts/Thread.md +++ b/docs/design/datacontracts/Thread.md @@ -122,6 +122,7 @@ The contract additionally depends on these data descriptors | `Thread` | `DebuggerControlledThreadState` | Thread state flags controlled by the debugger | | `Thread` | `PreemptiveGCDisabled` | Flag indicating if preemptive GC is disabled | | `Thread` | `Frame` | Pointer to current frame | +| `Thread` | `GCFrame` | Pointer to the head of the thread's GCFrame (GCPROTECT) chain (optional; readers should expect `TargetPointer.Null` when the field is absent) | | `Thread` | `CachedStackBase` | Pointer to the base of the stack | | `Thread` | `CachedStackLimit` | Pointer to the limit of the stack | | `Thread` | `ExposedObject` | Handle to the managed `Thread` object exposed to the debugger | diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index 7d524bb738e04d..dd45e816c58137 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -332,8 +332,11 @@ private void ReportExceptionTrackerRoots(ThreadData threadData, GcScanContext sc { // GetNestedExceptionInfo yields the address of the thrown-object slot (ExInfo::m_exception) // and the previous (nested) ExInfo; GCReportCallback reads the object through that slot. + // The ExInfo lives on the stack, so report its address as the StackPointer: native + // (DacStackReferenceWalker) reports frame-sourced roots with a non-zero SP, and consumers + // (SOSDacImpl.GetStackReferences) forward it. exceptionContract.GetNestedExceptionInfo(pExInfo, out TargetPointer previous, out TargetPointer thrownObjectSlot); - scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pExInfo); + scanContext.UpdateScanContext(pExInfo, TargetPointer.Null, pExInfo); scanContext.GCReportCallback(thrownObjectSlot, GcScanFlags.None); pExInfo = previous; } @@ -366,7 +369,10 @@ private void ReportGCFrameRoots(ThreadData threadData, GcScanContext scanContext while (pGCFrame != TargetPointer.Null && pGCFrame != terminator && seen.Add(pGCFrame)) { Data.GCFrame gcFrame = _target.ProcessedData.GetOrAdd(pGCFrame); - scanContext.UpdateScanContext(TargetPointer.Null, TargetPointer.Null, pGCFrame); + // The GCFrame lives on the stack, so report its address as the StackPointer: native + // (DacStackReferenceWalker) reports frame-sourced roots with a non-zero SP, and consumers + // (SOSDacImpl.GetStackReferences) forward it. + scanContext.UpdateScanContext(pGCFrame, TargetPointer.Null, pGCFrame); GcScanFlags flags = (GcScanFlags)gcFrame.GCFlags; for (uint i = 0; i < gcFrame.NumObjRefs; i++) { diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs index d957db98fdfa31..107fde1ebfd15a 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/DataType.cs @@ -34,7 +34,6 @@ public enum DataType EEAllocContext, Exception, ExceptionInfo, - GCFrame, EEExceptionClause, ExceptionLookupTableEntry, EEILException, @@ -161,6 +160,7 @@ public enum DataType HijackArgs, Frame, + GCFrame, InlinedCallFrame, SoftwareExceptionFrame, FramedMethodFrame, diff --git a/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs b/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs index 4b645eb86800bc..755735d9c945c0 100644 --- a/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs +++ b/src/native/managed/cdac/tests/DumpTests/StackReferenceDumpTests.cs @@ -189,10 +189,18 @@ public void GCProtect_GCFrameRootsAreReported(TestConfiguration config) if (!gcFrameNodes.Contains(r.Source) || r.Object == TargetPointer.Null) continue; - // A real heap object held alive by a GCFrame: its MethodTable must be readable. - TargetPointer methodTable = Target.ReadPointer(r.Object); - if (methodTable != TargetPointer.Null) - gcFrameRoots++; + // A real heap object held alive by a GCFrame: its MethodTable must be readable. A reported + // GCFrame root can be an interior pointer or otherwise unreadable, so guard the read. + try + { + TargetPointer methodTable = Target.ReadPointer(r.Object); + if (methodTable != TargetPointer.Null) + gcFrameRoots++; + } + catch + { + // Interior pointer or otherwise unreadable slot; not a countable heap object. + } } Assert.True(gcFrameRoots > 0, From 0786e5a145345e7729387143ae7d5c8acf017014 Mon Sep 17 00:00:00 2001 From: Lee Culver Date: Mon, 8 Jun 2026 21:44:06 -0400 Subject: [PATCH 4/4] cdac: final cleanup and hardening pass for stack-reference reporting Bring the in-process cdacstress oracle into parity with GetStackReferences. WalkStackReferences now reports the GCFrame (GCPROTECT) chain and the in-flight ExInfo chain, but CollectRuntimeStackRefs skipped GCFrame outright and never collected ExInfo, so any thread holding a live GCPROTECT frame or an in-flight exception produced a spurious cDAC/runtime count mismatch. Mirror ScanStackRoots on the runtime side so the two sets line up. Also correct the StackWalk data-contract doc: WalkStackReferences mirrors the GC's own ScanStackRoots (a superset of DacStackReferenceWalker, which covers only the per-frame roots), and add the Exception contract dependency. --- docs/design/datacontracts/StackWalk.md | 3 ++- src/coreclr/vm/cdacstress.cpp | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index acccceb768f246..1fecac7ab458c1 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -142,6 +142,7 @@ Contracts used: | `Thread` | | `RuntimeTypeSystem` | | `GCInfo` | +| `Exception` | ### Stackwalk Algorithm @@ -560,7 +561,7 @@ The implementation uses the same stack walk algorithm as `CreateStackWalk`, but ### GC Stack Reference Scanning -`WalkStackReferences` scans the stack for GC references by walking through each frame and reporting live object references and interior pointers. The native equivalent is `DacStackReferenceWalker` which calls `GcStackCrawlCallBack` at each frame. +`WalkStackReferences` scans the stack for GC references by walking through each frame and reporting live object references and interior pointers, then reporting the thread's GCFrame (GCPROTECT) chain and in-flight exception (ExInfo) chain. This mirrors the GC's own root enumeration, `ScanStackRoots` (`src/coreclr/vm/gcenv.ee.cpp`); the legacy `DacStackReferenceWalker` reports only the per-frame subset and omits the GCFrame and ExInfo roots. #### Stack Walk Integration diff --git a/src/coreclr/vm/cdacstress.cpp b/src/coreclr/vm/cdacstress.cpp index b750c069891af0..1f7743a09aeb79 100644 --- a/src/coreclr/vm/cdacstress.cpp +++ b/src/coreclr/vm/cdacstress.cpp @@ -692,10 +692,24 @@ static bool CollectRuntimeStackRefs(Thread* pThread, PCONTEXT regs, StackRef* ou pThread->StackWalkFrames(dacLikeCallback, &diagCtx, flagsStackWalk); - // NOTE: ScanStackRoots also scans the separate GCFrame linked list - // (Thread::GetGCFrame), but the DAC's GetStackReferences / DacStackReferenceWalker - // does NOT include those. We intentionally omit GCFrame scanning here so our - // runtime-side collection matches what the cDAC is expected to produce. + // ScanStackRoots also scans two root sets that are not part of the frame walk: the + // GCFrame (GCPROTECT) chain and the in-flight ExInfo chain. GetStackReferences reports + // both, so mirror them here to keep the runtime-side collection in parity. See + // ScanStackRoots in gcenv.ee.cpp. + GCFrame* pGCFrame = pThread->GetGCFrame(); + while (pGCFrame != GCFRAME_TOP) + { + pGCFrame->GcScanRoots(gcctx.f, gcctx.sc); + pGCFrame = pGCFrame->PtrNextFrame(); + } + + PTR_ExInfo pExInfo = pThread->GetExceptionState()->GetCurrentExceptionTracker(); + while (pExInfo != NULL) + { + PTR_PTR_Object pRef = dac_cast(&pExInfo->m_exception); + gcctx.f(pRef, gcctx.sc, 0); + pExInfo = pExInfo->GetPreviousExceptionTracker(); + } // Copy results out *outCount = collectCtx.count;