From 9977dd27dc25a9b7690ab4dbbe26404fb838278d Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 17:54:37 -0500 Subject: [PATCH 1/6] [cDAC][wasm] Continue stack walks from a TransitionFrame into its R2R caller On WASM a transition helper called from R2R code (for example the portable-entry-point thunk into the interpreter) records the caller's R2R linear-stack pointer in TransitionBlock.m_StackPointer and may leave m_ReturnAddress 0. The cDAC read neither, so a walk leaving an InterpreterFrame reported a native marker at the end of the TransitionBlock and skipped every R2R frame up to the next explicit Frame. Describe TransitionBlock.StackPointer on WASM and mirror native: - FramedMethodFrame::GetTransitionBlock_Impl: a 0 return address is the R2R virtual IP of the frame at m_StackPointer (FrameHelpers.GetReturnAddress). - TransitionFrame::GetSP / UpdateRegDisplay_Impl: with a stack pointer and a return address, the caller's SP is m_StackPointer and its FP that frame's base (WasmFrameHandler.HandleTransitionFrame). Fixes #135137 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 4 +- .../data-descriptor-meanings.json | 1 + .../vm/datadescriptor/datadescriptor.inc | 3 + .../StackWalk/FrameHandling/FrameHelpers.cs | 21 +- .../FrameHandling/WasmFrameHandler.cs | 28 +++ .../Data/Frames/TransitionBlock.cs | 3 + .../cdac/tests/UnitTests/StackWalkTests.cs | 223 ++++++++++++++++-- 7 files changed, 258 insertions(+), 25 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index b489bb209c593f..6bf00d2e096e26 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -212,6 +212,7 @@ Unwinding call frames on the stack usually requires an OS specific implementatio | `TransitionBlock` | `CalleeSavedRegisters` | `pointer` | Platform specific CalleeSavedRegisters struct associated with the TransitionBlock | | `TransitionBlock` | `FirstGCRefMapSlot` | `pointer` | Byte offset where GCRefMap slot enumeration begins. ARM64: RetBuffArgReg offset; others: ArgumentRegisters offset | | `TransitionBlock` | `ReturnAddress` | `CodePointer` | Return address associated with the TransitionBlock | +| `TransitionBlock` | `StackPointer` | `pointer` | WASM only: R2R linear-stack pointer of the caller, saved by the transition helper | | `VASigCookie` | `SizeOfArgs` | `uint32` | Total size in bytes of the varargs argument area; used on x86 to locate the argument base | ### Global variables used @@ -470,8 +471,9 @@ TransitionFrames hold a pointer to a `TransitionBlock`. The TransitionBlock hold When updating the context from a TransitionFrame, the IP, SP, and all ABI specified callee-saved registers are copied over. * On ARM, the additional register values stored in `ArgumentRegisters` are copied over. The `TransitionBlock` holds a pointer to the `ArgumentRegister` struct containing these values. +* 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 that frame's base (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. -**Return Address**: Read from `TransitionBlock.ReturnAddress`. This applies to all frame types that use the TransitionFrame mechanism. +**Return Address**: Read from `TransitionBlock.ReturnAddress` (on WASM, derived from `TransitionBlock.StackPointer` when it is 0, as above). This applies to all frame types that use the TransitionFrame mechanism. The following Frame types also use this mechanism: * FramedMethodFrame diff --git a/docs/design/datacontracts/data-descriptor-meanings.json b/docs/design/datacontracts/data-descriptor-meanings.json index 267bfa9c95794e..5496f118f1bbb9 100644 --- a/docs/design/datacontracts/data-descriptor-meanings.json +++ b/docs/design/datacontracts/data-descriptor-meanings.json @@ -696,6 +696,7 @@ "TransitionBlock.FirstGCRefMapSlot": "Byte offset where GCRefMap slot enumeration begins. ARM64: RetBuffArgReg offset; others: ArgumentRegisters offset", "TransitionBlock.ReturnAddress": "Return address associated with the TransitionBlock", "TransitionBlock.Size": "Size in bytes of the transition block, used to restore the caller's stack pointer", + "TransitionBlock.StackPointer": "WASM only: R2R linear-stack pointer of the caller, saved by the transition helper", "TypedByRef.Data": "Managed pointer (the byref) stored in a System.TypedReference value", "TypedByRef.Type": "Raw TypeHandle pointer of the referent type", "type.Size": "Size in bytes of each SHash table entry", diff --git a/src/coreclr/vm/datadescriptor/datadescriptor.inc b/src/coreclr/vm/datadescriptor/datadescriptor.inc index fd00097964a744..a96bd5358a2cf0 100644 --- a/src/coreclr/vm/datadescriptor/datadescriptor.inc +++ b/src/coreclr/vm/datadescriptor/datadescriptor.inc @@ -1311,6 +1311,9 @@ CDAC_TYPE_END(InterpreterFrame) CDAC_TYPE_BEGIN(TransitionBlock) CDAC_TYPE_SIZE(sizeof(TransitionBlock)) CDAC_TYPE_FIELD(TransitionBlock, TYPE(CodePointer), ReturnAddress, offsetof(TransitionBlock, m_ReturnAddress)) +#ifdef TARGET_WASM +CDAC_TYPE_FIELD(TransitionBlock, T_POINTER, StackPointer, offsetof(TransitionBlock, m_StackPointer)) +#endif // TARGET_WASM CDAC_TYPE_FIELD(TransitionBlock, TYPE(CalleeSavedRegisters), CalleeSavedRegisters, offsetof(TransitionBlock, m_calleeSavedRegisters)) // Offset to argument registers and first GCRefMap slot (platform-specific) #if (defined(TARGET_AMD64) && !defined(UNIX_AMD64_ABI)) || defined(TARGET_WASM) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs index 0112ece2f80aa6..8c8a173e4b7174 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/FrameHandling/FrameHelpers.cs @@ -210,6 +210,25 @@ public void UpdateContextFromFrame(Data.Frame frame, IPlatformAgnosticContext co } } + /// + /// Returns the return address recorded in . On WASM, a + /// transition helper called from R2R code may store only the caller's linear-stack pointer; + /// the return address is then that frame's R2R virtual IP, matching the lazy computation in + /// native FramedMethodFrame::GetTransitionBlock_Impl. + /// + public TargetCodePointer GetTransitionBlockReturnAddress(Data.TransitionBlock transitionBlock) + { + if (transitionBlock.ReturnAddress == TargetCodePointer.Null + && transitionBlock.StackPointer is TargetPointer stackPointer + && stackPointer != TargetPointer.Null) + { + Wasm.WasmUnwinder unwinder = new(_target, new Wasm.WasmR2RInfo(_target)); + return unwinder.GetVirtualIP(stackPointer); + } + + return transitionBlock.ReturnAddress; + } + /// /// Returns the return address for , matching native Frame::GetReturnAddress(). /// Returns TargetCodePointer.Null if the Frame has no return address (e.g., non-active ICF, @@ -235,7 +254,7 @@ public TargetCodePointer GetReturnAddress(Data.Frame frame) case FrameType.DynamicHelperFrame: Data.FramedMethodFrame fmf = _target.ProcessedData.GetOrAdd(frame.Address); Data.TransitionBlock tb = _target.ProcessedData.GetOrAdd(fmf.TransitionBlockPtr); - return tb.ReturnAddress; + return GetTransitionBlockReturnAddress(tb); // SoftwareExceptionFrame: stored m_ReturnAddress case FrameType.SoftwareExceptionFrame: 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 ca71ea5a716c37..03cfdd664a6d65 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 @@ -64,6 +64,34 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) } } + // Mirrors TransitionFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. A transition + // helper called from R2R code records the caller's linear-stack pointer; when it is set and a + // return address is known (stored, or derived from that stack pointer), the caller is the R2R + // frame at that stack pointer (native TransitionFrame::GetSP). Otherwise the frame was entered + // from interpreted or native code and the caller's stack pointer is the end of the TransitionBlock. + public override void HandleTransitionFrame(FramedMethodFrame framedMethodFrame) + { + Data.TransitionBlock transitionBlock = _target.ProcessedData.GetOrAdd(framedMethodFrame.TransitionBlockPtr); + TargetCodePointer instructionPointer = _frameHelpers.GetTransitionBlockReturnAddress(transitionBlock); + TargetPointer stackPointer = transitionBlock.StackPointer ?? TargetPointer.Null; + + _holder.Context.InstructionPointer = instructionPointer; + if (stackPointer != TargetPointer.Null && instructionPointer != TargetCodePointer.Null) + { + _holder.Context.StackPointer = stackPointer; + // Root-function frame base; the funclet-aware logical frame pointer is not modeled yet. + Wasm.WasmUnwinder unwinder = new(_target, new Wasm.WasmR2RInfo(_target)); + _holder.Context.FramePointer = unwinder.TryGetFramePointer(stackPointer, out TargetPointer framePointer) + ? framePointer + : TargetPointer.Null; + } + else + { + _holder.Context.StackPointer = framedMethodFrame.TransitionBlockPtr + Data.TransitionBlock.GetSize(_target); + _holder.Context.FramePointer = TargetPointer.Null; + } + } + public void HandleHijackFrame(HijackFrame frame) => throw new PlatformNotSupportedException("HijackFrame handling is not supported on WASM."); } diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Frames/TransitionBlock.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Frames/TransitionBlock.cs index 0bc4da6f58800e..184920a8dfd2b4 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Frames/TransitionBlock.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/Frames/TransitionBlock.cs @@ -8,6 +8,9 @@ internal partial class TransitionBlock : IData { [Field] public partial TargetCodePointer ReturnAddress { get; } + // WASM only: the R2R linear-stack pointer of the caller, saved by transition helpers. + [Field] public partial TargetPointer? StackPointer { get; } + [FieldAddress] public partial TargetPointer CalleeSavedRegisters { get; } diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index f0ab9af247a486..b73f52a191512b 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -1005,9 +1005,7 @@ public void CreateStackWalk_WasmInterpretedPInvoke_WalksInterpretedChainOnce(boo AddWasmR2RFunction(targetBuilder, allocator, functionTableIndex: 5, minVirtualIP: 0x0005_0000, functionBeginAddress: 0x100); AddWasmNullDebugger(targetBuilder, allocator); - // TransitionBlock: ReturnAddress followed by the (empty) callee-saved register area. - MockMemorySpace.HeapFragment transitionBlock = allocator.Allocate((ulong)pointerSize, "TransitionBlock"); - helpers.WritePointer(transitionBlock.Data.AsSpan(0, pointerSize), NativeCallerIp); + ulong transitionBlock = AddWasmTransitionBlock(helpers, allocator, NativeCallerIp, stackPointer: 0); // InterpreterFrame derives from FramedMethodFrame. Layout fmfLayout = frames!.FramedMethodFrameLayout; @@ -1022,7 +1020,7 @@ public void CreateStackWalk_WasmInterpretedPInvoke_WalksInterpretedChainOnce(boo MockFramedMethodFrame fmf = fmfLayout.Create(interpreterFrame); fmf.Identifier = MockFrameBuilder.InterpreterFrameIdentifierValue; fmf.Next = terminator; - helpers.WritePointer(interpreterFrame.Data.AsSpan(fmfLayout.Fields.Single(f => f.Name == nameof(Data.FramedMethodFrame.TransitionBlockPtr)).Offset, pointerSize), transitionBlock.Address); + helpers.WritePointer(interpreterFrame.Data.AsSpan(fmfLayout.Fields.Single(f => f.Name == nameof(Data.FramedMethodFrame.TransitionBlockPtr)).Offset, pointerSize), transitionBlock); helpers.WritePointer(interpreterFrame.Data.AsSpan(topOffset, pointerSize), imcfLeaf); interpreterFrameAddr = interpreterFrame.Address; @@ -1037,24 +1035,14 @@ public void CreateStackWalk_WasmInterpretedPInvoke_WalksInterpretedChainOnce(boo icfAddr = icf.Address; thread!.Frame = icfAddr; - targetBuilder.AddTypes(new Dictionary - { - [DataType.InterpreterFrame] = new() { Fields = interpreterFrameFields, Size = (uint)(isFaultingOffset + pointerSize) }, - [DataType.TransitionBlock] = new() + targetBuilder + .AddTypes(CreateWasmTransitionBlockTypes(pointerSize)) + .AddTypes(new Dictionary { - Fields = new Dictionary - { - [nameof(Data.TransitionBlock.ReturnAddress)] = new() { Offset = 0 }, - [nameof(Data.TransitionBlock.CalleeSavedRegisters)] = new() { Offset = pointerSize }, - [nameof(Data.TransitionBlock.ArgumentRegisters)] = new() { Offset = pointerSize }, - [nameof(Data.TransitionBlock.FirstGCRefMapSlot)] = new() { Offset = pointerSize }, - }, - Size = (uint)pointerSize, - }, - [DataType.CalleeSavedRegisters] = new() { Fields = new Dictionary(), Size = 0 }, - }); + [DataType.InterpreterFrame] = new() { Fields = interpreterFrameFields, Size = (uint)(isFaultingOffset + pointerSize) }, + }); }, - executionManager: CreateInterpreterExecutionManager(InterpIp1, InterpIp2)); + executionManager: CreateInterpreterExecutionManager([InterpIp1, InterpIp2])); IStackWalk stackWalk = target.Contracts.StackWalk; ThreadData threadData = target.Contracts.Thread.GetThreadData(new TargetPointer(thread!.Address)); @@ -1088,18 +1076,198 @@ public void CreateStackWalk_WasmInterpretedPInvoke_WalksInterpretedChainOnce(boo Assert.True(walked.Length <= 8, $"Walk did not terminate: {walked.Length} frames"); } - private static IExecutionManager CreateInterpreterExecutionManager(params ulong[] interpreterIps) + // A WASM TransitionFrame mirrors native TransitionFrame::GetSP / UpdateRegDisplay_Impl: when the + // helper recorded the caller's R2R linear-stack pointer and a return address is known (stored, or + // derived lazily from that stack pointer as in FramedMethodFrame::GetTransitionBlock_Impl), the + // caller is the R2R frame at that stack pointer; otherwise it is the end of the TransitionBlock. + [Theory] + [InlineData(0ul, true, true)] + [InlineData(0x0009_0000ul, true, false)] + [InlineData(0x0009_0000ul, false, false)] + [InlineData(0ul, false, false)] + public void UpdateContextFromFrame_WasmTransitionFrame_MirrorsNativeTransitionBlock(ulong returnAddress, bool hasStackPointer, bool expectDerivedIP) + { + 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; + + MockFramedMethodFrame? framedMethodFrame = null; + ulong r2rFrame = 0; + ulong transitionBlock = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => threadBuilder.AddThread(1, 1234), + frameBuilder => framedMethodFrame = frameBuilder.AddFramedMethodFrame(methodDescPtr: 0), + 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); + r2rFrame = AddWasmR2RFrameRecord(helpers, allocator, FunctionTableIndex, LocalVirtualIPHalf); + transitionBlock = AddWasmTransitionBlock(helpers, allocator, returnAddress, hasStackPointer ? r2rFrame : 0); + helpers.WritePointer( + framedMethodFrame!.Memory.Span.Slice(framedMethodFrame.Layout.GetField("TransitionBlockPtr").Offset, helpers.PointerSize), + transitionBlock); + targetBuilder.AddTypes(CreateWasmTransitionBlockTypes(helpers.PointerSize)); + }); + + ContextHolder context = new(); + FrameHelpers frameHelpers = new(target); + Data.Frame frame = target.ProcessedData.GetOrAdd(framedMethodFrame!.Address); + frameHelpers.UpdateContextFromFrame(frame, context); + + ulong expectedIP = expectDerivedIP ? MinVirtualIP + FunctionBeginAddress + LocalVirtualIPHalf * 2 : returnAddress; + bool callerIsR2R = hasStackPointer && expectedIP != 0; + Assert.Equal(expectedIP, context.InstructionPointer.Value); + Assert.Equal(expectedIP, frameHelpers.GetReturnAddress(frame).Value); + Assert.Equal(callerIsR2R ? r2rFrame : transitionBlock + 2 * sizeof(uint), context.StackPointer.Value); + Assert.Equal(callerIsR2R ? r2rFrame : 0ul, context.FramePointer.Value); + } + + // R2R code that enters an interpreted method through a portable-entry-point thunk leaves an + // InterpreterFrame whose TransitionBlock records only the R2R caller's linear-stack pointer. + // When the interpreted chain is exhausted, the walk must continue into that R2R caller rather + // than reporting a native marker at the end of the TransitionBlock and skipping the R2R frames. + [Fact] + public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RCaller() + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + const ulong InterpIp1 = 0x0005_1000; + const ulong InterpIp2 = 0x0005_2000; + const uint FunctionTableIndex = 5; + const ulong MinVirtualIP = 0x8001_0000; + const uint FunctionBeginAddress = 0x100; + const uint LocalVirtualIPHalf = 3; + const byte R2RFrameSize = 16; + const ulong R2RCallerIp = MinVirtualIP + FunctionBeginAddress + LocalVirtualIPHalf * 2; + + MockThread? thread = null; + MockFrameBuilder? frames = null; + ulong imcfLeaf = 0; + ulong r2rFrame = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => thread = threadBuilder.AddThread(1, 1234), + frameBuilder => + { + frames = frameBuilder; + ulong imcfRoot = frameBuilder.AddInterpMethodContextFrame(parentPtr: 0, ip: InterpIp2, stack: 0x0006_2000).Address; + imcfLeaf = frameBuilder.AddInterpMethodContextFrame(parentPtr: imcfRoot, ip: InterpIp1, stack: 0x0006_1000).Address; + }, + runtimeArchitecture: RuntimeInfoArchitecture.Wasm, + configureTarget: targetBuilder => + { + TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; + int pointerSize = helpers.PointerSize; + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000); + AddWasmR2RFunction(targetBuilder, allocator, FunctionTableIndex, MinVirtualIP, FunctionBeginAddress, R2RFrameSize); + AddWasmNullDebugger(targetBuilder, allocator); + + // The R2R caller's frame record; its own caller (frame base + frame size) is a + // TERMINATE_R2R_STACK_WALK marker, so the walk ends after it. + MockMemorySpace.HeapFragment r2rStack = allocator.Allocate(R2RFrameSize + 4, "R2RLinearStack"); + helpers.Write(r2rStack.Data.AsSpan(0, sizeof(uint)), FunctionTableIndex); + helpers.Write(r2rStack.Data.AsSpan(4, sizeof(uint)), LocalVirtualIPHalf); + helpers.Write(r2rStack.Data.AsSpan(R2RFrameSize, sizeof(uint)), 1u); + r2rFrame = r2rStack.Address; + + ulong transitionBlock = AddWasmTransitionBlock(helpers, allocator, returnAddress: 0, stackPointer: r2rFrame); + + Layout fmfLayout = frames!.FramedMethodFrameLayout; + int topOffset = fmfLayout.Size; + int isFaultingOffset = topOffset + pointerSize; + Dictionary interpreterFrameFields = new(TargetTestHelpers.CreateTypeInfo(fmfLayout).Fields) + { + [nameof(Data.InterpreterFrame.TopInterpMethodContextFrame)] = new() { Offset = topOffset }, + [nameof(Data.InterpreterFrame.IsFaulting)] = new() { Offset = isFaultingOffset }, + }; + MockMemorySpace.HeapFragment interpreterFrame = allocator.Allocate((ulong)(isFaultingOffset + pointerSize), "InterpreterFrame"); + MockFramedMethodFrame fmf = fmfLayout.Create(interpreterFrame); + fmf.Identifier = MockFrameBuilder.InterpreterFrameIdentifierValue; + fmf.Next = uint.MaxValue; + helpers.WritePointer(interpreterFrame.Data.AsSpan(fmfLayout.Fields.Single(f => f.Name == nameof(Data.FramedMethodFrame.TransitionBlockPtr)).Offset, pointerSize), transitionBlock); + helpers.WritePointer(interpreterFrame.Data.AsSpan(topOffset, pointerSize), imcfLeaf); + thread!.Frame = interpreterFrame.Address; + + targetBuilder + .AddTypes(CreateWasmTransitionBlockTypes(pointerSize)) + .AddTypes(new Dictionary + { + [DataType.InterpreterFrame] = new() { Fields = interpreterFrameFields, Size = (uint)(isFaultingOffset + pointerSize) }, + }); + }, + executionManager: CreateInterpreterExecutionManager([InterpIp1, InterpIp2], managedIps: [R2RCallerIp])); + + 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([InterpIp1, InterpIp2, R2RCallerIp], frameless.Select(f => f.Ip)); + Assert.Equal(r2rFrame, frameless[^1].Sp); + Assert.True(walked.Length <= 6, $"Walk did not terminate: {walked.Length} frames"); + } + + private static IExecutionManager CreateInterpreterExecutionManager(ulong[] interpreterIps, ulong[]? managedIps = null) { Mock executionManager = new(); executionManager .Setup(em => em.GetCodeBlockHandle(It.IsAny())) - .Returns((TargetCodePointer ip) => interpreterIps.Contains(ip.Value) ? new CodeBlockHandle(new TargetPointer(ip.Value)) : null); + .Returns((TargetCodePointer ip) => interpreterIps.Contains(ip.Value) || (managedIps?.Contains(ip.Value) ?? false) + ? new CodeBlockHandle(new TargetPointer(ip.Value)) + : null); executionManager .Setup(em => em.GetCodeKind(It.IsAny())) .Returns((TargetCodePointer ip) => interpreterIps.Contains(ip.Value) ? CodeKind.Interpreter : default); return executionManager.Object; } + // WASM TransitionBlock: m_ReturnAddress, then m_StackPointer. Matches the browser-wasm data + // descriptor, where ArgumentRegisters and FirstGCRefMapSlot are sizeof(TransitionBlock). + private static Dictionary CreateWasmTransitionBlockTypes(int pointerSize) + => new() + { + [DataType.TransitionBlock] = new() + { + Fields = new Dictionary + { + [nameof(Data.TransitionBlock.ReturnAddress)] = new() { Offset = 0 }, + [nameof(Data.TransitionBlock.StackPointer)] = new() { Offset = pointerSize }, + [nameof(Data.TransitionBlock.CalleeSavedRegisters)] = new() { Offset = 0 }, + [nameof(Data.TransitionBlock.ArgumentRegisters)] = new() { Offset = 2 * pointerSize }, + [nameof(Data.TransitionBlock.FirstGCRefMapSlot)] = new() { Offset = 2 * pointerSize }, + }, + Size = (uint)(2 * pointerSize), + }, + [DataType.CalleeSavedRegisters] = new() { Fields = new Dictionary(), Size = 0 }, + }; + + private static ulong AddWasmTransitionBlock(TargetTestHelpers helpers, MockMemorySpace.BumpAllocator allocator, ulong returnAddress, ulong stackPointer) + { + int pointerSize = helpers.PointerSize; + MockMemorySpace.HeapFragment transitionBlock = allocator.Allocate((ulong)(2 * pointerSize), "TransitionBlock"); + helpers.WritePointer(transitionBlock.Data.AsSpan(0, pointerSize), returnAddress); + helpers.WritePointer(transitionBlock.Data.AsSpan(pointerSize, pointerSize), stackPointer); + return transitionBlock.Address; + } + + // An R2R linear-stack frame record: the function-table index, then the function-local virtual + // IP / 2 (WASM_STACKFRAME_FUNCTION_INDEX_OFFSET / WASM_STACKFRAME_VIRTUALIP_OFFSET). + private static ulong AddWasmR2RFrameRecord(TargetTestHelpers helpers, MockMemorySpace.BumpAllocator allocator, uint functionTableIndex, uint localVirtualIPHalf) + { + MockMemorySpace.HeapFragment frame = allocator.Allocate(8, "R2RShadowFrame"); + helpers.Write(frame.Data.AsSpan(0, sizeof(uint)), functionTableIndex); + helpers.Write(frame.Data.AsSpan(4, sizeof(uint)), localVirtualIPHalf); + return frame.Address; + } + // WASM advertises the Debugger contract, but the in-process debugger is not built there, so // g_pDebugger stays null. private static void AddWasmNullDebugger(TestPlaceholderTarget.Builder targetBuilder, MockMemorySpace.BumpAllocator allocator) @@ -1114,7 +1282,8 @@ private static void AddWasmR2RFunction( MockMemorySpace.BumpAllocator allocator, uint functionTableIndex, ulong minVirtualIP, - uint functionBeginAddress) + uint functionBeginAddress, + byte frameSize = 0) { MockTarget.Architecture arch = targetBuilder.MemoryBuilder.TargetTestHelpers.Arch; TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; @@ -1135,6 +1304,14 @@ private static void AddWasmR2RFunction( MockMemorySpace.HeapFragment runtimeFunction = allocator.Allocate(runtimeFunctionLayout.Stride, "RuntimeFunction"); helpers.Write(runtimeFunction.Data.AsSpan(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.BeginAddress)].Offset, sizeof(uint)), functionBeginAddress); + if (frameSize != 0) + { + // Unwind data is the ULEB128 frame size, addressed as LoadedImageBase (0 here) + UnwindData. + Assert.True(frameSize < 0x80); + MockMemorySpace.HeapFragment unwindData = allocator.Allocate(1, "UnwindData"); + unwindData.Data[0] = frameSize; + helpers.Write(runtimeFunction.Data.AsSpan(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.UnwindData)].Offset, sizeof(uint)), (uint)unwindData.Address); + } MockReadyToRunInfo r2rInfo = r2rInfoLayout.Create(allocator.Allocate((ulong)r2rInfoLayout.Size, "ReadyToRunInfo")); r2rInfo.CompositeInfo = r2rInfo.Address; From dd4ec1e0b6edbd86a49f54a4987ec0f8f8c6375d Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 18:07:18 -0500 Subject: [PATCH 2/6] [cDAC][wasm] Recompute the frame pointer when unwinding an R2R frame WasmContext.Unwind advanced SP and IP but left FP alone, so each R2R caller after the first was reported with the previous frame's frame pointer. Native WasmUnwindStackFrame recomputes it from the caller's stack pointer. Use the root-function frame base; the funclet-aware logical frame pointer is not modeled yet. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../StackWalk/Context/WasmContext.cs | 6 +++++ .../cdac/tests/UnitTests/StackWalkTests.cs | 26 ++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) 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 24ac092843f18b..7c66dd9db4da36 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 @@ -88,11 +88,17 @@ public void Unwind(Target target) { StackPointer = sp; InstructionPointer = ip; + // Native WasmUnwindStackFrame recomputes the caller's frame pointer from its stack pointer. + // Root-function frame base; the funclet-aware logical frame pointer is not modeled yet. + FramePointer = unwinder.TryGetFramePointer(sp, out TargetPointer framePointer) + ? framePointer + : TargetPointer.Null; } else { StackPointer = TargetPointer.Null; InstructionPointer = TargetCodePointer.Null; + FramePointer = TargetPointer.Null; } } diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index b73f52a191512b..1fc3c4edd64429 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -1131,6 +1131,7 @@ public void UpdateContextFromFrame_WasmTransitionFrame_MirrorsNativeTransitionBl // InterpreterFrame whose TransitionBlock records only the R2R caller's linear-stack pointer. // When the interpreted chain is exhausted, the walk must continue into that R2R caller rather // than reporting a native marker at the end of the TransitionBlock and skipping the R2R frames. + // Each R2R frame's frame pointer is recomputed from its own stack pointer as the walk unwinds. [Fact] public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RCaller() { @@ -1141,8 +1142,10 @@ public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RC const ulong MinVirtualIP = 0x8001_0000; const uint FunctionBeginAddress = 0x100; const uint LocalVirtualIPHalf = 3; + const uint OuterLocalVirtualIPHalf = 5; const byte R2RFrameSize = 16; const ulong R2RCallerIp = MinVirtualIP + FunctionBeginAddress + LocalVirtualIPHalf * 2; + const ulong OuterR2RCallerIp = MinVirtualIP + FunctionBeginAddress + OuterLocalVirtualIPHalf * 2; MockThread? thread = null; MockFrameBuilder? frames = null; @@ -1166,12 +1169,14 @@ public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RC AddWasmR2RFunction(targetBuilder, allocator, FunctionTableIndex, MinVirtualIP, FunctionBeginAddress, R2RFrameSize); AddWasmNullDebugger(targetBuilder, allocator); - // The R2R caller's frame record; its own caller (frame base + frame size) is a - // TERMINATE_R2R_STACK_WALK marker, so the walk ends after it. - MockMemorySpace.HeapFragment r2rStack = allocator.Allocate(R2RFrameSize + 4, "R2RLinearStack"); + // Two R2R frame records, each frame size apart, then a TERMINATE_R2R_STACK_WALK + // marker, so the walk ends after the outer caller. + MockMemorySpace.HeapFragment r2rStack = allocator.Allocate(2 * R2RFrameSize + 4, "R2RLinearStack"); helpers.Write(r2rStack.Data.AsSpan(0, sizeof(uint)), FunctionTableIndex); helpers.Write(r2rStack.Data.AsSpan(4, sizeof(uint)), LocalVirtualIPHalf); - helpers.Write(r2rStack.Data.AsSpan(R2RFrameSize, sizeof(uint)), 1u); + helpers.Write(r2rStack.Data.AsSpan(R2RFrameSize, sizeof(uint)), FunctionTableIndex); + helpers.Write(r2rStack.Data.AsSpan(R2RFrameSize + 4, sizeof(uint)), OuterLocalVirtualIPHalf); + helpers.Write(r2rStack.Data.AsSpan(2 * R2RFrameSize, sizeof(uint)), 1u); r2rFrame = r2rStack.Address; ulong transitionBlock = AddWasmTransitionBlock(helpers, allocator, returnAddress: 0, stackPointer: r2rFrame); @@ -1199,20 +1204,21 @@ public void CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RC [DataType.InterpreterFrame] = new() { Fields = interpreterFrameFields, Size = (uint)(isFaultingOffset + pointerSize) }, }); }, - executionManager: CreateInterpreterExecutionManager([InterpIp1, InterpIp2], managedIps: [R2RCallerIp])); + executionManager: CreateInterpreterExecutionManager([InterpIp1, InterpIp2], managedIps: [R2RCallerIp, OuterR2RCallerIp])); 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 + (ulong Ip, ulong Sp, ulong Fp)[] frameless = walked .Where(f => f.State == StackWalkState.Frameless) - .Select(f => (stackWalk.GetInstructionPointer(f).Value, stackWalk.GetStackPointer(f).Value)) + .Select(f => (stackWalk.GetInstructionPointer(f).Value, stackWalk.GetStackPointer(f).Value, stackWalk.GetContextFramePointer(f).Value)) .ToArray(); - Assert.Equal([InterpIp1, InterpIp2, R2RCallerIp], frameless.Select(f => f.Ip)); - Assert.Equal(r2rFrame, frameless[^1].Sp); - Assert.True(walked.Length <= 6, $"Walk did not terminate: {walked.Length} frames"); + Assert.Equal([InterpIp1, InterpIp2, R2RCallerIp, OuterR2RCallerIp], frameless.Select(f => f.Ip)); + Assert.Equal((r2rFrame, r2rFrame), (frameless[2].Sp, frameless[2].Fp)); + Assert.Equal((r2rFrame + R2RFrameSize, r2rFrame + R2RFrameSize), (frameless[3].Sp, frameless[3].Fp)); + Assert.True(walked.Length <= 7, $"Walk did not terminate: {walked.Length} frames"); } private static IExecutionManager CreateInterpreterExecutionManager(ulong[] interpreterIps, ulong[]? managedIps = null) From 89485cce3b3d8524743ecb8631199b28be3f3f54 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 18:12:34 -0500 Subject: [PATCH 3/6] [cDAC][wasm] Add producer-layout funclet stack walk tests Model the linear-stack layouts RyuJIT and the VM produce for wasm funclets (genFuncletProlog 16-byte frame, funclet VIP at $sp[4], genCallFinally SP/FP passing, localloc indirection, CallFuncletWith[out]Throwable terminator + establishing FP) and assert SP/IP/logical FP for each frame across WasmContext.Unwind using the real WasmR2RInfo function-table lookup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../cdac/tests/UnitTests/WasmUnwinderTests.cs | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index cd215f35489e87..1ab98555ac34ad 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; using Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers.Wasm; using Microsoft.Diagnostics.DataContractReader.TestInfrastructure; using Xunit; @@ -264,4 +265,295 @@ public void TryUnwindOneFrame_MalformedUleb128_Throws() } private const uint StackWalkSentinelIndirect = 0; + + // --------------------------------------------------------------------------------------- + // Funclet stack walks over linear-stack layouts that mirror what the producers emit: + // * RyuJIT funclet prolog (genFuncletProlog, src/coreclr/jit/codegenwasm.cpp): an unwindable + // funclet moves its own $sp down by AlignUp(2 * pointer, STACK_ALIGN) (16 on wasm32), stores + // its own function table index at $sp[0], and its unwind blob encodes that 16-byte frame. + // * fgWasmVirtualIP (src/coreclr/jit/fgwasm.cpp): a funclet stores its virtual IP at $sp[4] of + // its own frame; virtual IPs are relative to the controlling method, so the funclet's table + // index resolves to the parent's base virtual IP. + // * genCallFinally (codegenwasm.cpp): a non-exceptional finally is called with the parent's + // current $sp and FP; WasmRegAlloc::AllocateFramePointer (regallocwasm.cpp) makes the + // funclet's FP local that parent FP. + // * genLclHeap (codegenwasm.cpp): after localloc, $sp[0] == STACK_WALK_INDIRECT_TO_FRAMEPOINTER + // and $sp[pointer] == FP. + // * CallFuncletWith[out]Throwable (src/coreclr/vm/wasm/helpers.cpp): the VM pushes a 16-byte + // frame holding TERMINATE_R2R_STACK_WALK at +0 and the establishing FP at + // TERMINATE_R2R_STACK_WALK_FP_OFFSET (one pointer), then calls the funclet with that $sp. + // Expected frame pointers follow native GetWasmFramePointerFromStackPointer, which reports a + // funclet's logical FP: the frame base of the establishing method. + // --------------------------------------------------------------------------------------- + + private const ulong LinearStackBase = 0x0001_0000; + private const int LinearStackSize = 0x1000; + private const ulong ProducerMinVirtualIP = 0x8001_0000; + private const uint WasmFuncletFlag = 0x8000_0000; + + // Function table indices are assigned consecutively per R2R module; a method's funclets + // immediately follow it, which is what lets a funclet index walk back to its parent. + private const uint ParentIndex = 100; + private const uint OuterFuncletIndex = 101; + private const uint InnerFuncletIndex = 102; + private const uint CalleeIndex = 103; + + private const uint ParentBegin = 0x100; + private const uint CalleeBegin = 0x200; + + private const uint ParentFrameSize = 0x30; + private const uint FuncletFrameSize = 16; // AlignUp(2 * TARGET_POINTER_SIZE, STACK_ALIGN) on wasm32 + private const uint CalleeFrameSize = 0x20; + + private const uint ParentVipHalf = 0x05; + private const uint OuterFuncletVipHalf = 0x21; + private const uint InnerFuncletVipHalf = 0x29; + private const uint CalleeVipHalf = 0x03; + + private const ulong ParentIp = ProducerMinVirtualIP + ParentBegin + ParentVipHalf * 2; + private const ulong OuterFuncletIp = ProducerMinVirtualIP + ParentBegin + OuterFuncletVipHalf * 2; + private const ulong InnerFuncletIp = ProducerMinVirtualIP + ParentBegin + InnerFuncletVipHalf * 2; + private const ulong CalleeIp = ProducerMinVirtualIP + CalleeBegin + CalleeVipHalf * 2; + + // The parent method's frame base, near the top of the modeled linear stack. Its own caller + // slot holds TERMINATE_R2R_STACK_WALK (e.g. it was entered from the interpreter). + private const ulong ParentFp = LinearStackBase + 0xF00; + + private sealed class LinearStack + { + private readonly TargetTestHelpers _helpers = new(WasmArch); + public byte[] Data { get; } = new byte[LinearStackSize]; + + private Span At(ulong address, int length) => Data.AsSpan((int)(address - LinearStackBase), length); + + public void Record(ulong address, uint functionIndex, uint vipHalf) + { + _helpers.Write(At(address, sizeof(uint)), functionIndex); + _helpers.Write(At(address + 4, sizeof(uint)), vipHalf); + } + + public void Terminator(ulong address, ulong establishingFp = 0) + { + _helpers.Write(At(address, sizeof(uint)), 1u); + _helpers.WritePointer(At(address + (ulong)_helpers.PointerSize, _helpers.PointerSize), establishingFp); + } + + public void LocallocSlot(ulong address, ulong framePointer) + { + _helpers.Write(At(address, sizeof(uint)), 0u); + _helpers.WritePointer(At(address + (ulong)_helpers.PointerSize, _helpers.PointerSize), 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) + { + TestPlaceholderTarget.Builder targetBuilder = new(WasmArch); + TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000); + + (uint BeginAddress, uint FrameSize)[] functions = + [ + (ParentBegin, ParentFrameSize), + (WasmFuncletFlag | (ParentBegin + 0x40), FuncletFrameSize), + (WasmFuncletFlag | (ParentBegin + 0x50), FuncletFrameSize), + (CalleeBegin, CalleeFrameSize), + ]; + + int hashMapStride = MockHashMap.CreateLayout(WasmArch).Size; + var moduleLayout = MockLoaderModule.CreateLayout(WasmArch); + var r2rInfoLayout = MockReadyToRunInfo.CreateLayout(WasmArch, hashMapStride, isWasm: true); + TargetTestHelpers.LayoutResult runtimeFunctionLayout = helpers.LayoutFields([ + new(nameof(Data.RuntimeFunction.BeginAddress), DataType.uint32), + new(nameof(Data.RuntimeFunction.UnwindData), DataType.uint32), + ]); + TargetTestHelpers.LayoutResult rangeSectionLayout = helpers.LayoutFields([ + new(nameof(Data.FunctionTableIndexRangeSection.MinFunctionTableIndex), DataType.uint32), + new(nameof(Data.FunctionTableIndexRangeSection.NumRuntimeFunctions), DataType.uint32), + new(nameof(Data.FunctionTableIndexRangeSection.R2RModule), DataType.pointer), + new(nameof(Data.FunctionTableIndexRangeSection.Next), DataType.pointer), + ]); + + MockMemorySpace.HeapFragment runtimeFunctions = allocator.Allocate(runtimeFunctionLayout.Stride * (ulong)functions.Length, "RuntimeFunctions"); + for (int i = 0; i < functions.Length; i++) + { + // Unwind data is the ULEB128 fixed frame size, addressed as LoadedImageBase (0) + UnwindData. + Assert.True(functions[i].FrameSize < 0x80); + MockMemorySpace.HeapFragment unwindData = allocator.Allocate(1, "UnwindData"); + unwindData.Data[0] = (byte)functions[i].FrameSize; + + Span entry = runtimeFunctions.Data.AsSpan(i * (int)runtimeFunctionLayout.Stride, (int)runtimeFunctionLayout.Stride); + helpers.Write(entry.Slice(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.BeginAddress)].Offset, sizeof(uint)), functions[i].BeginAddress); + helpers.Write(entry.Slice(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.UnwindData)].Offset, sizeof(uint)), (uint)unwindData.Address); + } + + MockReadyToRunInfo r2rInfo = r2rInfoLayout.Create(allocator.Allocate((ulong)r2rInfoLayout.Size, "ReadyToRunInfo")); + r2rInfo.CompositeInfo = r2rInfo.Address; + r2rInfo.NumRuntimeFunctions = (uint)functions.Length; + r2rInfo.RuntimeFunctions = runtimeFunctions.Address; + r2rInfo.MinVirtualIP = ProducerMinVirtualIP; + + MockLoaderModule module = moduleLayout.Create(allocator.Allocate((ulong)moduleLayout.Size, "Module")); + module.ReadyToRunInfo = r2rInfo.Address; + + MockMemorySpace.HeapFragment section = allocator.Allocate(rangeSectionLayout.Stride, "FunctionTableIndexRangeSection"); + helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.MinFunctionTableIndex)].Offset, sizeof(uint)), ParentIndex); + helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.NumRuntimeFunctions)].Offset, sizeof(uint)), (uint)functions.Length); + helpers.WritePointer(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.R2RModule)].Offset, helpers.PointerSize), module.Address); + + MockMemorySpace.HeapFragment listSlot = allocator.Allocate((ulong)helpers.PointerSize, "FunctionTableIndexRangeListSlot"); + helpers.WritePointer(listSlot.Data.AsSpan(), section.Address); + + targetBuilder.MemoryBuilder.AddHeapFragment(new MockMemorySpace.HeapFragment { Address = LinearStackBase, Data = stack.Data, Name = "LinearStack" }); + targetBuilder + .AddTypes(new Dictionary + { + [DataType.RuntimeFunction] = new() { Fields = runtimeFunctionLayout.Fields, Size = runtimeFunctionLayout.Stride }, + [DataType.ReadyToRunInfo] = TargetTestHelpers.CreateTypeInfo(r2rInfoLayout), + [DataType.Module] = TargetTestHelpers.CreateTypeInfo(moduleLayout), + [DataType.FunctionTableIndexRangeSection] = new() { Fields = rangeSectionLayout.Fields, Size = rangeSectionLayout.Stride }, + }) + .AddGlobals((Constants.Globals.FunctionTableIndexRangeList, listSlot.Address)); + return targetBuilder.Build(); + } + + // The walk starts in a root method called from inside the innermost funclet (for example + // a breakpoint or Debugger.Break in a method called from a catch/finally body). + private static WasmContext CalleeContext(ulong calleeSp) => new() + { + StackPointer = new TargetPointer(calleeSp), + InstructionPointer = new TargetCodePointer(CalleeIp), + FramePointer = new TargetPointer(calleeSp), + }; + + private static void AssertFrame(WasmContext context, ulong sp, ulong ip, ulong fp, string frame) + { + Assert.True(sp == context.StackPointer.Value, $"{frame}: SP 0x{context.StackPointer.Value:x}, expected 0x{sp:x}"); + Assert.True(ip == context.InstructionPointer.Value, $"{frame}: IP 0x{context.InstructionPointer.Value:x}, expected 0x{ip:x}"); + Assert.True(fp == context.FramePointer.Value, $"{frame}: FP 0x{context.FramePointer.Value:x}, expected 0x{fp:x}"); + } + + // The walk leaves R2R code at a TERMINATE_R2R_STACK_WALK frame; no R2R caller is reported. + private static void AssertLeftR2R(WasmContext context) + => Assert.Equal(TargetCodePointer.Null, context.InstructionPointer); + + /// + /// Non-exceptional finally: the parent calls the finally funclet directly (genCallFinally) + /// with its own $sp and FP, so the funclet's 16-byte frame sits immediately below the parent's + /// $sp. When , the parent's $sp is a localloc slot that + /// indirects to its FP, and the funclet frame sits below that slot. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Unwind_ProducerLayout_FinallyCalledByParent_ReportsParentFramePointer(bool parentUsedLocalloc) + { + ulong parentSp = parentUsedLocalloc ? ParentFp - 0x40 : ParentFp; + ulong funcletSp = parentSp - FuncletFrameSize; + ulong calleeSp = funcletSp - CalleeFrameSize; + + LinearStack stack = new(); + stack.Terminator(ParentFp + ParentFrameSize); + stack.Record(ParentFp, ParentIndex, ParentVipHalf); + if (parentUsedLocalloc) + stack.LocallocSlot(parentSp, ParentFp); + stack.Record(funcletSp, OuterFuncletIndex, OuterFuncletVipHalf); + stack.Record(calleeSp, CalleeIndex, CalleeVipHalf); + Target target = CreateProducerLayoutTarget(stack); + + WasmContext context = CalleeContext(calleeSp); + + context.Unwind(target); + AssertFrame(context, funcletSp, OuterFuncletIp, ParentFp, "finally funclet"); + + context.Unwind(target); + AssertFrame(context, parentSp, ParentIp, ParentFp, "parent"); + + context.Unwind(target); + AssertLeftR2R(context); + } + + /// + /// Catch, finally, fault or filter invoked by the VM (EECodeManager::CallFunclet -> + /// CallFuncletWith[out]Throwable). The funclet's frame sits directly below the synthetic + /// TERMINATE_R2R_STACK_WALK frame, which carries the establishing FP; the establishing frame + /// itself is far above, past native VM frames. A filter runs during the first pass, so the + /// R2R frames of the try body that threw are still live between the establishing frame and + /// the terminator (); they must not be visited. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Unwind_ProducerLayout_FuncletInvokedByVM_ReportsEstablishingFramePointer(bool throwingFramesStillLive) + { + ulong terminatorSp = LinearStackBase + 0x800; + ulong funcletSp = terminatorSp - FuncletFrameSize; + ulong calleeSp = funcletSp - CalleeFrameSize; + + LinearStack stack = new(); + stack.Terminator(ParentFp + ParentFrameSize); + stack.Record(ParentFp, ParentIndex, ParentVipHalf); + if (throwingFramesStillLive) + stack.Record(ParentFp - CalleeFrameSize, CalleeIndex, CalleeVipHalf); + stack.Terminator(terminatorSp, ParentFp); + stack.Record(funcletSp, OuterFuncletIndex, OuterFuncletVipHalf); + stack.Record(calleeSp, CalleeIndex, CalleeVipHalf); + Target target = CreateProducerLayoutTarget(stack); + + WasmContext context = CalleeContext(calleeSp); + + context.Unwind(target); + AssertFrame(context, funcletSp, OuterFuncletIp, ParentFp, "VM-invoked funclet"); + + context.Unwind(target); + AssertLeftR2R(context); + } + + /// + /// 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 + /// already the establishing FP. Both funclets report the establishing method's frame base, + /// whether the outer funclet was called by the parent or invoked by the VM. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Unwind_ProducerLayout_NestedFinally_ReportsEstablishingFramePointer(bool outerInvokedByVM) + { + ulong outerCallerSp = outerInvokedByVM ? LinearStackBase + 0x800 : ParentFp; + ulong outerSp = outerCallerSp - FuncletFrameSize; + ulong innerSp = outerSp - FuncletFrameSize; + ulong calleeSp = innerSp - CalleeFrameSize; + + LinearStack stack = new(); + stack.Terminator(ParentFp + ParentFrameSize); + stack.Record(ParentFp, ParentIndex, ParentVipHalf); + if (outerInvokedByVM) + stack.Terminator(outerCallerSp, ParentFp); + stack.Record(outerSp, OuterFuncletIndex, OuterFuncletVipHalf); + stack.Record(innerSp, InnerFuncletIndex, InnerFuncletVipHalf); + stack.Record(calleeSp, CalleeIndex, CalleeVipHalf); + Target target = CreateProducerLayoutTarget(stack); + + WasmContext context = CalleeContext(calleeSp); + + context.Unwind(target); + AssertFrame(context, innerSp, InnerFuncletIp, ParentFp, "inner finally"); + + context.Unwind(target); + AssertFrame(context, outerSp, OuterFuncletIp, ParentFp, "outer funclet"); + + context.Unwind(target); + if (outerInvokedByVM) + { + AssertLeftR2R(context); + } + else + { + AssertFrame(context, ParentFp, ParentIp, ParentFp, "parent"); + context.Unwind(target); + AssertLeftR2R(context); + } + } } From 5fb74a36c35542a4707d132ec24c2e262c6d7200 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 18:14:15 -0500 Subject: [PATCH 4/6] [cDAC][wasm] Report funclet logical frame pointer in WasmContext.Unwind Adapted from #133890: add IWasmR2RInfo.TryIsFunclet and WasmUnwinder.TryGetLogicalFramePointer, mirroring native GetWasmFramePointerFromStackPointer, and set WasmContext.FramePointer to the establishing method's frame base after each unwind. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Helpers/WasmFunctionTableIndexLookup.cs | 13 +++ .../StackWalk/Context/Wasm/WasmR2RInfo.cs | 3 + .../StackWalk/Context/Wasm/WasmUnwinder.cs | 87 +++++++++++++++++++ .../StackWalk/Context/WasmContext.cs | 8 +- .../cdac/tests/UnitTests/WasmUnwinderTests.cs | 7 ++ 5 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/ExecutionManager/Helpers/WasmFunctionTableIndexLookup.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/ExecutionManager/Helpers/WasmFunctionTableIndexLookup.cs index 3137eaaaef66d5..5589f8ee8c365e 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/ExecutionManager/Helpers/WasmFunctionTableIndexLookup.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/ExecutionManager/Helpers/WasmFunctionTableIndexLookup.cs @@ -93,6 +93,19 @@ public bool TryGetVirtualIPBase(uint functionTableIndex, out ulong baseVirtualIP } } + // Mirrors ExecutionManager::IsFuncletFunctionIndex. + public bool TryIsFunclet(uint functionTableIndex, out bool isFunclet) + { + isFunclet = false; + Data.FunctionTableIndexRangeSection? section = FindSection(functionTableIndex); + if (section is null) + return false; + + Data.ReadyToRunInfo r2rInfo = GetReadyToRunInfo(section); + isFunclet = _runtimeFunctions.IsFunclet(GetRuntimeFunction(r2rInfo, functionTableIndex - section.MinFunctionTableIndex)); + return true; + } + public bool TryGetUnwindData(uint functionTableIndex, out TargetPointer unwindDataAddress) { unwindDataAddress = TargetPointer.Null; diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmR2RInfo.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmR2RInfo.cs index d0c3d88455a11b..b2823fa028d096 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmR2RInfo.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/StackWalk/Context/Wasm/WasmR2RInfo.cs @@ -23,4 +23,7 @@ public bool TryGetVirtualIPBase(uint functionTableIndex, out ulong baseVirtualIP public bool TryGetUnwindData(uint functionTableIndex, out TargetPointer unwindDataAddress) => _lookup.TryGetUnwindData(functionTableIndex, out unwindDataAddress); + + public bool TryIsFunclet(uint functionTableIndex, out bool isFunclet) + => _lookup.TryIsFunclet(functionTableIndex, out isFunclet); } 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 f5d7c9049348c5..cdffac33b0e071 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 @@ -26,6 +26,13 @@ internal interface IWasmR2RInfo /// frame size. Returns false when the index does not map to a known R2R function. /// bool TryGetUnwindData(uint functionTableIndex, out TargetPointer unwindDataAddress); + + /// + /// Reports whether an R2R function table entry is a funclet rather than a method's root + /// function (ExecutionManager::IsFuncletFunctionIndex). Returns false when the index + /// does not map to a known R2R function. + /// + bool TryIsFunclet(uint functionTableIndex, out bool isFunclet); } /// @@ -174,6 +181,86 @@ public bool TryUnwindOneFrame(ref TargetPointer sp, out TargetCodePointer ip) return true; } + /// + /// Advances by one R2R frame and returns the caller's stack pointer, + /// without requiring the caller to be R2R-generated code. This is the frame-size half of + /// WasmUnwindStackFrameCore, which needs in + /// order to inspect a synthetic frame. + /// + private bool TryUnwindToCallerStackPointer(TargetPointer sp, out TargetPointer callerSp) + { + callerSp = TargetPointer.Null; + if (!TryGetFramePointer(sp, out TargetPointer frameBase)) + return false; + + uint functionIndex = _target.Read(frameBase.Value + FunctionIndexOffset); + if (!_r2rInfo.TryGetUnwindData(functionIndex, out TargetPointer unwindData)) + return false; + + uint frameSize = DecodeULEB128(unwindData.Value); + if (frameSize == 0) + return false; + + callerSp = new TargetPointer(frameBase.Value + frameSize); + return true; + } + + /// + /// Returns the logical (establishing) frame pointer for the frame at , + /// mirroring GetWasmFramePointerFromStackPointer in + /// src/coreclr/vm/wasm/helpers.cpp. + /// + /// + /// For a method's root function this is its own frame base. For a funclet it is the frame + /// base of the establishing method: the funclet's FP local is the FP its caller passed in + /// (WasmRegAlloc::AllocateFramePointer, CodeGen::genCallFinally), so reaching it + /// means unwinding out of the funclet, either to its containing method or funclet, or to the + /// synthetic frame that + /// CallFuncletWith[out]Throwable pushes, which carries the establishing frame pointer + /// beside the marker. + /// + public bool TryGetLogicalFramePointer(TargetPointer sp, out TargetPointer framePointer) + { + framePointer = TargetPointer.Null; + + // Native recurses until it reaches a non-funclet frame or a CallFunclet terminator. The + // cDAC reads untrusted memory, so require each step to move toward the caller; the step + // count is only a backstop. + const int MaxUnwindSteps = 4096; + TargetPointer current = sp; + + for (int i = 0; i < MaxUnwindSteps; i++) + { + if (!TryGetFramePointer(current, out TargetPointer frameBase)) + return false; + + uint functionIndex = _target.Read(frameBase.Value + FunctionIndexOffset); + if (!_r2rInfo.TryIsFunclet(functionIndex, out bool isFunclet)) + return false; + + if (!isFunclet) + { + framePointer = frameBase; + return true; + } + + if (!TryUnwindToCallerStackPointer(current, out TargetPointer callerSp) || callerSp.Value <= current.Value) + return false; + + if (_target.Read(callerSp.Value + FunctionIndexOffset) == TerminateR2RStackWalk) + { + // Invoked by the VM through CallFuncletWith[out]Throwable. + framePointer = GetEstablishingFramePointerFromTerminator(callerSp); + return true; + } + + // Called by its containing method or funclet; keep walking out. + current = callerSp; + } + + return false; + } + // Standard little-endian base-128 varint, matching the native DecodeULEB128AsU32. A ULEB128 // uint32 is at most 5 bytes (5 * 7 = 35 >= 32 bits); a longer encoding is malformed. private uint DecodeULEB128(ulong address) 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 7c66dd9db4da36..e4d54de44b9733 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 @@ -88,11 +88,9 @@ public void Unwind(Target target) { StackPointer = sp; InstructionPointer = ip; - // Native WasmUnwindStackFrame recomputes the caller's frame pointer from its stack pointer. - // Root-function frame base; the funclet-aware logical frame pointer is not modeled yet. - FramePointer = unwinder.TryGetFramePointer(sp, out TargetPointer framePointer) - ? framePointer - : TargetPointer.Null; + // 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; } else { diff --git a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs index 1ab98555ac34ad..2622df8713a3a7 100644 --- a/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/WasmUnwinderTests.cs @@ -28,6 +28,13 @@ private sealed class FakeWasmR2RInfo : IWasmR2RInfo { public Dictionary VirtualIpBases { get; } = new(); public Dictionary UnwindData { get; } = new(); + public HashSet Funclets { get; } = new(); + + public bool TryIsFunclet(uint functionTableIndex, out bool isFunclet) + { + isFunclet = Funclets.Contains(functionTableIndex); + return VirtualIpBases.ContainsKey(functionTableIndex) || UnwindData.ContainsKey(functionTableIndex) || isFunclet; + } public bool TryGetVirtualIPBase(uint functionTableIndex, out ulong baseVirtualIP) => VirtualIpBases.TryGetValue(functionTableIndex, out baseVirtualIP); From 30047665a6235295aa8c0c4d8642fc7a6f74f8c1 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 18:19:51 -0500 Subject: [PATCH 5/6] [cDAC][wasm] Use the funclet-aware frame pointer when seeding from R2R frames The R2R InlinedCallFrame and TransitionFrame paths seeded FP with the frame's own base, which is wrong when the R2R frame is a funclet. Use the logical frame pointer, as native GetWasmFramePointerFromStackPointer does, and document the WASM logical frame pointer in the StackWalk spec. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/datacontracts/StackWalk.md | 7 +- .../FrameHandling/WasmFrameHandler.cs | 8 +- .../cdac/tests/UnitTests/StackWalkTests.cs | 112 ++++++++++++++++-- 3 files changed, 109 insertions(+), 18 deletions(-) diff --git a/docs/design/datacontracts/StackWalk.md b/docs/design/datacontracts/StackWalk.md index 6bf00d2e096e26..e575c304e078d3 100644 --- a/docs/design/datacontracts/StackWalk.md +++ b/docs/design/datacontracts/StackWalk.md @@ -196,6 +196,7 @@ Unwinding call frames on the stack usually requires an OS specific implementatio | `ResumableFrame` | `TargetContextPtr` | `pointer` | Pointer to the Frame's Target Context | | `RuntimeFunction` | *(type size)* | `uint32` | Size of a runtime function entry in bytes | | `RuntimeFunction` | `BeginAddress` | `uint32` | Begin address of the function. On ARM32, bit 0 is the Thumb bit; on WebAssembly, bit 31 marks a funclet and is excluded from address arithmetic. | +| `RuntimeFunction` | `UnwindData` | `uint32` | Pointer to the unwind info for the function | | `SoftwareExceptionFrame` | `ReturnAddress` | `CodePointer` | Return address saved in Frame | | `SoftwareExceptionFrame` | `TargetContext` | `pointer` | Context object saved in Frame | | `String` | `m_StringLength` | `uint32` | Length of the string in UTF-16 characters | @@ -452,7 +453,7 @@ Most of the handlers are implemented in `BaseFrameHandler`. Platform specific co InlinedCallFrames store and update only the IP, SP, and FP of a given context. If the stored IP (CallerReturnAddress) is 0 then the InlinedCallFrame does not have an active call and should not update the context. * On ARM, the InlinedCallFrame stores the value of the SP after the prolog (`SPAfterProlog`) to allow unwinding for functions with stackalloc. When a function uses stackalloc, the CallSiteSP can already have been adjusted. This value should be placed in R9. -* On WASM, a `CallerReturnAddress` of `INLINED_PINVOKE_FROM_R2R` (`1`) marks an active inlined P/Invoke from ReadyToRun code rather than an address. SP is taken from `CallSiteSP`, IP is the R2R virtual IP of the shadow frame at `CallSiteSP`, and FP is that shadow frame's base. If no virtual IP can be recovered, IP is set to null. +* 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. An active InlinedCallFrame stays the current Frame after its context update so the skipped-Frame check can step past it once the walk reaches the managed caller. If the updated IP is not managed code (for example, no WASM R2R virtual IP could be recovered), the walk fails, matching native `StackFrameIterator::NextRaw`; otherwise it would never advance past the Frame. @@ -471,7 +472,9 @@ TransitionFrames hold a pointer to a `TransitionBlock`. The TransitionBlock hold When updating the context from a TransitionFrame, the IP, SP, and all ABI specified callee-saved registers are copied over. * On ARM, the additional register values stored in `ArgumentRegisters` are copied over. The `TransitionBlock` holds a pointer to the `ArgumentRegister` struct containing these values. -* 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 that frame's base (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. +* 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). **Return Address**: Read from `TransitionBlock.ReturnAddress` (on WASM, derived from `TransitionBlock.StackPointer` when it is 0, as above). This applies to all frame types that use the TransitionFrame mechanism. 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 03cfdd664a6d65..2c7922e361c71a 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 @@ -41,8 +41,8 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) Wasm.WasmUnwinder unwinder = new(_target, new Wasm.WasmR2RInfo(_target)); _holder.Context.StackPointer = inlinedCallFrame.CallSiteSP; _holder.Context.InstructionPointer = unwinder.GetVirtualIP(inlinedCallFrame.CallSiteSP); - // Root-function frame base; the funclet-aware logical frame pointer is not modeled yet. - _holder.Context.FramePointer = unwinder.TryGetFramePointer(inlinedCallFrame.CallSiteSP, out TargetPointer framePointer) + // Native GetWasmFramePointerFromStackPointer: a funclet reports its establishing method's frame. + _holder.Context.FramePointer = unwinder.TryGetLogicalFramePointer(inlinedCallFrame.CallSiteSP, out TargetPointer framePointer) ? framePointer : TargetPointer.Null; } @@ -79,9 +79,9 @@ public override void HandleTransitionFrame(FramedMethodFrame framedMethodFrame) if (stackPointer != TargetPointer.Null && instructionPointer != TargetCodePointer.Null) { _holder.Context.StackPointer = stackPointer; - // Root-function frame base; the funclet-aware logical frame pointer is not modeled yet. + // Native GetWasmFramePointerFromStackPointer: a funclet reports its establishing method's frame. Wasm.WasmUnwinder unwinder = new(_target, new Wasm.WasmR2RInfo(_target)); - _holder.Context.FramePointer = unwinder.TryGetFramePointer(stackPointer, out TargetPointer framePointer) + _holder.Context.FramePointer = unwinder.TryGetLogicalFramePointer(stackPointer, out TargetPointer framePointer) ? framePointer : TargetPointer.Null; } diff --git a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs index 1fc3c4edd64429..534425038426ba 100644 --- a/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs +++ b/src/native/managed/cdac/tests/UnitTests/StackWalkTests.cs @@ -1127,6 +1127,79 @@ public void UpdateContextFromFrame_WasmTransitionFrame_MirrorsNativeTransitionBl Assert.Equal(callerIsR2R ? r2rFrame : 0ul, context.FramePointer.Value); } + // A funclet's frame pointer is its establishing method's frame, not its own record (native + // GetWasmFramePointerFromStackPointer). Here the funclet was invoked by the VM through + // CallFuncletWith[out]Throwable, so a TERMINATE_R2R_STACK_WALK frame carrying the establishing + // frame pointer sits directly above it. Both frame kinds that seed from an R2R stack pointer + // (a transition helper's TransitionBlock and an R2R inlined P/Invoke) must report that pointer. + [Theory] + [InlineData(true)] + [InlineData(false)] + public void UpdateContextFromFrame_WasmFuncletCaller_ReportsEstablishingFramePointer(bool transitionFrame) + { + MockTarget.Architecture wasmArch = new() { IsLittleEndian = true, Is64Bit = false }; + const uint ParentIndex = 5; + const uint FuncletIndex = 6; + const ulong MinVirtualIP = 0x8001_0000; + const uint ParentBegin = 0x100; + const uint FuncletVipHalf = 0x21; + const byte FuncletFrameSize = 16; // AlignUp(2 * TARGET_POINTER_SIZE, STACK_ALIGN) on wasm32 + const ulong EstablishingFp = 0x0021_0F00; + + MockFramedMethodFrame? framedMethodFrame = null; + MockInlinedCallFrame? inlinedCallFrame = null; + ulong funcletFrame = 0; + TestPlaceholderTarget target = CreateTarget( + wasmArch, + threadBuilder => threadBuilder.AddThread(1, 1234), + frameBuilder => + { + if (transitionFrame) + framedMethodFrame = frameBuilder.AddFramedMethodFrame(methodDescPtr: 0); + else + inlinedCallFrame = frameBuilder.AddInlinedCallFrame(callerReturnAddress: 1, datum: 0); + }, + runtimeArchitecture: RuntimeInfoArchitecture.Wasm, + configureTarget: targetBuilder => + { + TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; + MockMemorySpace.BumpAllocator allocator = targetBuilder.MemoryBuilder.CreateAllocator(0x0020_0000, 0x0020_4000); + AddWasmR2RFunctions(targetBuilder, allocator, ParentIndex, MinVirtualIP, + [(ParentBegin, 0x30), (0x8000_0000 | (ParentBegin + 0x40), FuncletFrameSize)]); + + // The funclet's record, then the VM's terminator frame: [TERMINATE_R2R_STACK_WALK, establishing FP]. + MockMemorySpace.HeapFragment stack = allocator.Allocate(FuncletFrameSize + 2 * sizeof(uint), "LinearStack"); + helpers.Write(stack.Data.AsSpan(0, sizeof(uint)), FuncletIndex); + helpers.Write(stack.Data.AsSpan(4, sizeof(uint)), FuncletVipHalf); + helpers.Write(stack.Data.AsSpan(FuncletFrameSize, sizeof(uint)), 1u); + helpers.WritePointer(stack.Data.AsSpan(FuncletFrameSize + sizeof(uint), helpers.PointerSize), EstablishingFp); + funcletFrame = stack.Address; + + if (transitionFrame) + { + ulong transitionBlock = AddWasmTransitionBlock(helpers, allocator, returnAddress: 0, stackPointer: funcletFrame); + helpers.WritePointer( + framedMethodFrame!.Memory.Span.Slice(framedMethodFrame.Layout.GetField("TransitionBlockPtr").Offset, helpers.PointerSize), + transitionBlock); + targetBuilder.AddTypes(CreateWasmTransitionBlockTypes(helpers.PointerSize)); + } + else + { + inlinedCallFrame!.CallSiteSP = funcletFrame; + } + }); + + ContextHolder context = new(); + FrameHelpers frameHelpers = new(target); + ulong frameAddress = transitionFrame ? framedMethodFrame!.Address : inlinedCallFrame!.Address; + frameHelpers.UpdateContextFromFrame(target.ProcessedData.GetOrAdd(frameAddress), context); + + // Funclet virtual IPs are relative to the controlling method's base. + Assert.Equal(MinVirtualIP + ParentBegin + FuncletVipHalf * 2, context.InstructionPointer.Value); + Assert.Equal(funcletFrame, context.StackPointer.Value); + Assert.Equal(EstablishingFp, context.FramePointer.Value); + } + // R2R code that enters an interpreted method through a portable-entry-point thunk leaves an // InterpreterFrame whose TransitionBlock records only the R2R caller's linear-stack pointer. // When the interpreted chain is exhausted, the walk must continue into that R2R caller rather @@ -1290,6 +1363,17 @@ private static void AddWasmR2RFunction( ulong minVirtualIP, uint functionBeginAddress, byte frameSize = 0) + => AddWasmR2RFunctions(targetBuilder, allocator, functionTableIndex, minVirtualIP, [(functionBeginAddress, frameSize)]); + + // Registers one R2R module whose RUNTIME_FUNCTIONs occupy consecutive function-table indices + // starting at minFunctionTableIndex. BeginAddress bit 31 marks a funclet; a frame size of 0 + // leaves the unwind data unset. + private static void AddWasmR2RFunctions( + TestPlaceholderTarget.Builder targetBuilder, + MockMemorySpace.BumpAllocator allocator, + uint minFunctionTableIndex, + ulong minVirtualIP, + (uint BeginAddress, byte FrameSize)[] functions) { MockTarget.Architecture arch = targetBuilder.MemoryBuilder.TargetTestHelpers.Arch; TargetTestHelpers helpers = targetBuilder.MemoryBuilder.TargetTestHelpers; @@ -1308,29 +1392,33 @@ private static void AddWasmR2RFunction( new(nameof(Data.FunctionTableIndexRangeSection.Next), DataType.pointer), ]); - MockMemorySpace.HeapFragment runtimeFunction = allocator.Allocate(runtimeFunctionLayout.Stride, "RuntimeFunction"); - helpers.Write(runtimeFunction.Data.AsSpan(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.BeginAddress)].Offset, sizeof(uint)), functionBeginAddress); - if (frameSize != 0) + MockMemorySpace.HeapFragment runtimeFunctions = allocator.Allocate(runtimeFunctionLayout.Stride * (ulong)functions.Length, "RuntimeFunctions"); + for (int i = 0; i < functions.Length; i++) { - // Unwind data is the ULEB128 frame size, addressed as LoadedImageBase (0 here) + UnwindData. - Assert.True(frameSize < 0x80); - MockMemorySpace.HeapFragment unwindData = allocator.Allocate(1, "UnwindData"); - unwindData.Data[0] = frameSize; - helpers.Write(runtimeFunction.Data.AsSpan(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.UnwindData)].Offset, sizeof(uint)), (uint)unwindData.Address); + Span entry = runtimeFunctions.Data.AsSpan(i * (int)runtimeFunctionLayout.Stride, (int)runtimeFunctionLayout.Stride); + helpers.Write(entry.Slice(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.BeginAddress)].Offset, sizeof(uint)), functions[i].BeginAddress); + if (functions[i].FrameSize != 0) + { + // Unwind data is the ULEB128 frame size, addressed as LoadedImageBase (0 here) + UnwindData. + Assert.True(functions[i].FrameSize < 0x80); + MockMemorySpace.HeapFragment unwindData = allocator.Allocate(1, "UnwindData"); + unwindData.Data[0] = functions[i].FrameSize; + helpers.Write(entry.Slice(runtimeFunctionLayout.Fields[nameof(Data.RuntimeFunction.UnwindData)].Offset, sizeof(uint)), (uint)unwindData.Address); + } } MockReadyToRunInfo r2rInfo = r2rInfoLayout.Create(allocator.Allocate((ulong)r2rInfoLayout.Size, "ReadyToRunInfo")); r2rInfo.CompositeInfo = r2rInfo.Address; - r2rInfo.NumRuntimeFunctions = 1; - r2rInfo.RuntimeFunctions = runtimeFunction.Address; + r2rInfo.NumRuntimeFunctions = (uint)functions.Length; + r2rInfo.RuntimeFunctions = runtimeFunctions.Address; r2rInfo.MinVirtualIP = minVirtualIP; MockLoaderModule module = moduleLayout.Create(allocator.Allocate((ulong)moduleLayout.Size, "Module")); module.ReadyToRunInfo = r2rInfo.Address; MockMemorySpace.HeapFragment section = allocator.Allocate(rangeSectionLayout.Stride, "FunctionTableIndexRangeSection"); - helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.MinFunctionTableIndex)].Offset, sizeof(uint)), functionTableIndex); - helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.NumRuntimeFunctions)].Offset, sizeof(uint)), 1u); + helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.MinFunctionTableIndex)].Offset, sizeof(uint)), minFunctionTableIndex); + helpers.Write(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.NumRuntimeFunctions)].Offset, sizeof(uint)), (uint)functions.Length); helpers.WritePointer(section.Data.AsSpan(rangeSectionLayout.Fields[nameof(Data.FunctionTableIndexRangeSection.R2RModule)].Offset, helpers.PointerSize), module.Address); MockMemorySpace.HeapFragment listSlot = allocator.Allocate((ulong)helpers.PointerSize, "FunctionTableIndexRangeListSlot"); From e1030be582f32432228c0170158b564d262b8aa7 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 2 Oct 2026 20:08:28 -0500 Subject: [PATCH 6/6] cDAC WASM: tighten TransitionFrame and logical-FP comments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Contracts/StackWalk/Context/Wasm/WasmUnwinder.cs | 1 + .../Contracts/StackWalk/FrameHandling/WasmFrameHandler.cs | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) 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 cdffac33b0e071..8ac71e0d66542f 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 @@ -235,6 +235,7 @@ public bool TryGetLogicalFramePointer(TargetPointer sp, out TargetPointer frameP return false; uint functionIndex = _target.Read(frameBase.Value + FunctionIndexOffset); + // Native treats an unknown index as a root function; report no frame pointer instead. if (!_r2rInfo.TryIsFunclet(functionIndex, out bool isFunclet)) return false; 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 2c7922e361c71a..a09ef0fb12afe8 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 @@ -64,11 +64,9 @@ public override void HandleInlinedCallFrame(InlinedCallFrame inlinedCallFrame) } } - // Mirrors TransitionFrame::UpdateRegDisplay_Impl in src/coreclr/vm/wasm/helpers.cpp. A transition - // helper called from R2R code records the caller's linear-stack pointer; when it is set and a - // return address is known (stored, or derived from that stack pointer), the caller is the R2R - // frame at that stack pointer (native TransitionFrame::GetSP). Otherwise the frame was entered - // from interpreted or native code and the caller's stack pointer is the end of the TransitionBlock. + // 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. public override void HandleTransitionFrame(FramedMethodFrame framedMethodFrame) { Data.TransitionBlock transitionBlock = _target.ProcessedData.GetOrAdd(framedMethodFrame.TransitionBlockPtr);