[wasm] Make the finalizer slot callable from R2R before returning it - #130841
#130841Merged
lewing merged 3 commits intoJul 16, 2026
dotnet:maindotnet/runtime:mainfrom
lewing:wasm-r2r-finalizer-prestublewing/runtime:wasm-r2r-finalizer-prestubCopy head branch name to clipboard
Merged
[wasm] Make the finalizer slot callable from R2R before returning it#130841lewing merged 3 commits intodotnet:maindotnet/runtime:mainfrom lewing:wasm-r2r-finalizer-prestublewing/runtime:wasm-r2r-finalizer-prestubCopy head branch name to clipboard
lewing merged 3 commits into
dotnet:maindotnet/runtime:mainfrom
lewing:wasm-r2r-finalizer-prestublewing/runtime:wasm-r2r-finalizer-prestubCopy head branch name to clipboard
Conversation
|
Azure Pipelines: Successfully started running 3 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the CoreCLR finalizer QCall to ensure that, on FEATURE_PORTABLE_ENTRYPOINTS targets (wasm), the function pointer returned for an object’s finalizer slot is made callable by explicitly running the prestub when needed. This prevents returning a “cold” portable entrypoint that would otherwise be invalid to invoke via an R2R call_indirect.
Changes:
- Cache the finalizer slot index in a local (
finalizerSlot) and use it for both slot lookup andMethodDescretrieval. - Under
FEATURE_PORTABLE_ENTRYPOINTS, callDoPrestub(guarded byShouldCallPrestub()) to publish callable code into the portable entrypoint before returning it.
On FEATURE_PORTABLE_ENTRYPOINTS (wasm) targets, System.GC.RunFinalizers invokes each object's Finalize via a typed call_indirect on the finalizer method's portable entrypoint (PEP). GCInterface_GetNextFinalizableObject returned the raw GetRestoredSlot result, which for a not-yet-prepared method is a cold PEP (_pActualCode == 0, prefers the interpreter). A call_indirect on a cold PEP targets wasm table slot 0 (the reserved null slot) and traps with "null function or function signature mismatch". Make the slot callable explicitly, mirroring the class-constructor slot in MethodTable::CallClassConstructor. EnsurePortableEntryPointIsCallableFromR2R wires the R2R->interpreter thunk (or leaves already-published native code in place) so the call_indirect resolves to real code. This adds the finalizer QCall to the documented "call site makes the target callable before an R2R call_indirect" pattern used by GetMultiCallableAddrOfCode, the cctor slot, the managed main entry, the default ctor, P/Invoke, ExternalMethodFixupWorker, and EnC. Fixes the browser-wasm R2R crash in JIT/Regression b12795 and b38269 in both R2R-corelib mode (interpreted finalizer, wired via the R2R->interpreter thunk) and R2R-full mode (crossgen'd finalizer); the interpreter is unaffected. Same cold-first-call family as dotnet#130634. Fixes dotnet#130839.
lewing
force-pushed
the
wasm-r2r-finalizer-prestub
branch
from
July 16, 2026 04:48
369432b to
23a242a
Compare
Contributor
|
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara |
jkotas
reviewed
Jul 16, 2026
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
…omments The cctor and default-ctor call sites carried verbose, wasm-specific comments that referred to "typed call_indirect" and asserted the portable entrypoint "must resolve to real code ... rather than the temporary precode". FEATURE_PORTABLE_ENTRYPOINTS is a general feature (not wasm-only), and EnsurePortableEntryPointIsCallableFromR2R only resolves to real code when needed/possible. Reduce both comments to mirror the finalizer wording in GCInterface_GetNextFinalizableObject. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cb8914fd-15c1-4e1e-83c5-9a7028891c34
|
Azure Pipelines: Successfully started running 3 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
Member
Author
|
cc @dotnet/wasm-contrib |
pavelsavara
approved these changes
Jul 16, 2026
lewing
enabled auto-merge (squash)
July 16, 2026 17:27
AndyAyersMS
approved these changes
Jul 16, 2026
lewing
added a commit
that referenced
this pull request
Jul 22, 2026
# [wasm] Ensure R2R entry-point PEP is callable in RunMain
## Summary
On WebAssembly, the managed program entry point is invoked from
R2R-compiled code:
`System.Environment.CallEntryPoint` performs an **indirect call**
through the entry
point's address. When the entry point resolves to a `PortableEntryPoint`
(PEP) whose
`actualCode` slot has not yet been populated, that indirect
`call_indirect` targets an
uninitialized table slot and the module traps (`uninitialized element`)
— or, when the
condition arises on a cold path, the runtime falls back to interpreter
execution for
the entire process instead of running R2R code.
This adds the missing call to
`MethodDesc::EnsurePortableEntryPointIsCallableFromR2R`
in `RunMainInternal` (right after the entry-point address is
materialized via
`GetSingleCallableAddrOfCode()`), so the entry-point PEP is published to
real code
(native R2R body or a correctly-typed interpreter thunk) before it is
invoked.
```cpp
pParam->pFD->EnsureActive();
PCODE entryPoint = pParam->pFD->GetSingleCallableAddrOfCode();
#ifdef FEATURE_PORTABLE_ENTRYPOINTS
// The entry point is invoked from R2R-compiled code (Environment.CallEntryPoint performs an
// indirect call through this address), so it must resolve to real code (native R2R or a
// correctly-typed interpreter thunk) rather than an uninitialized portable entry point.
MethodDesc::EnsurePortableEntryPointIsCallableFromR2R(entryPoint);
#endif // FEATURE_PORTABLE_ENTRYPOINTS
```
## Motivation
The helper `MethodDesc::EnsurePortableEntryPointIsCallableFromR2R` and
its sibling
call-sites are already on `main` (introduced by #130386 and #130841).
Those PRs cover
finalizer slots, FCall/PInvoke helpers, class constructors, string
constructors, and
prestub paths — but **not** the program entry point invoked by
`RunMain`. As a result,
a ReadyToRun WASM image that dispatches its `Main` through
`Environment.CallEntryPoint`
can hit an uninitialized PEP and either trap or degrade to whole-program
interpreter
execution.
This is the last uncovered publication site on the hello-world startup
path. It is a
6-line addition scoped entirely under `#ifdef
FEATURE_PORTABLE_ENTRYPOINTS`, which is
set only for `CLR_CMAKE_TARGET_ARCH_WASM` (see
`src/coreclr/clrfeatures.cmake`), so it
has **zero effect on non-wasm targets**.
## Validation
Measured on a composite R2R WASI "Hello world" (CoreLib + System.Runtime
+
System.Console, crossgen'd `--composite --targetos:wasi
--targetarch:wasm`, run under
wasmtime).
A robust health signal for "R2R actually executes" (as opposed to the
program merely
surviving via interpreter fallback) is whether two guaranteed-reached
corelib bodies —
`System.String..ctor` and `System.SpanHelpers.Memmove` — execute as R2R,
together with
the absence of `TryPublishR2R FAILED` diagnostics.
| Build | `String..ctor` R2R hits | `Memmove` R2R hits | `TryPublishR2R
FAILED` |
|-------|------------------------:|-------------------:|-----------------------:|
| Without this fix (entry-point PEP uninitialized) | 0 | 0 | 5 |
| **With this fix** | 7 | 8 | 0 |
With the fix, the two guaranteed-reached bodies run as native R2R, the
reverse-thunk
fallback diagnostics disappear entirely, and the app prints its output
and exits `42`.
Result independently reproduced across three builds (identical `7` / `8`
/ `0` counts).
Note: exit code alone is **not** a sufficient signal — an image with the
entry-point
PEP uninitialized can still print and exit `42` while executing zero R2R
bodies (pure
interpreter fallback). The `String..ctor` + `Memmove` execution check is
the meaningful
gate.
## Risk
- Scoped to wasm only via `FEATURE_PORTABLE_ENTRYPOINTS`.
- Uses an existing, already-merged helper; no new API surface.
- Mirrors the established pattern of the other
`EnsurePortableEntryPointIsCallableFromR2R`
call-sites already on `main`.
Original prototype commit: `wasm: ensure R2R entry-point PEP is callable
in RunMain`
(authored by Andy Ayers).
> [!NOTE]
> This PR description was drafted with GitHub Copilot.
Co-authored-by: Andy Ayers <andya@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On
FEATURE_PORTABLE_ENTRYPOINTS(wasm) targets, running finalizers under ReadyToRun traps withRuntimeError: null function or function signature mismatchinSystem.GC.RunFinalizers.RunFinalizersinvokes each object'sFinalizevia a typedcall_indirecton the finalizer method's PortableEntryPoint (PEP).GCInterface_GetNextFinalizableObjectreturned the rawGetRestoredSlotresult, which for a not-yet-prepared method is a cold PEP (_pActualCode == 0, prefers the interpreter). Thecall_indirecttherefore targets wasm table slot 0 (the reserved null slot) and traps.This is the finalizer manifestation of the cold-first-call R2R trap family in #130634.
Fixes #130839.
Root cause
Per the documented invariant (
method.cpp:2947-2954,docs/design/coreclr/botr/clr-abi.md:865-871), any slot fetched directly and then invoked via an R2Rcall_indirectmust first be made callable — its PEP must resolve to real code (native R2R code or a correctly-typed interpreter thunk) rather than the temporary precode. Every other such call site already does this:GetMultiCallableAddrOfCodemethodtable.cpp:3579)assembly.cpp:1164)callhelpers.cpp:580)dllimport.cpp:5919)ExternalMethodFixupWorker(prestub.cpp:3074)method.cpp:3462)The finalizer QCall is the one missing site.
Fix
One line, mirroring the class-constructor slot handling in
MethodTable::CallClassConstructor(methodtable.cpp:3579):EnsurePortableEntryPointIsCallableFromR2Rwires the R2R→interpreter thunk for the finalizer signature into_pActualCode(or leaves already-published native code in place), so thecall_indirectresolves to real code instead of table slot 0. This isWRAPPER_NO_CONTRACT/ load-safe (no GC transition), so no extra GC bookkeeping is needed — same as the cctor call site.Validation
The bug only reproduces with the (not-yet-merged) R2R-on-wasm bring-up stack, so there is no wasm-R2R CI leg to exercise it on
mainyet. Validated on the R2R-on-wasm prototype, controlling for corerun identity (md5) and harness confounds, across both R2R modes:b12795b38269A same-build R2R-corelib sweep of the JIT/Regression suite (fixed vs. no-fix corerun, with only this change varying) corroborates: the finalizer/GC tests flip crash→pass and every other test produces an identical result with and without the change — i.e. no regressions. The change is a correct addition to the documented call-site-makes-callable pattern and applies to
mainas-is.Notes for reviewers
mainuntil the R2R-on-wasm consumption path lands.function signature mismatchon the first (cold) R2R call to a method whose native code is not yet on its portable entry point #130634 (canonical cold-first-call family); [wasm] R2R finalizer dispatch traps: GetNextFinalizableObject returns a cold PortableEntryPoint without making it callable #130839 (finalizer issue).Note
This pull request was authored with the assistance of GitHub Copilot.