From 8b474dac3b1615da670a023bc5a9abeb85ee5543 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 13:07:58 -0500 Subject: [PATCH 1/8] [cDAC] Resolve MethodDescs from portable entry points in PrecodeStubs With FEATURE_PORTABLE_ENTRYPOINTS (WASM) the runtime has no precode stubs and does not describe PrecodeMachineDescriptor, so constructing PrecodeStubs threw and MethodDesc validation rejected every MethodDesc with a temporary entry point. Mirror MethodDesc::GetMethodDescFromPrecode and read the owning MethodDesc from the PortableEntryPoint instead. Fixes #135035 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/PrecodeStubs.md | 4 ++ .../Contracts/PrecodeStubs_1.cs | 3 ++ .../Contracts/PrecodeStubs_Common.cs | 22 ++++++++-- .../cdac/tests/UnitTests/PrecodeStubsTests.cs | 40 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/docs/design/datacontracts/PrecodeStubs.md b/docs/design/datacontracts/PrecodeStubs.md index a0fef7697a0775..9e0e73cdab5368 100644 --- a/docs/design/datacontracts/PrecodeStubs.md +++ b/docs/design/datacontracts/PrecodeStubs.md @@ -29,6 +29,7 @@ This contract provides support for examining [precode](../coreclr/botr/method-de | `InterpByteCodeStart` | `Method` | `pointer` | pointer to the InterpMethod associated with the bytecode | | `InterpMethod` | `MethodDesc` | `pointer` | pointer to the MethodDesc for the interpreted method | | `InterpreterPrecodeData` | `ByteCodeAddr` | `pointer` | pointer to the InterpByteCodeStart for the interpreter bytecode | +| `PortableEntryPoint` | `MethodDesc` | `pointer` | Method desc of portable entrypoint (only defined if `FeaturePortableEntrypoints` is enabled) | | `PrecodeMachineDescriptor` | `DynamicHelperPrecodeType` | `uint8` | Precode type byte for a dynamic helper precode | | `PrecodeMachineDescriptor` | `FixupBytes` | `uint8[]` | Assembly code of a FixupStub | | `PrecodeMachineDescriptor` | `FixupIgnoredBytes` | `uint8[]` | Bytes to ignore when comparing FixupBytes to an actual block of memory in the target process. | @@ -54,6 +55,7 @@ _None._ | Contract Name | | --- | +| `FeatureFlags` | | `PlatformMetadata` | @@ -126,6 +128,8 @@ registers) into an address. On other architectures applying the mask is a no-op. ### `MethodDescFromStubAddress` +When the `FeatureFlags` contract reports `PortableEntrypoints` (for example on WASM), the runtime has no precode stubs and does not describe `PrecodeMachineDescriptor`. Every entry point is a `PortableEntryPoint`, so `GetMethodDescFromStubAddress` returns its `MethodDesc` field, matching native `MethodDesc::GetMethodDescFromPrecode`. `GetInterpreterCodeFromInterpreterPrecodeIfPresent` returns the entry point unchanged, and `GetPrecodeEntryPointFromInteriorAddress` is not supported. + ```csharp internal enum KnownPrecodeType { diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs index 1ed50a851c2b4f..36507eaa190a6c 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs @@ -113,6 +113,9 @@ public PrecodeStubs_1(Target target) : base(target) { } public override TargetCodePointer GetInterpreterCodeFromInterpreterPrecodeIfPresent( TargetCodePointer entryPoint) { + if (UsesPortableEntryPoints) + return entryPoint; + try { TargetPointer instrPointer = CodePointerReadableInstrPointer(entryPoint); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs index b80cf28877df7e..02572e12b5664a 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs @@ -32,7 +32,14 @@ internal class PrecodeStubsCommon : IPrecodeStubs w { private readonly Target _target; private readonly CodePointerFlags _codePointerFlags; - internal readonly Data.PrecodeMachineDescriptor MachineDescriptor; + private readonly bool _portableEntryPoints; + private readonly Data.PrecodeMachineDescriptor? _machineDescriptor; + + // Not available with portable entry points: the runtime has no precode stubs to describe. + internal Data.PrecodeMachineDescriptor MachineDescriptor + => _machineDescriptor ?? throw new InvalidOperationException("Precode stubs are not used with portable entry points."); + + protected bool UsesPortableEntryPoints => _portableEntryPoints; protected Target Target => _target; @@ -143,13 +150,22 @@ public PrecodeStubsCommon(Target target) { _target = target; IPlatformMetadata pm = target.Contracts.PlatformMetadata; - TargetPointer descAddr = pm.GetPrecodeMachineDescriptor(); - MachineDescriptor = target.ProcessedData.GetOrAdd(descAddr); + _portableEntryPoints = target.Contracts.FeatureFlags.IsEnabled(RuntimeFeature.PortableEntrypoints); + if (!_portableEntryPoints) + { + TargetPointer descAddr = pm.GetPrecodeMachineDescriptor(); + _machineDescriptor = target.ProcessedData.GetOrAdd(descAddr); + } _codePointerFlags = pm.GetCodePointerFlags(); } TargetPointer IPrecodeStubs.GetMethodDescFromStubAddress(TargetCodePointer entryPoint) { + // Mirrors MethodDesc::GetMethodDescFromPrecode: with FEATURE_PORTABLE_ENTRYPOINTS the + // entry point is a PortableEntryPoint that records its owning MethodDesc. + if (_portableEntryPoints) + return _target.ProcessedData.GetOrAdd(entryPoint.AsTargetPointer).MethodDesc; + ValidPrecode precode = GetPrecodeFromEntryPoint(entryPoint); return precode.GetMethodDesc(_target, MachineDescriptor); diff --git a/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs b/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs index 70a993c1788073..aec544222dfc38 100644 --- a/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs @@ -404,6 +404,7 @@ private static Target CreateTarget(PrecodeBuilder precodeBuilder) .AddTypes(precodeBuilder.Types) .AddGlobals(globals) .AddMockContract(platformMetadata) + .AddContract(version: "c1") .AddContract(version: precodeBuilder.PrecodesVersion) .Build(); @@ -541,4 +542,43 @@ public void GetInterpreterCode_Version1UnreadableAddress_ReturnsOriginalAddress( Assert.Equal(unreadableAddress, actual); } + + [Theory] + [ClassData(typeof(MockTarget.StdArch))] + public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodDesc(MockTarget.Architecture arch) + { + MockMemorySpace.Builder builder = new(new TargetTestHelpers(arch)); + TargetTestHelpers helpers = builder.TargetTestHelpers; + TargetTestHelpers.LayoutResult layout = helpers.LayoutFields([ + new(nameof(Data.PortableEntryPoint.ActualCode), DataType.pointer), + new(nameof(Data.PortableEntryPoint.MethodDesc), DataType.pointer), + new(nameof(Data.PortableEntryPoint.Flags), DataType.int32), + ]); + TargetPointer expectedMethodDesc = new(0x0eee_eee0u); + MockMemorySpace.BumpAllocator allocator = builder.CreateAllocator(0x0010_0000, 0x0010_1000); + MockMemorySpace.HeapFragment entryPoint = allocator.Allocate(layout.Stride, "PortableEntryPoint"); + helpers.WritePointer(entryPoint.Data.AsSpan(layout.Fields[nameof(Data.PortableEntryPoint.MethodDesc)].Offset, helpers.PointerSize), expectedMethodDesc); + + // Strict: with portable entry points there is no PrecodeMachineDescriptor to read. + Mock platformMetadata = new(MockBehavior.Strict); + platformMetadata.Setup(p => p.GetCodePointerFlags()).Returns(default(CodePointerFlags)); + + Target target = new TestPlaceholderTarget.Builder(arch) + .UseReader(builder.GetMemoryContext().ReadFromTarget) + .AddTypes(new Dictionary + { + [DataType.PortableEntryPoint] = new() { Fields = layout.Fields, Size = layout.Stride }, + }) + .AddGlobals((Constants.Globals.FeaturePortableEntrypoints, 1ul)) + .AddMockContract(platformMetadata) + .AddContract(version: "c1") + .AddContract(version: "c1") + .Build(); + + IPrecodeStubs precodeStubs = target.Contracts.PrecodeStubs; + TargetCodePointer entryPointAddress = new(entryPoint.Address); + + Assert.Equal(expectedMethodDesc, precodeStubs.GetMethodDescFromStubAddress(entryPointAddress)); + Assert.Equal(entryPointAddress, precodeStubs.GetInterpreterCodeFromInterpreterPrecodeIfPresent(entryPointAddress)); + } } From de46fdaacab0421ed3cade340c618401cfcc3292 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 13:07:58 -0500 Subject: [PATCH 2/8] [cDAC][wasm] Fix stack walks on WASM: Debugger contract and R2R InlinedCallFrame - Treat hijack kind as None on WASM, which has no hijack stubs and does not advertise the Debugger contract; skip validating it there. - Handle the INLINED_PINVOKE_FROM_R2R marker in WasmFrameHandler by deriving SP/IP/FP from the R2R shadow frame at CallSiteSP, like native InlinedCallFrame::UpdateRegDisplay_Impl. - Advance past an active InlinedCallFrame whose context is not managed code so the walk always makes progress instead of yielding the same Frame forever. Fixes #135034 Fixes #135036 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 15 +- .../FrameHandling/WasmFrameHandler.cs | 25 +++- .../Contracts/StackWalk/StackWalk_1.cs | 17 ++- .../CoreCLRContracts.cs | 6 +- .../TestPlaceholderTarget.cs | 3 +- .../cdac/tests/UnitTests/StackWalkTests.cs | 138 +++++++++++++++++- 6 files changed, 195 insertions(+), 9 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 4e0c27ef77a50f..6ec689dde57cb4 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -159,6 +159,10 @@ Unwinding call frames on the stack usually requires an OS specific implementatio | `FramedMethodFrame` | `TransitionBlockPtr` | `pointer` | Pointer to Frame's TransitionBlock | | `FuncEvalFrame` | `DebuggerEvalPtr` | `pointer` | Pointer to the Frame's DebuggerEval object | | `FuncEvalFrame` | `ReturnAddress` | `CodePointer` | Return address of the frame | +| `FunctionTableIndexRangeSection` | `MinFunctionTableIndex` | `uint32` | First runtime-global shared function-table index owned by the R2R module | +| `FunctionTableIndexRangeSection` | `Next` | `pointer` | Pointer to the next registered WASM R2R function-table range | +| `FunctionTableIndexRangeSection` | `NumRuntimeFunctions` | `uint32` | Number of consecutive RUNTIME_FUNCTION entries owned by the R2R module | +| `FunctionTableIndexRangeSection` | `R2RModule` | `pointer` | Pointer to the Module that owns this function-table range | | `GCFrame` | `GCFlags` | `uint32` | GC_CALL_* promotion flags applied when reporting the protected slots | | `GCFrame` | `Next` | `pointer` | Pointer to the next GCFrame toward the top of the chain | | `GCFrame` | `NumObjRefs` | `uint32` | Count of protected object reference slots starting at ObjRefs | @@ -185,8 +189,13 @@ Unwinding call frames on the stack usually requires an OS specific implementatio | `PInvokeCalliFrame` | `VASigCookiePtr` | `pointer` | Pointer to the varargs signature cookie for the unmanaged call | | `ReadyToRunInfo` | `ImportSections` | `pointer` | Pointer to the array of ReadyToRun import sections | | `ReadyToRunInfo` | `LoadedImageBase` | `pointer` | Base address of the loaded R2R image | +| `ReadyToRunInfo` | `MinVirtualIP` | `pointer` | Base virtual IP assigned to the ReadyToRun module on WebAssembly | | `ReadyToRunInfo` | `NumImportSections` | `uint32` | Number of ReadyToRun import sections | +| `ReadyToRunInfo` | `NumRuntimeFunctions` | `uint32` | Number of `RuntimeFunctions` | +| `ReadyToRunInfo` | `RuntimeFunctions` | `pointer` | Pointer to an array of `RuntimeFunctions` - [see R2R format](../coreclr/botr/readytorun-format.md#readytorunsectiontyperuntimefunctions) | | `ResumableFrame` | `TargetContextPtr` | `pointer` | Pointer to the Frame's Target Context | +| `RuntimeFunction` | *(type size)* | `uint32` | Size of a runtime function entry in bytes | +| `RuntimeFunction` | `BeginAddress` | `uint32` | Begin address of the function. On ARM32, bit 0 is the Thumb bit; on WebAssembly, bit 31 marks a funclet and is excluded from address arithmetic. | | `SoftwareExceptionFrame` | `ReturnAddress` | `CodePointer` | Return address saved in Frame | | `SoftwareExceptionFrame` | `TargetContext` | `pointer` | Context object saved in Frame | | `String` | `m_StringLength` | `uint32` | Length of the string in UTF-16 characters | @@ -211,6 +220,7 @@ Unwinding call frames on the stack usually requires an OS specific implementatio | --- | --- | --- | | `Identifier` *(name pattern)* | `pointer` | Per-frame-type sentinel address used to identify and classify runtime frames | | `Architecture` | `string` | Target architecture | +| `FunctionTableIndexRangeList` | `pointer` | Pointer to the head pointer of the registered WASM R2R function-table range list | | `ObjectToMethodTableUnmask` | `uint8` | Bits to clear when converting an object header value to a method table address | ### Contracts used @@ -439,6 +449,9 @@ Most of the handlers are implemented in `BaseFrameHandler`. Platform specific co InlinedCallFrames store and update only the IP, SP, and FP of a given context. If the stored IP (CallerReturnAddress) is 0 then the InlinedCallFrame does not have an active call and should not update the context. * On ARM, the InlinedCallFrame stores the value of the SP after the prolog (`SPAfterProlog`) to allow unwinding for functions with stackalloc. When a function uses stackalloc, the CallSiteSP can already have been adjusted. This value should be placed in R9. +* On WASM, a `CallerReturnAddress` of `INLINED_PINVOKE_FROM_R2R` (`1`) marks an active inlined P/Invoke from ReadyToRun code rather than an address. SP is taken from `CallSiteSP`, IP is the R2R virtual IP of the shadow frame at `CallSiteSP`, and FP is that shadow frame's base. If no virtual IP can be recovered, IP is set to null. + +An active InlinedCallFrame normally stays the current Frame after its context update so the skipped-Frame check can step past it once the walk reaches the managed caller. If the updated IP is not managed code, the walker advances past the Frame immediately instead, so a walk always makes progress. **Return Address**: `CallerReturnAddress`, but only when the frame has an active call (i.e., `CallerReturnAddress != 0`). Returns null otherwise. @@ -721,7 +734,7 @@ The runtime installs a small set of redirect/hijack stubs whose code blocks are The recovery step is driven by `IDebugger.GetHijackKind(controlPC)`, which returns a `HijackKind`: -* `HijackKind.None` — the IP is not inside any tracked stub; `Next()` does nothing special. +* `HijackKind.None` — the IP is not inside any tracked stub; `Next()` does nothing special. WASM has no hijack stubs and does not advertise the `Debugger` contract, so on WASM the walker always uses `HijackKind.None` without consulting it. * `HijackKind.UnhandledException` — the IP is inside the `ExceptionHijack` stub. The saved `PT_CONTEXT*` is at `*SP` (the stub pushed it directly), so the implementation reads `*context.StackPointer`. * `HijackKind.Other` — the IP is inside another redirect stub. The saved `PT_CONTEXT*` is at a fixed offset from SP or FP, matching the `REDIRECTSTUB_*` constants. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs index 35ffcfc3b4f430..0fa3bb37d4c41d 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs @@ -16,7 +16,8 @@ namespace Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; /// pointer. The base already reads that /// InlinedCallFrame.CallSiteSP (plus the caller return address and callee-saved frame /// pointer) into the three synthetic slots, which is the common -/// P/Invoke-boundary seeding path. The software/faulting exception frame handlers likewise read a +/// P/Invoke-boundary seeding path; an inlined P/Invoke from R2R code instead stores a marker and is +/// resolved from its R2R shadow frame. The software/faulting exception frame handlers likewise read a /// serialized blob from the frame's TargetContext. /// /// Hijack frames are a debugger / GC-suspension concept that is not yet supported on WASM. @@ -24,11 +25,31 @@ namespace Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; internal sealed class WasmFrameHandler(Target target, ContextHolder contextHolder) : BaseFrameHandler(target, contextHolder), IPlatformFrameHandler { + // INLINED_PINVOKE_FROM_R2R from src/coreclr/vm/frames.h. An R2R inlined P/Invoke has no native + // return address on WASM, so the runtime stores this marker and derives IP/SP from CallSiteSP. + private const ulong InlinedPInvokeFromR2R = 1; + private readonly ContextHolder _holder = contextHolder; public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) { - base.HandleInlinedCallFrame(inlinedCallFrame); + if (inlinedCallFrame.CallerReturnAddress.Value == InlinedPInvokeFromR2R) + { + // Mirrors InlinedCallFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. + // If no R2R virtual IP can be recovered the IP is left null (not managed code), so the + // stack walker steps past this frame rather than treating the marker as an address. + Wasm.WasmUnwinder unwinder = new(_target, new Wasm.WasmR2RInfo(_target)); + _holder.Context.StackPointer = inlinedCallFrame.CallSiteSP; + _holder.Context.InstructionPointer = unwinder.GetVirtualIP(inlinedCallFrame.CallSiteSP); + // Root-function frame base; the funclet-aware logical frame pointer is not modeled yet. + _holder.Context.FramePointer = unwinder.TryGetFramePointer(inlinedCallFrame.CallSiteSP, out TargetPointer framePointer) + ? framePointer + : TargetPointer.Null; + } + else + { + base.HandleInlinedCallFrame(inlinedCallFrame); + } // When the frame directly above this P/Invoke transition is an InterpreterFrame, stash its // address in the synthetic first-argument register so the subsequent interpreter virtual diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index 98c76b52196364..5935cae55e1114 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -866,7 +866,7 @@ private bool Next(StackWalkData handle) case StackWalkState.NativeMarker: { TargetCodePointer ip = handle.Context.InstructionPointer; - HijackKind hijackKind = _target.Contracts.Debugger.GetHijackKind(ip); + HijackKind hijackKind = GetHijackKind(ip); if (hijackKind != HijackKind.None) { IPlatformAgnosticContext recoveredContext = RetrieveHijackedContext(handle.Context, hijackKind == HijackKind.UnhandledException); @@ -908,7 +908,11 @@ private bool Next(StackWalkData handle) { handle.FrameIter.UpdateContextFromCurrentFrame(handle.Context); } - if (!isActiveICF) + // An active ICF is normally left current so CheckForSkippedFrames can pass it + // once the walk reaches its managed caller. If the context it produced is not + // managed code, nothing would ever advance past it, so step over it here to + // guarantee the walk makes progress. + if (!isActiveICF || !IsManaged(handle.Context.InstructionPointer, out _)) { handle.FrameIter.Next(); } @@ -1196,6 +1200,13 @@ static bool TryReadRegister(IPlatformAgnosticContext context, GenericContextStor : context.TryReadRegister((int)storage.RegisterNumber, out value); } + // WASM has no return-address hijacking and the runtime does not advertise the Debugger + // contract there (see datadescriptor.inc), so there is never a hijack stub to recover from. + private HijackKind GetHijackKind(TargetCodePointer controlPC) + => _target.Contracts.RuntimeInfo.GetTargetArchitecture() == RuntimeInfoArchitecture.Wasm + ? HijackKind.None + : _target.Contracts.Debugger.GetHijackKind(controlPC); + // See https://github.com/dotnet/runtime/blob/71830fdb091c9be1ad297b8649ac445af628fb81/src/coreclr/debug/daccess/dacdbiimplstackwalk.cpp#L659 private TargetPointer ComputeX86FramePointer(StackDataFrameHandle handle) { @@ -1218,7 +1229,7 @@ private TargetPointer ComputeX86FramePointer(StackDataFrameHandle handle) // Native marker / initial native context: RetrieveHijackedContext already returns the context // the stub unwinds to, so PCTAddr = hijackedContext.Esp - sizeof(DWORD). - HijackKind hijackKind = _target.Contracts.Debugger.GetHijackKind(handle.Context.InstructionPointer); + HijackKind hijackKind = GetHijackKind(handle.Context.InstructionPointer); IPlatformAgnosticContext hijacked = RetrieveHijackedContext(handle.Context, hijackKind == HijackKind.UnhandledException); return new TargetPointer(hijacked.StackPointer.Value - pointerSize); } diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs index 1e36b4ea04ed33..8ac354af8e0b2a 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs @@ -143,7 +143,11 @@ public static void ValidateForDataAccess(Target target, Lock? apiLock = null) // Transitive contract accesses from the implementations above. Validate(registry); // IComWrappers: ComWrappers_1.cs - Validate(registry); // IStackWalk: StackWalk_1.cs + // IStackWalk: StackWalk_1.cs. Not advertised on WASM, which has no debugger hijacking. + if (registry.RuntimeInfo.GetTargetArchitecture() != RuntimeInfoArchitecture.Wasm) + { + Validate(registry); + } Validate(registry); // IAuxiliarySymbols/IPrecodeStubs: CodePointerUtils.cs, PrecodeStubs_Common.cs Validate(registry); // ILoader: Loader_1.cs diff --git a/src/native/managed/cdac/tests/TestInfrastructure/TestPlaceholderTarget.cs b/src/native/managed/cdac/tests/TestInfrastructure/TestPlaceholderTarget.cs index 7ef4525766ca53..0f68ff943b8691 100644 --- a/src/native/managed/cdac/tests/TestInfrastructure/TestPlaceholderTarget.cs +++ b/src/native/managed/cdac/tests/TestInfrastructure/TestPlaceholderTarget.cs @@ -550,7 +550,8 @@ public override Target.TypeInfo GetTypeInfo(string typeName) public override bool TryGetTypeInfo(string typeName, out Target.TypeInfo info) => _typeInfoCache.TryGetValue(typeName, out info); - public override bool TryGetThreadContext(ulong threadId, uint contextFlags, Span bufferToFill) => throw new NotImplementedException(); + // No OS thread context is available (as on WASM); stack walks fall back to the Frame chain. + public override bool TryGetThreadContext(ulong threadId, uint contextFlags, Span bufferToFill) => false; public override bool TrySetThreadContext(ulong threadId, ReadOnlySpan context) => throw new NotImplementedException(); public override Target.IDataCache ProcessedData => _dataCache; diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 7ef05e11299899..38635f04edefa0 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -473,7 +473,8 @@ private static TestPlaceholderTarget CreateTarget( MockTarget.Architecture arch, Action configure, Action? configureFrames = null, - RuntimeInfoArchitecture? runtimeArchitecture = null) + RuntimeInfoArchitecture? runtimeArchitecture = null, + Action? configureTarget = null) { TestPlaceholderTarget.Builder targetBuilder = new(arch); MockThreadBuilder threadBuilder = new(targetBuilder.MemoryBuilder); @@ -521,6 +522,8 @@ private static TestPlaceholderTarget CreateTarget( targetBuilder.AddMockContract(runtimeInfo.Object); } + configureTarget?.Invoke(targetBuilder); + return targetBuilder .AddContract(version: "c1") .AddContract(version: "c1") @@ -838,6 +841,139 @@ public void WasmContext_MirrorsNativeLayoutAndRoundTripsRegisters() Assert.Equal(0x8000000u, context.RawContextFlags); } + // An InlinedCallFrame pushed by R2R code on WASM stores INLINED_PINVOKE_FROM_R2R (1) instead of + // a return address. Like native InlinedCallFrame::UpdateRegDisplay_Impl, the handler takes SP + // from CallSiteSP and derives the virtual IP from the R2R shadow frame at that SP. + [Fact] + public void UpdateContextFromFrame_WasmR2RInlinedCallFrame_DerivesVirtualIPFromCallSiteSP() + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + const uint FunctionTableIndex = 5; + const ulong MinVirtualIP = 0x0005_0000; + const uint FunctionBeginAddress = 0x100; + const uint LocalVirtualIPHalf = 3; + + ulong shadowFrameAddr = 0; + MockInlinedCallFrame? icf = null; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => threadBuilder.AddThread(1, 1234), + frameBuilder => icf = frameBuilder.AddInlinedCallFrame(callerReturnAddress: 1, datum: 0, callSiteSP: 0, calleeSavedFP: 0xBAD0), + configureTarget: targetBuilder => + { + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0010_0000, 0x0010_4000); + AddWasmR2RFunction(targetBuilder, allocator, FunctionTableIndex, MinVirtualIP, FunctionBeginAddress); + + MockMemorySpace.HeapFragment shadowFrame = allocator.Allocate(8, "R2RShadowFrame"); + targetBuilder.MemoryBuilder.TargetTestHelpers.Write(shadowFrame.Data.AsSpan(0, sizeof(uint)), FunctionTableIndex); + targetBuilder.MemoryBuilder.TargetTestHelpers.Write(shadowFrame.Data.AsSpan(4, sizeof(uint)), LocalVirtualIPHalf); + shadowFrameAddr = shadowFrame.Address; + icf!.CallSiteSP = shadowFrameAddr; + }); + + ContextHolder context = new(); + FrameHelpers frameHelpers = new(target); + Data.Frame frame = target.ProcessedData.GetOrAdd(icf!.Address); + frameHelpers.UpdateContextFromFrame(frame, context); + + Assert.Equal(shadowFrameAddr, context.StackPointer.Value); + Assert.Equal(MinVirtualIP + FunctionBeginAddress + LocalVirtualIPHalf * 2, context.InstructionPointer.Value); + Assert.Equal(shadowFrameAddr, context.FramePointer.Value); + } + + // A WASM walk seeded from the Frame chain must terminate when an active InlinedCallFrame does + // not lead to managed code: either an R2R marker whose shadow frame yields no virtual IP, or a + // return address outside any code range. The walk also must not require the Debugger + // contract, which WASM targets do not advertise. + [Theory] + [InlineData(1ul)] + [InlineData(0x0004_2000ul)] + public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Terminates(ulong callerReturnAddress) + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + + MockThread? thread = null; + ulong icfAddr = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => thread = threadBuilder.AddThread(1, 1234), + frameBuilder => + { + // CallSiteSP below the linear-stack floor: no R2R shadow frame to resolve. + icfAddr = frameBuilder.AddInlinedCallFrame(callerReturnAddress, datum: 0, callSiteSP: 0x800).Address; + }, + runtimeArchitecture: RuntimeInfoArchitecture.Wasm, + configureTarget: targetBuilder => AddWasmR2RFunction( + targetBuilder, + targetBuilder.MemoryBuilder.CreateAllocator(0x0010_0000, 0x0010_4000), + functionTableIndex: 5, + minVirtualIP: 0x0005_0000, + functionBeginAddress: 0x100)); + thread!.Frame = icfAddr; + + IStackWalk stackWalk = target.Contracts.StackWalk; + ThreadData threadData = target.Contracts.Thread.GetThreadData(new TargetPointer(thread.Address)); + IStackDataFrameHandle[] frames = stackWalk.CreateStackWalk(threadData).Take(16).ToArray(); + + Assert.InRange(frames.Length, 1, 2); + Assert.Equal(icfAddr, stackWalk.GetFrameAddress(frames[^1]).Value); + } + + private static void AddWasmR2RFunction( + TestPlaceholderTarget.Builder targetBuilder, + MockMemorySpace.BumpAllocator allocator, + uint functionTableIndex, + ulong minVirtualIP, + uint functionBeginAddress) + { + MockTarget.Architecture arch = targetBuilder.MemoryBuilder.TargetTestHelpers.Arch; + TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; + + int hashMapStride = MockHashMap.CreateLayout(arch).Size; + var moduleLayout = MockLoaderModule.CreateLayout(arch); + var r2rInfoLayout = MockReadyToRunInfo.CreateLayout(arch, hashMapStride, isWasm: true); + TargetTestHelpers.LayoutResult runtimeFunctionLayout = helpers.LayoutFields([ + new(nameof(Data.RuntimeFunction.BeginAddress), DataType.uint32), + new(nameof(Data.RuntimeFunction.UnwindData), DataType.uint32), + ]); + TargetTestHelpers.LayoutResult rangeSectionLayout = helpers.LayoutFields([ + new(nameof(Data.FunctionTableIndexRangeSection.MinFunctionTableIndex), DataType.uint32), + new(nameof(Data.FunctionTableIndexRangeSection.NumRuntimeFunctions), DataType.uint32), + new(nameof(Data.FunctionTableIndexRangeSection.R2RModule), DataType.pointer), + new(nameof(Data.FunctionTableIndexRangeSection.Next), DataType.pointer), + ]); + + MockMemorySpace.HeapFragment runtimeFunction = allocator.Allocate(runtimeFunctionLayout.Stride, "RuntimeFunction"); + helpers.Write(runtimeFunction.Data.AsSpan(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.BeginAddress)].Offset, sizeof(uint)), functionBeginAddress); + + MockReadyToRunInfo r2rInfo = r2rInfoLayout.Create(allocator.Allocate((ulong)r2rInfoLayout.Size, "ReadyToRunInfo")); + r2rInfo.CompositeInfo = r2rInfo.Address; + r2rInfo.NumRuntimeFunctions = 1; + r2rInfo.RuntimeFunctions = runtimeFunction.Address; + r2rInfo.MinVirtualIP = minVirtualIP; + + MockLoaderModule module = moduleLayout.Create(allocator.Allocate((ulong)moduleLayout.Size, "Module")); + module.ReadyToRunInfo = r2rInfo.Address; + + MockMemorySpace.HeapFragment section = allocator.Allocate(rangeSectionLayout.Stride, "FunctionTableIndexRangeSection"); + helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.MinFunctionTableIndex)].Offset, sizeof(uint)), functionTableIndex); + helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.NumRuntimeFunctions)].Offset, sizeof(uint)), 1u); + helpers.WritePointer(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.R2RModule)].Offset, helpers.PointerSize), module.Address); + + MockMemorySpace.HeapFragment listSlot = allocator.Allocate((ulong)helpers.PointerSize, "FunctionTableIndexRangeListSlot"); + helpers.WritePointer(listSlot.Data.AsSpan(), section.Address); + + targetBuilder + .AddTypes(new Dictionary + { + [DataType.RuntimeFunction] = new() { Fields = runtimeFunctionLayout.Fields, Size = runtimeFunctionLayout.Stride }, + [DataType.ReadyToRunInfo] = TargetTestHelpers.CreateTypeInfo(r2rInfoLayout), + [DataType.Module] = TargetTestHelpers.CreateTypeInfo(moduleLayout), + [DataType.FunctionTableIndexRangeSection] = new() { Fields = rangeSectionLayout.Fields, Size = rangeSectionLayout.Stride }, + }) + .AddGlobals((Constants.Globals.FunctionTableIndexRangeList, listSlot.Address)); + } + // When an active InlinedCallFrame is directly followed by an InterpreterFrame, WasmFrameHandler // stashes the InterpreterFrame address into the synthetic first-argument register // (InterpreterWalkFramePointer) so the subsequent interpreter virtual unwind can recover the From 2e5854aeee433967c6d9d337637f06a42f684d32 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 13:31:40 -0500 Subject: [PATCH 3/8] [cDAC] Walk an interpreted P/Invoke's InterpreterFrame once An active InlinedCallFrame pushed by the interpreter for a P/Invoke is followed by its owning InterpreterFrame. The walker applied the ICF context (an interpreter IP, so managed) and never advanced past the ICF, re-walking the outer interpreted chain forever. Mirror native StackFrameIterator: - In the Frame state, an ICF that IsInInterpreter moves straight to the owning InterpreterFrame without updating the context. - When starting in interpreted code, skip past the owning InterpreterFrame recorded in the first-argument register (native Init), falling back to skipping a head InterpreterFrame. Fixes #135037 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 4 +- .../StackWalk/FrameHandling/FrameHelpers.cs | 32 ++++ .../StackWalk/FrameHandling/FrameIterator.cs | 32 ++++ .../Contracts/StackWalk/StackWalk_1.cs | 58 +++++--- .../cdac/tests/UnitTests/StackWalkTests.cs | 138 +++++++++++++++++- 5 files changed, 240 insertions(+), 24 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 6ec689dde57cb4..ca506a07d153da 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -290,7 +290,9 @@ InterpreterFrame This produces three frames in order: C, B, A (innermost to outermost). -When the stack walk starts with an explicit context in interpreted code (e.g., from a debugger breakpoint), the interpreted frames are already yielded from the initial context as frameless frames. When the walker subsequently encounters the corresponding `InterpreterFrame`, it skips expanding it to prevent the same frames from being walked twice. +When the stack walk starts with a context in interpreted code (e.g., from a debugger breakpoint, or a context seeded from an interpreted P/Invoke's `InlinedCallFrame`), the interpreted frames are already yielded from the initial context as frameless frames. Like native `StackFrameIterator::Init`, the walker reads the owning `InterpreterFrame` from the context's first-argument register and moves the Frame iterator past it, skipping any Frames before it, so the same frames are not walked twice. If the context does not record its owner, a head `InterpreterFrame` is skipped instead. + +An interpreted P/Invoke pushes an active `InlinedCallFrame` whose `CallSiteSP` is the top `InterpMethodContextFrame` of the `InterpreterFrame` that immediately follows it (native `InlinedCallFrame::IsInInterpreter`). When the walker reaches such a Frame, it moves to that `InterpreterFrame` without updating the context; the `InterpreterFrame` then switches into the interpreted chain. #### Simple Example diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs index b1c59af11e2e8a..55680d7f136441 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs @@ -371,6 +371,29 @@ private IPlatformFrameHandler GetFrameHandler(IPlatformAgnosticContext context) }; } + /// + /// Mirrors native InlinedCallFrame::IsInInterpreter (frames.cpp): an active + /// InlinedCallFrame pushed by the interpreter for a P/Invoke is directly followed by the + /// owning InterpreterFrame, whose top InterpMethodContextFrame is the ICF's CallSiteSP. + /// + public bool IsInlinedCallFrameInInterpreter(Data.Frame frame) + { + if (GetFrameType(frame.Identifier) != FrameType.InlinedCallFrame) + return false; + + ulong terminator = _target.PointerSize == 8 ? ulong.MaxValue : uint.MaxValue; + if (frame.Next == TargetPointer.Null || frame.Next.Value == terminator) + return false; + + Data.Frame next = _target.ProcessedData.GetOrAdd(frame.Next); + if (GetFrameType(next.Identifier) != FrameType.InterpreterFrame) + return false; + + Data.InlinedCallFrame icf = _target.ProcessedData.GetOrAdd(frame.Address); + Data.InterpreterFrame interpreterFrame = _target.ProcessedData.GetOrAdd(next.Address); + return ResolveTopInterpMethodContextFrame(interpreterFrame) == icf.CallSiteSP; + } + private static bool InlinedCallFrameHasActiveCall(Data.InlinedCallFrame frame) { return frame.CallerReturnAddress != TargetCodePointer.Null; @@ -543,6 +566,15 @@ private void ApplyInterpreterFrameTransition(IPlatformAgnosticContext context, T GetFrameHandler(context).HandleTransitionFrame(framedMethodFrame); } + /// + /// Returns the owning InterpreterFrame recorded in the first-argument register of a context in + /// interpreted code, or if none is recorded. + /// + public TargetPointer GetOwningInterpreterFrame(IPlatformAgnosticContext context) + => context.TryReadRegister(GetFirstArgRegisterName(), out TargetNUInt value) + ? new TargetPointer(value.Value) + : TargetPointer.Null; + private TargetPointer GetFirstArgRegister(IPlatformAgnosticContext context) { string registerName = GetFirstArgRegisterName(); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs index cd53e98eb8e2fb..3cb9f08e92a1f4 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs @@ -45,6 +45,31 @@ public bool Next() return currentFramePointer != terminator; } + /// + /// If is at or after the current frame in the chain, moves the + /// cursor to the frame that follows it and returns true. Otherwise leaves the cursor unchanged. + /// + public bool TryMovePast(TargetPointer frameAddress) + { + TargetPointer candidate = currentFramePointer; + while (candidate != terminator && candidate != TargetPointer.Null) + { + if (candidate == frameAddress) + { + currentFramePointer = target.ProcessedData.GetOrAdd(candidate).Next; + return true; + } + + // Frames are pushed at decreasing addresses, so the chain is strictly increasing. + TargetPointer next = target.ProcessedData.GetOrAdd(candidate).Next; + if (next.Value <= candidate.Value) + break; + candidate = next; + } + + return false; + } + /// /// Returns the of the current frame. /// @@ -57,6 +82,13 @@ public FrameType GetCurrentFrameType() public TargetCodePointer GetCurrentReturnAddress() => frameHelpers.GetReturnAddress(CurrentFrame); + /// + /// Returns whether the current frame is an InlinedCallFrame for a P/Invoke made by the + /// interpreter (native InlinedCallFrame::IsInInterpreter). + /// + public bool IsCurrentInlinedCallFrameInInterpreter() + => frameHelpers.IsInlinedCallFrameInInterpreter(CurrentFrame); + /// /// Updates based on the current frame's type. /// diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index 5935cae55e1114..b75a172d9935cc 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -221,18 +221,7 @@ private IEnumerable RunStackWalk( bool isInterrupted = false, bool hasFaulted = false) { - // Skip the head InterpreterFrame when entering with a context already - // inside an interpreter execution (e.g. a managed-debugger breakpoint - // synthesized callback context). Without this, Frame would later - // re-process it and re-walk the same InterpMethodContextFrame chain. - // Mirrors the native walker fix in dotnet/runtime#126953. - if (state == StackWalkState.Frameless - && IsInterpreterCode(context.InstructionPointer) - && frameIterator.IsValid() - && frameIterator.GetCurrentFrameType() == FrameType.InterpreterFrame) - { - frameIterator.Next(); - } + SkipOwningInterpreterFrame(context, state, frameIterator); StackWalkData stackWalkData = new(context, state, frameIterator, threadData) { @@ -260,6 +249,30 @@ private IEnumerable RunStackWalk( } } + // When the walk starts inside interpreted code, move the frame iterator past the + // InterpreterFrame that owns the current InterpMethodContextFrame chain; otherwise a later + // Frame step would re-walk the same chain. Mirrors native StackFrameIterator::Init/ResetRegDisp, + // which read the owning InterpreterFrame from the first-argument register. Explicit Frames + // between the iterator and the owner (e.g. an interpreted P/Invoke's InlinedCallFrame) belong + // to the same chain and are skipped with it. Falls back to skipping a head InterpreterFrame when + // the context does not record its owner (see dotnet/runtime#126953). + private void SkipOwningInterpreterFrame(IPlatformAgnosticContext context, StackWalkState state, FrameIterator frameIterator) + { + if (state != StackWalkState.Frameless + || !frameIterator.IsValid() + || !IsInterpreterCode(context.InstructionPointer)) + { + return; + } + + TargetPointer owningFrame = _frameHelpers.GetOwningInterpreterFrame(context); + if (owningFrame != TargetPointer.Null && frameIterator.TryMovePast(owningFrame)) + return; + + if (frameIterator.GetCurrentFrameType() == FrameType.InterpreterFrame) + frameIterator.Next(); + } + IReadOnlyList IStackWalk.WalkStackReferences(ThreadData threadData, bool resolveInteriorPointers) { // Initialize the walk data directly @@ -268,15 +281,7 @@ IReadOnlyList IStackWalk.WalkStackReferences(ThreadData thre StackWalkState state = IsManaged(context.InstructionPointer, out _) ? StackWalkState.Frameless : StackWalkState.InitialNativeContext; FrameIterator frameIterator = new(_target, threadData); - // See CreateStackWalk: skip the head InterpreterFrame when entering - // already inside an interpreter execution to avoid double-walking. - if (state == StackWalkState.Frameless - && IsInterpreterCode(context.InstructionPointer) - && frameIterator.IsValid() - && frameIterator.GetCurrentFrameType() == FrameType.InterpreterFrame) - { - frameIterator.Next(); - } + SkipOwningInterpreterFrame(context, state, frameIterator); StackWalkData walkData = new(context, state, frameIterator, threadData); @@ -895,6 +900,17 @@ private bool Next(StackWalkData handle) bool isActiveICF = frameType == FrameType.InlinedCallFrame && returnAddress != TargetCodePointer.Null; + // Native SFITER_FRAME_FUNCTION: an active ICF for an interpreted P/Invoke + // moves straight to the owning InterpreterFrame without touching the context. + // That InterpreterFrame then switches into the interpreted chain and is + // consumed; applying the ICF context instead would leave the iterator on the + // ICF and re-walk the outer chain once it is exhausted. + if (isActiveICF && handle.FrameIter.IsCurrentInlinedCallFrameInInterpreter()) + { + handle.FrameIter.Next(); + break; + } + // Record the frame type so UpdateState can detect exception frames // and set IsInterrupted when transitioning to the managed frame. handle.LastProcessedFrameType = frameType; diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 38635f04edefa0..6152335028dafd 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -474,7 +474,8 @@ private static TestPlaceholderTarget CreateTarget( Action configure, Action? configureFrames = null, RuntimeInfoArchitecture? runtimeArchitecture = null, - Action? configureTarget = null) + Action? configureTarget = null, + IExecutionManager? executionManager = null) { TestPlaceholderTarget.Builder targetBuilder = new(arch); MockThreadBuilder threadBuilder = new(targetBuilder.MemoryBuilder); @@ -531,7 +532,7 @@ private static TestPlaceholderTarget CreateTarget( // when constructing its GcScanner. Our tests only exercise GetFrames / // IsExceptionHandlingHelperInlinedCallFrame / GetDebuggerEvalData, none of which // invoke ExecutionManager or GCInfo, so empty mocks satisfy construction. - .AddMockContract(Mock.Of()) + .AddMockContract(executionManager ?? Mock.Of()) .AddMockContract(Mock.Of()) .Build(); } @@ -919,6 +920,139 @@ public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Termi Assert.Equal(icfAddr, stackWalk.GetFrameAddress(frames[^1]).Value); } + // An interpreted P/Invoke pushes an active InlinedCallFrame whose CallSiteSP is the top + // InterpMethodContextFrame of the owning InterpreterFrame that follows it. Like native + // InlinedCallFrame::IsInInterpreter handling, the walk must move from that ICF to the + // InterpreterFrame and walk its interpreted chain exactly once, whether it is seeded from a + // native context below the ICF or from the Frame chain (which yields the ICF's context). + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateStackWalk_WasmInterpretedPInvoke_WalksInterpretedChainOnce(bool seedFromNativeContext) + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + const ulong InterpIp1 = 0x0005_1000; + const ulong InterpIp2 = 0x0005_2000; + const ulong NativeCallerIp = 0x0009_0000; + + MockThread? thread = null; + MockFrameBuilder? frames = null; + ulong imcfLeaf = 0; + ulong icfAddr = 0; + ulong interpreterFrameAddr = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => thread = threadBuilder.AddThread(1, 1234), + frameBuilder => + { + frames = frameBuilder; + ulong imcfRoot = frameBuilder.AddInterpMethodContextFrame(parentPtr: 0, ip: InterpIp2, stack: 0x0006_2000).Address; + imcfLeaf = frameBuilder.AddInterpMethodContextFrame(parentPtr: imcfRoot, ip: InterpIp1, stack: 0x0006_1000).Address; + }, + runtimeArchitecture: RuntimeInfoArchitecture.Wasm, + configureTarget: targetBuilder => + { + TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; + int pointerSize = helpers.PointerSize; + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000); + ulong terminator = uint.MaxValue; + AddWasmR2RFunction(targetBuilder, allocator, functionTableIndex: 5, minVirtualIP: 0x0005_0000, functionBeginAddress: 0x100); + + // TransitionBlock: ReturnAddress followed by the (empty) callee-saved register area. + MockMemorySpace.HeapFragment transitionBlock = allocator.Allocate((ulong)pointerSize, "TransitionBlock"); + helpers.WritePointer(transitionBlock.Data.AsSpan(0, pointerSize), NativeCallerIp); + + // InterpreterFrame derives from FramedMethodFrame. + Layout fmfLayout = frames!.FramedMethodFrameLayout; + int topOffset = fmfLayout.Size; + int isFaultingOffset = topOffset + pointerSize; + Dictionary interpreterFrameFields = new(TargetTestHelpers.CreateTypeInfo(fmfLayout).Fields) + { + [nameof(Data.InterpreterFrame.TopInterpMethodContextFrame)] = new() { Offset = topOffset }, + [nameof(Data.InterpreterFrame.IsFaulting)] = new() { Offset = isFaultingOffset }, + }; + MockMemorySpace.HeapFragment interpreterFrame = allocator.Allocate((ulong)(isFaultingOffset + pointerSize), "InterpreterFrame"); + MockFramedMethodFrame fmf = fmfLayout.Create(interpreterFrame); + fmf.Identifier = MockFrameBuilder.InterpreterFrameIdentifierValue; + fmf.Next = terminator; + helpers.WritePointer(interpreterFrame.Data.AsSpan(fmfLayout.Fields.Single(f => f.Name == nameof(Data.FramedMethodFrame.TransitionBlockPtr)).Offset, pointerSize), transitionBlock.Address); + helpers.WritePointer(interpreterFrame.Data.AsSpan(topOffset, pointerSize), imcfLeaf); + interpreterFrameAddr = interpreterFrame.Address; + + // The ICF is pushed below (at a lower address than) the owning InterpreterFrame. + Layout icfLayout = frames.InlinedCallFrameLayout; + MockMemorySpace.HeapFragment icfFragment = targetBuilder.MemoryBuilder.CreateAllocator(0x001F_0000, 0x001F_1000).Allocate((ulong)icfLayout.Size, "InlinedCallFrame"); + MockInlinedCallFrame icf = icfLayout.Create(icfFragment); + icf.Identifier = MockFrameBuilder.InlinedCallFrameIdentifierValue; + icf.Next = interpreterFrameAddr; + icf.CallerReturnAddress = InterpIp1; + icf.CallSiteSP = imcfLeaf; + icfAddr = icf.Address; + thread!.Frame = icfAddr; + + targetBuilder.AddTypes(new Dictionary + { + [DataType.InterpreterFrame] = new() { Fields = interpreterFrameFields, Size = (uint)(isFaultingOffset + pointerSize) }, + [DataType.TransitionBlock] = new() + { + Fields = new Dictionary + { + [nameof(Data.TransitionBlock.ReturnAddress)] = new() { Offset = 0 }, + [nameof(Data.TransitionBlock.CalleeSavedRegisters)] = new() { Offset = pointerSize }, + [nameof(Data.TransitionBlock.ArgumentRegisters)] = new() { Offset = pointerSize }, + [nameof(Data.TransitionBlock.FirstGCRefMapSlot)] = new() { Offset = pointerSize }, + }, + Size = (uint)pointerSize, + }, + [DataType.CalleeSavedRegisters] = new() { Fields = new Dictionary(), Size = 0 }, + }); + }, + executionManager: CreateInterpreterExecutionManager(InterpIp1, InterpIp2)); + + IStackWalk stackWalk = target.Contracts.StackWalk; + ThreadData threadData = target.Contracts.Thread.GetThreadData(new TargetPointer(thread!.Address)); + IEnumerable walk; + if (seedFromNativeContext) + { + ContextHolder nativeContext = new() { InstructionPointer = new TargetCodePointer(0x0009_9000) }; + walk = stackWalk.CreateStackWalk(threadData, nativeContext.GetBytes()); + } + else + { + walk = stackWalk.CreateStackWalk(threadData); + } + + IStackDataFrameHandle[] walked = walk.Take(32).ToArray(); + ulong[] interpretedIps = walked + .Select(f => stackWalk.GetInstructionPointer(f).Value) + .Where(ip => ip is InterpIp1 or InterpIp2) + .ToArray(); + ulong[] explicitFrames = walked + .Select(f => stackWalk.GetFrameAddress(f).Value) + .Where(a => a != 0) + .ToArray(); + + Assert.Equal([InterpIp1, InterpIp2], interpretedIps); + Assert.Equal(NativeCallerIp, stackWalk.GetInstructionPointer(walked[^1]).Value); + if (seedFromNativeContext) + { + Assert.Equal([icfAddr, interpreterFrameAddr], explicitFrames.Distinct()); + } + Assert.True(walked.Length <= 8, $"Walk did not terminate: {walked.Length} frames"); + } + + private static IExecutionManager CreateInterpreterExecutionManager(params ulong[] interpreterIps) + { + Mock executionManager = new(); + executionManager + .Setup(em => em.GetCodeBlockHandle(It.IsAny())) + .Returns((TargetCodePointer ip) => interpreterIps.Contains(ip.Value) ? new CodeBlockHandle(new TargetPointer(ip.Value)) : null); + executionManager + .Setup(em => em.GetCodeKind(It.IsAny())) + .Returns((TargetCodePointer ip) => interpreterIps.Contains(ip.Value) ? CodeKind.Interpreter : default); + return executionManager.Object; + } + private static void AddWasmR2RFunction( TestPlaceholderTarget.Builder targetBuilder, MockMemorySpace.BumpAllocator allocator, From b9df3219c51676af4f4b0763bda3176752435812 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 16:12:25 -0500 Subject: [PATCH 4/8] [cDAC] Document that the Debugger contract is absent on WebAssembly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/Debugger.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/design/datacontracts/Debugger.md b/docs/design/datacontracts/Debugger.md index 7d54c4ec32f1a7..9ec103e155f6b3 100644 --- a/docs/design/datacontracts/Debugger.md +++ b/docs/design/datacontracts/Debugger.md @@ -2,6 +2,8 @@ This contract is for reading debugger state from the target process, including initialization status, metadata update state, and JIT attach state. +The contract is not advertised on WebAssembly targets. The in-process debugger is not built there, and WebAssembly has no return-address hijacking or patchable code, so readers must not require this contract on WebAssembly. For example, the `StackWalk` contract treats every frame as `HijackKind.None` on WebAssembly instead of calling `GetHijackKind`. + ## APIs of contract ```csharp From 4de0e8adca40d36b66def1e516bd2c279d6188d9 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 17:18:20 -0500 Subject: [PATCH 5/8] [cDAC] Address review: match native frame-cursor and ICF failure handling - Set the Frame cursor to the owning InterpreterFrame's Next, as native StackFrameIterator::Init/ResetRegDisp do, instead of scanning the chain. - Fail the walk when an active InlinedCallFrame's context is not managed code, matching native NextRaw (SWA_FAILED), instead of skipping the Frame. - Combine the duplicate first-argument register readers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 4 +-- .../StackWalk/FrameHandling/FrameHelpers.cs | 11 ++---- .../StackWalk/FrameHandling/FrameIterator.cs | 25 ++----------- .../FrameHandling/WasmFrameHandler.cs | 4 +-- .../Contracts/StackWalk/StackWalk_1.cs | 35 ++++++++++++------- 5 files changed, 33 insertions(+), 46 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index ca506a07d153da..2795c5389f2356 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -290,7 +290,7 @@ InterpreterFrame This produces three frames in order: C, B, A (innermost to outermost). -When the stack walk starts with a context in interpreted code (e.g., from a debugger breakpoint, or a context seeded from an interpreted P/Invoke's `InlinedCallFrame`), the interpreted frames are already yielded from the initial context as frameless frames. Like native `StackFrameIterator::Init`, the walker reads the owning `InterpreterFrame` from the context's first-argument register and moves the Frame iterator past it, skipping any Frames before it, so the same frames are not walked twice. If the context does not record its owner, a head `InterpreterFrame` is skipped instead. +When the stack walk starts with a context in interpreted code (e.g., from a debugger breakpoint, or a context seeded from an interpreted P/Invoke's `InlinedCallFrame`), the interpreted frames are already yielded from the initial context as frameless frames. Like native `StackFrameIterator::Init`, the walker reads the owning `InterpreterFrame` from the context's first-argument register and sets the Frame iterator to that Frame's `Next`, so the same frames are not walked twice. If the context does not record its owner, a head `InterpreterFrame` is skipped instead. An interpreted P/Invoke pushes an active `InlinedCallFrame` whose `CallSiteSP` is the top `InterpMethodContextFrame` of the `InterpreterFrame` that immediately follows it (native `InlinedCallFrame::IsInInterpreter`). When the walker reaches such a Frame, it moves to that `InterpreterFrame` without updating the context; the `InterpreterFrame` then switches into the interpreted chain. @@ -453,7 +453,7 @@ InlinedCallFrames store and update only the IP, SP, and FP of a given context. I * On ARM, the InlinedCallFrame stores the value of the SP after the prolog (`SPAfterProlog`) to allow unwinding for functions with stackalloc. When a function uses stackalloc, the CallSiteSP can already have been adjusted. This value should be placed in R9. * On WASM, a `CallerReturnAddress` of `INLINED_PINVOKE_FROM_R2R` (`1`) marks an active inlined P/Invoke from ReadyToRun code rather than an address. SP is taken from `CallSiteSP`, IP is the R2R virtual IP of the shadow frame at `CallSiteSP`, and FP is that shadow frame's base. If no virtual IP can be recovered, IP is set to null. -An active InlinedCallFrame normally stays the current Frame after its context update so the skipped-Frame check can step past it once the walk reaches the managed caller. If the updated IP is not managed code, the walker advances past the Frame immediately instead, so a walk always makes progress. +An active InlinedCallFrame stays the current Frame after its context update so the skipped-Frame check can step past it once the walk reaches the managed caller. If the updated IP is not managed code (for example, no WASM R2R virtual IP could be recovered), the walk fails, matching native `StackFrameIterator::NextRaw`; otherwise it would never advance past the Frame. **Return Address**: `CallerReturnAddress`, but only when the frame has an active call (i.e., `CallerReturnAddress != 0`). Returns null otherwise. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs index 55680d7f136441..0112ece2f80aa6 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs @@ -567,15 +567,10 @@ private void ApplyInterpreterFrameTransition(IPlatformAgnosticContext context, T } /// - /// Returns the owning InterpreterFrame recorded in the first-argument register of a context in - /// interpreted code, or if none is recorded. + /// Returns the first-argument register, which holds the owning InterpreterFrame for a context + /// in interpreted code (native GetFirstArgReg). /// - public TargetPointer GetOwningInterpreterFrame(IPlatformAgnosticContext context) - => context.TryReadRegister(GetFirstArgRegisterName(), out TargetNUInt value) - ? new TargetPointer(value.Value) - : TargetPointer.Null; - - private TargetPointer GetFirstArgRegister(IPlatformAgnosticContext context) + public TargetPointer GetFirstArgRegister(IPlatformAgnosticContext context) { string registerName = GetFirstArgRegisterName(); if (!context.TryReadRegister(registerName, out TargetNUInt value)) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs index 3cb9f08e92a1f4..384304a64f3699 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameIterator.cs @@ -46,29 +46,10 @@ public bool Next() } /// - /// If is at or after the current frame in the chain, moves the - /// cursor to the frame that follows it and returns true. Otherwise leaves the cursor unchanged. + /// Moves the cursor to (native m_crawl.pFrame = ...). /// - public bool TryMovePast(TargetPointer frameAddress) - { - TargetPointer candidate = currentFramePointer; - while (candidate != terminator && candidate != TargetPointer.Null) - { - if (candidate == frameAddress) - { - currentFramePointer = target.ProcessedData.GetOrAdd(candidate).Next; - return true; - } - - // Frames are pushed at decreasing addresses, so the chain is strictly increasing. - TargetPointer next = target.ProcessedData.GetOrAdd(candidate).Next; - if (next.Value <= candidate.Value) - break; - candidate = next; - } - - return false; - } + public void MoveTo(TargetPointer frameAddress) + => currentFramePointer = frameAddress; /// /// Returns the of the current frame. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs index 0fa3bb37d4c41d..ca71ea5a716c37 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs @@ -36,8 +36,8 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) if (inlinedCallFrame.CallerReturnAddress.Value == InlinedPInvokeFromR2R) { // Mirrors InlinedCallFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. - // If no R2R virtual IP can be recovered the IP is left null (not managed code), so the - // stack walker steps past this frame rather than treating the marker as an address. + // If no R2R virtual IP can be recovered the IP is left null (not managed code), and the + // stack walker fails the walk as native does, rather than treating the marker as an address. Wasm.WasmUnwinder unwinder = new(_target, new Wasm.WasmR2RInfo(_target)); _holder.Context.StackPointer = inlinedCallFrame.CallSiteSP; _holder.Context.InstructionPointer = unwinder.GetVirtualIP(inlinedCallFrame.CallSiteSP); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index b75a172d9935cc..5cc2a4ddd76c49 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -252,10 +252,9 @@ private IEnumerable RunStackWalk( // When the walk starts inside interpreted code, move the frame iterator past the // InterpreterFrame that owns the current InterpMethodContextFrame chain; otherwise a later // Frame step would re-walk the same chain. Mirrors native StackFrameIterator::Init/ResetRegDisp, - // which read the owning InterpreterFrame from the first-argument register. Explicit Frames - // between the iterator and the owner (e.g. an interpreted P/Invoke's InlinedCallFrame) belong - // to the same chain and are skipped with it. Falls back to skipping a head InterpreterFrame when - // the context does not record its owner (see dotnet/runtime#126953). + // which set the Frame cursor to the Next of the owning InterpreterFrame recorded in the + // first-argument register. Falls back to skipping a head InterpreterFrame when the context does + // not record its owner (see dotnet/runtime#126953). private void SkipOwningInterpreterFrame(IPlatformAgnosticContext context, StackWalkState state, FrameIterator frameIterator) { if (state != StackWalkState.Frameless @@ -265,9 +264,16 @@ private void SkipOwningInterpreterFrame(IPlatformAgnosticContext context, StackW return; } - TargetPointer owningFrame = _frameHelpers.GetOwningInterpreterFrame(context); - if (owningFrame != TargetPointer.Null && frameIterator.TryMovePast(owningFrame)) - return; + TargetPointer owningFrame = _frameHelpers.GetFirstArgRegister(context); + if (owningFrame != TargetPointer.Null) + { + Data.Frame owning = _target.ProcessedData.GetOrAdd(owningFrame); + if (_frameHelpers.GetFrameType(owning.Identifier) == FrameType.InterpreterFrame) + { + frameIterator.MoveTo(owning.Next); + return; + } + } if (frameIterator.GetCurrentFrameType() == FrameType.InterpreterFrame) frameIterator.Next(); @@ -924,11 +930,16 @@ private bool Next(StackWalkData handle) { handle.FrameIter.UpdateContextFromCurrentFrame(handle.Context); } - // An active ICF is normally left current so CheckForSkippedFrames can pass it - // once the walk reaches its managed caller. If the context it produced is not - // managed code, nothing would ever advance past it, so step over it here to - // guarantee the walk makes progress. - if (!isActiveICF || !IsManaged(handle.Context.InstructionPointer, out _)) + // An active ICF is left current so CheckForSkippedFrames can pass it once the + // walk reaches its managed caller. If its context is not managed code (e.g. no + // WASM R2R virtual IP could be recovered), nothing would ever advance past it; + // native NextRaw fails the walk (SWA_FAILED) in that case, so do the same. + if (isActiveICF && !IsManaged(handle.Context.InstructionPointer, out _)) + { + handle.State = StackWalkState.Error; + return false; + } + if (!isActiveICF) { handle.FrameIter.Next(); } From c7eaee71ee5b2b1e12beddd0c5c4959251988db6 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 17:25:06 -0500 Subject: [PATCH 6/8] [cDAC][wasm] Advertise the Debugger contract on WebAssembly The in-process debugger is not built for wasm, but Debugger_1 already describes a target whose debugger is not initialized (null g_pDebugger: no debugger data, no hijacks). Advertise c1 on wasm with g_pDebugger null and CLRJitAttachState 0, replacing the mistyped g_pDebugger linker stub with correctly typed definitions. The stack walker and ValidateForDataAccess now use the contract, and only on wasm treat a missing contract (runtimes built before this change) as no hijacks instead of an error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/Debugger.md | 2 +- docs/design/datacontracts/StackWalk.md | 2 +- .../vm/datadescriptor/datadescriptor.inc | 12 ++++---- src/coreclr/vm/wasm/helpers.cpp | 8 +++-- .../Contracts/StackWalk/StackWalk_1.cs | 21 +++++++++---- .../CoreCLRContracts.cs | 8 ++++- .../cdac/tests/UnitTests/DebuggerTests.cs | 10 +++++++ .../cdac/tests/UnitTests/StackWalkTests.cs | 30 ++++++++++++------- 8 files changed, 66 insertions(+), 27 deletions(-) diff --git a/docs/design/datacontracts/Debugger.md b/docs/design/datacontracts/Debugger.md index 9ec103e155f6b3..09d097f6cfa368 100644 --- a/docs/design/datacontracts/Debugger.md +++ b/docs/design/datacontracts/Debugger.md @@ -2,7 +2,7 @@ This contract is for reading debugger state from the target process, including initialization status, metadata update state, and JIT attach state. -The contract is not advertised on WebAssembly targets. The in-process debugger is not built there, and WebAssembly has no return-address hijacking or patchable code, so readers must not require this contract on WebAssembly. For example, the `StackWalk` contract treats every frame as `HijackKind.None` on WebAssembly instead of calling `GetHijackKind`. +On WebAssembly the in-process debugger is not built, so `g_pDebugger` is always null and `CLRJitAttachState` is always 0. The contract is still advertised there, and Version 1 reports what it reports before a debugger initializes: no debugger data, no hijacks, and no JIT attach state. WebAssembly runtimes built before the contract was advertised do not have it; readers that need hijack information treat its absence on WebAssembly as `HijackKind.None`. ## APIs of contract diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 2795c5389f2356..8acdde167ef25a 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -736,7 +736,7 @@ The runtime installs a small set of redirect/hijack stubs whose code blocks are The recovery step is driven by `IDebugger.GetHijackKind(controlPC)`, which returns a `HijackKind`: -* `HijackKind.None` — the IP is not inside any tracked stub; `Next()` does nothing special. WASM has no hijack stubs and does not advertise the `Debugger` contract, so on WASM the walker always uses `HijackKind.None` without consulting it. +* `HijackKind.None` — the IP is not inside any tracked stub; `Next()` does nothing special. WASM has no hijack stubs; its `Debugger` contract reports `HijackKind.None` for every IP, and if a WASM target does not advertise the contract (runtimes built before it was advertised there), the walker uses `HijackKind.None` without it. * `HijackKind.UnhandledException` — the IP is inside the `ExceptionHijack` stub. The saved `PT_CONTEXT*` is at `*SP` (the stub pushed it directly), so the implementation reads `*context.StackPointer`. * `HijackKind.Other` — the IP is inside another redirect stub. The saved `PT_CONTEXT*` is at a fixed offset from SP or FP, matching the `REDIRECTSTUB_*` constants. diff --git a/src/coreclr/vm/datadescriptor/datadescriptor.inc b/src/coreclr/vm/datadescriptor/datadescriptor.inc index 7c1bbd7bf289f3..8a714b8cdc011a 100644 --- a/src/coreclr/vm/datadescriptor/datadescriptor.inc +++ b/src/coreclr/vm/datadescriptor/datadescriptor.inc @@ -497,7 +497,7 @@ CDAC_TYPE_FIELD(SystemDomain, TYPE(LoaderAllocator), GlobalLoaderAllocator, cdac CDAC_TYPE_FIELD(SystemDomain, T_POINTER, SystemAssembly, cdac_data::SystemAssembly) CDAC_TYPE_END(SystemDomain) -#if defined(DEBUGGING_SUPPORTED) && !defined(TARGET_WASM) +#ifdef DEBUGGING_SUPPORTED CDAC_TYPE_BEGIN(Debugger) CDAC_TYPE_INDETERMINATE(Debugger) CDAC_TYPE_FIELD(Debugger, T_INT32, LeftSideInitialized, offsetof(Debugger, m_fLeftSideInitialized)) @@ -532,7 +532,7 @@ CDAC_TYPE_SIZE(sizeof(MemoryRange)) CDAC_TYPE_FIELD(MemoryRange, T_POINTER, StartAddress, cdac_data::StartAddress) CDAC_TYPE_FIELD(MemoryRange, T_NUINT, Size, cdac_data::Size) CDAC_TYPE_END(MemoryRange) -#endif // DEBUGGING_SUPPORTED && !TARGET_WASM +#endif // DEBUGGING_SUPPORTED CDAC_TYPE_BEGIN(ArrayListBase) CDAC_TYPE_INDETERMINATE(ArrayListBase) @@ -1733,7 +1733,7 @@ CDAC_GLOBAL_POINTER(EEConfig, &::g_pConfig) #ifndef FEATURE_PORTABLE_ENTRYPOINTS CDAC_GLOBAL_POINTER(ThePreStub, &g_cdacThePreStub) #endif // !FEATURE_PORTABLE_ENTRYPOINTS -#if defined(DEBUGGING_SUPPORTED) && !defined(TARGET_WASM) +#ifdef DEBUGGING_SUPPORTED CDAC_GLOBAL_POINTER(Debugger, &::g_pDebugger) #if defined(TARGET_AMD64) CDAC_GLOBAL_POINTER(DebuggerPatchTable, cdac_data::PatchTable) @@ -1741,7 +1741,7 @@ CDAC_GLOBAL_POINTER(DebuggerPatchTable, cdac_data::PatchTabl CDAC_GLOBAL_POINTER(CLRJitAttachState, &::CLRJitAttachState) CDAC_GLOBAL_POINTER(CORDebuggerControlFlags, &::g_CORDebuggerControlFlags) CDAC_GLOBAL(MaxHijackFunctions, T_UINT32, cdac_data::MaxHijackFunctions) -#endif // DEBUGGING_SUPPORTED && !TARGET_WASM +#endif // DEBUGGING_SUPPORTED #ifdef FEATURE_METADATA_UPDATER CDAC_GLOBAL_POINTER(MetadataUpdatesApplied, &::g_metadataUpdatesApplied) #endif @@ -1901,9 +1901,9 @@ CDAC_GLOBAL_CONTRACT(ComWrappers, c1) #endif // FEATURE_COMWRAPPERS CDAC_GLOBAL_CONTRACT(ConditionalWeakTable, c1) CDAC_GLOBAL_CONTRACT(DacStreams, c1) -#if defined(DEBUGGING_SUPPORTED) && !defined(TARGET_WASM) +#ifdef DEBUGGING_SUPPORTED CDAC_GLOBAL_CONTRACT(Debugger, c1) -#endif // DEBUGGING_SUPPORTED && !TARGET_WASM +#endif // DEBUGGING_SUPPORTED CDAC_GLOBAL_CONTRACT(DebugInfo, c1) CDAC_GLOBAL_CONTRACT(EcmaMetadata, c1) #ifdef FEATURE_METADATA_UPDATER diff --git a/src/coreclr/vm/wasm/helpers.cpp b/src/coreclr/vm/wasm/helpers.cpp index 064c2679f9d396..4311f5b61b87bd 100644 --- a/src/coreclr/vm/wasm/helpers.cpp +++ b/src/coreclr/vm/wasm/helpers.cpp @@ -679,8 +679,12 @@ void _DacGlobals::Initialize() /* no-op on wasm */ } -// Incorrectly typed temporary symbol to satisfy the linker. -int g_pDebugger; +// The in-process debugger (src/coreclr/debug/ee) is not built for wasm. These definitions back the +// declarations in debug/ee/debugger.h so the cDAC Debugger contract can be advertised: g_pDebugger +// stays null (no debugger, so no hijacks) and CLRJitAttachState stays 0 (no JIT attach). +class Debugger; +Debugger* g_pDebugger = nullptr; +ULONG CLRJitAttachState = 0; void InvokeCalliStub(PCODE ftn, InterpreterCalliCookie cookie, int8_t *pArgs, int8_t *pRet, Object** pContinuationRet) { diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index 5cc2a4ddd76c49..64b9d1cc64c214 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -1227,12 +1227,23 @@ static bool TryReadRegister(IPlatformAgnosticContext context, GenericContextStor : context.TryReadRegister((int)storage.RegisterNumber, out value); } - // WASM has no return-address hijacking and the runtime does not advertise the Debugger - // contract there (see datadescriptor.inc), so there is never a hijack stub to recover from. + // WASM runtimes built before the Debugger contract was advertised there do not have it. WASM + // has no in-process debugger and so no hijack stubs, so treat a missing contract as no hijack. private HijackKind GetHijackKind(TargetCodePointer controlPC) - => _target.Contracts.RuntimeInfo.GetTargetArchitecture() == RuntimeInfoArchitecture.Wasm - ? HijackKind.None - : _target.Contracts.Debugger.GetHijackKind(controlPC); + { + if (!_target.Contracts.TryGetContract(out IDebugger debugger, out System.Exception? failure)) + { + if (failure is ContractMissingException + && _target.Contracts.RuntimeInfo.GetTargetArchitecture() == RuntimeInfoArchitecture.Wasm) + { + return HijackKind.None; + } + + throw failure; + } + + return debugger.GetHijackKind(controlPC); + } // See https://github.com/dotnet/runtime/blob/71830fdb091c9be1ad297b8649ac445af628fb81/src/coreclr/debug/daccess/dacdbiimplstackwalk.cpp#L659 private TargetPointer ComputeX86FramePointer(StackDataFrameHandle handle) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs index 8ac354af8e0b2a..08453a9b294b97 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs @@ -143,11 +143,17 @@ public static void ValidateForDataAccess(Target target, Lock? apiLock = null) // Transitive contract accesses from the implementations above. Validate(registry); // IComWrappers: ComWrappers_1.cs - // IStackWalk: StackWalk_1.cs. Not advertised on WASM, which has no debugger hijacking. + // IStackWalk: StackWalk_1.cs. WASM runtimes built before the contract was advertised there + // do not have it, and the stack walker tolerates that; any other failure is still an error. if (registry.RuntimeInfo.GetTargetArchitecture() != RuntimeInfoArchitecture.Wasm) { Validate(registry); } + else if (!registry.TryValidate(out System.Exception? debuggerFailure) + && debuggerFailure is not ContractMissingException) + { + throw debuggerFailure; + } Validate(registry); // IAuxiliarySymbols/IPrecodeStubs: CodePointerUtils.cs, PrecodeStubs_Common.cs Validate(registry); // ILoader: Loader_1.cs diff --git a/src/native/managed/cdac/tests/UnitTests/DebuggerTests.cs b/src/native/managed/cdac/tests/UnitTests/DebuggerTests.cs index b3b567c5b533f0..a94afa3d36a864 100644 --- a/src/native/managed/cdac/tests/UnitTests/DebuggerTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/DebuggerTests.cs @@ -271,6 +271,16 @@ public void MetadataUpdatesApplied_ReturnsFalse_WhenGlobalMissing(MockTarget.Arc Assert.False(debugger.MetadataUpdatesApplied()); } + [Theory] + [ClassData(typeof(MockTarget.StdArch))] + public void GetHijackKind_ReturnsNone_WhenDebuggerNull(MockTarget.Architecture arch) + { + Target target = BuildNullDebuggerTarget(arch); + IDebugger debugger = target.Contracts.Debugger; + + Assert.Equal(HijackKind.None, debugger.GetHijackKind(new TargetCodePointer(0x1234))); + } + [Theory] [ClassData(typeof(MockTarget.StdArch))] public void RequestSyncAtEvent_DoesNothing_WhenDebuggerNull(MockTarget.Architecture arch) diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 6152335028dafd..74e0b068247b5b 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -884,12 +884,14 @@ public void UpdateContextFromFrame_WasmR2RInlinedCallFrame_DerivesVirtualIPFromC // A WASM walk seeded from the Frame chain must terminate when an active InlinedCallFrame does // not lead to managed code: either an R2R marker whose shadow frame yields no virtual IP, or a - // return address outside any code range. The walk also must not require the Debugger - // contract, which WASM targets do not advertise. + // return address outside any code range. WASM advertises the Debugger contract with a null + // g_pDebugger; runtimes built before that do not advertise it, and the walk must work either way. [Theory] - [InlineData(1ul)] - [InlineData(0x0004_2000ul)] - public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Terminates(ulong callerReturnAddress) + [InlineData(1ul, false)] + [InlineData(0x0004_2000ul, false)] + [InlineData(1ul, true)] + [InlineData(0x0004_2000ul, true)] + public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Terminates(ulong callerReturnAddress, bool advertiseDebugger) { MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; @@ -904,12 +906,18 @@ public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Termi icfAddr = frameBuilder.AddInlinedCallFrame(callerReturnAddress, datum: 0, callSiteSP: 0x800).Address; }, runtimeArchitecture: RuntimeInfoArchitecture.Wasm, - configureTarget: targetBuilder => AddWasmR2RFunction( - targetBuilder, - targetBuilder.MemoryBuilder.CreateAllocator(0x0010_0000, 0x0010_4000), - functionTableIndex: 5, - minVirtualIP: 0x0005_0000, - functionBeginAddress: 0x100)); + configureTarget: targetBuilder => + { + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0010_0000, 0x0010_4000); + AddWasmR2RFunction(targetBuilder, allocator, functionTableIndex: 5, minVirtualIP: 0x0005_0000, functionBeginAddress: 0x100); + if (advertiseDebugger) + { + // g_pDebugger: the in-process debugger is not built for WASM, so it stays null. + MockMemorySpace.HeapFragment debuggerSlot = allocator.Allocate(4, "g_pDebugger"); + targetBuilder.AddGlobals((Constants.Globals.Debugger, debuggerSlot.Address)); + targetBuilder.AddContract(version: "c1"); + } + }); thread!.Frame = icfAddr; IStackWalk stackWalk = target.Contracts.StackWalk; From 289085e0e060f99fa8082008be36d5fd08c6f308 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 17:29:11 -0500 Subject: [PATCH 7/8] [cDAC] Add a PrecodeStubs version for portable entry points Runtimes with FEATURE_PORTABLE_ENTRYPOINTS have no executable precode stubs: every entry point is a PortableEntryPoint. Rather than gating PrecodeStubs_1 on the feature flag, add PrecodeStubs_2, which reads the owning MethodDesc from the PortableEntryPoint, and advertise c2 from those runtimes. PrecodeStubs_1 goes back to precode-only logic. Runtimes with portable entry points built before c2 advertise c1; the c1 registration serves them with PrecodeStubs_2 when FeatureFlags reports PortableEntrypoints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/PrecodeStubs.md | 34 +++++++++++++++++-- .../vm/datadescriptor/datadescriptor.inc | 4 +++ .../Contracts/PrecodeStubs_1.cs | 3 -- .../Contracts/PrecodeStubs_2.cs | 22 ++++++++++++ .../Contracts/PrecodeStubs_Common.cs | 22 ++---------- .../CoreCLRContracts.cs | 6 +++- .../cdac/tests/UnitTests/PrecodeStubsTests.cs | 16 +++++++-- 7 files changed, 78 insertions(+), 29 deletions(-) create mode 100644 src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_2.cs diff --git a/docs/design/datacontracts/PrecodeStubs.md b/docs/design/datacontracts/PrecodeStubs.md index 9e0e73cdab5368..1949dfa3360bb1 100644 --- a/docs/design/datacontracts/PrecodeStubs.md +++ b/docs/design/datacontracts/PrecodeStubs.md @@ -55,7 +55,6 @@ _None._ | Contract Name | | --- | -| `FeatureFlags` | | `PlatformMetadata` | @@ -128,8 +127,6 @@ registers) into an address. On other architectures applying the mask is a no-op. ### `MethodDescFromStubAddress` -When the `FeatureFlags` contract reports `PortableEntrypoints` (for example on WASM), the runtime has no precode stubs and does not describe `PrecodeMachineDescriptor`. Every entry point is a `PortableEntryPoint`, so `GetMethodDescFromStubAddress` returns its `MethodDesc` field, matching native `MethodDesc::GetMethodDescFromPrecode`. `GetInterpreterCodeFromInterpreterPrecodeIfPresent` returns the entry point unchanged, and `GetPrecodeEntryPointFromInteriorAddress` is not supported. - ```csharp internal enum KnownPrecodeType { @@ -310,3 +307,34 @@ computes the entry point of the precode. return new TargetPointer(entryPointAddress); } ``` + +## Version 2 + + +### Data descriptors used + +| Data Descriptor | Field | Type | Meaning | +| --- | --- | --- | --- | +| `PortableEntryPoint` | `MethodDesc` | `pointer` | Method desc of portable entrypoint (only defined if `FeaturePortableEntrypoints` is enabled) | + +### Global variables used + +_None._ + +### Contracts used + +_None._ + + +Version 2 is advertised by runtimes built with `FEATURE_PORTABLE_ENTRYPOINTS` (for example WebAssembly). Those runtimes have no executable precode stubs and do not describe `PrecodeMachineDescriptor`: every entry point is a `PortableEntryPoint` that records its owning `MethodDesc`. Runtimes with portable entry points that were built before Version 2 existed advertise Version 1; readers serve them with the Version 2 algorithm when the `FeatureFlags` contract reports `PortableEntrypoints`. + +```csharp + // Mirrors the FEATURE_PORTABLE_ENTRYPOINTS path of MethodDesc::GetMethodDescFromPrecode. + TargetPointer IPrecodeStubs.GetMethodDescFromStubAddress(TargetCodePointer entryPoint) + { + Data.PortableEntryPoint portableEntryPoint = // read PortableEntryPoint at entryPoint + return portableEntryPoint.MethodDesc; + } +``` + +There are no interpreter precodes, so `GetInterpreterCodeFromInterpreterPrecodeIfPresent` returns the entry point unchanged. `GetPrecodeEntryPointFromInteriorAddress` is not supported. diff --git a/src/coreclr/vm/datadescriptor/datadescriptor.inc b/src/coreclr/vm/datadescriptor/datadescriptor.inc index 8a714b8cdc011a..fd00097964a744 100644 --- a/src/coreclr/vm/datadescriptor/datadescriptor.inc +++ b/src/coreclr/vm/datadescriptor/datadescriptor.inc @@ -1922,7 +1922,11 @@ CDAC_GLOBAL_CONTRACT(ObjectiveCMarshal, c1) CDAC_GLOBAL_CONTRACT(Object, c1) CDAC_GLOBAL_CONTRACT(FeatureFlags, c1) CDAC_GLOBAL_CONTRACT(PlatformMetadata, c1) +#ifdef FEATURE_PORTABLE_ENTRYPOINTS +CDAC_GLOBAL_CONTRACT(PrecodeStubs, c2) +#else CDAC_GLOBAL_CONTRACT(PrecodeStubs, c1) +#endif // FEATURE_PORTABLE_ENTRYPOINTS #ifdef PROFILING_SUPPORTED CDAC_GLOBAL_CONTRACT(ReJIT, c1) #endif // PROFILING_SUPPORTED diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs index 36507eaa190a6c..1ed50a851c2b4f 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_1.cs @@ -113,9 +113,6 @@ public PrecodeStubs_1(Target target) : base(target) { } public override TargetCodePointer GetInterpreterCodeFromInterpreterPrecodeIfPresent( TargetCodePointer entryPoint) { - if (UsesPortableEntryPoints) - return entryPoint; - try { TargetPointer instrPointer = CodePointerReadableInstrPointer(entryPoint); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_2.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_2.cs new file mode 100644 index 00000000000000..de7c45915ac99b --- /dev/null +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_2.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Diagnostics.DataContractReader.Contracts; + +// Runtimes built with FEATURE_PORTABLE_ENTRYPOINTS (e.g. WebAssembly) have no executable precode +// stubs: every entry point is a PortableEntryPoint that records its owning MethodDesc. There are no +// interpreter precodes, so GetInterpreterCodeFromInterpreterPrecodeIfPresent keeps the interface +// default (the entry point unchanged), and GetPrecodeEntryPointFromInteriorAddress is not supported. +internal sealed class PrecodeStubs_2 : IPrecodeStubs +{ + private readonly Target _target; + + public PrecodeStubs_2(Target target) + { + _target = target; + } + + // Mirrors the FEATURE_PORTABLE_ENTRYPOINTS path of MethodDesc::GetMethodDescFromPrecode. + TargetPointer IPrecodeStubs.GetMethodDescFromStubAddress(TargetCodePointer entryPoint) + => _target.ProcessedData.GetOrAdd(entryPoint.AsTargetPointer).MethodDesc; +} diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs index 02572e12b5664a..b80cf28877df7e 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/PrecodeStubs_Common.cs @@ -32,14 +32,7 @@ internal class PrecodeStubsCommon : IPrecodeStubs w { private readonly Target _target; private readonly CodePointerFlags _codePointerFlags; - private readonly bool _portableEntryPoints; - private readonly Data.PrecodeMachineDescriptor? _machineDescriptor; - - // Not available with portable entry points: the runtime has no precode stubs to describe. - internal Data.PrecodeMachineDescriptor MachineDescriptor - => _machineDescriptor ?? throw new InvalidOperationException("Precode stubs are not used with portable entry points."); - - protected bool UsesPortableEntryPoints => _portableEntryPoints; + internal readonly Data.PrecodeMachineDescriptor MachineDescriptor; protected Target Target => _target; @@ -150,22 +143,13 @@ public PrecodeStubsCommon(Target target) { _target = target; IPlatformMetadata pm = target.Contracts.PlatformMetadata; - _portableEntryPoints = target.Contracts.FeatureFlags.IsEnabled(RuntimeFeature.PortableEntrypoints); - if (!_portableEntryPoints) - { - TargetPointer descAddr = pm.GetPrecodeMachineDescriptor(); - _machineDescriptor = target.ProcessedData.GetOrAdd(descAddr); - } + TargetPointer descAddr = pm.GetPrecodeMachineDescriptor(); + MachineDescriptor = target.ProcessedData.GetOrAdd(descAddr); _codePointerFlags = pm.GetCodePointerFlags(); } TargetPointer IPrecodeStubs.GetMethodDescFromStubAddress(TargetCodePointer entryPoint) { - // Mirrors MethodDesc::GetMethodDescFromPrecode: with FEATURE_PORTABLE_ENTRYPOINTS the - // entry point is a PortableEntryPoint that records its owning MethodDesc. - if (_portableEntryPoints) - return _target.ProcessedData.GetOrAdd(entryPoint.AsTargetPointer).MethodDesc; - ValidPrecode precode = GetPrecodeFromEntryPoint(entryPoint); return precode.GetMethodDesc(_target, MachineDescriptor); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs index 08453a9b294b97..7f348a3620900b 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs @@ -51,7 +51,11 @@ public static void Register(ContractRegistry registry) registry.Register("c1", static t => new FeatureFlags_1(t)); - registry.Register("c1", static t => new PrecodeStubs_1(t)); + // Runtimes built with portable entry points before c2 existed advertise c1; serve them with c2. + registry.Register("c1", static t => t.Contracts.FeatureFlags.IsEnabled(RuntimeFeature.PortableEntrypoints) + ? new PrecodeStubs_2(t) + : new PrecodeStubs_1(t)); + registry.Register("c2", static t => new PrecodeStubs_2(t)); registry.Register("c1", static t => new ReJIT_1(t)); diff --git a/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs b/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs index aec544222dfc38..33985bb37dd0a7 100644 --- a/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs @@ -543,9 +543,19 @@ public void GetInterpreterCode_Version1UnreadableAddress_ReturnsOriginalAddress( Assert.Equal(unreadableAddress, actual); } + public static IEnumerable PortableEntryPointVersions() + { + foreach (object[] data in new MockTarget.StdArch()) + { + // c2 is advertised with portable entry points; c1 covers runtimes built before it existed. + yield return [data[0], "c2"]; + yield return [data[0], "c1"]; + } + } + [Theory] - [ClassData(typeof(MockTarget.StdArch))] - public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodDesc(MockTarget.Architecture arch) + [MemberData(nameof(PortableEntryPointVersions))] + public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodDesc(MockTarget.Architecture arch, string contractVersion) { MockMemorySpace.Builder builder = new(new TargetTestHelpers(arch)); TargetTestHelpers helpers = builder.TargetTestHelpers; @@ -572,7 +582,7 @@ public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodD .AddGlobals((Constants.Globals.FeaturePortableEntrypoints, 1ul)) .AddMockContract(platformMetadata) .AddContract(version: "c1") - .AddContract(version: "c1") + .AddContract(version: contractVersion) .Build(); IPrecodeStubs precodeStubs = target.Contracts.PrecodeStubs; From b996b7187da1825a00624beda8cc38ca74fa74ee Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 14:03:08 -0500 Subject: [PATCH 8/8] [cDAC] Address review: drop older-WASM fallbacks, require owning InterpreterFrame WASM is not a shipping cDAC scenario for .NET 11, so readers need not support WASM runtimes built before they advertised Debugger c1 and PrecodeStubs c2: - Require the Debugger contract everywhere again, in the walker and in ValidateForDataAccess. - Register PrecodeStubs c1 as precode-only; c2 serves portable entry points. When a walk starts in interpreted code, require the first-argument register to name the owning InterpreterFrame, as native StackFrameIterator::Init and ResetRegDisp assert, instead of falling back to skipping a head InterpreterFrame. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/Debugger.md | 2 +- docs/design/datacontracts/PrecodeStubs.md | 3 +- docs/design/datacontracts/StackWalk.md | 4 +- .../Contracts/StackWalk/StackWalk_1.cs | 56 +++++-------------- .../CoreCLRContracts.cs | 17 +----- .../cdac/tests/UnitTests/PrecodeStubsTests.cs | 24 +------- .../cdac/tests/UnitTests/StackWalkTests.cs | 28 +++++----- 7 files changed, 38 insertions(+), 96 deletions(-) diff --git a/docs/design/datacontracts/Debugger.md b/docs/design/datacontracts/Debugger.md index 09d097f6cfa368..f27653253ca279 100644 --- a/docs/design/datacontracts/Debugger.md +++ b/docs/design/datacontracts/Debugger.md @@ -2,7 +2,7 @@ This contract is for reading debugger state from the target process, including initialization status, metadata update state, and JIT attach state. -On WebAssembly the in-process debugger is not built, so `g_pDebugger` is always null and `CLRJitAttachState` is always 0. The contract is still advertised there, and Version 1 reports what it reports before a debugger initializes: no debugger data, no hijacks, and no JIT attach state. WebAssembly runtimes built before the contract was advertised do not have it; readers that need hijack information treat its absence on WebAssembly as `HijackKind.None`. +On WebAssembly the in-process debugger is not built, so `g_pDebugger` is always null and `CLRJitAttachState` is always 0. The contract is still advertised there, and Version 1 reports what it reports before a debugger initializes: no debugger data, no hijacks, and no JIT attach state. ## APIs of contract diff --git a/docs/design/datacontracts/PrecodeStubs.md b/docs/design/datacontracts/PrecodeStubs.md index 1949dfa3360bb1..1e3638239c8e41 100644 --- a/docs/design/datacontracts/PrecodeStubs.md +++ b/docs/design/datacontracts/PrecodeStubs.md @@ -29,7 +29,6 @@ This contract provides support for examining [precode](../coreclr/botr/method-de | `InterpByteCodeStart` | `Method` | `pointer` | pointer to the InterpMethod associated with the bytecode | | `InterpMethod` | `MethodDesc` | `pointer` | pointer to the MethodDesc for the interpreted method | | `InterpreterPrecodeData` | `ByteCodeAddr` | `pointer` | pointer to the InterpByteCodeStart for the interpreter bytecode | -| `PortableEntryPoint` | `MethodDesc` | `pointer` | Method desc of portable entrypoint (only defined if `FeaturePortableEntrypoints` is enabled) | | `PrecodeMachineDescriptor` | `DynamicHelperPrecodeType` | `uint8` | Precode type byte for a dynamic helper precode | | `PrecodeMachineDescriptor` | `FixupBytes` | `uint8[]` | Assembly code of a FixupStub | | `PrecodeMachineDescriptor` | `FixupIgnoredBytes` | `uint8[]` | Bytes to ignore when comparing FixupBytes to an actual block of memory in the target process. | @@ -326,7 +325,7 @@ _None._ _None._ -Version 2 is advertised by runtimes built with `FEATURE_PORTABLE_ENTRYPOINTS` (for example WebAssembly). Those runtimes have no executable precode stubs and do not describe `PrecodeMachineDescriptor`: every entry point is a `PortableEntryPoint` that records its owning `MethodDesc`. Runtimes with portable entry points that were built before Version 2 existed advertise Version 1; readers serve them with the Version 2 algorithm when the `FeatureFlags` contract reports `PortableEntrypoints`. +Version 2 is advertised by runtimes built with `FEATURE_PORTABLE_ENTRYPOINTS` (for example WebAssembly). Those runtimes have no executable precode stubs and do not describe `PrecodeMachineDescriptor`: every entry point is a `PortableEntryPoint` that records its owning `MethodDesc`. ```csharp // Mirrors the FEATURE_PORTABLE_ENTRYPOINTS path of MethodDesc::GetMethodDescFromPrecode. diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 8acdde167ef25a..b489bb209c593f 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -290,7 +290,7 @@ InterpreterFrame This produces three frames in order: C, B, A (innermost to outermost). -When the stack walk starts with a context in interpreted code (e.g., from a debugger breakpoint, or a context seeded from an interpreted P/Invoke's `InlinedCallFrame`), the interpreted frames are already yielded from the initial context as frameless frames. Like native `StackFrameIterator::Init`, the walker reads the owning `InterpreterFrame` from the context's first-argument register and sets the Frame iterator to that Frame's `Next`, so the same frames are not walked twice. If the context does not record its owner, a head `InterpreterFrame` is skipped instead. +When the stack walk starts with a context in interpreted code (e.g., from a debugger breakpoint, or a context seeded from an interpreted P/Invoke's `InlinedCallFrame`), the interpreted frames are already yielded from the initial context as frameless frames. Like native `StackFrameIterator::Init`, the walker reads the owning `InterpreterFrame` from the context's first-argument register and sets the Frame iterator to that Frame's `Next`, so the same frames are not walked twice. If the first-argument register is null or does not name an `InterpreterFrame`, the walk fails (native asserts both). An interpreted P/Invoke pushes an active `InlinedCallFrame` whose `CallSiteSP` is the top `InterpMethodContextFrame` of the `InterpreterFrame` that immediately follows it (native `InlinedCallFrame::IsInInterpreter`). When the walker reaches such a Frame, it moves to that `InterpreterFrame` without updating the context; the `InterpreterFrame` then switches into the interpreted chain. @@ -736,7 +736,7 @@ The runtime installs a small set of redirect/hijack stubs whose code blocks are The recovery step is driven by `IDebugger.GetHijackKind(controlPC)`, which returns a `HijackKind`: -* `HijackKind.None` — the IP is not inside any tracked stub; `Next()` does nothing special. WASM has no hijack stubs; its `Debugger` contract reports `HijackKind.None` for every IP, and if a WASM target does not advertise the contract (runtimes built before it was advertised there), the walker uses `HijackKind.None` without it. +* `HijackKind.None` — the IP is not inside any tracked stub; `Next()` does nothing special. WASM has no hijack stubs; its `Debugger` contract reports `HijackKind.None` for every IP. * `HijackKind.UnhandledException` — the IP is inside the `ExceptionHijack` stub. The saved `PT_CONTEXT*` is at `*SP` (the stub pushed it directly), so the implementation reads `*context.StackPointer`. * `HijackKind.Other` — the IP is inside another redirect stub. The saved `PT_CONTEXT*` is at a fixed offset from SP or FP, matching the `REDIRECTSTUB_*` constants. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs index 64b9d1cc64c214..46f53022ce29a2 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/StackWalk_1.cs @@ -249,34 +249,24 @@ private IEnumerable RunStackWalk( } } - // When the walk starts inside interpreted code, move the frame iterator past the - // InterpreterFrame that owns the current InterpMethodContextFrame chain; otherwise a later - // Frame step would re-walk the same chain. Mirrors native StackFrameIterator::Init/ResetRegDisp, - // which set the Frame cursor to the Next of the owning InterpreterFrame recorded in the - // first-argument register. Falls back to skipping a head InterpreterFrame when the context does - // not record its owner (see dotnet/runtime#126953). + // When the walk starts inside interpreted code, set the Frame cursor to the Next of the + // InterpreterFrame that owns the current InterpMethodContextFrame chain, so a later Frame step + // does not re-walk the same chain. Mirrors native StackFrameIterator::Init/ResetRegDisp, which + // read the owning InterpreterFrame from the first-argument register. private void SkipOwningInterpreterFrame(IPlatformAgnosticContext context, StackWalkState state, FrameIterator frameIterator) { - if (state != StackWalkState.Frameless - || !frameIterator.IsValid() - || !IsInterpreterCode(context.InstructionPointer)) - { + if (state != StackWalkState.Frameless || !IsInterpreterCode(context.InstructionPointer)) return; - } TargetPointer owningFrame = _frameHelpers.GetFirstArgRegister(context); - if (owningFrame != TargetPointer.Null) - { - Data.Frame owning = _target.ProcessedData.GetOrAdd(owningFrame); - if (_frameHelpers.GetFrameType(owning.Identifier) == FrameType.InterpreterFrame) - { - frameIterator.MoveTo(owning.Next); - return; - } - } + if (owningFrame == TargetPointer.Null) + throw new InvalidOperationException("Interpreted context does not record its owning InterpreterFrame."); - if (frameIterator.GetCurrentFrameType() == FrameType.InterpreterFrame) - frameIterator.Next(); + Data.Frame owning = _target.ProcessedData.GetOrAdd(owningFrame); + if (_frameHelpers.GetFrameType(owning.Identifier) != FrameType.InterpreterFrame) + throw new InvalidOperationException($"Owning frame {owningFrame} of an interpreted context is not an InterpreterFrame."); + + frameIterator.MoveTo(owning.Next); } IReadOnlyList IStackWalk.WalkStackReferences(ThreadData threadData, bool resolveInteriorPointers) @@ -877,7 +867,7 @@ private bool Next(StackWalkData handle) case StackWalkState.NativeMarker: { TargetCodePointer ip = handle.Context.InstructionPointer; - HijackKind hijackKind = GetHijackKind(ip); + HijackKind hijackKind = _target.Contracts.Debugger.GetHijackKind(ip); if (hijackKind != HijackKind.None) { IPlatformAgnosticContext recoveredContext = RetrieveHijackedContext(handle.Context, hijackKind == HijackKind.UnhandledException); @@ -1227,24 +1217,6 @@ static bool TryReadRegister(IPlatformAgnosticContext context, GenericContextStor : context.TryReadRegister((int)storage.RegisterNumber, out value); } - // WASM runtimes built before the Debugger contract was advertised there do not have it. WASM - // has no in-process debugger and so no hijack stubs, so treat a missing contract as no hijack. - private HijackKind GetHijackKind(TargetCodePointer controlPC) - { - if (!_target.Contracts.TryGetContract(out IDebugger debugger, out System.Exception? failure)) - { - if (failure is ContractMissingException - && _target.Contracts.RuntimeInfo.GetTargetArchitecture() == RuntimeInfoArchitecture.Wasm) - { - return HijackKind.None; - } - - throw failure; - } - - return debugger.GetHijackKind(controlPC); - } - // See https://github.com/dotnet/runtime/blob/71830fdb091c9be1ad297b8649ac445af628fb81/src/coreclr/debug/daccess/dacdbiimplstackwalk.cpp#L659 private TargetPointer ComputeX86FramePointer(StackDataFrameHandle handle) { @@ -1267,7 +1239,7 @@ private TargetPointer ComputeX86FramePointer(StackDataFrameHandle handle) // Native marker / initial native context: RetrieveHijackedContext already returns the context // the stub unwinds to, so PCTAddr = hijackedContext.Esp - sizeof(DWORD). - HijackKind hijackKind = GetHijackKind(handle.Context.InstructionPointer); + HijackKind hijackKind = _target.Contracts.Debugger.GetHijackKind(handle.Context.InstructionPointer); IPlatformAgnosticContext hijacked = RetrieveHijackedContext(handle.Context, hijackKind == HijackKind.UnhandledException); return new TargetPointer(hijacked.StackPointer.Value - pointerSize); } diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs index 7f348a3620900b..f5a40c3edd4067 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/CoreCLRContracts.cs @@ -51,10 +51,7 @@ public static void Register(ContractRegistry registry) registry.Register("c1", static t => new FeatureFlags_1(t)); - // Runtimes built with portable entry points before c2 existed advertise c1; serve them with c2. - registry.Register("c1", static t => t.Contracts.FeatureFlags.IsEnabled(RuntimeFeature.PortableEntrypoints) - ? new PrecodeStubs_2(t) - : new PrecodeStubs_1(t)); + registry.Register("c1", static t => new PrecodeStubs_1(t)); registry.Register("c2", static t => new PrecodeStubs_2(t)); registry.Register("c1", static t => new ReJIT_1(t)); @@ -147,17 +144,7 @@ public static void ValidateForDataAccess(Target target, Lock? apiLock = null) // Transitive contract accesses from the implementations above. Validate(registry); // IComWrappers: ComWrappers_1.cs - // IStackWalk: StackWalk_1.cs. WASM runtimes built before the contract was advertised there - // do not have it, and the stack walker tolerates that; any other failure is still an error. - if (registry.RuntimeInfo.GetTargetArchitecture() != RuntimeInfoArchitecture.Wasm) - { - Validate(registry); - } - else if (!registry.TryValidate(out System.Exception? debuggerFailure) - && debuggerFailure is not ContractMissingException) - { - throw debuggerFailure; - } + Validate(registry); // IStackWalk: StackWalk_1.cs Validate(registry); // IAuxiliarySymbols/IPrecodeStubs: CodePointerUtils.cs, PrecodeStubs_Common.cs Validate(registry); // ILoader: Loader_1.cs diff --git a/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs b/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs index 33985bb37dd0a7..0d1f27e80b058e 100644 --- a/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/PrecodeStubsTests.cs @@ -404,7 +404,6 @@ private static Target CreateTarget(PrecodeBuilder precodeBuilder) .AddTypes(precodeBuilder.Types) .AddGlobals(globals) .AddMockContract(platformMetadata) - .AddContract(version: "c1") .AddContract(version: precodeBuilder.PrecodesVersion) .Build(); @@ -543,19 +542,9 @@ public void GetInterpreterCode_Version1UnreadableAddress_ReturnsOriginalAddress( Assert.Equal(unreadableAddress, actual); } - public static IEnumerable PortableEntryPointVersions() - { - foreach (object[] data in new MockTarget.StdArch()) - { - // c2 is advertised with portable entry points; c1 covers runtimes built before it existed. - yield return [data[0], "c2"]; - yield return [data[0], "c1"]; - } - } - [Theory] - [MemberData(nameof(PortableEntryPointVersions))] - public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodDesc(MockTarget.Architecture arch, string contractVersion) + [ClassData(typeof(MockTarget.StdArch))] + public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodDesc(MockTarget.Architecture arch) { MockMemorySpace.Builder builder = new(new TargetTestHelpers(arch)); TargetTestHelpers helpers = builder.TargetTestHelpers; @@ -569,20 +558,13 @@ public void GetMethodDescFromStubAddress_PortableEntryPoint_ReturnsOwningMethodD MockMemorySpace.HeapFragment entryPoint = allocator.Allocate(layout.Stride, "PortableEntryPoint"); helpers.WritePointer(entryPoint.Data.AsSpan(layout.Fields[nameof(Data.PortableEntryPoint.MethodDesc)].Offset, helpers.PointerSize), expectedMethodDesc); - // Strict: with portable entry points there is no PrecodeMachineDescriptor to read. - Mock platformMetadata = new(MockBehavior.Strict); - platformMetadata.Setup(p => p.GetCodePointerFlags()).Returns(default(CodePointerFlags)); - Target target = new TestPlaceholderTarget.Builder(arch) .UseReader(builder.GetMemoryContext().ReadFromTarget) .AddTypes(new Dictionary { [DataType.PortableEntryPoint] = new() { Fields = layout.Fields, Size = layout.Stride }, }) - .AddGlobals((Constants.Globals.FeaturePortableEntrypoints, 1ul)) - .AddMockContract(platformMetadata) - .AddContract(version: "c1") - .AddContract(version: contractVersion) + .AddContract(version: "c2") .Build(); IPrecodeStubs precodeStubs = target.Contracts.PrecodeStubs; diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 74e0b068247b5b..052570e6ad35bb 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -885,13 +885,11 @@ public void UpdateContextFromFrame_WasmR2RInlinedCallFrame_DerivesVirtualIPFromC // A WASM walk seeded from the Frame chain must terminate when an active InlinedCallFrame does // not lead to managed code: either an R2R marker whose shadow frame yields no virtual IP, or a // return address outside any code range. WASM advertises the Debugger contract with a null - // g_pDebugger; runtimes built before that do not advertise it, and the walk must work either way. + // g_pDebugger (no in-process debugger), which reports no hijacks. [Theory] - [InlineData(1ul, false)] - [InlineData(0x0004_2000ul, false)] - [InlineData(1ul, true)] - [InlineData(0x0004_2000ul, true)] - public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Terminates(ulong callerReturnAddress, bool advertiseDebugger) + [InlineData(1ul)] + [InlineData(0x0004_2000ul)] + public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Terminates(ulong callerReturnAddress) { MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; @@ -910,13 +908,7 @@ public void CreateStackWalk_WasmActiveInlinedCallFrameWithoutManagedCaller_Termi { MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0010_0000, 0x0010_4000); AddWasmR2RFunction(targetBuilder, allocator, functionTableIndex: 5, minVirtualIP: 0x0005_0000, functionBeginAddress: 0x100); - if (advertiseDebugger) - { - // g_pDebugger: the in-process debugger is not built for WASM, so it stays null. - MockMemorySpace.HeapFragment debuggerSlot = allocator.Allocate(4, "g_pDebugger"); - targetBuilder.AddGlobals((Constants.Globals.Debugger, debuggerSlot.Address)); - targetBuilder.AddContract(version: "c1"); - } + AddWasmNullDebugger(targetBuilder, allocator); }); thread!.Frame = icfAddr; @@ -965,6 +957,7 @@ public void CreateStackWalk_WasmInterpretedPInvoke_WalksInterpretedChainOnce(boo MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000); ulong terminator = uint.MaxValue; AddWasmR2RFunction(targetBuilder, allocator, functionTableIndex: 5, minVirtualIP: 0x0005_0000, functionBeginAddress: 0x100); + AddWasmNullDebugger(targetBuilder, allocator); // TransitionBlock: ReturnAddress followed by the (empty) callee-saved register area. MockMemorySpace.HeapFragment transitionBlock = allocator.Allocate((ulong)pointerSize, "TransitionBlock"); @@ -1061,6 +1054,15 @@ private static IExecutionManager CreateInterpreterExecutionManager(params ulong[ return executionManager.Object; } + // WASM advertises the Debugger contract, but the in-process debugger is not built there, so + // g_pDebugger stays null. + private static void AddWasmNullDebugger(TestPlaceholderTarget.Builder targetBuilder, MockMemorySpace.BumpAllocator allocator) + { + MockMemorySpace.HeapFragment debuggerSlot = allocator.Allocate(4, "g_pDebugger"); + targetBuilder.AddGlobals((Constants.Globals.Debugger, debuggerSlot.Address)); + targetBuilder.AddContract(version: "c1"); + } + private static void AddWasmR2RFunction( TestPlaceholderTarget.Builder targetBuilder, MockMemorySpace.BumpAllocator allocator,