[wasm][coreclr][R2R] Publish existing method bodies before interpreter fallback - #134606
Conversation
Resolve an existing precompiled body during external method fixup before leaving a portable entrypoint on its R2R-to-interpreter thunk. Reuse the same publication helper for unmanaged callers with the appropriate caller GC mode and notification policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0f02cd6-bda1-42a0-b039-ec7b21696013
Keep the existing UnmanagedCallersOnly publication helper intact and add a focused helper for managed external method fixups. This avoids unnecessary overlap with the native-callback dispatch work while preserving cooperative-mode R2R publication. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0f02cd6-bda1-42a0-b039-ec7b21696013
…asm-r2r-generic-adapter
…asm-r2r-generic-adapter
…asm-r2r-generic-adapter
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @JulieLeeMSFT, @BrzVlad, @janvorli |
|
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara |
|
Tagging subscribers to this area: @dotnet/crossgen-contrib |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Unboxing-stub handling must be corrected before approval; an automated regression is also recommended.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (2)
What changed in this PR
Updates browser-Wasm CoreCLR ReadyToRun fixups to publish existing compiled bodies before interpreter fallback.
Changes:
- Adds a cooperative R2R publication probe.
- Integrates it into non-virtual external method fixups.
- Declares the helper on
MethodDesc.
| File | Description |
|---|---|
src/coreclr/vm/prestub.cpp |
Implements and integrates R2R publication probing. |
src/coreclr/vm/method.hpp |
Declares the new publication helper. |
Unboxing and instantiating stubs need portable-entrypoint state that is initialized only by their dedicated DoPrestub paths. Limit the external method fixup R2R publication probe to ordinary IL methods, matching the methods DoPrestub resolves through PrepareInitialCode. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0f02cd6-bda1-42a0-b039-ec7b21696013
Move the VirtualDispatchPortableEntryPoint publication into PatchExternalMethodWithVirtualDispatchPortableEntryPoint and call it from the virtual fixup branch, after making the resolved target callable from R2R. This removes the trailing EnsurePortableEntryPointIsCallableFromR2R call, which was redundant for non-virtual fixups. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0f02cd6-bda1-42a0-b039-ec7b21696013
…thod Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b0f02cd6-bda1-42a0-b039-ec7b21696013
|
The CI failure is #134681 which is exposed by this fix |
|
The With this PR, The fix is a separate JIT change in #134686, which restores Note This comment was generated with GitHub Copilot. |
…134686) ## 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 #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 - Unblocks the `WasmInterpreterTransitions` failure in #134606. This PR is independent of #134606 and has its own regression test on main, and #134606 doesn't need to be stacked on it. - Helps the open #134355, where native callers dispatch directly to R2R UCO bodies. There the UCO epilog is the only place that can restore `__stack_pointer` for the native caller. Resolves #134681 > [!NOTE] > This PR description was generated with the help of GitHub Copilot. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Andy Ayers <andya@microsoft.com> Copilot-Session: eea66043-479c-4397-ae20-f4c776bc3782
|
/azp run runtime |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
|
/ba-g failing test is fixed by #134686 and verified locally |


Summary
On browser-Wasm,
ExternalMethodFixupWorkernow prepares the target method with the standardShouldCallPrestub()/DoPrestub()pattern before patching a non-virtual method import cell. This publishes an existing precompiled R2R body instead of leaving the import bound to an R2R-to-interpreter thunk.This allows closed generic MethodSpec imports to call their already-emitted exact specialized bodies while preserving the interpreter path when no body exists.
Root cause
The original suspected four-argument-versus-five-argument adapter mismatch was not the actual hot call.
MemoryExtensions.Contains<int>is inlined into the application caller; the remaining cross-module import targetsSpanHelpers.NonPackedContainsValueType<int>, and both its import and exact R2R body use the same five-argument Wasm signature (the trailing argument is the portable entrypoint).The failure was publication ordering:
ExternalMethodFixupWorkerread the portable entrypoint before the method had been prepared, so it stored a correctly typed interpreter thunk in the import cell even when the exact compiled body was present in the R2R image.Implementation
Under
FEATURE_PORTABLE_ENTRYPOINTS, in the non-virtual branch ofExternalMethodFixupWorker:pMD->DoPrestub(NULL)whenpMD->ShouldCallPrestub()is true. Portable entrypoints are stable, so this is safe before patching;DoPrestubpublishes an R2R body when one exists (including unboxing/instantiating stub handling) and otherwise prepares the interpreter path.EnsurePortableEntryPointIsCallableFromR2RonpMD->GetMethodEntryPoint()before storing it in the import cell, so R2R code can never observe an entrypoint it cannot call.FuncPtrStubselection is skipped because portable entrypoints do not need it.In the virtual branch,
EnsurePortableEntryPointIsCallableFromR2Rnow runs before the import cell is published, and the existingVirtualDispatchPortableEntryPointpublication is factored intoPatchExternalMethodWithVirtualDispatchPortableEntryPoint. This replaces the shared post-branch block, so each path makes the entrypoint callable exactly once, before publishing.The non-portable-entrypoint path is unchanged. No ReadyToRun format, ABI, signature encoding, or version change is required.
Validation
./build.sh -os browser -c Release -subset clr+libs(0 warnings, 0 errors)AssemblyLoadEvent_ReentrantMethodCompilation: 1 passed, 0 failedSystem.Runtime.Loader.Tests: only the 21ApplyUpdateTestcases fail, withNotImplementedException(FEATURE_METADATA_UPDATERis not enabled on Wasm)A controlled
MemoryExtensions.Contains<int>application was published with deterministic method-list MIBC profiles:NonPackedContainsValueType<int>body rooted, Chrome CDP breakpoints at the compiled body's entry, vector setup, andv128loop each fired (50/50), always called directly from theiiiipdelay-load import with noWasmR2RToInterpreterThunkframe.Testing limitation
A result-only managed regression would pass before and after this change because the interpreter fallback is functionally correct. The current Wasm R2R test infrastructure does not expose the selected portable-entrypoint target to managed tests, so the dispatch distinction was validated using positive and negative live CDP controls. An automated check based on runtime method-jitting events (asserting that the target method is never compiled for the interpreter) is planned as a follow-up.
This does not address #134200, where the required R2R body is genuinely absent.
Resolves #134565
Note
This pull request description was generated with GitHub Copilot assistance.