Skip to content

[wasm][RyuJIT] Restore __stack_pointer in reverse P/Invoke epilogs - #134686

Merged
lewing merged 3 commits into
mainfrom
lewing-wasm-uco-stack-pointer-restore
Sep 26, 2026
Merged

lewing merged 3 commits into
mainfrom
lewing-wasm-uco-stack-pointer-restore

Conversation

@lewing

@lewing lewing commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Problem

R2R Wasm code keeps the shadow SP in a local and leaves the __stack_pointer global stale. JIT_PInvokeBegin and the SuppressGCTransition publish (#130924) set __stack_pointer to the caller's shadow SP and leave it there. So when a reverse P/Invoke (UnmanagedCallersOnly) method's body, or any R2R code it calls, did an inlined P/Invoke, the method returned to its native caller with the global still lowered. That breaks the native ABI, which requires __stack_pointer to be restored on return.

In the observed case the P/Invoke was CoreLib's CastHelpers.ChkCastAny_NoCacheLookup under GetUserData<T>. When the UCO was reached through an R2R inlined P/Invoke (a delegate* unmanaged calli), the Debug check in JIT_PInvokeEndImpl fired: sp == stack_pointer_global_value at src/coreclr/vm/wasm/helpers.cpp:420. After that came GetFrame asserts and an out-of-bounds access. See #134681 for the full analysis.

The bug is reachable on main whenever an R2R method calls a UCO that itself does an inlined P/Invoke (see the test below). #134606 exposes it more broadly by making TestEntryPoint in readytorun/wasm/WasmInterpreterTransitions run as R2R instead of interpreted, which also routes the existing StreamLengthProxy case through R2R.

Fix

In CodeGen::genFnEpilog (codegenwasm.cpp), reverse P/Invoke methods now emit this sequence before return/end:

local.get <FP local, or SP local if there is no frame pointer>
i32.const genTotalFrameSize()
i32.add
global.set __stack_pointer

This restores the global to its entry value, the post-prolog SP plus the frame size. The sequence leaves the Wasm operand stack unchanged, so an already-pushed return value is preserved. It replaces the TODO-WASM: shadow stack maintenance comment. The emit pattern matches the SuppressGCTransition publish from #130924.

Test

WasmInterpreterTransitions gains R2RCallsNestingUco: an R2R method does a delegate* unmanaged calli into UcoWithInlinedPInvoke, which does a calli into a second UCO, UcoLeaf. Each calli is an inlined P/Invoke. The inner one lowers __stack_pointer, and the outer one's JIT_PInvokeEnd asserts that the global is back at the caller's SP.

Run against the Checked browser Core_Root from main CI build 1612746 (without #134606), with crossgen2 and the universal wasm JIT built from main:

test main JIT fixed JIT
main WasmInterpreterTransitions pass pass
with R2RCallsNestingUco assert sp == stack_pointer_global_value (helpers.cpp:420), then GetFrame assert pass (exit 100)

The test IL for this run was compiled locally with csc rather than through the repo's test build.

Validation

  • Rebuilt clrjit_universal_wasm_arm64, swapped it into crossgen2, and recompiled WasmInterpreterTransitions against the CI Checked browser runtime from build 1612477, which includes [wasm][coreclr][R2R] Publish existing method bodies before interpreter fallback #134606's runtime changes.
    • Old JIT: reproduces the CI failure (the helpers.cpp:420 assert, then GetFrame asserts and OOB).
    • New JIT: passes with exit 100 and no asserts.
  • Before the new test was added, a disassembly diff of composite-r2r.wasm showed exactly two changes: the two UCO epilogs (StreamLengthProxy and UnmanagedCallerCallsInterpreted), each with the 4-instruction restore added. Everything else was identical.
  • The other 6 tests in the Helix readytorun work item pass: readytorun, fieldlayout, crossgen2smoke_donotalwaysusecrossgen2, Breadth1Test, Depth1Test, DynamicMethodGCStress.
  • ./build.sh clr.alljits -c checked on osx-arm64, which includes the universal wasm JIT, builds cleanly, and jit-format reports no changes.

Related

Resolves #134681

Note

This PR description was generated with the help of GitHub Copilot.

Managed Wasm code keeps the shadow SP in a local and leaves the
__stack_pointer global stale; inlined P/Invokes (JIT_PInvokeBegin and
the SuppressGCTransition publish) lower the global to the caller's SP
and leave it there. A reverse P/Invoke (UnmanagedCallersOnly) method is
called with the native ABI, so it must return with __stack_pointer at
its entry value.

In genFnEpilog, for reverse P/Invoke methods, emit
  local.get <FP or SP local>; i32.const frameSize; i32.add;
  global.set __stack_pointer
before the return, replacing the shadow stack maintenance TODO.

Fixes #134681.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eea66043-479c-4397-ae20-f4c776bc3782
@github-actions github-actions Bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Sep 25, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 6 pipeline(s).
10 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: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.

@lewing lewing added the arch-wasm WebAssembly architecture label Sep 25, 2026
@lewing
lewing marked this pull request as ready for review September 25, 2026 22:07
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

An R2R caller does a delegate* unmanaged calli into a UCO that itself
does a calli into a second UCO. Each calli is an inlined P/Invoke; the
inner one lowers the __stack_pointer global, and the outer one's
JIT_PInvokeEnd checks that the global is back at the caller's SP. On
main (without the epilog restore) this hits the
sp == stack_pointer_global_value assert in vm/wasm/helpers.cpp.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: eea66043-479c-4397-ae20-f4c776bc3782
Comment thread src/coreclr/jit/codegenwasm.cpp Outdated
Co-authored-by: Andy Ayers <andya@microsoft.com>
@lewing

lewing commented Sep 25, 2026

Copy link
Copy Markdown
Member Author

@AndyAyersMS fixed the comment, reapprove when you get a chance

@lewing
lewing enabled auto-merge (squash) September 25, 2026 23:37
@lewing
lewing requested a review from radekdoulik September 26, 2026 01:25
@lewing
lewing merged commit f7a5da5 into main Sep 26, 2026
141 of 144 checks passed
@lewing
lewing deleted the lewing-wasm-uco-stack-pointer-restore branch September 26, 2026 01:26
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[wasm][coreclr] R2R UnmanagedCallersOnly methods return with __stack_pointer lowered by nested inlined P/Invokes

2 participants