Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[wasm] Make the finalizer slot callable from R2R before returning it - #130841

#130841
Merged
lewing merged 3 commits into
dotnet:maindotnet/runtime:mainfrom
lewing:wasm-r2r-finalizer-prestublewing/runtime:wasm-r2r-finalizer-prestubCopy head branch name to clipboard
Jul 16, 2026
Merged

[wasm] Make the finalizer slot callable from R2R before returning it#130841
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

@lewing

@lewing lewing commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

On FEATURE_PORTABLE_ENTRYPOINTS (wasm) targets, running finalizers under ReadyToRun traps with RuntimeError: null function or function signature mismatch in System.GC.RunFinalizers.

RunFinalizers invokes each object's Finalize via a typed call_indirect on the finalizer method's PortableEntryPoint (PEP). GCInterface_GetNextFinalizableObject returned the raw GetRestoredSlot result, which for a not-yet-prepared method is a cold PEP (_pActualCode == 0, prefers the interpreter). The call_indirect therefore 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 R2R call_indirect must 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:

  • GetMultiCallableAddrOfCode
  • cctor slot (methodtable.cpp:3579)
  • managed main entry (assembly.cpp:1164)
  • default ctor (callhelpers.cpp:580)
  • P/Invoke (dllimport.cpp:5919)
  • ExternalMethodFixupWorker (prestub.cpp:3074)
  • EnC (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):

funcPtr = pMT->GetRestoredSlot(g_pObjectFinalizerMD->GetSlot());
#ifdef FEATURE_PORTABLE_ENTRYPOINTS
    MethodDesc::EnsurePortableEntryPointIsCallableFromR2R(funcPtr);
#endif

EnsurePortableEntryPointIsCallableFromR2R wires the R2R→interpreter thunk for the finalizer signature into _pActualCode (or leaves already-published native code in place), so the call_indirect resolves to real code instead of table slot 0. This is WRAPPER_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 main yet. Validated on the R2R-on-wasm prototype, controlling for corerun identity (md5) and harness confounds, across both R2R modes:

test R2R-corelib (interpreted finalizer) R2R-full (crossgen'd finalizer) interpreter
b12795 crash → pass crash → pass pass
b38269 crash → pass crash → pass pass

A 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 main as-is.

Notes for reviewers

Note

This pull request was authored with the assistance of GitHub Copilot.

Copilot AI review requested due to automatic review settings July 16, 2026 04:32
@azure-pipelines

Copy link
Copy Markdown
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.

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.

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 and MethodDesc retrieval.
  • Under FEATURE_PORTABLE_ENTRYPOINTS, call DoPrestub (guarded by ShouldCallPrestub()) 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.
Copilot AI review requested due to automatic review settings July 16, 2026 04:48
@lewing
lewing force-pushed the wasm-r2r-finalizer-prestub branch from 369432b to 23a242a Compare July 16, 2026 04:48
@lewing lewing added the arch-wasm WebAssembly architecture label Jul 16, 2026
@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.

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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Comment thread src/coreclr/vm/comutilnative.cpp Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
Copilot AI review requested due to automatic review settings July 16, 2026 13:19

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.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

…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
Copilot AI review requested due to automatic review settings July 16, 2026 16:14
@lewing
lewing marked this pull request as ready for review July 16, 2026 16:15
@azure-pipelines

Copy link
Copy Markdown
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.

@lewing
lewing requested a review from AndyAyersMS July 16, 2026 16:18
@lewing

lewing commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

cc @dotnet/wasm-contrib

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.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread src/coreclr/vm/comutilnative.cpp
Comment thread src/coreclr/vm/callhelpers.cpp
Comment thread src/coreclr/vm/methodtable.cpp

@jkotas jkotas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks

@lewing
lewing enabled auto-merge (squash) July 16, 2026 17:27
@lewing
lewing merged commit 7c1cb8a into dotnet:main Jul 16, 2026
114 checks passed
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 11.0-preview7 milestone Jul 17, 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-VM-coreclr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[wasm] R2R finalizer dispatch traps: GetNextFinalizableObject returns a cold PortableEntryPoint without making it callable

5 participants

Morty Proxy This is a proxified and sanitized view of the page, visit original site.