Skip to content

[cDAC][wasm] Continue stack walks from a TransitionFrame into its R2R caller - #135140

Open
lewing wants to merge 7 commits into
mainfrom
lewing-cdac-wasm-stackwalk-fixes
Open

lewing wants to merge 7 commits into
mainfrom
lewing-cdac-wasm-stackwalk-fixes

Conversation

@lewing

@lewing lewing commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

On WASM, a cDAC IStackWalk walk drops every R2R frame between an InterpreterFrame and the next explicit Frame when R2R code entered the interpreter through a transition helper, such as the portable-entry-point thunk WasmR2RToInterpreterThunk → ExecuteInterpretedMethodWithArgs_PortableEntryPoint. The walk covers the interpreted chain, reports NativeMarker ip=0 sp=<end of TransitionBlock>, and jumps to the next Frame. The walk still ends and every reported frame is named, so frames go missing without any error.

Cause

On WASM such a helper records the R2R caller's linear-stack pointer in TransitionBlock.m_StackPointer, and may leave m_ReturnAddress 0. Native handles that in src/coreclr/vm/frames.h and src/coreclr/vm/wasm/helpers.cpp:

  • FramedMethodFrame::GetTransitionBlock_Impl fills a 0 return address lazily with GetWasmVirtualIPFromStackPointer(m_StackPointer).
  • TransitionFrame::GetSP returns m_StackPointer when both fields are set, and UpdateRegDisplay_Impl uses those values plus that frame's frame pointer.

The cDAC didn't describe m_StackPointer, and BaseFrameHandler.HandleTransitionFrame used the raw return address (0) and the end of the TransitionBlock.

Changes

  • datadescriptor.inc: describe TransitionBlock.StackPointer under TARGET_WASM; it's an optional field (Data.TransitionBlock.StackPointer).
  • FrameHelpers.GetTransitionBlockReturnAddress: if the return address is 0 and StackPointer is set, derive the R2R virtual IP from it (WasmUnwinder.GetVirtualIP). GetReturnAddress uses this for all TransitionFrame types.
  • WasmFrameHandler.HandleTransitionFrame: 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 there (see below). Otherwise the SP is the end of the TransitionBlock and the FP is null. The same path runs when the interpreted chain under an InterpreterFrame is exhausted (InterpreterVirtualUnwind → ApplyInterpreterFrameTransition).
  • WasmContext.Unwind: recompute the frame pointer from each caller's stack pointer, as native WasmUnwindStackFrame does. Before, every R2R caller after the first kept the previous frame's frame pointer.
  • Funclet-aware logical frame pointer, mirroring native GetWasmFramePointerFromStackPointer, adapted from [cDAC][wasm] Decode R2R variable locations and expose function identity #133890. IWasmR2RInfo.TryIsFunclet reads the RUNTIME_FUNCTION funclet bit. WasmUnwinder.TryGetLogicalFramePointer returns a method's own frame base. For a funclet, it unwinds out to the establishing frame: either the parent method or funclet, or the frame pointer stored beside the TERMINATE_R2R_STACK_WALK marker when the VM invoked the funclet through CallFuncletWith[out]Throwable. Each step must move toward the caller. WasmContext.Unwind and both R2R seeding paths (the InlinedCallFrame marker and HandleTransitionFrame) use it.
  • StackWalk.md TransitionFrame section updated, and the generated tables regenerated.

#133890 carries an overlapping version of this fix and the logical frame pointer, and will drop its copy when it restacks.

Validation

  • ./dotnet.sh test src/native/managed/cdac/tests/UnitTests/Microsoft.Diagnostics.DataContractReader.Tests.csproj: passed, 3266/3266.

  • pwsh src/native/managed/cdac/tools/CdacUsageGraph/generate-docs.ps1 -Check: passed, docs up to date.

  • ./build.sh -os browser -c Debug -subset clr.runtime: passed. The generated descriptor lays out TransitionBlock as ReturnAddress@0, StackPointer@4, size 8.

  • New tests:

    • UpdateContextFromFrame_WasmTransitionFrame_MirrorsNativeTransitionBlock: four cases, return address and stack pointer each set or 0.
    • CreateStackWalk_WasmInterpreterFrameEnteredFromR2R_ContinuesIntoR2RCaller: the walk continues from an exhausted interpreted chain into two R2R callers, checking each one's IP, SP and frame pointer.
    • WasmUnwinderTests.Unwind_ProducerLayout_* (six cases): funclet linear-stack layouts taken from the JIT and runtime producers. They cover a finally called by its parent (with and without localloc), a funclet invoked by the VM (catch, and filter with the throwing frames still live), and nested finallys. Each uses real R2R function-table data and checks SP, IP and FP for every frame through WasmContext.Unwind.
    • UpdateContextFromFrame_WasmFuncletCaller_ReportsEstablishingFramePointer: both R2R seeding paths report the establishing frame pointer for a VM-invoked funclet.

    Each new test fails with its fix removed:

    • TransitionFrame change reverted: the walk test and both stack-pointer cases fail; the other two cases pass, as expected.
    • Only the WasmContext.Unwind FP change reverted: the walk test fails on the outer caller's frame pointer.
    • Frame base instead of the logical frame pointer: both seeding cases fail (checked on this branch), and all six producer-layout cases fail (checked when they were written, on main @ 25c3eaec650).
  • Live browser-wasm validation through Blazor-Playground/nesm, with nesm's workarounds and frame guard off: Release runtime packs built from 9977dd27dc2 (descriptor TransitionBlock.StackPointer@4), with the reader at dd4ec1e0b6e and again at 30047665a62 (funclet commits included; no reader-attributable differences; only interpreter bytecode addresses and async-pause depth vary between runs).

    • R2R paused at a breakpoint: pass. Seeded from the frame chain (13 frames) and from a leaf $sp (21 frames). The 8 R2R CoreLib frames the issue reported missing (IntrinsicInvokeHelper.InvokeEmitted … <BindManagedFunction>b__0) now appear between the interpreted wrapper and the next InterpreterFrame, in wasm-stack order. Their names match a separate static ReadyToRun lookup. The NativeMarker ip=0 sp=<TransitionBlock end> is gone.
    • R2R paused in a [JSImport] call (23 frames), and interpreter-only paused in a tight loop (18) and in a [JSImport] call (22): pass. Every walk completes with no errors.
    • Funclets, reader 30047665a62: an R2R app breaking inside each funclet layout: finally called by its parent (with and without localloc), catch and finally invoked by the VM, a finally nested in a VM-invoked catch, and a filter. Both seeds complete in every scenario. Each unwound funclet frame reports its establishing frame's frame pointer, and the localloc parent reports FP ≠ SP from its indirect slot. Finallys called by their parent were marked NoOptimization, because otherwise the JIT clones them and no funclet runs. This checks consistency with the parent frame's frame pointer, not a direct comparison with native.

Not covered: a leaf funclet that makes no calls has no frame record. Neither native nor the cDAC can walk it, so it's only visible at a debugger pause inside it. Open #134608 may change that layout. Starting a walk inside a funclet still takes the leaf frame pointer from the frame base, tracked in #135147.

Resolves #135137

Note

This PR description was generated with assistance from GitHub Copilot.

… 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>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.

@lewing
lewing requested review from max-charlamb and rcj1 and a balanced review from Copilot October 2, 2026 22:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

The recovered frame pointer becomes incorrect for funclets and subsequent R2R callers.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Fixes cDAC WASM stack walks so interpreter transitions resume through ReadyToRun callers.

Changes:

  • Describes the WASM transition stack pointer.
  • Restores R2R IP/SP/FP state from transition frames.
  • Adds documentation and regression coverage.
File Description
StackWalkTests.cs Adds WASM transition and stack-walk tests.
TransitionBlock.cs Exposes the optional WASM stack pointer.
WasmFrameHandler.cs Restores R2R context from transitions.
FrameHelpers.cs Derives missing R2R return addresses.
datadescriptor.inc Publishes the WASM field descriptor.
StackWalk.md Documents transition behavior.
data-descriptor-meanings.json Adds the generated field description.

lewing and others added 5 commits October 2, 2026 18:07
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>
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>
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>
…R 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>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Resolve conflicts with the x86 TransitionFrame changes in FrameHelpers.GetReturnAddress and StackWalk.md; keep both.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

3 participants