From ebc4f3fa69831c60d966d915b945410f6e6aa64f Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 8 Oct 2026 17:12:23 +0200 Subject: [PATCH 1/6] [cDAC][wasm] Stop R2R unwinding at reverse P/Invoke frames Mirror native RtlVirtualUnwind and WasmUnwindStackFrameCore when unwinding a WASM ReadyToRun frame: - Always advance SP to the caller's stack pointer. When the caller is not R2R code, report a null IP and FP instead of nulling SP, so the walk continues through the Frame chain from the right stack position. - When the frame being unwound has a reverse P/Invoke frame in its GC info and is not a funclet, its caller is native code. Report no R2R caller rather than reading the native caller's stack bytes as an R2R frame record. Expose GCInfoHeader.HasReversePInvokeFrame from both the common decoder and the x86 decoder. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/GCInfo.md | 3 +- docs/design/datacontracts/StackWalk.md | 2 + .../Contracts/IGCInfo.cs | 3 +- .../Contracts/GCInfo/GCInfoDecoder.cs | 3 +- .../Contracts/GCInfo/X86/GCInfo.cs | 3 +- .../StackWalk/Context/Wasm/WasmUnwinder.cs | 40 ++++-- .../StackWalk/Context/WasmContext.cs | 17 ++- .../cdac/tests/UnitTests/StackWalkTests.cs | 70 ++++++++++ .../cdac/tests/UnitTests/WasmUnwinderTests.cs | 131 +++++++++++++++++- 9 files changed, 249 insertions(+), 23 deletions(-) diff --git a/docs/design/datacontracts/GCInfo.md b/docs/design/datacontracts/GCInfo.md index a136cffdac2dbc..0970b7a0e87351 100644 --- a/docs/design/datacontracts/GCInfo.md +++ b/docs/design/datacontracts/GCInfo.md @@ -100,7 +100,8 @@ public readonly record struct GCInfoHeader( uint GSCookieValidRangeEnd, // End (exclusive) of the GS cookie valid range SpecialSlot? PSPSym, // PSP sym stack slot, or null if none SpecialSlot? GenericsInstContext, // Generics instantiation context stack slot, or null if none - GenericsContextKind GenericsInstContextKind); // Kind of the generics instantiation context + GenericsContextKind GenericsInstContextKind, // Kind of the generics instantiation context + bool HasReversePInvokeFrame); // True if the method has a reverse P/Invoke frame (its caller is native code) // Unified lifetime (live code range) of a GC slot, register or stack. public readonly record struct GCSlotLifetime( diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 659cb19799244d..31990984e619ac 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -478,6 +478,8 @@ When updating the context from a TransitionFrame, the IP, SP, and all ABI specif **WASM logical frame pointer.** R2R frames on WASM keep a record on the linear stack: the function-table index, then the function-local virtual IP / 2. Unwinding one frame (`WasmContext.Unwind`) adds the function's frame size from its unwind data to the frame base, and sets the caller's FP as native `GetWasmFramePointerFromStackPointer` does. For a method, the FP is its own frame base. For a funclet (its `RUNTIME_FUNCTION.BeginAddress` has bit 31 set), the FP is the establishing method's frame. The walker unwinds out of the funclet: if the caller slot holds the `TERMINATE_R2R_STACK_WALK` marker, the funclet was invoked by the VM through `CallFuncletWith[out]Throwable`, and the establishing frame pointer is stored one pointer after the marker. Otherwise the funclet was called by its parent method or funclet, and the walker repeats from there. Each step must move toward the caller, or the frame pointer is unknown (null). +Like native `RtlVirtualUnwind`, unwinding a WASM R2R frame always advances SP to the caller's stack pointer. The caller's IP and FP are null when the caller is not R2R code: when no R2R virtual IP can be read at the caller's SP, or when the frame being unwound has a reverse P/Invoke frame in its GC info (`GCInfoHeader.HasReversePInvokeFrame`) and is not a funclet. A reverse P/Invoke frame's caller is native code, so its stack bytes are never read as an R2R frame. The walker then continues through the explicit Frame chain. + **Return Address**: Read from `TransitionBlock.ReturnAddress`. On WASM, it is derived from `TransitionBlock.StackPointer` when it is 0, as above. For an x86 `StubDispatchFrame` with neither a method nor a GCRefMap, subtract five bytes to return the adjusted call address, matching the context update. The following Frame types also use this mechanism: diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IGCInfo.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IGCInfo.cs index 8820b2d2d85604..89ad4f8f372158 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IGCInfo.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IGCInfo.cs @@ -35,7 +35,8 @@ public readonly record struct GCInfoHeader( uint GSCookieValidRangeEnd, SpecialSlot? PSPSym, SpecialSlot? GenericsInstContext, - GenericsContextKind GenericsInstContextKind); + GenericsContextKind GenericsInstContextKind, + bool HasReversePInvokeFrame); /// Unified lifetime of a GC slot (register or stack). /// True if the slot is a CPU register; false if it is a stack location. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/GCInfoDecoder.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/GCInfoDecoder.cs index b5ac9c24c5fa9b..1423c297d83ae7 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/GCInfoDecoder.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/GCInfoDecoder.cs @@ -548,7 +548,8 @@ public GCInfoHeader GetHeader() GSCookieValidRangeEnd: gsCookie.HasValue ? _validRangeEnd : 0, PSPSym: pspSym, GenericsInstContext: genericsInstContext, - GenericsInstContextKind: genericsContextKind); + GenericsInstContextKind: genericsContextKind, + HasReversePInvokeFrame: _reversePInvokeFrameStackSlot != TTraits.NO_REVERSE_PINVOKE_FRAME); } public uint GetCodeLength() diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs index cba61f303eab15..4374867c7681b9 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/GCInfo/X86/GCInfo.cs @@ -1024,7 +1024,8 @@ GCInfoHeader IGCInfoDecoder.GetHeader() GSCookieValidRangeEnd: gsCookie.HasValue ? MethodSize : 0, PSPSym: null, GenericsInstContext: null, - GenericsInstContextKind: genericsKind); + GenericsInstContextKind: genericsKind, + HasReversePInvokeFrame: Header.RevPInvokeOffset != InfoHdr.INVALID_REV_PINVOKE_OFFSET); } IReadOnlyList IGCInfoDecoder.GetSafePoints() diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs index 8ac71e0d66542f..6754bc5fe2d1fc 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs @@ -140,11 +140,17 @@ public TargetCodePointer GetVirtualIP(TargetPointer sp) /// /// Advances by one R2R frame and produces the caller's virtual IP, - /// mirroring WasmUnwindStackFrameCore. Returns false when the R2R walk terminates - /// (no R2R frame at ), in which case is set to - /// . + /// mirroring WasmUnwindStackFrameCore. Returns false when the R2R walk cannot advance + /// (no R2R frame at , missing unwind data, or a zero frame size), in + /// which case is set to . Otherwise + /// is the caller's stack pointer, and is null when + /// the caller is not ReadyToRun code. /// - public bool TryUnwindOneFrame(ref TargetPointer sp, out TargetCodePointer ip) + /// + /// The virtual IP of the frame at , used to detect a reverse P/Invoke + /// frame as native RtlVirtualUnwind does. Pass null when it is unknown. + /// + public bool TryUnwindOneFrame(ref TargetPointer sp, TargetCodePointer controlPC, out TargetCodePointer ip) { ip = TargetCodePointer.Null; if (!TryGetFramePointer(sp, out TargetPointer frameBase)) @@ -169,16 +175,30 @@ public bool TryUnwindOneFrame(ref TargetPointer sp, out TargetCodePointer ip) } sp = new TargetPointer(frameBase.Value + frameSize); - ip = GetVirtualIP(sp); - if (ip == TargetCodePointer.Null) + // A reverse P/Invoke frame's caller is native code, so its stack bytes must not be read as + // an R2R frame. Funclets share their parent's GC info and are never entered from native code. + if (!IsReversePInvokeFrame(functionIndex, controlPC)) + ip = GetVirtualIP(sp); + + return true; + } + + public bool TryUnwindOneFrame(ref TargetPointer sp, out TargetCodePointer ip) + => TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out ip); + + private bool IsReversePInvokeFrame(uint functionIndex, TargetCodePointer controlPC) + { + if (controlPC == TargetCodePointer.Null || + !_r2rInfo.TryIsFunclet(functionIndex, out bool isFunclet) || + isFunclet || + _target.Contracts.ExecutionManager.GetCodeBlockHandle(controlPC) is not CodeBlockHandle codeBlock) { - // The caller is not R2R-generated code (an interpreter transition or the stack top); - // the R2R walk is exhausted. - sp = TargetPointer.Null; return false; } - return true; + _target.Contracts.ExecutionManager.GetGCInfo(codeBlock, out TargetPointer gcInfoAddress, out uint gcVersion); + IGCInfoHandle gcInfo = _target.Contracts.GCInfo.DecodePlatformSpecificGCInfo(gcInfoAddress, gcVersion); + return _target.Contracts.GCInfo.GetHeader(gcInfo).HasReversePInvokeFrame; } /// diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs index e4d54de44b9733..d0930335ea0dab 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs @@ -79,18 +79,21 @@ public TargetPointer FramePointer public void Unwind(Target target) { - // Advance one ReadyToRun frame over the managed linear stack. When the R2R walk - // terminates (an interpreter transition or the stack top), StackPointer becomes null and - // the caller falls back to the explicit Frame chain / interpreter frame chain. + // Advance one ReadyToRun frame over the managed linear stack, mirroring native + // RtlVirtualUnwind. When the caller is not R2R code (an interpreter transition, native code + // above a reverse P/Invoke, or the stack top), StackPointer is the caller's SP, the IP and FP + // are null, and the walker continues through the explicit Frame chain. Wasm.WasmUnwinder unwinder = new(target, new Wasm.WasmR2RInfo(target)); TargetPointer sp = StackPointer; - if (unwinder.TryUnwindOneFrame(ref sp, out TargetCodePointer ip)) + if (unwinder.TryUnwindOneFrame(ref sp, InstructionPointer, out TargetCodePointer ip)) { StackPointer = sp; InstructionPointer = ip; - // Native WasmUnwindStackFrame recomputes the caller's frame pointer from its stack pointer; - // funclets report the establishing method's frame base (GetWasmFramePointerFromStackPointer). - FramePointer = unwinder.TryGetLogicalFramePointer(sp, out TargetPointer fp) ? fp : TargetPointer.Null; + // Native recomputes the caller's FP only for an R2R caller; funclets report the + // establishing method's frame (GetWasmFramePointerFromStackPointer). + FramePointer = ip != TargetCodePointer.Null && unwinder.TryGetLogicalFramePointer(sp, out TargetPointer fp) + ? fp + : TargetPointer.Null; } else { diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 7c692ad791b0bf..652deaf7eb7032 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -17,6 +17,76 @@ public unsafe class StackWalkTests { private const uint X86TransitionBlockSize = 7 * sizeof(uint); + [Theory] + [InlineData(RuntimeInfoArchitecture.Wasm, true)] + [InlineData(RuntimeInfoArchitecture.Wasm, false)] + [InlineData(RuntimeInfoArchitecture.X86, true)] + [InlineData(RuntimeInfoArchitecture.X86, false)] + public void GCInfoHeader_ReportsReversePInvokeFrame(RuntimeInfoArchitecture architecture, bool hasReversePInvokeFrame) + { + byte[] encoded = architecture == RuntimeInfoArchitecture.X86 + ? EncodeX86GCInfoHeader(hasReversePInvokeFrame) + : EncodeWasmFatGCInfoHeader(hasReversePInvokeFrame); + byte[] gcInfo = new byte[32]; + encoded.CopyTo(gcInfo, 0); + + TestPlaceholderTarget.Builder builder = new(new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = false }); + builder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = 0x1000, Data = gcInfo, Name = "GC info" }); + Mock runtimeInfo = new(); + runtimeInfo.Setup(r => r.GetTargetArchitecture()).Returns(architecture); + IGCInfo contract = builder + .AddMockContract(runtimeInfo) + .AddContract(version: "c1") + .Build().Contracts.GCInfo; + + IGCInfoHandle handle = contract.DecodePlatformSpecificGCInfo(new TargetPointer(0x1000), 4); + + Assert.Equal(hasReversePInvokeFrame, contract.GetHeader(handle).HasReversePInvokeFrame); + } + + // Fat header in gcinfodecoder.cpp order: fat bit, flags, code length, reverse P/Invoke slot, + // safe-point count. Wasm32GcInfoEncoding has no interruptible-range count. + private static byte[] EncodeWasmFatGCInfoHeader(bool hasReversePInvokeFrame) + { + const uint GcInfoReversePInvokeFrame = 0x200; + List bits = []; + void Write(uint value, int count) + { + for (int i = 0; i < count; i++) + bits.Add(((value >> i) & 1) != 0); + } + void WriteVarLength(uint value, int baseBits) + { + do + { + uint chunk = value & ((1u << baseBits) - 1); + value >>= baseBits; + Write(chunk | (value != 0 ? 1u << baseBits : 0), baseBits + 1); + } + while (value != 0); + } + + Write(1, 1); + Write(hasReversePInvokeFrame ? GcInfoReversePInvokeFrame : 0, 10); + WriteVarLength(0x20, 6); + if (hasReversePInvokeFrame) + WriteVarLength(0x8, 6); + WriteVarLength(0, 4); + + byte[] bytes = new byte[(bits.Count + 7) / 8]; + for (int i = 0; i < bits.Count; i++) + { + if (bits[i]) + bytes[i / 8] |= (byte)(1 << (i % 8)); + } + return bytes; + } + + // Method size, then InfoHdr table entry 0. FLIP_REV_PINVOKE_FRAME (0x4E) marks a reverse P/Invoke + // frame whose offset follows the header. + private static byte[] EncodeX86GCInfoHeader(bool hasReversePInvokeFrame) + => hasReversePInvokeFrame ? [0x10, 0x80, 0x4E, 0x08] : [0x10, 0x00]; + [Theory] [InlineData(false, new byte[] { 0x04, 0x05, 0x21, 0x02 }, uint.MaxValue)] [InlineData(false, new byte[] { 0x81, 0x08, 0x0a, 0x30, 0x42, 0x04 }, 8u)] diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index 2622df8713a3a7..629c7e0595c6d9 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using Microsoft.Diagnostics.DataContractReader.Contracts; using Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; using Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers.Wasm; using Microsoft.Diagnostics.DataContractReader.TestInfrastructure; +using Moq; using Xunit; namespace Microsoft.Diagnostics.DataContractReader.Tests; @@ -51,14 +53,49 @@ public bool TryGetUnwindData(uint functionTableIndex, out TargetPointer unwindDa } } - private static TestPlaceholderTarget CreateTarget(MockMemorySpace.HeapFragment[] fragments) + private static TestPlaceholderTarget CreateTarget(MockMemorySpace.HeapFragment[] fragments, ulong? reversePInvokeIp = null) { TestPlaceholderTarget.Builder builder = new(WasmArch); foreach (MockMemorySpace.HeapFragment fragment in fragments) builder.MemoryBuilder.AddHeapFragment(fragment); + AddGCInfoContracts(builder, reversePInvokeIp); return builder.Build(); } + // Every IP is managed code; only the method at reversePInvokeIp has a reverse P/Invoke frame in + // its GC info. The GC info "address" is the IP, so the header can be selected per method. + private static void AddGCInfoContracts(TestPlaceholderTarget.Builder builder, ulong? reversePInvokeIp) + { + Mock executionManager = new(); + executionManager + .Setup(e => e.GetCodeBlockHandle(It.IsAny())) + .Returns((TargetCodePointer ip) => new CodeBlockHandle(new TargetPointer(ip.Value))); + executionManager + .Setup(e => e.GetGCInfo(It.IsAny(), out It.Ref.IsAny, out It.Ref.IsAny)) + .Callback(new GetGCInfoCallback((CodeBlockHandle handle, out TargetPointer gcInfo, out uint version) => + { + gcInfo = handle.Address; + version = 4; + })); + + Mock gcInfoContract = new(); + gcInfoContract + .Setup(g => g.DecodePlatformSpecificGCInfo(It.IsAny(), It.IsAny())) + .Returns((TargetPointer gcInfo, uint _) => new FakeGCInfoHandle(gcInfo.Value)); + gcInfoContract + .Setup(g => g.GetHeader(It.IsAny())) + .Returns((IGCInfoHandle handle) => default(GCInfoHeader) with + { + HasReversePInvokeFrame = ((FakeGCInfoHandle)handle).Ip == reversePInvokeIp, + }); + + builder.AddMockContract(executionManager).AddMockContract(gcInfoContract); + } + + private delegate void GetGCInfoCallback(CodeBlockHandle handle, out TargetPointer gcInfo, out uint version); + + private sealed record FakeGCInfoHandle(ulong Ip) : IGCInfoHandle; + // Builds an R2R frame: [0] = function index, [4] = function-local virtual IP / 2. private static MockMemorySpace.HeapFragment Frame(ulong address, uint functionIndex, uint localVirtualIPHalf, string name) { @@ -205,6 +242,73 @@ public void TryUnwindOneFrame_DecodesMultiByteFrameSize() Assert.Equal(callerBase, sp.Value); } + /// + /// Native RtlVirtualUnwind always advances SP to the caller's stack pointer and reports IP 0 + /// when the caller is not R2R code, so the walk continues through the explicit Frame chain + /// from the right stack position. + /// + [Fact] + public void TryUnwindOneFrame_CallerWithoutVirtualIp_PreservesCallerStackPointer() + { + const uint leafFrameSize = 0x20; + ulong callerBase = FramesBase + leafFrameSize; + + FakeWasmR2RInfo info = new(); + info.UnwindData[FuncIndexLeaf] = BlobsBase; + + TestPlaceholderTarget target = CreateTarget( + [ + Frame(FramesBase, FuncIndexLeaf, 3, "leaf"), + Frame(callerBase, FuncIndexCaller, 7, "nonR2RCaller"), + Blob(BlobsBase, [(byte)leafFrameSize], "leafUnwind"), + ]); + WasmUnwinder unwinder = new(target, info); + + TargetPointer sp = new(FramesBase); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, out TargetCodePointer ip)); + Assert.Equal(callerBase, sp.Value); + Assert.Equal(TargetCodePointer.Null, ip); + } + + /// + /// A method with a reverse P/Invoke frame is called from native code, so native + /// RtlVirtualUnwind reports no R2R caller even when the native caller's stack bytes resemble an + /// R2R frame. Funclets share their parent's GC info and are excluded. + /// + [Theory] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + [InlineData(false, false, true)] + public void TryUnwindOneFrame_ReversePInvokeFrame_DoesNotReadNativeCallerAsR2RFrame( + bool hasReversePInvokeFrame, bool isFunclet, bool expectR2RCaller) + { + const uint frameSize = 0x20; + const ulong controlPc = VirtualIpBase + 6; + ulong callerSp = FramesBase + frameSize; + + FakeWasmR2RInfo info = new(); + info.UnwindData[FuncIndexLeaf] = BlobsBase; + if (isFunclet) + info.Funclets.Add(FuncIndexLeaf); + // The caller's bytes are a valid R2R frame record, so only the reverse P/Invoke check can + // stop the unwinder from reporting it as an R2R caller. + info.VirtualIpBases[FuncIndexCaller] = VirtualIpBase + 0x100; + + TestPlaceholderTarget target = CreateTarget( + [ + Frame(FramesBase, FuncIndexLeaf, 3, "leaf"), + Frame(callerSp, FuncIndexCaller, 2, "caller"), + Blob(BlobsBase, [(byte)frameSize], "frameSize"), + ], + reversePInvokeIp: hasReversePInvokeFrame ? controlPc : null); + WasmUnwinder unwinder = new(target, info); + + TargetPointer sp = new(FramesBase); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, new TargetCodePointer(controlPc), out TargetCodePointer ip)); + Assert.Equal(callerSp, sp.Value); + Assert.Equal(expectR2RCaller ? VirtualIpBase + 0x100 + 4 : 0, ip.Value); + } + [Fact] public void TryUnwindOneFrame_AtTerminator_ReturnsFalse() { @@ -354,7 +458,7 @@ public void LocallocSlot(ulong address, ulong framePointer) // Parent root method, the two funclets it owns, and an unrelated callee root method, in one // R2R module, registered through FunctionTableIndexRangeList exactly as the runtime does. - private static TestPlaceholderTarget CreateProducerLayoutTarget(LinearStack stack) + private static TestPlaceholderTarget CreateProducerLayoutTarget(LinearStack stack, ulong? reversePInvokeIp = null) { TestPlaceholderTarget.Builder targetBuilder = new(WasmArch); TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; @@ -422,6 +526,7 @@ private static TestPlaceholderTarget CreateProducerLayoutTarget(LinearStack stac [DataType.FunctionTableIndexRangeSection] = new() { Fields = rangeSectionLayout.Fields, Size = rangeSectionLayout.Stride }, }) .AddGlobals((Constants.Globals.FunctionTableIndexRangeList, listSlot.Address)); + AddGCInfoContracts(targetBuilder, reversePInvokeIp); return targetBuilder.Build(); } @@ -517,6 +622,28 @@ public void Unwind_ProducerLayout_FuncletInvokedByVM_ReportsEstablishingFramePoi AssertLeftR2R(context); } + /// + /// Unwinding a method with a reverse P/Invoke frame leaves R2R code: the context keeps the + /// native caller's SP and clears IP and FP, even though the bytes there resemble the parent's + /// R2R frame. Native RtlVirtualUnwind recomputes FP only for an R2R caller. + /// + [Fact] + public void Unwind_ReversePInvokeFrame_KeepsCallerStackPointerAndClearsInstructionAndFramePointers() + { + ulong calleeSp = ParentFp - CalleeFrameSize; + + LinearStack stack = new(); + stack.Terminator(ParentFp + ParentFrameSize); + stack.Record(ParentFp, ParentIndex, ParentVipHalf); + stack.Record(calleeSp, CalleeIndex, CalleeVipHalf); + Target target = CreateProducerLayoutTarget(stack, reversePInvokeIp: CalleeIp); + + WasmContext context = CalleeContext(calleeSp); + context.Unwind(target); + + AssertFrame(context, ParentFp, 0, 0, "native caller of reverse P/Invoke"); + } + /// /// A finally nested in another funclet (for example try/finally inside a catch) is called /// directly by the outer funclet with the outer funclet's $sp and its FP local, which is From 298908d9b9a4ba45f7179d0ec93d3fe45a541014 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 8 Oct 2026 17:24:26 +0200 Subject: [PATCH 2/6] [cDAC][wasm] Restore the frame pointer from SoftwareExceptionFrame contexts Mirror native SoftwareExceptionFrame::UpdateRegDisplay_Impl and InlinedCallFrame::UpdateRegDisplay_Impl on WASM: - WASM's callee-saved register set is InterpreterFP, so restore IP, SP, and FP from a SoftwareExceptionFrame's saved context. The CalleeSavedRegisters data descriptor is empty on WASM, so the base handler left FP from the previous frame. - Return before any update for an inactive InlinedCallFrame, including the interpreter-frame stash, as native does. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 4 +- .../FrameHandling/WasmFrameHandler.cs | 22 ++++- .../cdac/tests/UnitTests/StackWalkTests.cs | 91 +++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 31990984e619ac..729f93869c6864 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -450,7 +450,7 @@ Most of the handlers are implemented in `BaseFrameHandler`. Platform specific co #### InlinedCallFrame -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. +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 WASM an active InlinedCallFrame directly above an `InterpreterFrame` also records that `InterpreterFrame` in the context's first-argument register; an inactive one does not. * 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 the WASM logical frame pointer at `CallSiteSP` (see below). If no virtual IP can be recovered, IP is set to null. @@ -461,7 +461,7 @@ An active InlinedCallFrame stays the current Frame after its context update so t #### SoftwareExceptionFrame -SoftwareExceptionFrames store a copy of the context struct. The IP, SP, and all ABI specified (platform specific) callee-saved registers are copied from the stored context to the working context. +SoftwareExceptionFrames store a copy of the context struct. The IP, SP, and all ABI specified (platform specific) callee-saved registers are copied from the stored context to the working context. On WASM the callee-saved register set is the frame pointer (`InterpreterFP`), so IP, SP, and FP are copied. **Return Address**: Read from the `ReturnAddress` field on the 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 a09ef0fb12afe8..da01fb751ccb0a 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 @@ -18,7 +18,8 @@ namespace Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; /// pointer) into the three synthetic slots, which is the common /// 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. +/// serialized blob from the frame's TargetContext, restoring +/// IP, SP and the frame pointer. /// /// Hijack frames are a debugger / GC-suspension concept that is not yet supported on WASM. /// @@ -33,6 +34,11 @@ internal sealed class WasmFrameHandler(Target target, ContextHolder public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) { + // Native InlinedCallFrame::UpdateRegDisplay_Impl returns before any update, including the + // interpreter-frame stash below, when the frame has no active call. + if (inlinedCallFrame.CallerReturnAddress == TargetCodePointer.Null) + return; + if (inlinedCallFrame.CallerReturnAddress.Value == InlinedPInvokeFromR2R) { // Mirrors InlinedCallFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. @@ -64,6 +70,20 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) } } + // Mirrors SoftwareExceptionFrame::UpdateRegDisplay_Impl: on WASM the callee-saved register set + // is InterpreterFP (ENUM_CALLEE_SAVED_REGISTERS in src/coreclr/vm/wasm/cgencpu.h), so FP is + // restored from the saved context along with IP and SP. The CalleeSavedRegisters data + // descriptor is empty on WASM, so the base handler would leave FP stale. + public override void HandleSoftwareExceptionFrame(SoftwareExceptionFrame softwareExceptionFrame) + { + ContextHolder saved = new(); + saved.ReadFromAddress(_target, softwareExceptionFrame.TargetContext); + + _holder.Context.InstructionPointer = saved.Context.InstructionPointer; + _holder.Context.StackPointer = saved.Context.StackPointer; + _holder.Context.FramePointer = saved.Context.FramePointer; + } + // Mirrors TransitionFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. With a recorded // R2R stack pointer and a known return address, the caller is the R2R frame at that stack pointer // (TransitionFrame::GetSP); otherwise the caller's stack pointer is the end of the TransitionBlock. diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 652deaf7eb7032..c040cd63b4e4ec 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -1740,6 +1740,97 @@ private static void AddWasmR2RFunctions( // stashes the InterpreterFrame address into the synthetic first-argument register // (InterpreterWalkFramePointer) so the subsequent interpreter virtual unwind can recover the // owning frame -- mirroring native SetFirstArgReg on the P/Invoke-into-interpreter transition. + /// + /// Native InlinedCallFrame::UpdateRegDisplay_Impl returns before recording the owning + /// InterpreterFrame when the frame has no active call. + /// + [Fact] + public void UpdateContextFromFrame_WasmInactiveInlinedCallFrameOverInterpreterFrame_LeavesContextUnchanged() + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + + ulong icfAddr = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => threadBuilder.AddThread(1, 1234), + frameBuilder => + { + ulong interpAddr = frameBuilder.AddFrame(MockFrameBuilder.InterpreterFrameIdentifierValue, "InterpreterFrame").Address; + MockInlinedCallFrame icf = frameBuilder.AddInlinedCallFrame(callerReturnAddress: 0, datum: 0, callSiteSP: 0x0004_1000); + icf.Next = interpAddr; + icfAddr = icf.Address; + }); + + ContextHolder context = new(); + context.Context.StackPointer = new TargetPointer(0x0005_0000); + context.Context.InstructionPointer = new TargetCodePointer(0x0005_1000); + context.Context.FramePointer = new TargetPointer(0x0005_2000); + FrameHelpers frameHelpers = new(target); + Data.Frame frame = target.ProcessedData.GetOrAdd(icfAddr); + frameHelpers.UpdateContextFromFrame(frame, context); + + Assert.True(context.TryReadRegister(WasmContext.InterpreterWalkFramePointerRegister, out TargetNUInt stashed)); + Assert.Equal(0ul, stashed.Value); + Assert.Equal(0x0005_0000ul, context.StackPointer.Value); + Assert.Equal(0x0005_1000ul, context.InstructionPointer.Value); + Assert.Equal(0x0005_2000ul, context.FramePointer.Value); + } + + /// + /// On WASM the callee-saved register set is InterpreterFP, so a SoftwareExceptionFrame restores + /// the frame pointer from its saved context along with IP and SP (native + /// SoftwareExceptionFrame::UpdateRegDisplay_Impl). WASM's CalleeSavedRegisters descriptor is + /// empty, so the frame pointer must not be left from the previous frame. + /// + [Fact] + public void UpdateContextFromFrame_WasmSoftwareExceptionFrame_RestoresFramePointer() + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + TargetTestHelpers helpers = new(wasmArch); + const ulong FrameAddress = 0x0006_0000; + const int ReturnAddressOffset = 8; + const int TargetContextOffset = 12; + + // WasmContext layout: ContextFlags, InterpreterWalkFramePointer, InterpreterSP, InterpreterFP, InterpreterIP. + byte[] data = new byte[TargetContextOffset + 5 * sizeof(uint)]; + helpers.Write(data.AsSpan(0, sizeof(uint)), (uint)MockFrameBuilder.SoftwareExceptionFrameIdentifierValue); + helpers.Write(data.AsSpan(TargetContextOffset + 8, sizeof(uint)), 0x0004_1000u); + helpers.Write(data.AsSpan(TargetContextOffset + 12, sizeof(uint)), 0x0004_1040u); + helpers.Write(data.AsSpan(TargetContextOffset + 16, sizeof(uint)), 0x8001_0106u); + + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => threadBuilder.AddThread(1, 1234), + frameBuilder => { }, + runtimeArchitecture: RuntimeInfoArchitecture.Wasm, + configureTarget: builder => + { + builder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = FrameAddress, Data = data, Name = "SoftwareExceptionFrame" }); + builder.AddTypes(new Dictionary + { + [DataType.SoftwareExceptionFrame] = new() + { + Fields = new Dictionary + { + [nameof(Data.SoftwareExceptionFrame.ReturnAddress)] = new() { Offset = ReturnAddressOffset, TypeName = "CodePointer" }, + [nameof(Data.SoftwareExceptionFrame.TargetContext)] = new() { Offset = TargetContextOffset }, + }, + }, + [DataType.CalleeSavedRegisters] = new() { Fields = new Dictionary() }, + }); + }); + + ContextHolder context = new(); + context.Context.FramePointer = new TargetPointer(0x0007_0000); + FrameHelpers frameHelpers = new(target); + Data.Frame frame = target.ProcessedData.GetOrAdd(new TargetPointer(FrameAddress)); + frameHelpers.UpdateContextFromFrame(frame, context); + + Assert.Equal(0x0004_1000ul, context.StackPointer.Value); + Assert.Equal(0x8001_0106ul, context.InstructionPointer.Value); + Assert.Equal(0x0004_1040ul, context.FramePointer.Value); + } + [Fact] public void UpdateContextFromFrame_WasmInlinedCallFrameOverInterpreterFrame_StashesInterpreterFrame() { From 5a77547404a993affc83ba354791ae3e20dd9bd7 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 8 Oct 2026 18:01:14 +0200 Subject: [PATCH 3/6] [cDAC][wasm] Add pseudocode for the R2R unwind step Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 729f93869c6864..2ffb397dfc2009 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -480,6 +480,28 @@ When updating the context from a TransitionFrame, the IP, SP, and all ABI specif Like native `RtlVirtualUnwind`, unwinding a WASM R2R frame always advances SP to the caller's stack pointer. The caller's IP and FP are null when the caller is not R2R code: when no R2R virtual IP can be read at the caller's SP, or when the frame being unwound has a reverse P/Invoke frame in its GC info (`GCInfoHeader.HasReversePInvokeFrame`) and is not a funclet. A reverse P/Invoke frame's caller is native code, so its stack bytes are never read as an R2R frame. The walker then continues through the explicit Frame chain. +```csharp +// WasmContext.Unwind: one R2R frame, mirroring native RtlVirtualUnwind / WasmUnwindStackFrameCore. +void Unwind() +{ + TargetPointer frameBase = /* frame base of the R2R frame at SP (frame record: function index, VIP / 2) */; + uint functionIndex = target.Read(frameBase + FunctionIndexOffset); + uint frameSize = /* ULEB128 at the start of the function's unwind data */; + if (/* no frame at SP, no unwind data, or frameSize == 0 */) + { + SP = IP = FP = null; // the walk cannot advance + return; + } + + SP = frameBase + frameSize; + bool callerIsNative = IP != null + && !IsFunclet(functionIndex) + && GCInfo.GetHeader(/* GC info of the code block containing IP */).HasReversePInvokeFrame; + IP = callerIsNative ? null : /* R2R virtual IP at SP, or null if SP has no R2R frame record */; + FP = IP != null ? /* WASM logical frame pointer at SP */ : null; +} +``` + **Return Address**: Read from `TransitionBlock.ReturnAddress`. On WASM, it is derived from `TransitionBlock.StackPointer` when it is 0, as above. For an x86 `StubDispatchFrame` with neither a method nor a GCRefMap, subtract five bytes to return the adjusted call address, matching the context update. The following Frame types also use this mechanism: From 79bf1cf406e937c7d58be6a5980f64d652480b06 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 9 Oct 2026 10:26:44 +0200 Subject: [PATCH 4/6] Move WASM context unwind into WasmUnwinder Follow the AMD64Context pattern: WasmContext.Unwind delegates to WasmUnwinder.Unwind(ref WasmContext). Remove the TryUnwindOneFrame overload without a control PC, which only tests used. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 4 +-- .../StackWalk/Context/Wasm/WasmUnwinder.cs | 28 +++++++++++++++++-- .../StackWalk/Context/WasmContext.cs | 22 +-------------- .../cdac/tests/UnitTests/WasmUnwinderTests.cs | 12 ++++---- 4 files changed, 35 insertions(+), 31 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 2ffb397dfc2009..24589309a3e154 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -476,12 +476,12 @@ When updating the context from a TransitionFrame, the IP, SP, and all ABI specif * For an x86 `StubDispatchFrame` without a GCRefMap, resolve its method from the stored MethodDesc or its representative MethodTable and slot. If no method is available, the SP stays at the end of the transition block and the IP is adjusted to the call instruction by subtracting five bytes from the saved return address. * On WASM, a transition helper called from R2R code records the caller's R2R linear-stack pointer in `TransitionBlock.StackPointer`, and may leave `ReturnAddress` 0. When `ReturnAddress` is 0 and `StackPointer` is set, the return address is the R2R virtual IP of the frame at `StackPointer` (native `FramedMethodFrame::GetTransitionBlock_Impl`). When `StackPointer` is set and a return address is known, the caller's SP is `StackPointer` and its FP is the WASM logical frame pointer at `StackPointer` (native `TransitionFrame::GetSP`); otherwise the SP is the end of the TransitionBlock and the FP is null. This also applies when the interpreted chain under an `InterpreterFrame` is exhausted and the walker applies the `InterpreterFrame`'s transition. -**WASM logical frame pointer.** R2R frames on WASM keep a record on the linear stack: the function-table index, then the function-local virtual IP / 2. Unwinding one frame (`WasmContext.Unwind`) adds the function's frame size from its unwind data to the frame base, and sets the caller's FP as native `GetWasmFramePointerFromStackPointer` does. For a method, the FP is its own frame base. For a funclet (its `RUNTIME_FUNCTION.BeginAddress` has bit 31 set), the FP is the establishing method's frame. The walker unwinds out of the funclet: if the caller slot holds the `TERMINATE_R2R_STACK_WALK` marker, the funclet was invoked by the VM through `CallFuncletWith[out]Throwable`, and the establishing frame pointer is stored one pointer after the marker. Otherwise the funclet was called by its parent method or funclet, and the walker repeats from there. Each step must move toward the caller, or the frame pointer is unknown (null). +**WASM logical frame pointer.** R2R frames on WASM keep a record on the linear stack: the function-table index, then the function-local virtual IP / 2. Unwinding one frame (`WasmUnwinder.Unwind`) adds the function's frame size from its unwind data to the frame base, and sets the caller's FP as native `GetWasmFramePointerFromStackPointer` does. For a method, the FP is its own frame base. For a funclet (its `RUNTIME_FUNCTION.BeginAddress` has bit 31 set), the FP is the establishing method's frame. The walker unwinds out of the funclet: if the caller slot holds the `TERMINATE_R2R_STACK_WALK` marker, the funclet was invoked by the VM through `CallFuncletWith[out]Throwable`, and the establishing frame pointer is stored one pointer after the marker. Otherwise the funclet was called by its parent method or funclet, and the walker repeats from there. Each step must move toward the caller, or the frame pointer is unknown (null). Like native `RtlVirtualUnwind`, unwinding a WASM R2R frame always advances SP to the caller's stack pointer. The caller's IP and FP are null when the caller is not R2R code: when no R2R virtual IP can be read at the caller's SP, or when the frame being unwound has a reverse P/Invoke frame in its GC info (`GCInfoHeader.HasReversePInvokeFrame`) and is not a funclet. A reverse P/Invoke frame's caller is native code, so its stack bytes are never read as an R2R frame. The walker then continues through the explicit Frame chain. ```csharp -// WasmContext.Unwind: one R2R frame, mirroring native RtlVirtualUnwind / WasmUnwindStackFrameCore. +// WasmUnwinder.Unwind: one R2R frame, mirroring native RtlVirtualUnwind / WasmUnwindStackFrameCore. void Unwind() { TargetPointer frameBase = /* frame base of the R2R frame at SP (frame record: function index, VIP / 2) */; diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs index 6754bc5fe2d1fc..cb9c165c7c2c3f 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs @@ -183,8 +183,32 @@ public bool TryUnwindOneFrame(ref TargetPointer sp, TargetCodePointer controlPC, return true; } - public bool TryUnwindOneFrame(ref TargetPointer sp, out TargetCodePointer ip) - => TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out ip); + /// + /// Unwinds by one R2R frame, mirroring native RtlVirtualUnwind. + /// When the caller is not R2R code (an interpreter transition, native code above a reverse + /// P/Invoke, or the stack top), SP is the caller's SP, IP and FP are null, and the walker + /// continues through the explicit Frame chain. If no R2R frame can be unwound, SP, IP and FP + /// are all null. + /// + public void Unwind(ref WasmContext context) + { + TargetPointer sp = context.StackPointer; + if (!TryUnwindOneFrame(ref sp, context.InstructionPointer, out TargetCodePointer ip)) + { + context.StackPointer = TargetPointer.Null; + context.InstructionPointer = TargetCodePointer.Null; + context.FramePointer = TargetPointer.Null; + return; + } + + context.StackPointer = sp; + context.InstructionPointer = ip; + // Native recomputes the caller's FP only for an R2R caller; funclets report the + // establishing method's frame (GetWasmFramePointerFromStackPointer). + context.FramePointer = ip != TargetCodePointer.Null && TryGetLogicalFramePointer(sp, out TargetPointer fp) + ? fp + : TargetPointer.Null; + } private bool IsReversePInvokeFrame(uint functionIndex, TargetCodePointer controlPC) { diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs index d0930335ea0dab..5169a116791cd4 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/WasmContext.cs @@ -79,28 +79,8 @@ public TargetPointer FramePointer public void Unwind(Target target) { - // Advance one ReadyToRun frame over the managed linear stack, mirroring native - // RtlVirtualUnwind. When the caller is not R2R code (an interpreter transition, native code - // above a reverse P/Invoke, or the stack top), StackPointer is the caller's SP, the IP and FP - // are null, and the walker continues through the explicit Frame chain. Wasm.WasmUnwinder unwinder = new(target, new Wasm.WasmR2RInfo(target)); - TargetPointer sp = StackPointer; - if (unwinder.TryUnwindOneFrame(ref sp, InstructionPointer, out TargetCodePointer ip)) - { - StackPointer = sp; - InstructionPointer = ip; - // Native recomputes the caller's FP only for an R2R caller; funclets report the - // establishing method's frame (GetWasmFramePointerFromStackPointer). - FramePointer = ip != TargetCodePointer.Null && unwinder.TryGetLogicalFramePointer(sp, out TargetPointer fp) - ? fp - : TargetPointer.Null; - } - else - { - StackPointer = TargetPointer.Null; - InstructionPointer = TargetCodePointer.Null; - FramePointer = TargetPointer.Null; - } + unwinder.Unwind(ref this); } // WASM has no hardware single-step flag; like other architectures without one (ARM, LoongArch64, diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index 629c7e0595c6d9..937ca3953eed8e 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -213,7 +213,7 @@ public void TryUnwindOneFrame_AdvancesBySingleByteFrameSize_AndYieldsCallerVirtu WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, out TargetCodePointer ip)); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out TargetCodePointer ip)); Assert.Equal(callerBase, sp.Value); Assert.Equal(VirtualIpBase + 14, ip.Value); // caller local VIP 7*2 } @@ -238,7 +238,7 @@ public void TryUnwindOneFrame_DecodesMultiByteFrameSize() WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, out _)); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); Assert.Equal(callerBase, sp.Value); } @@ -265,7 +265,7 @@ public void TryUnwindOneFrame_CallerWithoutVirtualIp_PreservesCallerStackPointer WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, out TargetCodePointer ip)); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out TargetCodePointer ip)); Assert.Equal(callerBase, sp.Value); Assert.Equal(TargetCodePointer.Null, ip); } @@ -316,7 +316,7 @@ public void TryUnwindOneFrame_AtTerminator_ReturnsFalse() WasmUnwinder unwinder = new(target, new FakeWasmR2RInfo()); TargetPointer sp = new(FramesBase); - Assert.False(unwinder.TryUnwindOneFrame(ref sp, out _)); + Assert.False(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); Assert.Equal(TargetPointer.Null, sp); } @@ -353,7 +353,7 @@ public void TryUnwindOneFrame_ZeroFrameSize_TerminatesCleanly() WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.False(unwinder.TryUnwindOneFrame(ref sp, out _)); + Assert.False(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); Assert.Equal(TargetPointer.Null, sp); } @@ -372,7 +372,7 @@ public void TryUnwindOneFrame_MalformedUleb128_Throws() WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.Throws(() => unwinder.TryUnwindOneFrame(ref sp, out _)); + Assert.Throws(() => unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); } private const uint StackWalkSentinelIndirect = 0; From 69a157723225b670622611a7dac80a02e86c8be7 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 13 Oct 2026 17:26:26 +0200 Subject: [PATCH 5/6] [cDAC][wasm] Require a control PC to unwind R2R frames; add walker-level test Native RtlVirtualUnwind asserts that the control PC is an R2R virtual IP, so WasmUnwinder.TryUnwindOneFrame now throws when it is null instead of silently skipping the reverse P/Invoke check. Every production caller already unwinds a managed context. Tests pass a real control PC. Add a StackWalk test for an R2R method whose caller is not R2R code and whose active InlinedCallFrame lives in its own frame. Keeping the caller SP lets the walk pass the frame and report the method once; with the SP nulled, the walk reports the method repeatedly. Also reattach the StashesInterpreterFrame test comment that the previous commit separated from its test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cf17d923-d419-4c13-bcb4-094d592f8243 --- docs/design/datacontracts/StackWalk.md | 7 +- .../StackWalk/Context/Wasm/WasmUnwinder.cs | 10 ++- .../cdac/tests/UnitTests/StackWalkTests.cs | 82 ++++++++++++++++++- .../cdac/tests/UnitTests/WasmUnwinderTests.cs | 38 +++++++-- 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 24589309a3e154..9eb78d390c5cc7 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -484,6 +484,10 @@ Like native `RtlVirtualUnwind`, unwinding a WASM R2R frame always advances SP to // WasmUnwinder.Unwind: one R2R frame, mirroring native RtlVirtualUnwind / WasmUnwindStackFrameCore. void Unwind() { + // Native RtlVirtualUnwind asserts that IP is an R2R virtual IP. + if (IP == null) + throw new InvalidOperationException(); + TargetPointer frameBase = /* frame base of the R2R frame at SP (frame record: function index, VIP / 2) */; uint functionIndex = target.Read(frameBase + FunctionIndexOffset); uint frameSize = /* ULEB128 at the start of the function's unwind data */; @@ -494,8 +498,7 @@ void Unwind() } SP = frameBase + frameSize; - bool callerIsNative = IP != null - && !IsFunclet(functionIndex) + bool callerIsNative = !IsFunclet(functionIndex) && GCInfo.GetHeader(/* GC info of the code block containing IP */).HasReversePInvokeFrame; IP = callerIsNative ? null : /* R2R virtual IP at SP, or null if SP has no R2R frame record */; FP = IP != null ? /* WASM logical frame pointer at SP */ : null; diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs index cb9c165c7c2c3f..101ff024e94680 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs @@ -148,10 +148,15 @@ public TargetCodePointer GetVirtualIP(TargetPointer sp) /// /// /// The virtual IP of the frame at , used to detect a reverse P/Invoke - /// frame as native RtlVirtualUnwind does. Pass null when it is unknown. + /// frame as native RtlVirtualUnwind does. /// + /// is null. public bool TryUnwindOneFrame(ref TargetPointer sp, TargetCodePointer controlPC, out TargetCodePointer ip) { + // Native RtlVirtualUnwind asserts that the control PC is an R2R virtual IP. + if (controlPC == TargetCodePointer.Null) + throw new InvalidOperationException($"Cannot unwind the WASM R2R frame at SP 0x{sp.Value:X} without its virtual IP."); + ip = TargetCodePointer.Null; if (!TryGetFramePointer(sp, out TargetPointer frameBase)) { @@ -212,8 +217,7 @@ public void Unwind(ref WasmContext context) private bool IsReversePInvokeFrame(uint functionIndex, TargetCodePointer controlPC) { - if (controlPC == TargetCodePointer.Null || - !_r2rInfo.TryIsFunclet(functionIndex, out bool isFunclet) || + if (!_r2rInfo.TryIsFunclet(functionIndex, out bool isFunclet) || isFunclet || _target.Contracts.ExecutionManager.GetCodeBlockHandle(controlPC) is not CodeBlockHandle codeBlock) { diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index c040cd63b4e4ec..a1f151c9af4ac4 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -1595,6 +1595,80 @@ public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RC Assert.True(walked.Length <= 7, $"Walk did not terminate: {walked.Length} frames"); } + // An R2R method with an active inlined P/Invoke, entered from code that is not R2R (an + // interpreter transition or native code). Its InlinedCallFrame lives inside the method's own + // linear-stack frame. Like native RtlVirtualUnwind, unwinding the method keeps the caller's SP + // even though the caller has no R2R virtual IP, so the walk passes the InlinedCallFrame + // (frame address below the caller SP) and reports the method exactly once. With the caller SP + // lost, the InlinedCallFrame would be re-applied after every unwind and the method reported + // again. + [Fact] + public void CreateStackWalk_WasmR2RMethodWithNonR2RCaller_PassesInlinedCallFrameInItsFrame() + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + const uint FunctionTableIndex = 5; + const ulong MinVirtualIP = 0x8001_0000; + const uint FunctionBeginAddress = 0x100; + const uint LocalVirtualIPHalf = 3; + const byte R2RFrameSize = 0x70; + const ulong R2RIp = MinVirtualIP + FunctionBeginAddress + LocalVirtualIPHalf * 2; + const ulong InlinedPInvokeFromR2R = 1; + const uint TerminateR2RStackWalk = 1; + + MockThread? thread = null; + MockInlinedCallFrame? icf = null; + int icfSize = 0; + ulong r2rFrame = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => thread = threadBuilder.AddThread(1, 1234), + frameBuilder => + { + icf = frameBuilder.AddInlinedCallFrame(InlinedPInvokeFromR2R, datum: 0); + icfSize = frameBuilder.InlinedCallFrameLayout.Size; + }, + runtimeArchitecture: RuntimeInfoArchitecture.Wasm, + configureTarget: targetBuilder => + { + TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000); + AddWasmR2RFunction(targetBuilder, allocator, FunctionTableIndex, MinVirtualIP, FunctionBeginAddress, R2RFrameSize); + AddWasmNullDebugger(targetBuilder, allocator); + + // The method's frame record sits just below the InlinedCallFrame, and its caller's + // stack pointer is above it. The caller slot holds TERMINATE_R2R_STACK_WALK, so the + // caller has no R2R virtual IP. + r2rFrame = icf!.Address - 0x10; + ulong callerSp = r2rFrame + R2RFrameSize; + Assert.True(icf.Address + (ulong)icfSize <= callerSp); + + byte[] record = new byte[8]; + helpers.Write(record.AsSpan(0, sizeof(uint)), FunctionTableIndex); + helpers.Write(record.AsSpan(4, sizeof(uint)), LocalVirtualIPHalf); + targetBuilder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = r2rFrame, Data = record, Name = "R2RFrame" }); + + byte[] callerRecord = new byte[8]; + helpers.Write(callerRecord.AsSpan(0, sizeof(uint)), TerminateR2RStackWalk); + targetBuilder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = callerSp, Data = callerRecord, Name = "NonR2RCaller" }); + + icf.CallSiteSP = r2rFrame; + thread!.Frame = icf.Address; + }, + executionManager: CreateInterpreterExecutionManager([], managedIps: [R2RIp])); + + IStackWalk stackWalk = target.Contracts.StackWalk; + ThreadData threadData = target.Contracts.Thread.GetThreadData(new TargetPointer(thread!.Address)); + IStackDataFrameHandle[] walked = stackWalk.CreateStackWalk(threadData).Take(16).ToArray(); + + (ulong Ip, ulong Sp)[] frameless = walked + .Where(f => f.State == StackWalkState.Frameless) + .Select(f => (stackWalk.GetInstructionPointer(f).Value, stackWalk.GetStackPointer(f).Value)) + .ToArray(); + + Assert.Equal([(R2RIp, r2rFrame)], frameless); + Assert.True(walked.Length <= 3, $"Walk did not terminate: {walked.Length} frames"); + } + private static IExecutionManager CreateInterpreterExecutionManager(ulong[] interpreterIps, ulong[]? managedIps = null) { Mock executionManager = new(); @@ -1736,10 +1810,6 @@ private static void AddWasmR2RFunctions( .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 - // owning frame -- mirroring native SetFirstArgReg on the P/Invoke-into-interpreter transition. /// /// Native InlinedCallFrame::UpdateRegDisplay_Impl returns before recording the owning /// InterpreterFrame when the frame has no active call. @@ -1831,6 +1901,10 @@ public void UpdateContextFromFrame_WasmSoftwareExceptionFrame_RestoresFramePoint Assert.Equal(0x0004_1040ul, context.FramePointer.Value); } + // 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 + // owning frame -- mirroring native SetFirstArgReg on the P/Invoke-into-interpreter transition. [Fact] public void UpdateContextFromFrame_WasmInlinedCallFrameOverInterpreterFrame_StashesInterpreterFrame() { diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index 937ca3953eed8e..30be77e13e5165 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -26,6 +26,10 @@ public class WasmUnwinderTests private const uint FuncIndexLeaf = 10; private const uint FuncIndexCaller = 11; + // Virtual IP of the leaf frame being unwound (local VIP / 2 of 3); its GC info has no reverse + // P/Invoke frame unless a test passes it as reversePInvokeIp. + private static readonly TargetCodePointer LeafControlPC = new(VirtualIpBase + 6); + private sealed class FakeWasmR2RInfo : IWasmR2RInfo { public Dictionary VirtualIpBases { get; } = new(); @@ -213,7 +217,7 @@ public void TryUnwindOneFrame_AdvancesBySingleByteFrameSize_AndYieldsCallerVirtu WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out TargetCodePointer ip)); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, LeafControlPC, out TargetCodePointer ip)); Assert.Equal(callerBase, sp.Value); Assert.Equal(VirtualIpBase + 14, ip.Value); // caller local VIP 7*2 } @@ -238,7 +242,7 @@ public void TryUnwindOneFrame_DecodesMultiByteFrameSize() WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, LeafControlPC, out _)); Assert.Equal(callerBase, sp.Value); } @@ -265,7 +269,7 @@ public void TryUnwindOneFrame_CallerWithoutVirtualIp_PreservesCallerStackPointer WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out TargetCodePointer ip)); + Assert.True(unwinder.TryUnwindOneFrame(ref sp, LeafControlPC, out TargetCodePointer ip)); Assert.Equal(callerBase, sp.Value); Assert.Equal(TargetCodePointer.Null, ip); } @@ -316,10 +320,32 @@ public void TryUnwindOneFrame_AtTerminator_ReturnsFalse() WasmUnwinder unwinder = new(target, new FakeWasmR2RInfo()); TargetPointer sp = new(FramesBase); - Assert.False(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); + Assert.False(unwinder.TryUnwindOneFrame(ref sp, LeafControlPC, out _)); Assert.Equal(TargetPointer.Null, sp); } + /// + /// Native RtlVirtualUnwind asserts that the control PC is an R2R virtual IP; without it the + /// reverse P/Invoke boundary cannot be checked, so the unwinder throws instead of guessing. + /// + [Fact] + public void TryUnwindOneFrame_NullControlPC_Throws() + { + FakeWasmR2RInfo info = new(); + info.UnwindData[FuncIndexLeaf] = BlobsBase; + + TestPlaceholderTarget target = CreateTarget( + [ + Frame(FramesBase, FuncIndexLeaf, 3, "leaf"), + Blob(BlobsBase, [0x20], "frameSize"), + ]); + WasmUnwinder unwinder = new(target, info); + + TargetPointer sp = new(FramesBase); + Assert.Throws(() => unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); + Assert.Equal(FramesBase, sp.Value); + } + [Fact] public void TryGetFramePointer_LocallocToBelowFloor_ReturnsFalse() { @@ -353,7 +379,7 @@ public void TryUnwindOneFrame_ZeroFrameSize_TerminatesCleanly() WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.False(unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); + Assert.False(unwinder.TryUnwindOneFrame(ref sp, LeafControlPC, out _)); Assert.Equal(TargetPointer.Null, sp); } @@ -372,7 +398,7 @@ public void TryUnwindOneFrame_MalformedUleb128_Throws() WasmUnwinder unwinder = new(target, info); TargetPointer sp = new(FramesBase); - Assert.Throws(() => unwinder.TryUnwindOneFrame(ref sp, TargetCodePointer.Null, out _)); + Assert.Throws(() => unwinder.TryUnwindOneFrame(ref sp, LeafControlPC, out _)); } private const uint StackWalkSentinelIndirect = 0; From c4660f2da79bd8af557200d923284d22142c081c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 19 Oct 2026 17:40:37 -0500 Subject: [PATCH 6/6] Trim duplicate test and comments in the WASM unwind tests and frame handler Drop TryUnwindOneFrame_CallerWithoutVirtualIp_PreservesCallerStackPointer: the WasmContext_UnwindCallerWithoutVirtualIp test sets up the same frames and asserts the same SP and IP plus FP. State the invariant in one or two lines on the new StackWalk test and on HandleSoftwareExceptionFrame. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../FrameHandling/WasmFrameHandler.cs | 6 ++--- .../cdac/tests/UnitTests/StackWalkTests.cs | 9 ++------ .../cdac/tests/UnitTests/WasmUnwinderTests.cs | 23 ------------------- 3 files changed, 4 insertions(+), 34 deletions(-) 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 da01fb751ccb0a..a590625c4d4863 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 @@ -70,10 +70,8 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) } } - // Mirrors SoftwareExceptionFrame::UpdateRegDisplay_Impl: on WASM the callee-saved register set - // is InterpreterFP (ENUM_CALLEE_SAVED_REGISTERS in src/coreclr/vm/wasm/cgencpu.h), so FP is - // restored from the saved context along with IP and SP. The CalleeSavedRegisters data - // descriptor is empty on WASM, so the base handler would leave FP stale. + // Native restores FP with IP and SP, but the CalleeSavedRegisters descriptor is empty on WASM, + // so the base handler would leave FP stale. public override void HandleSoftwareExceptionFrame(SoftwareExceptionFrame softwareExceptionFrame) { ContextHolder saved = new(); diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 1e8f96710cea9f..20f505534f942c 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -1546,13 +1546,8 @@ public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RC Assert.True(walked.Length <= 7, $"Walk did not terminate: {walked.Length} frames"); } - // An R2R method with an active inlined P/Invoke, entered from code that is not R2R (an - // interpreter transition or native code). Its InlinedCallFrame lives inside the method's own - // linear-stack frame. Like native RtlVirtualUnwind, unwinding the method keeps the caller's SP - // even though the caller has no R2R virtual IP, so the walk passes the InlinedCallFrame - // (frame address below the caller SP) and reports the method exactly once. With the caller SP - // lost, the InlinedCallFrame would be re-applied after every unwind and the method reported - // again. + // Unwinding keeps the caller's SP when the caller is not R2R, so the walk passes the + // InlinedCallFrame in the method's own frame and reports the method once. [Fact] public void CreateStackWalk_WasmR2RMethodWithNonR2RCaller_PassesInlinedCallFrameInItsFrame() { diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index eb28635f5af49b..c53fa9e4598a14 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -227,29 +227,6 @@ public void TryUnwindOneFrame_AdvancesBySingleByteFrameSize_AndYieldsCallerVirtu Assert.Equal(VirtualIpBase + 14, ip.Value); // caller local VIP 7*2 } - [Fact] - public void TryUnwindOneFrame_CallerWithoutVirtualIp_PreservesCallerStackPointer() - { - const uint leafFrameSize = 0x20; - ulong callerBase = FramesBase + leafFrameSize; - - FakeWasmR2RInfo info = new(); - info.UnwindData[LeafControlPc] = BlobsBase; - - TestPlaceholderTarget target = CreateTarget( - [ - Frame(FramesBase, FuncIndexLeaf, 3, "leaf"), - Frame(callerBase, FuncIndexCaller, 7, "nonR2RCaller"), - Blob(BlobsBase, [(byte)leafFrameSize], "leafUnwind"), - ]); - WasmUnwinder unwinder = new(target, info); - - TargetPointer sp = new(FramesBase); - Assert.True(unwinder.TryUnwindOneFrame(ref sp, new TargetCodePointer(LeafControlPc), out TargetCodePointer ip)); - Assert.Equal(callerBase, sp.Value); - Assert.Equal(TargetCodePointer.Null, ip); - } - [Fact] public void WasmContext_UnwindCallerWithoutVirtualIp_PreservesStackPointerAndClearsIPAndFP() {