From fca7ed2373060943834083ca27419c6f533cee62 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 17:03:49 -0500 Subject: [PATCH 1/3] [wasm][RyuJIT] Restore __stack_pointer in reverse P/Invoke epilogs 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 ; 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 --- src/coreclr/jit/codegenwasm.cpp | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/coreclr/jit/codegenwasm.cpp b/src/coreclr/jit/codegenwasm.cpp index 96d1443f5f75b1..c938d670e17783 100644 --- a/src/coreclr/jit/codegenwasm.cpp +++ b/src/coreclr/jit/codegenwasm.cpp @@ -474,7 +474,31 @@ void CodeGen::genFnEpilog(BasicBlock* block) return; } - // TODO-WASM: shadow stack maintenance + // Managed code leaves the __stack_pointer global stale, and inlined PInvokes (JIT_PInvokeBegin, + // SuppressGCTransition publishes) leave it lowered to this or a callee's shadow SP. A reverse + // PInvoke method is called with the native ABI, so restore the global to its value on entry + // (the post-prolog SP plus the frame size) before returning to the native caller. This is a + // net-zero operation on the Wasm operand stack, so any return value already pushed is preserved. + if (m_compiler->opts.IsReversePInvoke()) + { + assert(m_compiler->funCurrentFuncIdx() == ROOT_FUNC_IDX); + regNumber fpReg = GetFramePointerReg(ROOT_FUNC_IDX); + regNumber spReg = GetStackPointerReg(ROOT_FUNC_IDX); + assert(spReg != REG_NA); + + // The FP local is fixed after the prolog; the SP local is only moved by localloc, which + // requires a frame pointer. + regNumber frameBaseReg = (fpReg != REG_NA) ? fpReg : spReg; + GetEmitter()->emitIns_I(INS_local_get, EA_PTRSIZE, WasmRegToIndex(frameBaseReg)); + if (genTotalFrameSize() != 0) + { + GetEmitter()->emitIns_I(INS_I_const, EA_PTRSIZE, genTotalFrameSize()); + GetEmitter()->emitIns(INS_I_add); + } + GetEmitter()->emitIns_I(INS_global_set, EA_HANDLE_CNS_RELOC, + (cnsval_ssize_t)(size_t)m_compiler->eeGetWasmWellKnownGlobals()->stackPointer); + } + // TODO-WASM: we need to handle the end-of-function case if we reach the end of a codegen for a function // and do NOT have an epilog. In those cases we currently will not emit an end instruction. if (block->IsLast() || m_compiler->bbIsFuncletBeg(block->Next())) From 732debb1dd9918f79f75ae70541ec22330e7ecf1 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 17:25:56 -0500 Subject: [PATCH 2/3] Add nested reverse P/Invoke test to WasmInterpreterTransitions 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 --- .../WasmInterpreterTransitions.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.cs b/src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.cs index 7a488f80566abe..67615287754209 100644 --- a/src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.cs +++ b/src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.cs @@ -122,6 +122,8 @@ public static void TestEntryPoint() delegate* unmanaged fp = &StreamLengthProxy; Assert.Equal(C, fp(IntPtr.Zero, ctx)); } + + Assert.Equal(42, R2RCallsNestingUco()); } finally { @@ -510,6 +512,26 @@ private static T GetUserData(IntPtr ptr, out GCHandle handle) where T : class return (T)handle.Target!; } + // R2R caller -> UCO -> UCO. Each calli is an inlined PInvoke; the inner one lowers the + // __stack_pointer global, and the outer one's epilog asserts that the global is back at the + // caller's SP, so this fails unless the UCO epilog restores the global before returning. + [MethodImpl(MethodImplOptions.NoInlining)] + private static unsafe int R2RCallsNestingUco() + { + delegate* unmanaged fp = &UcoWithInlinedPInvoke; + return fp(); + } + + [UnmanagedCallersOnly] + private static unsafe int UcoWithInlinedPInvoke() + { + delegate* unmanaged fp = &UcoLeaf; + return fp() + 1; + } + + [UnmanagedCallersOnly] + private static int UcoLeaf() => 41; + [UnmanagedCallersOnly] private static int StreamLengthProxy(IntPtr s, IntPtr context) { From 01f38a514c213ee49894ab0f3f40fc18e775c2fd Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 18:35:51 -0500 Subject: [PATCH 3/3] Apply suggestion from @AndyAyersMS Co-authored-by: Andy Ayers --- src/coreclr/jit/codegenwasm.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/coreclr/jit/codegenwasm.cpp b/src/coreclr/jit/codegenwasm.cpp index c938d670e17783..972141db58229f 100644 --- a/src/coreclr/jit/codegenwasm.cpp +++ b/src/coreclr/jit/codegenwasm.cpp @@ -474,11 +474,7 @@ void CodeGen::genFnEpilog(BasicBlock* block) return; } - // Managed code leaves the __stack_pointer global stale, and inlined PInvokes (JIT_PInvokeBegin, - // SuppressGCTransition publishes) leave it lowered to this or a callee's shadow SP. A reverse - // PInvoke method is called with the native ABI, so restore the global to its value on entry - // (the post-prolog SP plus the frame size) before returning to the native caller. This is a - // net-zero operation on the Wasm operand stack, so any return value already pushed is preserved. + // Restore __stack_pointer to the value it had on entry if (m_compiler->opts.IsReversePInvoke()) { assert(m_compiler->funCurrentFuncIdx() == ROOT_FUNC_IDX);