From b9e49a951b123debab2dbf214587acefd99aafc6 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 8 Oct 2026 17:12:23 +0200 Subject: [PATCH 1/4] [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 7ba3cfbc90ab9d..44718863e04bcd 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 a53b37afc70d8b3eb674fff7fedfba35f431feba Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 8 Oct 2026 17:24:26 +0200 Subject: [PATCH 2/4] [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 4dea7f45905b3d092da46b444ac31e6fa186f05a Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 8 Oct 2026 18:01:14 +0200 Subject: [PATCH 3/4] [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 22f6dc86c6bb7d8f19d6f2c20bc87bf923cf3e12 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 9 Oct 2026 10:26:44 +0200 Subject: [PATCH 4/4] 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;