From c33a5787bada086171640773d7d2d8d807c75158 Mon Sep 17 00:00:00 2001 From: Max Charlamb Date: Wed, 17 Jun 2026 19:00:12 -0400 Subject: [PATCH 01/33] [cdac] x86: implement IGCInfoDecoder.EnumerateLiveSlots; unblock GCRoots stackref tests Builds on the partial x86 IGCInfo support added in #129456 by porting the remaining decoder pieces required for GC-root scanning on x86, so that `IStackWalk.WalkStackReferences` returns live frame slots on x86 cDAC. The x86 GC info uses the legacy bit-packed `InfoHdr` byte-stream encoding (`src/coreclr/vm/gc_unwind_x86.inl`, `src/coreclr/inc/gcdecoder.cpp`) instead of the modern `GcInfoDecoder` shared by other architectures, so the implementation lives entirely on the existing `X86GCInfo` decoder under `Contracts/GCInfo/X86/`. Changes ------- * `X86GCInfo`: add `UntrackedSlots` lazy property + `DecodeUntrackedSlots()` -- delta-decoded signed varints with the double-align-frame rebase from `gc_unwind_x86.inl:3467`. * `X86GCInfo`: add `VarPtrLifetimes` lazy property + `DecodeVarPtrLifetimes()` -- triplets of (varOffs, begOffs delta, endOffs delta) for EBP-frame tracked locals. * Two new public record types `UntrackedSlot` and `VarPtrLifetime` capture the decoded entries. * `IsCodeOffsetInProlog` / `IsCodeOffsetInEpilog` helpers (offset-parameterised, so EnumerateLiveSlots can answer for any instruction offset without re-constructing X86GCInfo). * `RegMaskToRegisterNumber` helper maps the single-bit `RegMask` flags-enum values to the x86 ModRM register numbers used by `X86Context.TryReadRegister` and `LiveSlot.RegisterNumber`. * Implement `IGCInfoDecoder.EnumerateLiveSlots(uint offset, options)`: early-return empty in prolog/epilog (or aborted+non-interruptible), emit untracked locals (suppressed for filter funclets), emit VarPtr lifetimes covering `offset`, walk `Transitions` up to `offset` accumulating live registers + pushed pointer args, and emit a partially-interruptible `GcTransitionCall` exactly at `offset`. * Flip `IGCInfoDecoder.GetSizeOfStackParameterArea` from `NotSupportedException` to `return 0` for x86 -- x86 has no separate outgoing-argument scratch area; per-offset transitions report pushed args directly, so the GcScanner scratch-area filter is a no-op (correct). * Remove the `[SkipOnArch("x86", "GCInfo decoder does not support x86")]` markers on `GCRoots_WalkStackReferences_FindsRefs` and `GCRoots_RefsPointToValidObjects`. * `DumpTests.targets`: add optional `DebuggeeFilter=` to restrict `GenerateAllDumps` to a single debuggee. Useful for iterative local x86 work where some other debuggee's publish may fail. * `docs/design/datacontracts/GCInfo.md`: enumerate which `IGCInfoDecoder` APIs are wired up on x86. Out of scope (deferred) ----------------------- * `GetInterruptibleRanges` for x86 -- the only consumer is the catch-handler PC override in `StackWalk_1`; no x86-relevant scenarios today. * "this"-pointer special-case reporting for synchronized methods (VarPtr 0x2 bit currently masked out). * IPtrMask interior-pointer bitmaps for pushed args (uses the simpler per-push `Iptr` flag). * Funclet handling beyond the existing `IsParentOfFuncletStackFrame` caller-side early-skip. * Finer `IsActiveFrame` register filter precision. Validation ---------- * All 2525 cDAC unit tests pass. * The two unblocked `GCRoots_*` tests pass against a freshly generated x86 GCRoots dump. * Broader `DumpTests` x86 sweep: 34 pass / 46 fail / 830 skip -- net +2 vs. before this change (the two GCRoots tests), zero regressions. The 46 pre-existing failures are all unrelated to GCInfo (`ThreadDumpTests` / `ComWrappersDumpTests` / `RuntimeInfoDumpTests` / `WorkstationGCDumpTests` and similar). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/GCInfo.md | 2 +- .../Contracts/GCInfo/X86/GCInfo.cs | 449 +++++++++++++++++- .../cdac/tests/DumpTests/DumpTests.targets | 7 +- .../DumpTests/StackReferenceDumpTests.cs | 2 - 4 files changed, 450 insertions(+), 10 deletions(-) diff --git a/docs/design/datacontracts/GCInfo.md b/docs/design/datacontracts/GCInfo.md index 70964f409a93d1..871356c2ab9756 100644 --- a/docs/design/datacontracts/GCInfo.md +++ b/docs/design/datacontracts/GCInfo.md @@ -2,7 +2,7 @@ This contract is for fetching information related to GCInfo associated with native code. -The GCInfo contract has platform specific implementations as GCInfo differs per architecture. With the exception of x86, all platforms have a common encoding scheme with different encoding lengths and normalization functions for data. x86 uses an entirely different scheme which is partially supported by this contract. +The GCInfo contract has platform specific implementations as GCInfo differs per architecture. With the exception of x86, all platforms have a common encoding scheme with different encoding lengths and normalization functions for data. x86 uses an entirely different scheme which is partially supported by this contract: x86 currently implements `GetCodeLength`, `GetStackBaseRegister`, `GetSizeOfStackParameterArea`, `GetCalleePoppedArgumentsSize`, and `EnumerateLiveSlots` (sufficient for SOS code-size lookups and for `WalkStackReferences` GC-root scanning). `GetInterruptibleRanges` is not yet implemented on x86 -- x86 does not encode explicit interruptible ranges; per-offset transitions are used instead, and the only consumer (catch-handler PC override in `StackWalk_1`) has no x86-relevant scenarios today. ## APIs of contract diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs index 9a8c44f7234f38..93e23ac9e47484 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs @@ -69,6 +69,20 @@ public record X86GCInfo : IGCInfoDecoder public uint PushedArgSize => _pushedArgSize.Value; private readonly Lazy _pushedArgSize; + /// + /// The untracked frame variable table, always-live GC frame slots. + /// Decoded lazily on first access. + /// + public ImmutableArray UntrackedSlots => _untrackedSlots.Value; + private readonly Lazy> _untrackedSlots; + + /// + /// The frame variable lifetime (VarPtr) table, per-offset-range tracked GC variables. + /// Decoded lazily on first access. Empty for non-EBP frames (only EBP frames track variables this way). + /// + public ImmutableArray VarPtrLifetimes => _varPtrLifetimes.Value; + private readonly Lazy> _varPtrLifetimes; + public X86GCInfo(Target target, TargetPointer gcInfoAddress, uint gcInfoVersion, uint relativeOffset = 0) { if (gcInfoVersion < MINIMUM_SUPPORTED_GCINFO_VERSION) @@ -151,6 +165,13 @@ public X86GCInfo(Target target, TargetPointer gcInfoAddress, uint gcInfoVersion, // Lazily calculate the pushed argument size. This forces the transitions to be decoded. _pushedArgSize = new(CalculatePushedArgSize); + + // Lazily decode the untracked-locals and VarPtr tables. These live between the + // NoGCRegion table and the argument table in the bitstream; see DecodeTransitions + // for the layout. Like the transitions, the underlying GC info bytes are typically + // only present in full (or selectively included) memory dumps. + _untrackedSlots = new(DecodeUntrackedSlots); + _varPtrLifetimes = new(DecodeVarPtrLifetimes); } private ImmutableDictionary> DecodeTransitions() @@ -243,6 +264,135 @@ private uint CalculatePushedArgSize() return (uint)(depth * _target.PointerSize); } + private ImmutableArray DecodeUntrackedSlots() + { + if (Header.UntrackedCount == 0) + return ImmutableArray.Empty; + + // The untracked-locals table follows the NoGCRegions table in the bitstream + // (see DecodeTransitions for the section layout). + TargetPointer offset = _gcInfoAddress + _infoHdrSize; + for (int i = 0; i < Header.NoGCRegionCount; i++) + { + _target.GCDecodeUnsigned(ref offset); + _target.GCDecodeUnsigned(ref offset); + } + + // Each entry is a signed varint, delta-encoded against the previous entry. + // Low 2 bits hold flags (byref=0x1, pinned=0x2); the remainder is the frame-relative + // stack offset. On EBP-frames the offset is EBP-relative; on ESP-frames it is + // ESP-relative. Double-aligned frames use a hybrid encoding: offsets that lie + // above the frame are EBP-relative even when the rest of the frame is ESP-based. + // Reference: gc_unwind_x86.inl:3467 (EnumGcRefsX86 untracked path) and + // ILCompiler.Reflection.ReadyToRun/x86/GcSlotTable.cs:127 (DecodeUntracked). + uint calleeSavedRegsCount = 0; + if (Header.DoubleAlign) + { + if (Header.EdiSaved) calleeSavedRegsCount++; + if (Header.EsiSaved) calleeSavedRegsCount++; + if (Header.EbxSaved) calleeSavedRegsCount++; + } + + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder((int)Header.UntrackedCount); + int lastStkOffs = 0; + for (uint i = 0; i < Header.UntrackedCount; i++) + { + int delta = _target.GCDecodeSigned(ref offset); + int stkOffs = lastStkOffs - delta; + lastStkOffs = stkOffs; + + uint lowBits = OFFSET_MASK & (uint)stkOffs; + stkOffs = (int)((uint)stkOffs & ~OFFSET_MASK); + + bool isEbpRelative = Header.EbpFrame; + if (Header.DoubleAlign && + (uint)stkOffs >= _target.PointerSize * (Header.FrameSize + calleeSavedRegsCount)) + { + // Double-aligned frame: offsets above the frame proper are EBP-relative. + isEbpRelative = true; + stkOffs -= (int)(_target.PointerSize * (Header.FrameSize + calleeSavedRegsCount)); + } + + builder.Add(new UntrackedSlot(stkOffs, isEbpRelative, lowBits)); + } + + return builder.MoveToImmutable(); + } + + private ImmutableArray DecodeVarPtrLifetimes() + { + if (Header.VarPtrTableSize == 0) + return ImmutableArray.Empty; + + // The VarPtr table follows the untracked-locals table in the bitstream. + TargetPointer offset = _gcInfoAddress + _infoHdrSize; + for (int i = 0; i < Header.NoGCRegionCount; i++) + { + _target.GCDecodeUnsigned(ref offset); + _target.GCDecodeUnsigned(ref offset); + } + for (int i = 0; i < Header.UntrackedCount; i++) + { + _target.GCDecodeSigned(ref offset); + } + + // Each entry is three unsigned varints: (varOffs, begOffs, endOffs). + // varOffs is absolute; begOffs is delta-from-previous-begOffs; endOffs is delta-from-begOffs. + // Low 2 bits of varOffs hold flags (byref=0x1, this=0x2 -- NOT pinned for tracked locals). + // Reference: gc_unwind_x86.inl varPtrTable processing and + // ILCompiler.Reflection.ReadyToRun/x86/GcSlotTable.cs:168 (DecodeFrameVariableLifetimeTable). + ImmutableArray.Builder builder = ImmutableArray.CreateBuilder((int)Header.VarPtrTableSize); + uint curOffs = 0; + for (uint i = 0; i < Header.VarPtrTableSize; i++) + { + uint varOffsRaw = _target.GCDecodeUnsigned(ref offset); + uint begOffs = _target.GCDecodeUDelta(ref offset, curOffs); + uint endOffs = _target.GCDecodeUDelta(ref offset, begOffs); + + uint lowBits = varOffsRaw & OFFSET_MASK; + int stkOffs = (int)(varOffsRaw & ~OFFSET_MASK); + + curOffs = begOffs; + + builder.Add(new VarPtrLifetime(begOffs, endOffs, stkOffs, lowBits)); + } + + return builder.MoveToImmutable(); + } + + private const uint OFFSET_MASK = 0x3; + + /// + /// Returns true if falls within the method's prolog. + /// + private bool IsCodeOffsetInProlog(uint codeOffset) + => codeOffset < Header.PrologSize; + + /// + /// Returns true if falls within any epilog. + /// + private bool IsCodeOffsetInEpilog(uint codeOffset) + { + foreach (uint epilogStart in Header.Epilogs) + { + if (codeOffset > epilogStart && codeOffset < epilogStart + Header.EpilogSize) + return true; + } + return false; + } + + /// + /// Converts a single-bit value to the platform-agnostic + /// register number used by X86Context.TryReadRegister and by . + /// EAX=0, ECX=1, EDX=2, EBX=3, ESP=4, EBP=5, ESI=6, EDI=7 -- matches the x86 ModRM encoding. + /// + private static uint RegMaskToRegisterNumber(RegMask reg) + { + // RegMask is a flags enum where each register sits on its own bit + // (EAX=0x1, ECX=0x2, ..., EDI=0x80). Log2 yields the register number. + return (uint)System.Numerics.BitOperations.Log2((uint)reg); + } + uint IGCInfoDecoder.GetCodeLength() => MethodSize; uint IGCInfoDecoder.GetStackBaseRegister() @@ -255,10 +405,14 @@ uint IGCInfoDecoder.GetStackBaseRegister() } uint IGCInfoDecoder.GetSizeOfStackParameterArea() - => throw new NotSupportedException( - "x86 GC info does not encode a separate outgoing-argument scratch area; the cDAC " + - "GC scanner does not consume scratch-area sizing on x86 (the legacy x86 GC walker " + - "reasons over per-offset transitions instead)."); + { + // x86 GC info does not encode a separate outgoing-argument scratch area; the + // per-offset transitions report pushed argument pointers directly at each offset. + // Returning 0 disables the GcScanner's scratch-area filter on x86, which is the + // correct behaviour: the live state at a given offset (call site or fully-interruptible + // point) already excludes any args that have been popped by the time we resume there. + return 0; + } uint IGCInfoDecoder.GetCalleePoppedArgumentsSize() { @@ -272,5 +426,290 @@ IReadOnlyList IGCInfoDecoder.GetInterruptibleRanges() => throw new NotSupportedException("x86 GC info does not encode explicit interruptible ranges; per-offset transitions are used instead. Decoding for the cDAC IGCInfoDecoder consumers is not yet implemented."); IReadOnlyList IGCInfoDecoder.EnumerateLiveSlots(uint instructionOffset, GcSlotEnumerationOptions options) - => throw new NotSupportedException("x86 GC info live-slot enumeration through IGCInfoDecoder is not yet implemented; the underlying InfoHdr/Transitions data is decoded but the IGCInfoDecoder.EnumerateLiveSlots adapter is future work."); + { + // x86 stack base encoding for LiveSlot.SpBase: 1 = SP-relative, 2 = FRAMEREG (EBP/EBP-double-aligned) relative. + // (See IGCInfo.cs LiveSlot docs and GcScanner.EnumGcRefsForManagedFrame for how these get resolved.) + const uint SP_REL = 1; + const uint FRAMEREG_REL = 2; + + // In the prolog (before all locals are initialised) and in the epilog (after they've + // been torn down) the GC info doesn't accurately describe live slots. The runtime + // never suspends a thread inside the prolog/epilog under normal circumstances; the only + // path that reaches here is ExecutionAborted (thread abort or stack overflow). In that + // case we still drop reporting -- this matches native EnumGcRefsX86 in gc_unwind_x86.inl:3091 + // which returns true without enumerating when we're in the prolog/epilog. + if (IsCodeOffsetInProlog(instructionOffset) || IsCodeOffsetInEpilog(instructionOffset)) + return Array.Empty(); + + // For non-interruptible methods, an ExecutionAborted offset that isn't at a recorded + // safe point yields no reliable GC info; skip reporting as the native walker does + // (gc_unwind_x86.inl:3093). + if (options.IsExecutionAborted && !Header.Interruptible) + return Array.Empty(); + + List result = []; + + // (1) Untracked frame locals -- always live for the entire method body. + // Filter funclets suppress untracked reporting because the parent frame already + // reports them (matches native EnumGcRefsX86 isFilterFunclet path). + if (!options.SuppressUntrackedSlots) + { + foreach (UntrackedSlot us in UntrackedSlots) + { + // LowBits encoding matches LiveSlot.GcFlags exactly: 0x1 = interior, 0x2 = pinned. + uint spBase = us.IsEbpRelative ? FRAMEREG_REL : SP_REL; + result.Add(new LiveSlot(IsRegister: false, RegisterNumber: 0, SpOffset: us.StackOffset, SpBase: spBase, GcFlags: us.LowBits)); + } + } + + // (2) VarPtr-tracked frame locals -- live when instructionOffset is within [Begin, End). + // Only EBP-frames produce entries here; the table is empty for ESP-frames. + { + uint spBase = Header.EbpFrame ? FRAMEREG_REL : SP_REL; + foreach (VarPtrLifetime vp in VarPtrLifetimes) + { + if (instructionOffset < vp.BeginOffset || instructionOffset >= vp.EndOffset) + continue; + + // VarPtr LowBits encoding differs from untracked: 0x1 = interior, 0x2 = "this" + // pointer (NOT pinned). The "this" pointer flag is consumed by special-case + // synchronized-method reporting in native code that this MVP cDAC decoder does + // not replicate. Map only the interior bit into LiveSlot.GcFlags. + uint gcFlags = vp.LowBits & 0x1u; + result.Add(new LiveSlot(IsRegister: false, RegisterNumber: 0, SpOffset: vp.StackOffset, SpBase: spBase, GcFlags: gcFlags)); + } + } + + // (3) Live registers and pushed pointer args from the transition stream. + EnumerateTransitionLiveSlots(instructionOffset, options, result, SP_REL); + + return result; + } + + /// + /// Walks up to and including , + /// accumulating live register state and currently-pushed pointer arguments, and emits a + /// per live register / pushed pointer. + /// + /// + /// For fully-interruptible methods, every transition strictly before + /// contributes to the current state. For partially- + /// interruptible methods, the JIT only emits transitions at call sites (and at LIVE/DEAD + /// markers); the live state at the exact is whatever + /// the most-recent call-site transition described. + /// + private void EnumerateTransitionLiveSlots( + uint instructionOffset, + GcSlotEnumerationOptions options, + List result, + uint spRelBase) + { + // Set of registers currently holding live GC refs at the walked offset. + RegMask liveRegs = RegMask.NONE; + RegMask liveIptrRegs = RegMask.NONE; + + // Pushed pointer args on the stack, keyed by ESP-relative byte offset (positive). + // Value: GcFlags (0x1 = interior). Use a sorted dictionary for deterministic output. + SortedDictionary pushedPtrs = new(); + + // Stack depth in pointer-size units, used by GcTransitionPointer offsets which are + // expressed as "argOffs from top of pushed args". Mirrors the depth bookkeeping in + // CalculatePushedArgSize. + int depthSlots = 0; + + // For partially-interruptible methods, only the call-site state matters -- we collect + // the most-recent call site at-or-before instructionOffset and emit its registers/args. + GcTransitionCall? activeCallSite = null; + + foreach (int offset in Transitions.Keys.OrderBy(o => o)) + { + // Stop AFTER processing transitions at instructionOffset for fully-interruptible + // code. For partially-interruptible code, GcTransitionCall transitions can also + // contain register/arg state describing the call site. + if (offset > instructionOffset) + break; + + foreach (BaseGcTransition transition in Transitions[offset]) + { + switch (transition) + { + case GcTransitionRegister regT: + ApplyRegisterTransition(regT, ref liveRegs, ref liveIptrRegs, ref depthSlots, pushedPtrs); + break; + case GcTransitionPointer ptrT: + ApplyPointerTransition(ptrT, ref depthSlots, pushedPtrs); + break; + case StackDepthTransition stackT: + depthSlots += stackT.StackDepthChange; + if (depthSlots < 0) depthSlots = 0; + break; + case GcTransitionCall callT when offset == (int)instructionOffset: + // Partially-interruptible: this is the only kind of transition that + // directly describes the call-site live state. For fully-interruptible + // code, GcTransitionCall is informational only -- the surrounding + // PUSH/POP/LIVE/DEAD transitions already maintain the state. + activeCallSite = callT; + break; + case IPtrMask: + case CalleeSavedRegister: + case GcTransitionCall: + // CalleeSavedRegister is purely informational for the stack walker. + // IPtrMask is reserved for future interior-pointer-bitmap support; + // the current MVP decoder does not propagate it onto pushed slots. + // GcTransitionCall at offset != instructionOffset is also ignored. + break; + default: + throw new InvalidOperationException($"Unsupported x86 GC transition: {transition.GetType().Name}"); + } + } + } + + // Emit live registers from accumulated state. + // The IsActiveFrame option controls whether scratch (callee-trashed) registers are + // reported. On non-leaf frames, the live registers are by definition scratch from the + // caller's perspective, so they should not be reported. Native EnumGcRefsX86 handles + // this via the willContinueExecution / ExecutionAborted flags interacting with the + // CHK_AND_REPORT_REG macro family. + if (options.IsActiveFrame || activeCallSite is not null) + { + // Iterate single-bit register values via RM_ALL. + foreach (RegMask r in EnumerateSingleRegs()) + { + if ((liveRegs & r) == 0) continue; + uint gcFlags = (liveIptrRegs & r) != 0 ? 0x1u : 0u; + result.Add(new LiveSlot(IsRegister: true, RegisterNumber: RegMaskToRegisterNumber(r), SpOffset: 0, SpBase: 0, GcFlags: gcFlags)); + } + } + + // Emit pushed pointer args as SP-relative stack slots. + foreach (KeyValuePair pushed in pushedPtrs) + { + result.Add(new LiveSlot(IsRegister: false, RegisterNumber: 0, SpOffset: pushed.Key, SpBase: spRelBase, GcFlags: pushed.Value)); + } + + // Partially-interruptible call-site: emit its register set and pointer args directly. + if (activeCallSite is not null) + { + foreach (GcTransitionCall.CallRegister cr in activeCallSite.CallRegisters) + { + uint gcFlags = cr.IsByRef ? 0x1u : 0u; + result.Add(new LiveSlot(IsRegister: true, RegisterNumber: RegMaskToRegisterNumber(cr.Register), SpOffset: 0, SpBase: 0, GcFlags: gcFlags)); + } + foreach (GcTransitionCall.PtrArg pa in activeCallSite.PtrArgs) + { + uint gcFlags = pa.LowBit != 0 ? 0x1u : 0u; + result.Add(new LiveSlot(IsRegister: false, RegisterNumber: 0, SpOffset: (int)pa.StackOffset, SpBase: spRelBase, GcFlags: gcFlags)); + } + } + } + + private static void ApplyRegisterTransition( + GcTransitionRegister regT, + ref RegMask liveRegs, + ref RegMask liveIptrRegs, + ref int depthSlots, + SortedDictionary pushedPtrs) + { + switch (regT.IsLive) + { + case Action.LIVE: + liveRegs |= regT.Register; + if (regT.Iptr) liveIptrRegs |= regT.Register; + else liveIptrRegs &= ~regT.Register; + break; + case Action.DEAD: + liveRegs &= ~regT.Register; + liveIptrRegs &= ~regT.Register; + break; + case Action.PUSH: + // The register's value is pushed onto the stack as a callee argument. + // Each push advances depth; record the slot at the new top-of-stack. + for (int i = 0; i < regT.PushCountOrPopSize; i++) + { + depthSlots++; + int sp = -depthSlots * 4; // x86 grows down; offset relative to call-site SP + pushedPtrs[sp] = regT.Iptr ? 0x1u : 0u; + } + break; + case Action.POP: + // Pop unrolls the most recently pushed slots. + for (int i = 0; i < regT.PushCountOrPopSize && depthSlots > 0; i++) + { + int sp = -depthSlots * 4; + pushedPtrs.Remove(sp); + depthSlots--; + } + break; + case Action.KILL: + // Used by EBP-frame partial-interrupt encoding to invalidate pushed args + // (kill all currently-tracked pushed pointers up to argOffset). + pushedPtrs.Clear(); + depthSlots = 0; + break; + } + } + + private static void ApplyPointerTransition( + GcTransitionPointer ptrT, + ref int depthSlots, + SortedDictionary pushedPtrs) + { + switch (ptrT.Act) + { + case Action.PUSH: + depthSlots++; + int spPush = -depthSlots * 4; + pushedPtrs[spPush] = ptrT.Iptr ? 0x1u : 0u; + break; + case Action.POP: + // ArgOffset slots are popped from the top of the pushed-args stack. + for (uint i = 0; i < ptrT.ArgOffset && depthSlots > 0; i++) + { + int sp = -depthSlots * 4; + pushedPtrs.Remove(sp); + depthSlots--; + } + break; + case Action.KILL: + pushedPtrs.Clear(); + depthSlots = 0; + break; + } + } + + private static IEnumerable EnumerateSingleRegs() + { + yield return RegMask.EAX; + yield return RegMask.ECX; + yield return RegMask.EDX; + yield return RegMask.EBX; + yield return RegMask.EBP; + yield return RegMask.ESI; + yield return RegMask.EDI; + // ESP is intentionally excluded -- it's never a live GC ref holder. + } } + +/// +/// An always-live GC frame slot (entry of the untracked-locals table). +/// The slot is live for the entire method body (post-prolog, pre-epilog). +/// +/// Frame-relative byte offset of the slot. +/// True if is EBP-relative; false if ESP-relative. +/// Raw flag bits from the encoded offset (0x1 = byref/interior, 0x2 = pinned). +public readonly record struct UntrackedSlot(int StackOffset, bool IsEbpRelative, uint LowBits); + +/// +/// A tracked GC frame variable with a per-offset lifetime range (entry of the +/// FrameVariableLifetime / VarPtr table). The slot is live while the executing +/// instruction offset lies in [BeginOffset, EndOffset). +/// VarPtr-tracked variables only exist on EBP-based frames. +/// +/// Inclusive code offset (relative to method start) at which the slot becomes live. +/// Exclusive code offset at which the slot becomes dead. +/// Frame-relative byte offset of the slot (EBP-relative on EBP frames, ESP-relative otherwise). +/// +/// Raw flag bits from the encoded offset (0x1 = byref/interior, 0x2 = "this" pointer -- note that for +/// tracked locals the 0x2 bit means "this", not "pinned" as it does for untracked slots). +/// +public readonly record struct VarPtrLifetime(uint BeginOffset, uint EndOffset, int StackOffset, uint LowBits); diff --git a/src/native/managed/cdac/tests/DumpTests/DumpTests.targets b/src/native/managed/cdac/tests/DumpTests/DumpTests.targets index cac9cc85219315..36ecbb466094e4 100644 --- a/src/native/managed/cdac/tests/DumpTests/DumpTests.targets +++ b/src/native/managed/cdac/tests/DumpTests/DumpTests.targets @@ -72,9 +72,12 @@ $([MSBuild]::NormalizeDirectory('$(RepoRoot)', 'artifacts', 'bin', 'testhost', '$(NetCoreAppCurrent)-$(HostOS)-$(_TestHostConfig)-$(_HostArch)')) - + - + + + - - + + $(NoWarn);SA1136;CS0649 + + diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/CallSignatures/Program.cs b/src/native/managed/cdac/tests/StressTests/Debuggees/CallSignatures/Program.cs new file mode 100644 index 00000000000000..83b3c4e5b645ad --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/CallSignatures/Program.cs @@ -0,0 +1,493 @@ +// 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; +using System.Runtime.InteropServices; + +/// +/// Exhaustive cdacstress ArgIterator debuggee. Covers a wide variety of +/// argument shapes that exercise different paths through the runtime +/// ComputeCallRefMap encoder and the cDAC CallingConventionGCRefMapBuilder: +/// - Register vs stack-passed parameters; long signatures that spill +/// - ByRef / in / out parameters (managed pointers -> INTERIOR) +/// - Native pointer / function pointer parameters (no token) +/// - Single- and multi-dimensional arrays (REF) +/// - Empty / tiny / pointer-sized / multi-field structs by value +/// - Structs containing object refs at the start, middle, end +/// - Nested structs (refs at deep offsets); deep nesting +/// - Value-type 'this' (interior pointer); ByRef return value +/// - ByRefLike value types: Span / ReadOnlySpan +/// - ByRefLike with only PTR fields (no INTERIOR expected) +/// - ByRefLike with multiple BYREF fields +/// - Nested ByRefLike (ref struct containing Span) +/// - Generic methods: T as ref / value type; multiple type params +/// - Generic value-type instance methods (interface dispatch) +/// - Enum arguments (Int32-, Int64-, byte-backed) +/// - Large-struct return (HasRetBuffArg) +/// - Mutually-recursive deep stack +/// +/// __arglist / vararg coverage lives in the dedicated VarArgs debuggee +/// because the native varargs calling convention is only supported on +/// Windows x86/x64/ARM64 (see src/coreclr/jit/target.h::compFeatureVarArg). +/// Building this debuggee with vararg methods would fail to JIT on +/// Linux/macOS, Windows ARM32, RISC-V, LoongArch64, and WASM. +/// Every test method begins with AllocBurst() so the cdacstress allocation +/// trigger fires while the frame is on the stack and per-MD dedup actually +/// produces an ARG_PASS / ARG_FAIL log line for it. +/// +internal static unsafe class Program +{ + // Static sink to keep allocations from being elided by the JIT. + private static object? s_sink; + + // Each test method calls this at entry. AllocBurst itself is also + // NoInlining so it shows up as a distinct frame, but the important + // thing is that the CALLER (the test method we want verified) is + // still on the stack at the moment of allocation. + // + // 32 allocations is intentional: the cdacstress allocation trigger + // serializes verifications on an internal lock, and other threads / + // helper allocations may swallow the trigger for a given alloc call. + // A bigger burst maximizes the chance that at least one fires while + // the caller's frame is live, which is what per-MD dedup needs to + // record an [ARG_PASS] / [ARG_FAIL] line for the caller. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AllocBurst() + { + for (int i = 0; i < 32; i++) + { + s_sink = new object(); + } + } + + private static int Main() + { + for (int iter = 0; iter < 50; iter++) + { + Drive(); + } + // Suppress unused warning for stack-allocated buffers in test methods. + GC.KeepAlive(s_sink); + return 100; + } + + // ---- Driver: invokes every test method. NoInlining so it stays its own + // frame; the test methods themselves are NoInlining (see attribute on + // each). Wrapped categories live in helpers below. + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Drive() + { + ArgCountCategory(); + ByRefCategory(); + PointerCategory(); + ArrayCategory(); + StructByValueCategory(); + NestedStructCategory(); + ValueTypeThisCategory(); + ByRefLikeCategory(); + GenericCategory(); + EnumCategory(); + ReturnCategory(); + DeepStackCategory(); + } + + // ===== Category 1: argument count / register vs stack ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ArgCountCategory() + { + OneRef("a"); + TwoRefs("a", "b"); + ThreeRefs("a", "b", "c"); + FourRefs("a", "b", "c", "d"); + EightRefs("a", "b", "c", "d", "e", "f", "g", "h"); + ManyPrimitives(1, 2, 3, 4, 5, 6, 7, 8); + ManyLongs(1, 2, 3, 4); + MixedSizes(1, 2L, "a", 3, "b", 4L); + MixedRefAndPrimitive("x", 1, "y", 2, "z", 3); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void OneRef(string a) { AllocBurst(); GC.KeepAlive(a); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void TwoRefs(string a, string b) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive(b); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ThreeRefs(string a, string b, string c) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive(b); GC.KeepAlive(c); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void FourRefs(string a, string b, string c, string d) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive(b); GC.KeepAlive(c); GC.KeepAlive(d); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void EightRefs(string a, string b, string c, string d, string e, string f, string g, string h) + { + AllocBurst(); + GC.KeepAlive(a); GC.KeepAlive(b); GC.KeepAlive(c); GC.KeepAlive(d); + GC.KeepAlive(e); GC.KeepAlive(f); GC.KeepAlive(g); GC.KeepAlive(h); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static int ManyPrimitives(int a, int b, int c, int d, int e, int f, int g, int h) { AllocBurst(); return a + b + c + d + e + f + g + h; } + [MethodImpl(MethodImplOptions.NoInlining)] private static long ManyLongs(long a, long b, long c, long d) { AllocBurst(); return a + b + c + d; } + [MethodImpl(MethodImplOptions.NoInlining)] private static void MixedSizes(int a, long b, string c, int d, string e, long f) { AllocBurst(); GC.KeepAlive(c); GC.KeepAlive(e); GC.KeepAlive((object)(a + b + d + f)); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void MixedRefAndPrimitive(string a, int b, string c, int d, string e, int f) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive(c); GC.KeepAlive(e); GC.KeepAlive((object)(b + d + f)); } + + // ===== Category 2: by-ref / in / out ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ByRefCategory() + { + int x = 1; + ByRefInt(ref x); + InInt(in x); + OutInt(out _); + + string s = "a"; + ByRefRef(ref s); + + ByRefMixed(1, ref x, "lit", ref s); + + Holder h = default; + ByRefStruct(ref h); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void ByRefInt(ref int x) { AllocBurst(); x++; } + [MethodImpl(MethodImplOptions.NoInlining)] private static void InInt(in int x) { AllocBurst(); GC.KeepAlive((object)x); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void OutInt(out int x) { AllocBurst(); x = 1; } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ByRefRef(ref string s) { AllocBurst(); GC.KeepAlive(s); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ByRefMixed(int a, ref int b, string c, ref string d) { AllocBurst(); b += a; GC.KeepAlive(c); GC.KeepAlive(d); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ByRefStruct(ref Holder h) { AllocBurst(); GC.KeepAlive(h.Ref); } + + private struct Holder + { + public int Pad; + public object? Ref; + } + + // ===== Category 3: pointers (native, function) ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void PointerCategory() + { + int v = 1; + PtrInt(&v); + Ptr2Int(&v, &v); + PtrMix("a", &v, "b"); + VoidPtr((void*)1); + FnPtrArg(&HelperForFnPtr); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void PtrInt(int* p) { AllocBurst(); GC.KeepAlive((object)(*p)); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void Ptr2Int(int* a, int* b) { AllocBurst(); GC.KeepAlive((object)(*a + *b)); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void PtrMix(string a, int* b, string c) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive((object)(*b)); GC.KeepAlive(c); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void VoidPtr(void* p) { AllocBurst(); GC.KeepAlive((object)(nint)p); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void FnPtrArg(delegate* f) { AllocBurst(); GC.KeepAlive((object)f(1)); } + private static int HelperForFnPtr(int x) => x + 1; + + // ===== Category 4: arrays ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ArrayCategory() + { + ArrayOne(new int[3]); + Array2D(new int[3, 3]); + ArrayJagged(new int[3][]); + ArrayObj(new object[3]); + ArrayMix(new int[3], "a", new object[3]); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void ArrayOne(int[] a) { AllocBurst(); GC.KeepAlive(a); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void Array2D(int[,] a) { AllocBurst(); GC.KeepAlive(a); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ArrayJagged(int[][] a) { AllocBurst(); GC.KeepAlive(a); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ArrayObj(object[] a) { AllocBurst(); GC.KeepAlive(a); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void ArrayMix(int[] a, string b, object[] c) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive(b); GC.KeepAlive(c); } + + // ===== Category 5: structs by value ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void StructByValueCategory() + { + Empty(); + Tiny(new TinyStruct { B = 1 }); + IntSized(new IntStruct { I = 1 }); + TwoInts(new TwoIntStruct { A = 1, B = 2 }); + DoubleSized(new DoubleStruct { D = 1.0 }); + Big(new BigStruct { A = 1, B = 2, C = 3, D = 4 }); + RefAtStart(new RefAtStartStruct { R = "a", Trailer = 1 }); + RefAtEnd(new RefAtEndStruct { Header = 1, R = "a" }); + RefInMiddle(new RefInMiddleStruct { Header = 1, R = "a", Trailer = 2 }); + TwoRefStructArg(new TwoRefStruct { A = "a", B = "b" }); + AlternatingRefs(new AlternatingRefsStruct { I1 = 1, R1 = "a", I2 = 2, R2 = "b" }); + RefAndArray(new RefAndArrayStruct { R = "a", Arr = new int[3] }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void Empty() { AllocBurst(); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void Tiny(TinyStruct s) { AllocBurst(); GC.KeepAlive((object)s.B); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void IntSized(IntStruct s) { AllocBurst(); GC.KeepAlive((object)s.I); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void TwoInts(TwoIntStruct s) { AllocBurst(); GC.KeepAlive((object)(s.A + s.B)); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void DoubleSized(DoubleStruct s) { AllocBurst(); GC.KeepAlive((object)s.D); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void Big(BigStruct s) { AllocBurst(); GC.KeepAlive((object)(s.A + s.B + s.C + s.D)); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void RefAtStart(RefAtStartStruct s) { AllocBurst(); GC.KeepAlive(s.R); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void RefAtEnd(RefAtEndStruct s) { AllocBurst(); GC.KeepAlive(s.R); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void RefInMiddle(RefInMiddleStruct s) { AllocBurst(); GC.KeepAlive(s.R); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void TwoRefStructArg(TwoRefStruct s) { AllocBurst(); GC.KeepAlive(s.A); GC.KeepAlive(s.B); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void AlternatingRefs(AlternatingRefsStruct s) { AllocBurst(); GC.KeepAlive(s.R1); GC.KeepAlive(s.R2); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void RefAndArray(RefAndArrayStruct s) { AllocBurst(); GC.KeepAlive(s.R); GC.KeepAlive(s.Arr); } + + private struct TinyStruct { public byte B; } + private struct IntStruct { public int I; } + private struct TwoIntStruct { public int A; public int B; } + private struct DoubleStruct { public double D; } + private struct BigStruct { public long A, B, C, D; } + private struct RefAtStartStruct { public object R; public int Trailer; } + private struct RefAtEndStruct { public int Header; public object R; } + private struct RefInMiddleStruct { public int Header; public object R; public int Trailer; } + private struct TwoRefStruct { public object A; public object B; } + private struct AlternatingRefsStruct { public int I1; public object R1; public int I2; public object R2; } + private struct RefAndArrayStruct { public object R; public int[] Arr; } + + // ===== Category 6: nested structs ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void NestedStructCategory() + { + NestedPlain(new OuterPlain { I = new InnerPlain { A = 1 } }); + NestedRef(new OuterWithRef { H = 1, I = new InnerWithRef { Pad = 2, R = "a" }, T = "b" }); + DoublyNested(new Doubly { L0 = new L0 { L1 = new L1 { L2 = new L2 { R = "deep" } } } }); + NestedTwoLevelMixed(new MixedOuter + { + Pre = 1, + Mid = new MixedInner { A = "a", I = 2, B = "b" }, + Post = 3, + }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static void NestedPlain(OuterPlain o) { AllocBurst(); GC.KeepAlive((object)o.I.A); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void NestedRef(OuterWithRef o) { AllocBurst(); GC.KeepAlive(o.I.R); GC.KeepAlive(o.T); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void DoublyNested(Doubly d) { AllocBurst(); GC.KeepAlive(d.L0.L1.L2.R); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void NestedTwoLevelMixed(MixedOuter m) { AllocBurst(); GC.KeepAlive(m.Mid.A); GC.KeepAlive(m.Mid.B); } + + private struct InnerPlain { public int A; } + private struct OuterPlain { public InnerPlain I; } + private struct InnerWithRef { public int Pad; public object R; } + private struct OuterWithRef { public int H; public InnerWithRef I; public string T; } + private struct L2 { public object R; } + private struct L1 { public L2 L2; } + private struct L0 { public L1 L1; } + private struct Doubly { public L0 L0; } + private struct MixedInner { public string A; public int I; public string B; } + private struct MixedOuter { public int Pre; public MixedInner Mid; public int Post; } + + // ===== Category 7: value-type 'this' (interior) ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ValueTypeThisCategory() + { + // Instance method on a struct receives 'this' as a managed + // pointer interior to the struct -> INTERIOR. + var ws = new WithRefStructInstance { R = "a" }; + ws.Instance(); + + var gs = new GenericStructInstance { V = "a" }; + gs.Instance(); + + IDispatch d = new DispatchStruct { R = "b" }; + d.Method(); + } + + private struct WithRefStructInstance + { + public object R; + [MethodImpl(MethodImplOptions.NoInlining)] + public void Instance() { AllocBurst(); GC.KeepAlive(R); } + } + + private struct GenericStructInstance + { + public T V; + [MethodImpl(MethodImplOptions.NoInlining)] + public T Instance() { AllocBurst(); return V; } + } + + private interface IDispatch + { + void Method(); + } + + private struct DispatchStruct : IDispatch + { + public object R; + [MethodImpl(MethodImplOptions.NoInlining)] + public void Method() { AllocBurst(); GC.KeepAlive(R); } + } + + // ===== Category 8: ByRefLike (ref structs) ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ByRefLikeCategory() + { + Span sp = stackalloc byte[16]; + ProcessSpan(sp); + ProcessReadOnlySpan(sp); + + // Two Span args next to each other. + Span sp2 = stackalloc byte[8]; + ProcessTwoSpans(sp, sp2); + + // Span + reference + Span mix. + ProcessSpanMix(sp, "x", sp2); + + // ByRefLike whose only field is a void* (no INTERIOR expected). + var ptrOnly = new PtrOnlyRefStruct { P = (void*)1 }; + ProcessPtrOnlyRefStruct(ptrOnly); + + // ByRefLike with two ref fields. + int a1 = 1, a2 = 2; + var multi = new TwoByRefStruct(ref a1, ref a2); + ProcessTwoByRefStruct(multi); + + // Ref struct containing a Span (nested ByRefLike). + Span nested = stackalloc byte[16]; + var nestedRef = new OuterRefWithSpan { Header = 1, Payload = nested, Trailer = 2 }; + ProcessOuterRefWithSpan(nestedRef); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static int ProcessSpan(Span s) { AllocBurst(); return s.Length; } + [MethodImpl(MethodImplOptions.NoInlining)] private static int ProcessReadOnlySpan(ReadOnlySpan s) { AllocBurst(); return s.Length; } + [MethodImpl(MethodImplOptions.NoInlining)] private static int ProcessTwoSpans(Span a, Span b) { AllocBurst(); return a.Length + b.Length; } + [MethodImpl(MethodImplOptions.NoInlining)] private static int ProcessSpanMix(Span a, string b, Span c) { AllocBurst(); GC.KeepAlive(b); return a.Length + c.Length; } + [MethodImpl(MethodImplOptions.NoInlining)] private static nint ProcessPtrOnlyRefStruct(PtrOnlyRefStruct s) { AllocBurst(); return (nint)s.P; } + [MethodImpl(MethodImplOptions.NoInlining)] private static int ProcessTwoByRefStruct(TwoByRefStruct s) { AllocBurst(); return s.A + s.B; } + [MethodImpl(MethodImplOptions.NoInlining)] private static int ProcessOuterRefWithSpan(OuterRefWithSpan o) { AllocBurst(); return o.Header + o.Payload.Length + o.Trailer; } + + private unsafe ref struct PtrOnlyRefStruct { public void* P; } + + private ref struct TwoByRefStruct + { + public ref int A; + public ref int B; + public TwoByRefStruct(ref int a, ref int b) { A = ref a; B = ref b; } + } + + private ref struct OuterRefWithSpan + { + public int Header; + public Span Payload; + public int Trailer; + } + + // ===== Category 9: generics ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void GenericCategory() + { + GenericRef("a"); + GenericRef(new object()); + GenericVT(42); + GenericVT(3.14); + GenericMulti("a", new object()); + GenericConstrained(new MemoryStream()); + + // Generic instance method on a generic value type (shared + // canonical impl pulls in the param type via HasParamType). + var c1 = new Container { V = "v" }; + c1.Get(); + var c2 = new Container { V = new object() }; + c2.Get(); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static T GenericRef(T v) where T : class { AllocBurst(); return v; } + [MethodImpl(MethodImplOptions.NoInlining)] private static T GenericVT(T v) where T : struct { AllocBurst(); return v; } + [MethodImpl(MethodImplOptions.NoInlining)] private static void GenericMulti(TA a, TB b) { AllocBurst(); GC.KeepAlive(a); GC.KeepAlive(b); } + [MethodImpl(MethodImplOptions.NoInlining)] private static void GenericConstrained(T v) where T : class, IDisposable { AllocBurst(); GC.KeepAlive(v); } + + private struct Container + { + public T V; + [MethodImpl(MethodImplOptions.NoInlining)] public T Get() { AllocBurst(); return V; } + } + + // ===== Category 10: enums ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void EnumCategory() + { + EnumInt(SomeEnum.A); + EnumLong(SomeLongEnum.A); + EnumByte(SomeByteEnum.A); + EnumInStruct(new EnumWrapper { E = SomeEnum.A, R = "a" }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static SomeEnum EnumInt(SomeEnum e) { AllocBurst(); return e; } + [MethodImpl(MethodImplOptions.NoInlining)] private static SomeLongEnum EnumLong(SomeLongEnum e) { AllocBurst(); return e; } + [MethodImpl(MethodImplOptions.NoInlining)] private static SomeByteEnum EnumByte(SomeByteEnum e) { AllocBurst(); return e; } + [MethodImpl(MethodImplOptions.NoInlining)] private static void EnumInStruct(EnumWrapper w) { AllocBurst(); GC.KeepAlive(w.R); } + + private enum SomeEnum { A, B, C } + private enum SomeLongEnum : long { A, B, C } + private enum SomeByteEnum : byte { A, B, C } + private struct EnumWrapper { public SomeEnum E; public object R; } + + // ===== Category 11: returns ===== + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void ReturnCategory() + { + _ = ReturnRef(); + _ = ReturnLarge(); + Span sp = stackalloc byte[16]; + _ = ReturnSpan(sp); + } + + [MethodImpl(MethodImplOptions.NoInlining)] private static string ReturnRef() { AllocBurst(); return "x"; } + // Large struct on Windows AMD64 (> 8 bytes, not power-of-2) -> HasRetBuffArg shifts arg offsets. + [MethodImpl(MethodImplOptions.NoInlining)] private static BigStruct ReturnLarge() { AllocBurst(); return new BigStruct { A = 1 }; } + [MethodImpl(MethodImplOptions.NoInlining)] private static Span ReturnSpan(Span s) { AllocBurst(); return s; } + + // ===== Category 12: deep stack ===== + // Mutually-recursive chains of methods with mixed signatures. At any + // given allocation trigger many frames are simultaneously live, so a + // single stack-walk verification run touches multiple MDs across + // diverse signature shapes. + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepStackCategory() + { + DeepA("a", 1, 2L); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepA(string a, int b, long c) + { + AllocBurst(); + DeepB(b, a, new RefAtStartStruct { R = a, Trailer = (int)c }); + GC.KeepAlive(a); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepB(int b, string a, RefAtStartStruct s) + { + AllocBurst(); + DeepC(s, a, b); + GC.KeepAlive(s.R); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepC(RefAtStartStruct s, string a, int b) + { + AllocBurst(); + Span sp = stackalloc byte[8]; + DeepD(sp, a, s, b); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepD(Span sp, string a, RefAtStartStruct s, int b) + { + AllocBurst(); + DeepE("inner", a, s); + GC.KeepAlive(sp.Length); + GC.KeepAlive((object)b); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepE(string label, string a, RefAtStartStruct s) + { + AllocBurst(); + GC.KeepAlive(label); + GC.KeepAlive(a); + GC.KeepAlive(s.R); + } +} diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/CrossModule.csproj b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/CrossModule.csproj new file mode 100644 index 00000000000000..a003eef81d04f2 --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/CrossModule.csproj @@ -0,0 +1,13 @@ + + + latest + + + + + + + diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Lib/CrossModuleLib.csproj b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Lib/CrossModuleLib.csproj new file mode 100644 index 00000000000000..418b268dca0edd --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Lib/CrossModuleLib.csproj @@ -0,0 +1,12 @@ + + + + Library + $(NetCoreAppToolCurrent) + true + enable + $(NoWarn);CS0649 + + diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Lib/Types.cs b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Lib/Types.cs new file mode 100644 index 00000000000000..a0d033148bf443 --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Lib/Types.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace CrossModuleLib; + +/// +/// Reference type with embedded refs. Used as a method-arg type in the +/// CrossModule debuggee; the cDAC encoder's REF token emission for the +/// argument slot doesn't need cross-module metadata for the arg itself, +/// but the type's identity is resolved through this assembly's +/// MetadataReader. +/// +public class ManagedHolder +{ + public object? Ref1; + public string? Ref2; + public int Pad; +} + +/// +/// Value type with an embedded GC ref. Exercises the encoder's +/// GCDesc-driven REF emission across module boundaries: the +/// argument's TypeHandle resolves through the main module's +/// CrossModule.exe metadata, but the field-list walk (and offset +/// arithmetic) crosses into this library's MethodTable. +/// +public struct StructWithRef +{ + public int Header; + public object? Ref; + public int Trailer; +} + +/// +/// Nested value type whose Inner field is a value type defined in the +/// same library. Exercises GetFieldDescApproxTypeHandle's cross-module +/// resolution when the outer struct's enclosing module differs from +/// the inner field's referenced module. +/// +public struct OuterWithCrossModuleInner +{ + public int Pre; + public StructWithRef Inner; + public string? Tail; +} + +/// +/// ByRefLike struct defined in another module. Exercises the cDAC's +/// MethodTableFlags.IsByRefLike check after metadata resolution +/// crosses module boundaries. +/// +public ref struct CrossModuleRefStruct +{ + public int Header; + public Span Payload; + public int Trailer; +} + +/// +/// Generic class definition. The closed instantiation Generic<string> +/// is constructed at the use site in the main module, so the signature +/// TypeRef→TypeSpec resolution path walks both modules. +/// +public class Generic +{ + public T? Value; +} + +public struct GenericStruct +{ + public T? Value; + public int Tag; +} + +/// +/// Generic struct with an embedded GC ref. Encoder must walk this +/// type's GCDesc when an instantiation (e.g. GenericRefStruct<int>) +/// is used as a by-value arg in the main module. +/// +public struct GenericRefStruct +{ + public object? Ref; + public T? Value; +} diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Program.cs b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Program.cs new file mode 100644 index 00000000000000..51a1d0d7ad018f --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/CrossModule/Program.cs @@ -0,0 +1,144 @@ +// 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.Runtime.CompilerServices; + +using CrossModuleLib; + +/// +/// Stresses the cDAC ArgIterator encoder across module boundaries. +/// Every test method's signature references a type defined in +/// CrossModuleLib.dll; the encoder must resolve those TypeRef tokens +/// against the lib's MetadataReader (not the main exe's) and walk +/// fields whose enclosing MethodTable lives in the lib module. +/// +/// Coverage: +/// - Class arg from other module (REF) +/// - By-value struct with embedded ref from other module (REF inside struct) +/// - Nested struct: outer + inner both in lib (cross-module GetFieldDescApproxTypeHandle) +/// - Mixed: outer in lib, contains string ref-field +/// - ByRefLike (ref struct) defined in other module +/// - Generic class instantiated with a main-module type +/// - Generic value type instantiated with a main-module type +/// - Generic struct with embedded ref, instantiated cross-module +/// +internal static class Program +{ + private static object? s_sink; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AllocBurst() + { + for (int i = 0; i < 32; i++) + s_sink = new object(); + } + + private static int Main() + { + for (int iter = 0; iter < 50; iter++) + { + Drive(); + } + GC.KeepAlive(s_sink); + return 100; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Drive() + { + TakeClass(new ManagedHolder { Ref1 = new object(), Ref2 = "abc", Pad = 1 }); + + TakeStructWithRef(new StructWithRef { Header = 1, Ref = new object(), Trailer = 2 }); + + TakeOuter(new OuterWithCrossModuleInner + { + Pre = 1, + Inner = new StructWithRef { Header = 10, Ref = new object(), Trailer = 11 }, + Tail = "tail", + }); + + Span sp = stackalloc byte[16]; + TakeCrossModuleRefStruct(new CrossModuleRefStruct + { + Header = 1, + Payload = sp, + Trailer = 2, + }); + + TakeGenericClass(new Generic { Value = "g" }); + TakeGenericClassMainType(new Generic { Value = new MainModuleClass { R = "m" } }); + TakeGenericValue(new GenericStruct { Value = 42, Tag = 1 }); + TakeGenericValueWithRef(new GenericRefStruct { Ref = new object(), Value = 7 }); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TakeClass(ManagedHolder h) + { + AllocBurst(); + GC.KeepAlive(h.Ref1); + GC.KeepAlive(h.Ref2); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TakeStructWithRef(StructWithRef s) + { + AllocBurst(); + GC.KeepAlive(s.Ref); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TakeOuter(OuterWithCrossModuleInner o) + { + AllocBurst(); + GC.KeepAlive(o.Inner.Ref); + GC.KeepAlive(o.Tail); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int TakeCrossModuleRefStruct(CrossModuleRefStruct s) + { + AllocBurst(); + return s.Header + s.Payload.Length + s.Trailer; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TakeGenericClass(Generic g) + { + AllocBurst(); + GC.KeepAlive(g); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TakeGenericClassMainType(Generic g) + { + AllocBurst(); + GC.KeepAlive(g); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int TakeGenericValue(GenericStruct g) + { + AllocBurst(); + return g.Value + g.Tag; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TakeGenericValueWithRef(GenericRefStruct g) + { + AllocBurst(); + GC.KeepAlive(g.Ref); + } +} + +/// +/// Defined in the main module. Used as a generic type argument so the +/// closed instantiation Generic<MainModuleClass> combines a lib- +/// module open generic with a main-module type arg. The signature +/// TypeSpec for the parameter mixes TypeRef (Generic`1 from lib) with +/// TypeDef (MainModuleClass from main). +/// +internal class MainModuleClass +{ + public string? R; +} diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/StructScenarios/Program.cs b/src/native/managed/cdac/tests/StressTests/Debuggees/StructScenarios/Program.cs index 9067337495def2..561ec5123ec949 100644 --- a/src/native/managed/cdac/tests/StressTests/Debuggees/StructScenarios/Program.cs +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/StructScenarios/Program.cs @@ -20,6 +20,7 @@ static int Main() SmallStructReturnScenario(); StructWithRefsScenario(); InterfaceDispatchScenario(); + NestedStructScenario(); } return 100; } @@ -154,4 +155,157 @@ static void InterfaceDispatchScenario() GC.KeepAlive(s); GC.KeepAlive(gs); } + + // ===== Scenario 5: Nested structs ===== + // Argument GC scanning for by-value structs has to walk the GCDesc + // recursively when value-type fields contain (a) other value-type + // fields that in turn carry GC refs, or (b) ref-fields buried inside + // nested ByRefLike value types. The combinations below exercise the + // ArgIterator + GCDesc / ByRefPointerOffsetsReporter paths: + // - Plain nested value type with no refs (encoder should emit + // nothing, runtime should emit nothing). + // - Nested value type with GC refs at non-zero offsets (GCDesc + // series must aggregate inner ref offsets relative to the outer + // argument start). + // - Three levels of nesting with refs at the deepest level. + // - Nested ByRefLike struct (Span inside an outer ref struct): + // the encoder must walk the inner type's BYREF fields and emit + // INTERIOR at the correct offset within the outer struct. + + // Static sink so the JIT can't elide allocations / inline the + // NoInlining methods below by proving the result is dead. + static object? s_sink; + + struct InnerPlain + { + public int A; + } + + struct OuterPlain + { + public InnerPlain Inner; + } + + struct InnerWithRef + { + public int Pad; + public object Ref; + } + + struct OuterWithInnerRef + { + public int Header; + public InnerWithRef Inner; + public string Tail; + } + + struct DeepLevel0 + { + public object Ref; + } + + struct DeepLevel1 + { + public int Pad; + public DeepLevel0 Inner; + } + + struct DeepLevel2 + { + public DeepLevel1 Inner; + public int Trailer; + } + + ref struct OuterRefStructWithSpan + { + public int Header; + public Span Payload; + public int Trailer; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int ProcessNestedPlain(OuterPlain p) + { + // Burn allocations in a loop so the cdacstress allocation trigger + // fires multiple times while this MD is live on the stack, and + // route the results through a static sink so the JIT can't elide + // them or inline this frame away. + for (int i = 0; i < 16; i++) + { + s_sink = new object(); + } + return p.Inner.A; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static object ProcessNestedRef(OuterWithInnerRef o) + { + for (int i = 0; i < 16; i++) + { + s_sink = new object(); + } + GC.KeepAlive(o.Tail); + return o.Inner.Ref; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static object ProcessDeeplyNested(DeepLevel2 d) + { + for (int i = 0; i < 16; i++) + { + s_sink = new object(); + } + return d.Inner.Inner.Ref; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static int ProcessNestedSpan(OuterRefStructWithSpan o) + { + for (int i = 0; i < 16; i++) + { + s_sink = new object(); + } + return o.Header + o.Payload.Length + o.Trailer; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + static void NestedStructScenario() + { + OuterPlain plain = new OuterPlain { Inner = new InnerPlain { A = 7 } }; + int v = ProcessNestedPlain(plain); + GC.KeepAlive(v); + + OuterWithInnerRef withRef = new OuterWithInnerRef + { + Header = 1, + Inner = new InnerWithRef { Pad = 2, Ref = new object() }, + Tail = "tail", + }; + object inner = ProcessNestedRef(withRef); + GC.KeepAlive(inner); + GC.KeepAlive(withRef.Tail); + + DeepLevel2 deep = new DeepLevel2 + { + Inner = new DeepLevel1 + { + Pad = 3, + Inner = new DeepLevel0 { Ref = new object() }, + }, + Trailer = 4, + }; + object deepRef = ProcessDeeplyNested(deep); + GC.KeepAlive(deepRef); + + byte[] buffer = new byte[16]; + OuterRefStructWithSpan refStruct = new OuterRefStructWithSpan + { + Header = 1, + Payload = buffer, + Trailer = 2, + }; + int sum = ProcessNestedSpan(refStruct); + GC.KeepAlive(sum); + GC.KeepAlive(buffer); + } } diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/VarArgs/Program.cs b/src/native/managed/cdac/tests/StressTests/Debuggees/VarArgs/Program.cs new file mode 100644 index 00000000000000..221755217422ab --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/VarArgs/Program.cs @@ -0,0 +1,106 @@ +// 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.Runtime.CompilerServices; + +/// +/// Stresses the cDAC ArgIterator encoder's __arglist support +/// (.VASigCookie). +/// +/// +/// Lives in its own debuggee because the CLI's native varargs calling +/// convention is only supported on Windows x86 / x64 / ARM64. The JIT +/// gates the feature in src/coreclr/jit/target.h::compFeatureVarArg: +/// +/// return TargetOS::IsWindows && !TargetArchitecture::IsArm32; +/// +/// So this debuggee's methods will fail to JIT on Linux/macOS (all +/// architectures), Windows ARM32, RISC-V, LoongArch64, and WASM. The +/// xunit harness skips VarArgs on those targets via the +/// WindowsOnly flag on the Debuggee record. +/// +/// +/// +/// The VarArgs entry in CdacStressTests.Debuggees also sets +/// SkipGCRefs: true: the cDAC's GetStackReferences does not +/// yet walk the VASigCookie signature blob to enumerate variadic-tail GC +/// refs, so the GCREFS sub-check reports false failures on vararg frames. +/// ARGITER has no such gap (the encoder emits +/// GCRefMapToken.VASigCookie and stops, matching the runtime's +/// FakeGcScanRoots short-circuit), so we still exercise this +/// debuggee under the ArgIterStress_* theory. +/// +/// +internal static class Program +{ + private static object? s_sink; + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void AllocBurst() + { + for (int i = 0; i < 32; i++) + { + s_sink = new object(); + } + } + + private static int Main() + { + for (int iter = 0; iter < 50; iter++) + { + Drive(); + } + GC.KeepAlive(s_sink); + return 100; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void Drive() + { + VarargMixed(1, __arglist("a", 2, "b", 3.14)); + VarargAllRefs(1, __arglist("x", "y", "z")); + VarargFixedPrimitive(__arglist(1, 2L, 3.0)); + + var s = new InstanceVarargStruct { R = "this-ref" }; + s.Method(1, __arglist("inst-a", "inst-b")); + + DeepArglistOuter("outer", 1); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void VarargMixed(int first, __arglist) { AllocBurst(); GC.KeepAlive((object)first); } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void VarargAllRefs(int first, __arglist) { AllocBurst(); GC.KeepAlive((object)first); } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void VarargFixedPrimitive(__arglist) { AllocBurst(); } + + private struct InstanceVarargStruct + { + public object R; + + [MethodImpl(MethodImplOptions.NoInlining)] + public void Method(int first, __arglist) + { + AllocBurst(); + GC.KeepAlive(R); + GC.KeepAlive((object)first); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepArglistOuter(string label, int n) + { + AllocBurst(); + DeepArglistInner(n, __arglist(label, n + 1, "tail")); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void DeepArglistInner(int n, __arglist) + { + AllocBurst(); + GC.KeepAlive((object)n); + } +} diff --git a/src/native/managed/cdac/tests/StressTests/Debuggees/VarArgs/VarArgs.csproj b/src/native/managed/cdac/tests/StressTests/Debuggees/VarArgs/VarArgs.csproj new file mode 100644 index 00000000000000..ecab08f1f919fd --- /dev/null +++ b/src/native/managed/cdac/tests/StressTests/Debuggees/VarArgs/VarArgs.csproj @@ -0,0 +1,5 @@ + + + latest + + diff --git a/src/native/managed/cdac/tests/StressTests/README.md b/src/native/managed/cdac/tests/StressTests/README.md index 50aae0aa6158ba..4ec90b3917816a 100644 --- a/src/native/managed/cdac/tests/StressTests/README.md +++ b/src/native/managed/cdac/tests/StressTests/README.md @@ -38,17 +38,42 @@ turns on hooks in `src/coreclr/vm/cdacstress.cpp`. The native hook: ### `DOTNET_CdacStress` flag layout -One trigger point is wired today: allocation (`gchelpers.cpp`). This is -unrelated to `DOTNET_GCStress` (the JIT instruction stress feature). +The DWORD is split into byte-wide regions: -| Bits | Name | Meaning | -|----------|-----------|-----------------------------------------------------------------| -| `0x001` | ALLOC | Verify at every managed allocation | -| `0x200` | VERBOSE | Rich per-ref diagnostics in the log | +| Byte | Region | Bits | Meaning | +|------|----------|-------------|-----------------------------------------------| +| 0 | WHERE | `0x000000FF`| Trigger points -- when the harness fires | +| 1 | WHAT | `0x0000FF00`| Sub-checks -- which comparison runs | +| 2 | MODIFIERS| `0x00FF0000`| Output / behavior knobs | + +A useful configuration sets at least one WHERE and at least one WHAT bit. + +| Bits | Region | Name | Meaning | +|--------------|----------|-----------|------------------------------------------------------------------------------| +| `0x00000001` | WHERE | ALLOC | Verify at every managed allocation (`gchelpers.cpp`) | +| `0x00000100` | WHAT | GCREFS | Compare cDAC `GetStackReferences` vs runtime GC root oracle | +| `0x00000200` | WHAT | ARGITER | Compare cDAC `CallingConvention.EnumerateArguments`-derived GCRefMap blobs vs runtime `ComputeCallRefMap` byte-for-byte (`[ARG_PASS]` / `[ARG_FAIL]` / `[ARG_SKIP]` / `[ARG_ERROR]` per MD, with a `[ARG_STATS]` summary at shutdown) | +| `0x00010000` | MODIFIER | VERBOSE | Rich per-ref diagnostics in the log | Common combinations: -- `0x001` — ALLOC (default for `RunStressTests.ps1` and the xUnit tests) -- `0x201` — ALLOC + VERBOSE (use when triaging a mismatch) +- `0x00101` -- ALLOC + GCREFS (default for `RunStressTests.ps1` and `GCStress_*` xunit theories) +- `0x00201` -- ALLOC + ARGITER (default for `ArgIterStress_*` xunit theories; independent run on the same Helix build so the two sub-checks don't share state) +- `0x00301` -- ALLOC + GCREFS + ARGITER (validates both sub-checks in one process) +- `0x10101` -- ALLOC + GCREFS + VERBOSE (use when triaging a GCREFS mismatch) + +### Per-sub-check summary markers + +The native harness emits one machine-readable line per enabled sub-check at +shutdown, parsed by `CdacStressResults`: + +- `[GC_STATS] verifications=N pass=N fail=N known_issue=N` -- emitted iff GCREFS ran +- `[ARG_STATS] pass=N fail=N skip=N error=N` -- emitted iff ARGITER ran + +Both lines are gated on their respective `IsCdacStress*Enabled()` helpers, so a +pure-ARGITER run does not produce `[GC_STATS]` and vice versa. The xunit +`AssertAll*Passed` helpers use the presence of the marker (`AnyGcRefsRecorded` +/ `AnyArgIterRecorded`) to distinguish "sub-check did not run" from "ran but +recorded zero verifications". ### Pass/fail semantics in the log @@ -72,7 +97,7 @@ See [known-issues.md § Log Format](known-issues.md#log-format) for the per-fram .\RunStressTests.ps1 -SkipBuild -Configuration Checked -Debuggee BasicAlloc # Run with verbose per-ref diagnostics (use when triaging a mismatch) -.\RunStressTests.ps1 -SkipBuild -Configuration Checked -CdacStress 0x201 +.\RunStressTests.ps1 -SkipBuild -Configuration Checked -CdacStress 0x10101 ``` Logs land under @@ -80,7 +105,7 @@ Logs land under ### Using `dotnet test` (xUnit harness — same path CI runs) -The xUnit harness defaults to `DOTNET_CdacStress=0x001` (ALLOC). +The xUnit harness defaults to `DOTNET_CdacStress=0x101` (ALLOC + GCREFS). ```powershell # Build and run all stress tests @@ -90,7 +115,7 @@ The xUnit harness defaults to `DOTNET_CdacStress=0x001` (ALLOC). .\.dotnet\dotnet.exe test src\native\managed\cdac\tests\StressTests --filter "FullyQualifiedName~BasicAlloc" # Override CdacStress flags for a single run (e.g. enable verbose diagnostics) -$env:DOTNET_CdacStress = "0x201" +$env:DOTNET_CdacStress = "0x10101" .\.dotnet\dotnet.exe test src\native\managed\cdac\tests\StressTests # Point at an existing Core_Root explicitly @@ -116,7 +141,7 @@ $env:CORE_ROOT = "path\to\Core_Root" 3. `Main()` must return `100` on success 4. Use `[MethodImpl(MethodImplOptions.NoInlining)]` on methods to prevent inlining 5. Use `GC.KeepAlive()` to ensure objects are live at GC stress points -6. Add the debuggee name to `BasicStressTests.Debuggees` +6. Add the debuggee name to `CdacStressTests.Debuggees` ## Debuggee Catalog @@ -126,11 +151,14 @@ $env:CORE_ROOT = "path\to\Core_Root" | **ExceptionHandling** | try/catch/finally funclets, nested exceptions, filter funclets, rethrow | | **DeepStack** | Deep recursion with live refs at each frame | | **Generics** | Generic method instantiations, interface dispatch, delegates | -| **PInvoke** | P/Invoke transitions, pinned GC handles, struct with object refs | +| **PInvoke** | P/Invoke transitions, pinned GC handles, struct with object refs (Windows-only) | | **MultiThread** | Concurrent threads with synchronized GC stress | | **Comprehensive** | All-in-one: every scenario in a single run | | **StructScenarios** | Struct returns, by-ref params | | **DynamicMethods** | DynamicMethod / IL emit | +| **CallSignatures** | Wide signature surface for the ARGITER sub-check (primitives, byref/ptr, structs, generics) | +| **CrossModule** | Calls across multiple assemblies exercising cross-module type references | +| **VarArgs** | `__arglist` / VASigCookie validation for ARGITER (Windows x86/x64/ARM64 only; excluded from GCREFS until GetStackReferences walks the cookie signature) | ## Architecture @@ -141,7 +169,7 @@ CdacStressTestBase.RunGCStressAsync(debuggeeName) ├── Locate debuggee DLL (artifacts/bin/StressTests//...) ├── Start Process: corerun │ Environment: - │ DOTNET_CdacStress=0x001 + │ DOTNET_CdacStress=0x101 │ DOTNET_CdacStressLogFile= │ DOTNET_ContinueOnAssert=1 ├── Wait for exit (timeout: 300s) diff --git a/src/native/managed/cdac/tests/StressTests/RunStressTests.ps1 b/src/native/managed/cdac/tests/StressTests/RunStressTests.ps1 index dcaa176f20d65f..dbc80e7c1a46b5 100644 --- a/src/native/managed/cdac/tests/StressTests/RunStressTests.ps1 +++ b/src/native/managed/cdac/tests/StressTests/RunStressTests.ps1 @@ -11,14 +11,18 @@ Supports Windows, Linux, and macOS. - The DOTNET_CdacStress environment variable controls WHEN verification fires: - TRIGGERS: - 0x001 = ALLOC — verify at every managed allocation - MODIFIER: - 0x200 = VERBOSE — rich per-ref diagnostics in the log - - The runtime's own GC root enumeration is the single oracle. Any trigger - causes cDAC's GetStackReferences output to be compared against it. + The DOTNET_CdacStress environment variable is split into byte regions: + WHERE (byte 0): when verification fires + 0x00000001 = ALLOC — verify at every managed allocation + WHAT (byte 1): which sub-check runs at each fired trigger + 0x00000100 = GCREFS — compare cDAC GetStackReferences vs runtime GC roots + 0x00000200 = ARGITER — compare cDAC EnumerateArguments vs runtime ComputeCallRefMap + MODIFIER (byte 2): + 0x00010000 = VERBOSE — rich per-ref diagnostics in the log + + The runtime's own GC root enumeration is the single oracle for GCREFS. + A useful configuration combines at least one WHERE bit with at least one + WHAT bit (e.g. 0x101 = ALLOC + GCREFS). .PARAMETER Configuration Runtime configuration: Checked (default) or Debug. @@ -32,9 +36,10 @@ specific failure. .PARAMETER CdacStress - Hex value for DOTNET_CdacStress flags. Default: 0x001 (ALLOC). + Hex value for DOTNET_CdacStress flags. Default: 0x101 (ALLOC + GCREFS). Common values: - 0x001 = ALLOC (allocation points only, every hit verified) + 0x101 = ALLOC + GCREFS (allocation points, GC-refs comparison) + 0x301 = ALLOC + GCREFS + ARGITER (also runs the ArgIterator sub-check) .PARAMETER Debuggee Which debuggee(s) to run. Default: All. @@ -49,7 +54,7 @@ .EXAMPLE ./RunStressTests.ps1 -SkipBuild ./RunStressTests.ps1 -Debuggee BasicAlloc -SkipBuild - ./RunStressTests.ps1 -CdacStress 0x201 -SkipBuild # ALLOC + VERBOSE + ./RunStressTests.ps1 -CdacStress 0x10101 -SkipBuild # ALLOC + GCREFS + VERBOSE #> param( [ValidateSet("Checked", "Debug")] @@ -58,7 +63,7 @@ param( [ValidateSet("Release", "Checked", "Debug")] [string]$CdacConfiguration = "Release", - [string]$CdacStress = "0x001", + [string]$CdacStress = "0x101", [string[]]$Debuggee = @(), diff --git a/src/native/managed/cdac/tests/StressTests/known-issues.md b/src/native/managed/cdac/tests/StressTests/known-issues.md index 7fe3c98b9d9a95..37e8890fba70b6 100644 --- a/src/native/managed/cdac/tests/StressTests/known-issues.md +++ b/src/native/managed/cdac/tests/StressTests/known-issues.md @@ -6,8 +6,8 @@ enumeration and the runtime's own GC root scanning, exposed by the ## Verification verdicts -When running `RunStressTests.ps1` (Checked, `DOTNET_CdacStress=0x001` = -`ALLOC`), each verification is bucketed into one of: +When running `RunStressTests.ps1` (Checked, `DOTNET_CdacStress=0x101` = +`ALLOC + GCREFS`), each verification is bucketed into one of: | Verdict | Meaning | |---------|---------| @@ -150,6 +150,6 @@ Each verification emits a single header line followed by, on `[FAIL]` or ``` Frames whose counts match are omitted from the per-frame block in -concise mode; verbose mode (`DOTNET_CdacStress=0x201`) also emits the +concise mode; verbose mode (`DOTNET_CdacStress=0x10101`) also emits the matched refs. From 8bed0e2b9c903f3601190bd25878bb629dda11f6 Mon Sep 17 00:00:00 2001 From: Max Charlamb Date: Thu, 25 Jun 2026 11:29:52 -0400 Subject: [PATCH 33/33] [cdac] x86: rewire stack-walker to use shared CallingConvention/GCRefMap Replaces the X86ArgIterator helper added earlier in this PR with calls into the shared CallingConvention contract that was squashed in from the cdac-shared-argiterator branch. The net effect on x86 GCRefStress is 0 failures, 0 known issues (down from 0.12-5.66% known previously). Changes ------- * GCRefMapDecoder: add a second constructor that takes a host-side byte[] in addition to the existing TargetPointer source. Lets us decode blobs we synthesize via ICallingConvention.TryComputeArgGCRefMapBlob using the same bit-stream reader as the R2R-resident blob path. * GcScanner.PromoteCallerStack: replace the RecordDeferredFrame stub with a real implementation. On Windows x86 / x64 it asks the shared CallingConvention contract for the synthesized GCRefMap blob and runs the same token-iteration loop as the R2R-backed PromoteCallerStackUsingGCRefMap. On unsupported targets and on any failure to synthesize a blob, falls back to RecordDeferredFrame so the stress harness still buckets the resulting diff as a known issue. Factored the token-iteration loop into a shared EnumerateGCRefMapTokens helper. * X86FrameHandler.HandleTransitionFrame: replaces the X86ArgIterator cbStackPop calculation with TryComputeArgGCRefMapBlob + ReadStackPop on the leading bytes. Same call site keeps the VASigCookie fast path for PInvokeCalliFrame. * X86ArgIterator.cs: deleted. * CdacStressTests.cs: removed the hardcoded if (arch == Architecture.X86) throw new SkipTestException for GCRefStress_AllVerificationsPass. The previous skip comment said "x86 has not been brought up yet" -- this PR brings it up. * CdacStressTestBase.AssertAllPassed: on Windows x86 / x64, fail the test if results.KnownIssues > 0. Every transition Frame's caller-stack scan must succeed via the shared ICallingConvention.TryComputeArgGCRefMapBlob path on those targets; a non-zero count signals a regression in CallingConvention_1.ComputeArgGCRefMapBlobCore. Local validation (windows-x86 Checked) ------------------------------------- GCRefStress, single full run: Debuggee Total Pass Fail Known ------------- ------- ------- ------- ------- BasicAlloc 4,792 4,792 0 0 CallSignatures 5,240 5,240 0 0 Comprehensive 4,862 4,862 0 0 CrossModule 4,846 4,846 0 0 DeepStack 4,828 4,828 0 0 DynamicMethods 6,356 6,356 0 0 ExceptionHandling 4,834 4,834 0 0 Generics 4,806 4,806 0 0 MultiThread 4,878 4,878 0 0 PInvoke 4,818 4,818 0 0 StructScenarios 4,834 4,834 0 0 ------------- ------- Total 55,094 55,094 0 0 cDAC unit tests: 2571 / 2571 pass. > [!NOTE] > This commit was authored with assistance from GitHub Copilot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../StackWalk/FrameHandling/X86ArgIterator.cs | 258 ------------------ .../FrameHandling/X86FrameHandler.cs | 20 +- .../Contracts/StackWalk/GC/GCRefMapDecoder.cs | 32 ++- .../Contracts/StackWalk/GC/GcScanner.cs | 63 ++++- .../tests/StressTests/CdacStressTestBase.cs | 32 ++- .../cdac/tests/StressTests/CdacStressTests.cs | 6 - 6 files changed, 128 insertions(+), 283 deletions(-) delete mode 100644 src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86ArgIterator.cs diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86ArgIterator.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86ArgIterator.cs deleted file mode 100644 index ecec55c3ca248b..00000000000000 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86ArgIterator.cs +++ /dev/null @@ -1,258 +0,0 @@ -// 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.Diagnostics; -using System.Reflection.Metadata; -using System.Reflection.Metadata.Ecma335; -using Microsoft.Diagnostics.DataContractReader.Data; -using Microsoft.Diagnostics.DataContractReader.SignatureHelpers; - -namespace Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; - -/// -/// Computes the x86 stdcall callee-popped argument byte count (cbStackPop) for -/// a managed method given its MethodDesc. -/// -/// Mirrors MethodDesc::CbStackPop in src/coreclr/vm/method.cpp, -/// which delegates to ArgIteratorTemplate::CbStackPop / -/// ::SizeOfArgStack in src/coreclr/vm/callingconvention.h. -/// -/// This is a minimal subset of the native ArgIterator targeted only at x86's -/// callee-popped convention: we count how many bytes of argument stack the -/// callee will pop on return. That's what the transition Frame's -/// UpdateRegDisplay_Impl needs to recover the caller's SP. -/// -/// Limitations (intentional, kept simple): -/// - Value-type-in-register optimization (the recursive single-field unwrap -/// in native IsArgumentInRegister) is approximated by treating value -/// types as stack-passed always. This may over-count cbStackPop by a few -/// bytes in rare cases, but on x86 the resulting SP is still >= the true -/// caller SP and walks above this frame continue to track via EBP chain. -/// - HasParamType / HasAsyncContinuation: handled, since both can end up on -/// the stack on x86 when the two argument registers (ECX, EDX) are full. -/// -internal static class X86ArgIterator -{ - private const int NumArgumentRegisters = 2; // ECX, EDX - private const int PointerSize = 4; - - /// - /// Returns the cbStackPop (in bytes) for the method identified by - /// , or 0 if the value could not be - /// computed (e.g. caller can't recover module/signature). - /// - public static uint Compute(Target target, TargetPointer methodDescPtr) - { - if (methodDescPtr == TargetPointer.Null) - return 0; - - try - { - return ComputeCore(target, methodDescPtr); - } - catch - { - // Best-effort: any failure to resolve metadata/signature degrades to 0, - // which matches the pre-fix behavior for these Frame types. - return 0; - } - } - - private static uint ComputeCore(Target target, TargetPointer methodDescPtr) - { - IRuntimeTypeSystem rts = target.Contracts.RuntimeTypeSystem; - MethodDescHandle md = rts.GetMethodDescHandle(methodDescPtr); - - // Resolve module + token to read the signature blob. - TargetPointer mt = rts.GetMethodTable(md); - TypeHandle owningType = rts.GetTypeHandle(mt); - TargetPointer modulePtr = rts.GetModule(owningType); - ModuleHandle moduleHandle = target.Contracts.Loader.GetModuleHandleFromModulePtr(modulePtr); - - MetadataReader mdReader = target.Contracts.EcmaMetadata.GetMetadata(moduleHandle)!; - uint token = rts.GetMethodToken(md); - if (token == 0) - return 0; - - MethodDefinition methodDef = mdReader.GetMethodDefinition(MetadataTokens.MethodDefinitionHandle((int)token)); - - SignatureTypeProvider provider = new(target, moduleHandle); - BlobReader blobReader = mdReader.GetBlobReader(methodDef.Signature); - RuntimeSignatureDecoder decoder = new(provider, target, mdReader, owningType); - MethodSignature sig = decoder.DecodeMethodSignature(ref blobReader); - - bool isInstance = !methodDef.Attributes.HasFlag(System.Reflection.MethodAttributes.Static); - // hasParamType corresponds to the runtime's "ParamTypeArg" - a hidden arg that carries - // the generic context. It's needed when the method needs explicit generic context that - // cannot be derived from `this` or from being a closed instance: matches native - // MethodDesc::RequiresInstArg. - bool hasParamType = false; - try - { - GenericContextLoc loc = target.Contracts.RuntimeTypeSystem.GetGenericContextLoc(md); - hasParamType = (loc == GenericContextLoc.InstArgMethodDesc) || (loc == GenericContextLoc.InstArgMethodTable); - } - catch { /* default false */ } - bool isAsync = false; - try { isAsync = rts.IsAsyncMethod(md); } - catch { /* contract may be absent; default false */ } - bool isVarArg = (sig.Header.CallingConvention == SignatureCallingConvention.VarArgs); - bool hasRetBufArg = NeedsReturnBuffer(target, sig.ReturnType); - - int numRegistersUsed = 0; - uint stackBytes = 0; - - if (isInstance) numRegistersUsed++; - if (hasRetBufArg) numRegistersUsed++; - - if (isVarArg) - { - // Vararg cookie consumes a stack slot and fills the remaining argument registers. - stackBytes += PointerSize; - numRegistersUsed = NumArgumentRegisters; - } - - // Walk fixed parameters in order. Each one either consumes the next - // argument register (if eligible) or contributes to the stack size. - foreach (TypeHandle paramType in sig.ParameterTypes) - { - CorElementType corType = rts.GetSignatureCorElementType(paramType); - - if (numRegistersUsed < NumArgumentRegisters && IsRegisterEligible(corType)) - { - numRegistersUsed++; - continue; - } - - int argSize = GetArgSize(target, rts, paramType, corType); - stackBytes += (uint)StackElemSize(argSize); - } - - // HasAsyncContinuation and HasParamType: tail spots that take the - // remaining argument register if any, else go on the stack. - if (isAsync) - { - if (numRegistersUsed >= NumArgumentRegisters) - stackBytes += PointerSize; - else - numRegistersUsed++; - } - if (hasParamType) - { - if (numRegistersUsed >= NumArgumentRegisters) - stackBytes += PointerSize; - // else: register slot consumed; cbStackPop unchanged - } - - return stackBytes; - } - - // Eligible types for register passing on x86. Source of truth: - // gElementTypeInfo[].m_enregister in src/coreclr/vm/siginfo.cpp. - private static bool IsRegisterEligible(CorElementType t) - { - switch (t) - { - case CorElementType.Boolean: - case CorElementType.Char: - case CorElementType.I1: - case CorElementType.U1: - case CorElementType.I2: - case CorElementType.U2: - case CorElementType.I4: - case CorElementType.U4: - case CorElementType.String: - case CorElementType.Ptr: - case CorElementType.Byref: - case CorElementType.Class: - case CorElementType.Var: - case CorElementType.MVar: - case CorElementType.Array: - case CorElementType.I: - case CorElementType.U: - case CorElementType.FnPtr: - case CorElementType.Object: - case CorElementType.SzArray: - return true; - // I8/U8/R4/R8/ValueType/TypedByRef/GenericInst etc. go on stack on x86. - default: - return false; - } - } - - private static int GetArgSize(Target target, IRuntimeTypeSystem rts, TypeHandle paramType, CorElementType corType) - { - switch (corType) - { - case CorElementType.I1: - case CorElementType.U1: - case CorElementType.Boolean: - return 1; - case CorElementType.I2: - case CorElementType.U2: - case CorElementType.Char: - return 2; - case CorElementType.I4: - case CorElementType.U4: - case CorElementType.R4: - return 4; - case CorElementType.I8: - case CorElementType.U8: - case CorElementType.R8: - return 8; - case CorElementType.TypedByRef: - return PointerSize * 2; - case CorElementType.ValueType: - case CorElementType.GenericInst: - // Value-type size = instance size = BaseSize - sizeof(ObjHeader) - sizeof(MethodTable*). - try - { - uint baseSize = rts.GetBaseSize(paramType); - int instanceSize = (int)baseSize - 2 * target.PointerSize; - return instanceSize > 0 ? instanceSize : PointerSize; - } - catch - { - return PointerSize; - } - default: - return PointerSize; - } - } - - // Round up to STACK_SLOT_SIZE (= sizeof(void*) on x86 = 4). - private static int StackElemSize(int byteSize) - { - return (byteSize + (PointerSize - 1)) & ~(PointerSize - 1); - } - - // Native equivalent: ArgIterator::HasRetBuffArg. Value types larger than a - // pointer (and not in the small "enregister" set) need a return buffer. - private static bool NeedsReturnBuffer(Target target, TypeHandle returnType) - { - try - { - IRuntimeTypeSystem rts = target.Contracts.RuntimeTypeSystem; - CorElementType retType = rts.GetSignatureCorElementType(returnType); - if (retType != CorElementType.ValueType && retType != CorElementType.GenericInst - && retType != CorElementType.TypedByRef) - { - return false; - } - - if (retType == CorElementType.TypedByRef) - return true; - - uint baseSize = rts.GetBaseSize(returnType); - int instanceSize = (int)baseSize - 2 * target.PointerSize; - // x86 enregisters return-by-value structs of size 1/2/4/8 in EAX[:EDX]. - return instanceSize != 1 && instanceSize != 2 && instanceSize != 4 && instanceSize != 8; - } - catch - { - // Be conservative: if we can't resolve, assume no retbuf. - return false; - } - } -} diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86FrameHandler.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86FrameHandler.cs index 6b5568112ba9e8..6af95413615273 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86FrameHandler.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/X86FrameHandler.cs @@ -34,7 +34,10 @@ public override void HandleTransitionFrame(FramedMethodFrame framedMethodFrame) // unwind data, so they are unaffected. // // PInvokeCalliFrame has no MethodDesc -- pull cbStackPop from the VASigCookie. - // Every other transition Frame has a MethodDesc that X86ArgIterator can walk. + // Every other transition Frame has a MethodDesc: ask the shared + // CallingConvention contract to produce its GCRefMap blob (which begins + // with the x86 stack-pop prefix written by GCRefMapBuilder::WriteStackPop) + // and decode just that prefix to recover cbStackPop in pointer-size units. FrameHelpers frameHelpers = new(_target); FrameType frameType = frameHelpers.GetFrameType( _target.ProcessedData.GetOrAdd(framedMethodFrame.Address).Identifier); @@ -50,10 +53,17 @@ public override void HandleTransitionFrame(FramedMethodFrame framedMethodFrame) return; } - if (framedMethodFrame.MethodDescPtr != TargetPointer.Null) - { - _context.Context.Esp += X86ArgIterator.Compute(_target, framedMethodFrame.MethodDescPtr); - } + if (framedMethodFrame.MethodDescPtr == TargetPointer.Null) + return; + + MethodDescHandle md = _target.Contracts.RuntimeTypeSystem.GetMethodDescHandle(framedMethodFrame.MethodDescPtr); + if (!_target.Contracts.CallingConvention.TryComputeArgGCRefMapBlob(md, out byte[] blob) || blob.Length == 0) + return; + + // ReadStackPop returns the count in pointer-size units (4 bytes on x86). + GCRefMapDecoder decoder = new(blob); + uint stackPopSlots = decoder.ReadStackPop(); + _context.Context.Esp += stackPopSlots * (uint)_target.PointerSize; } public override void HandleTailCallFrame(TailCallFrame frame) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GCRefMapDecoder.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GCRefMapDecoder.cs index 6815878ec65c86..e502e42988cc8f 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GCRefMapDecoder.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GCRefMapDecoder.cs @@ -34,8 +34,14 @@ internal enum GCRefMapToken /// internal ref struct GCRefMapDecoder { - private readonly Target _target; + // The decoder can source its bytes either from target memory (the R2R image, + // for ExternalMethodFrame / StubDispatchFrame) or from a host-side byte[] + // (for blobs we synthesize via ICallingConvention.TryComputeArgGCRefMapBlob). + // Exactly one of (_target + _currentByte) or (_blob) is non-null per instance. + private readonly Target? _target; private TargetPointer _currentByte; + private readonly byte[]? _blob; + private int _blobIndex; private int _pendingByte; private int _pos; @@ -43,6 +49,18 @@ public GCRefMapDecoder(Target target, TargetPointer blob) { _target = target; _currentByte = blob; + _blob = null; + _blobIndex = 0; + _pendingByte = 0x80; // Forces first byte read + _pos = 0; + } + + public GCRefMapDecoder(byte[] blob) + { + _target = null; + _currentByte = TargetPointer.Null; + _blob = blob; + _blobIndex = 0; _pendingByte = 0x80; // Forces first byte read _pos = 0; } @@ -56,8 +74,16 @@ private int GetBit() int x = _pendingByte; if ((x & 0x80) != 0) { - x = _target.Read(_currentByte); - _currentByte = new TargetPointer(_currentByte.Value + 1); + if (_blob is not null) + { + x = _blobIndex < _blob.Length ? _blob[_blobIndex] : 0; + _blobIndex++; + } + else + { + x = _target!.Read(_currentByte); + _currentByte = new TargetPointer(_currentByte.Value + 1); + } x |= (x & 0x80) << 7; } _pendingByte = x >> 1; diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GcScanner.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GcScanner.cs index fbc3cc529ff60a..4aeb9de2d66dd2 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GcScanner.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/GC/GcScanner.cs @@ -196,6 +196,20 @@ private void PromoteCallerStackUsingGCRefMap( if (_target.Contracts.RuntimeInfo.GetTargetArchitecture() is RuntimeInfoArchitecture.X86) decoder.ReadStackPop(); + EnumerateGCRefMapTokens(ref decoder, tb, scanContext); + } + + /// + /// Iterates the token stream of a GCRefMap and reports refs at each tagged + /// transition-block slot. Shared by both the R2R-blob path + /// () and the synthesized-blob + /// path (). + /// + private void EnumerateGCRefMapTokens( + ref GCRefMapDecoder decoder, + Data.TransitionBlock tb, + GcScanContext scanContext) + { while (!decoder.AtEnd) { int pos = decoder.CurrentPos; @@ -340,14 +354,51 @@ private TargetPointer FindGCRefMap(TargetPointer indirection) /// Matches native TransitionFrame::PromoteCallerStack (frames.cpp:1494). /// /// - /// Not yet ported. Every call records a deferred frame so the stress harness - /// buckets the resulting cDAC-vs-runtime diff at this frame as a known issue - /// rather than a real cDAC bug. Will be replaced with a real port once the - /// signature- and ArgIterator-based ref enumeration lands. + /// On supported targets (Windows x86 / x64), synthesizes a GCRefMap from the + /// callee's MethodDesc via ICallingConvention.TryComputeArgGCRefMapBlob and + /// runs the same token-iteration loop as the R2R-backed path. On unsupported + /// targets (SystemV-AMD64 / ARM64 struct-in-registers, arm32, etc.) and on + /// any failure to synthesize a blob, records the frame as deferred so the + /// stress harness buckets the resulting diff as a known issue rather than a + /// real cDAC bug. /// - private static void PromoteCallerStack(TargetPointer frameAddress, GcScanContext scanContext) + private void PromoteCallerStack(TargetPointer frameAddress, GcScanContext scanContext) { - scanContext.RecordDeferredFrame(frameAddress); + IRuntimeInfo runtimeInfo = _target.Contracts.RuntimeInfo; + RuntimeInfoArchitecture arch = runtimeInfo.GetTargetArchitecture(); + bool supportedByCallingConvention = + runtimeInfo.GetTargetOperatingSystem() == RuntimeInfoOperatingSystem.Windows + && arch is RuntimeInfoArchitecture.X86 or RuntimeInfoArchitecture.X64; + + if (!supportedByCallingConvention) + { + scanContext.RecordDeferredFrame(frameAddress); + return; + } + + Data.FramedMethodFrame fmf = _target.ProcessedData.GetOrAdd(frameAddress); + if (fmf.MethodDescPtr == TargetPointer.Null) + { + scanContext.RecordDeferredFrame(frameAddress); + return; + } + + MethodDescHandle md = _target.Contracts.RuntimeTypeSystem.GetMethodDescHandle(fmf.MethodDescPtr); + if (!_target.Contracts.CallingConvention.TryComputeArgGCRefMapBlob(md, out byte[] blob) || blob.Length == 0) + { + scanContext.RecordDeferredFrame(frameAddress); + return; + } + + Data.TransitionBlock tb = _target.ProcessedData.GetOrAdd(fmf.TransitionBlockPtr); + GCRefMapDecoder decoder = new(blob); + + // x86 prefix: callee-popped stack-pop count in pointer-size units. The + // ref enumeration uses positions only, so consume and discard. + if (arch is RuntimeInfoArchitecture.X86) + decoder.ReadStackPop(); + + EnumerateGCRefMapTokens(ref decoder, tb, scanContext); } private TargetPointer AddressFromGCRefMapPos(Data.TransitionBlock tb, int pos) diff --git a/src/native/managed/cdac/tests/StressTests/CdacStressTestBase.cs b/src/native/managed/cdac/tests/StressTests/CdacStressTestBase.cs index f67ad3e5329ec7..f3cd90148ac9ed 100644 --- a/src/native/managed/cdac/tests/StressTests/CdacStressTestBase.cs +++ b/src/native/managed/cdac/tests/StressTests/CdacStressTestBase.cs @@ -158,11 +158,13 @@ private async Task RunStressAsync(string debuggeeName, Stress /// /// Asserts the GCREFS stress run produced a [GC_STATS] summary /// with at least one verification and no hard failures. - /// is intentionally - /// tolerated (the native harness emits [KNOWN_ISSUE] for acknowledged - /// divergences via s_knownIssueCount, separate from - /// s_failCount) but is logged so regressions in the known-issue - /// count are visible during triage. + /// + /// On Windows x86 / x64 the cDAC stack-ref enumeration is at parity with + /// the runtime and the framework rejects any : + /// a non-zero count signals a regression in the ICallingConvention port and + /// fails the test. On other targets we still tolerate KnownIssues because + /// PromoteCallerStack falls back to RecordDeferredFrame there + /// (see GcScanner.PromoteCallerStack). /// internal static void AssertAllPassed(CdacStressResults results, string debuggeeName) { @@ -187,6 +189,26 @@ internal static void AssertAllPassed(CdacStressResults results, string debuggeeN $"({results.KnownIssues} known issue(s) tolerated).\n" + $"Log: {results.LogFilePath}\n\n{analysis}"); } + + // On supported targets every Frame's caller-arg refs are enumerated via + // the GCRefMap blob synthesized by ICallingConvention -- there should be + // no deferred frames at all, so any KnownIssue count is a regression. + GetTargetPlatform(out OSPlatform os, out Architecture arch); + bool requiresZeroKnownIssues = + os == OSPlatform.Windows && arch is Architecture.X86 or Architecture.X64; + if (requiresZeroKnownIssues && results.KnownIssues > 0) + { + string analysis = results.AnalyzeFailures(maxFailures: 3); + Assert.Fail( + $"GCREFS stress test '{debuggeeName}' had {results.KnownIssues} known issue(s) " + + $"out of {results.TotalVerifications} verifications. " + + "Windows x86 / x64 do not accept any deferred frames in this PR's scope -- " + + "every transition Frame's caller-stack scan must succeed via the shared " + + "ICallingConvention.TryComputeArgGCRefMapBlob path. A non-zero count likely " + + "indicates the encoder declined a method it previously handled (regression " + + "in CallingConvention_1.ComputeArgGCRefMapBlobCore).\n" + + $"Log: {results.LogFilePath}\n\n{analysis}"); + } } /// diff --git a/src/native/managed/cdac/tests/StressTests/CdacStressTests.cs b/src/native/managed/cdac/tests/StressTests/CdacStressTests.cs index 4c6676b5e8b8a9..57841006264962 100644 --- a/src/native/managed/cdac/tests/StressTests/CdacStressTests.cs +++ b/src/native/managed/cdac/tests/StressTests/CdacStressTests.cs @@ -58,12 +58,6 @@ public async Task GCRefStress_AllVerificationsPass(Debuggee debuggee) if (debuggee.SkipGCRefs) throw new SkipTestException($"{debuggee.Name} is excluded from GCREFS pending follow-up work."); - // The GCREFS sub-check has only been validated on architectures where - // the cDAC GC root enumeration is at parity with the runtime. x86 has - // not been brought up yet (a separate effort); skip there until it is. - if (arch == Architecture.X86) - throw new SkipTestException("GCREFS stress is not yet validated on x86 (ARGITER stress runs there instead)"); - CdacStressResults results = await RunGCRefStressAsync(debuggee.Name); AssertAllPassed(results, debuggee.Name); }