[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[wasm] Generate the native-entry-point-to-interpreter thunk table - #132926

Closed
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures
Closed

[wasm] Generate the native-entry-point-to-interpreter thunk table#132926
pavelsavara wants to merge 17 commits into
dotnet:mainfrom
pavelsavara:wasm_thunk_signatures

Conversation

@pavelsavara

@pavelsavarapavelsavara commented Aug 29, 2026

Copy link
Copy Markdown
Member

Summary

The native entry point of an interpreted method on wasm is a thunk (the 'I' thunks): the wasm funcref stored in PortableEntryPoint._pActualCode, which under the wasm managed calling convention captures the arguments and dispatches into the interpreter. These were hand-written C++ in vm/wasm/helpers.cpp, so every call shape needed a hand-authored thunk — some missing, some corrupting memory. The WasmAppBuilder generator now emits that table for both browser and wasi: 17 hand-written entries become 70 generated, none left by hand, and helpers.cpp loses ~330 lines.

The thunk is needed by any caller reaching an interpreted method through a materialized code pointer — a calli/delegate/ldftn, a vtable slot, GetMultiCallableAddrOfCode, or an R2R call — including the interpreter itself, so it is required even with no R2R present. It is the managed transition; the C-ABI reverse (UnmanagedCallersOnly) thunk is a separate mechanism.

The parameter convention

crossgen2 lays out the wasm parameters of both thunk directions as

(callersStackPointer, [this], [retBuf], args..., portableEntrypoint)

reading the return buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0) (WasmNativeEntryPointToInterpreterThunkNode.EmitCode). Two consequences the generator honours:

  • A thunk returning a struct is declared void with an explicit int8_t* retBuf. Returning the struct by value instead makes clang insert its own sret pointer at parameter 0, ahead of callersStackPointer. This applies in both directions.
  • retBuf follows this on an instance method rather than coming first.

Neither is detectable at run time: the stack pointer, the return buffer, this and every by-reference argument are all i32, so a transposed order still passes call_indirect type checking and the corruption surfaces far from its cause. Floating-point arguments are stored as their own bits rather than through an (int64_t) cast, which would convert the value.

Changes

  • InterpToNativeGenerator — emits g_wasmGeneratedPortableEntryPointThunks; struct returns take an explicit retBuf in both directions; float/double arguments are stored as themselves; a multi-slot l2 value (Int128/UInt128/Decimal128, carried across two i64 parameters) expands into one parameter per slot.
  • PortableEntryPointThunkSignature (new) — owns the parameter ordering; the emitter builds its declarations from it and the tests assert against it, so a test cannot pass while the emitted file disagrees.
  • SignatureMapper — split into a partial class so the pure token half links into the test project without dragging MSBuild into a compiler test assembly; the emitted tables are byte-identical across the split.
  • crossgen2 (CorInfoImpl.ReadyToRun) — roots the native-entry-point-to-interpreter thunk for every compiled method's signature, so a same-shaped interpreted method reached through a code pointer is covered even when no R2R call site shares the signature.
  • helpers.cpp / CMakeLists.txt / callhelpers.hpp — no hand-written thunks remain; the generated table is wired unconditionally for both browser and wasi.
  • Naming — the 'I' node is WasmNativeEntryPointToInterpreterThunkNode and the VM API is EnsurePortableEntryPointIsCallableFromNativeCode, naming these for the native entry point they produce rather than for R2R, which is only one of their callers. The runtime lookup key ('I'+signature) is unchanged, and the PortableEntryPoint data structure and its GetPortableEntryPointToInterpreterThunk lookup keep their names.

Build fixes (separable)

Two fixes let a Windows-host wasi build configure and compile; they are independent of the thunk work:

  • build-runtime.cmd and build-native.cmd did not treat wasi as a cross-target, so they ran copy_version_files.cmd (which copies only *.h/*.rc) instead of the .ps1 that also produces _version.c; CMake configure then failed with Cannot find source file.
  • configureplatform.cmake keyed CLR_CMAKE_HOST_WASI off CLR_CMAKE_TARGET_OS while every other platform block keys off CLR_CMAKE_HOST_OS, so a wasi cross-components build handed cl.exe clang flags (D8021: invalid numeric argument '/Werror').

Both are invisible on a Linux host, where the wrong flags land on clang and are accepted.

Validation

Runtime test (src/tests/readytorun/wasm/WasmInterpreterTransitions) — [BypassReadyToRun] makes crossgen2 skip selected methods so one assembly exercises both directions across struct returns of 8/12/16 bytes (instance and static), 1- and 2-byte structs, struct arguments, mixed float/double/long, float/double returns, void, a delegate to an interpreted method, and an interpreted callback into compiled code. Every case asserts a value; callees are NoInlining so an inlined callee cannot skip the transition and pass vacuously.

Unit tests — added to WasmArgumentLayoutTests:

  • GeneratedThunkMatchesLoweredWasmSignature — crossgen2 lowers a managed signature and the generator must produce the same wasm parameter arity and types for the resulting key.
  • ThunkParametersFollowCrossgen2Order — pins parameter positions, separate from the above because a transposition of two i32 parameters leaves the type sequence identical.
  • GenericContextArgumentFollowsTheReturnBuffer.

Known gaps

  • This does not make R2R work on wasi. An R2R image is a wasm module that must be instantiated at run time against the runtime's linear memory and indirect function table, which only the JavaScript host does (libCorerun.js, host/assets.ts); wasi has no equivalent, so its table compiles and links but nothing reaches it yet.
  • No v128 shape is supported.V2/V4 (Vector256/Vector512) fail with a specific message naming what is missing, and a bare V (Vector128) falls to the generic invalid-token error. Nothing in the cookie list or in interop needs one today.
  • The positional unit test is a pinning test, transcribed from crossgen2's lowering because parameter order is not recoverable from WasmFuncType when the parameters are all i32; the runtime test covers that gap.

Why this is needed for pure interp

See #132965 alternative

On wasm a native function pointer is a typed index into the function table. Even a pure interpreter must (A) convert a managed method (delegate / ldftn) into such a native pointer, and (B) have that pointer be callable — from the interpreter and from native/host code — which requires a real, per-signature wasm function (the thunk) that marshals the typed args and re-enters the interpreter.

  • Default constructorscallhelpers.cpp:581: RuntimeHelpers.CallDefaultConstructor does a calli ctorCode. In pure interp that helper runs interpreted, and its calli calls through _pActualCode = the thunk.
  • Finalizerscomutilnative.cpp:797: RunFinalizers invokes the finalizer via its function pointer.
  • Class constructorsmethodtable.cpp:3580: CallClassConstructor invokes the cctor via its function pointer.
  • Plus the original CI failure: Dictionary.Add materializing an interpreted comparer's entry point.

Note

This description was generated with GitHub Copilot.

@pavelsavarapavelsavara added this to the 12.0.0 milestone Aug 29, 2026
@pavelsavarapavelsavara self-assigned this Aug 29, 2026
@pavelsavarapavelsavara added arch-wasm WebAssembly architecture area-ReadyToRun os-browser Browser variant of arch-wasm labels Aug 29, 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.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

"Interpreter-to-R2R thunk for compiled method");
AddAdditionalDependency(
_compilation.NodeFactory.WasmR2RToInterpreterThunk(wasmSig),
"R2R-to-interpreter thunk for compiled method signature");

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.

Why do we need R2R-to-interpreter thunk here?

@pavelsavarapavelsavaraSep 1, 2026

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

recordCallSite roots the I thunk only for signatures R2R code calls directly. It misses an interpreted method reached solely through a materialized native entry point — delegate/ldftn, a vtable slot used as a code address, or GetMultiCallableAddrOfCode. The interesting case is generics: Foo<int> is compiled, Foo<__Canon> runs interpreted, both lower to the same wasm signature S; if Foo<__Canon> is only reached via a delegate/ftn/vtable there's no R2R call site of S, so its thunk is never rooted. The thunk here isn't for the compiled method (it has native code) — it's keyed by signature, for a same-shaped interpreted method, and compiling M is the signal that S is live in this image so it self-contains the thunk without relinking.

Caveat: this is a superset (every compiled signature), since the exact "address-taken interpreted method" set isn't cheaply available at compile time — happy to tighten the trigger if you'd prefer.

Catches the checked-in table up with main: dotnet#132274 removed the only managed caller of compressBound(), so the P/Invoke is no longer in the shipping System.IO.Compression, and ZipArchive now reaches the native RNG directly.
The 'I' thunks that let R2R code call an interpreted method were hand-written
in vm/wasm/helpers.cpp, so every new call shape needed a hand-authored thunk.
Emit them from the WasmAppBuilder generator instead: 17 hand-written entries
become 69 generated plus 1, and helpers.cpp loses ~330 lines.
Fix two parameter transpositions that the struct-returning shapes hit as soon
as the generator started emitting them. crossgen2 lays the wasm parameters out
as (callersStackPointer, [this], [retBuf], args..., portableEntrypoint), reading
the buffer back from retBufLocalIndex = 1 + (hasThis ? 1 : 0):
- Returning the struct by value makes clang insert its own sret pointer at
parameter 0, ahead of callersStackPointer. These thunks are now void with an
explicit int8_t* retBuf.
- retBuf was placed first unconditionally; for an instance method it follows
'this'.
Neither is detectable at run time. The stack pointer, the return buffer, 'this'
and every by-reference argument are all i32, so a transposed order still passes
call_indirect type checking and instead writes the return value over the
caller's frame pointer, surfacing later as an unrelated NullReferenceException
or an out-of-bounds trap.
The generated table is browser-only; wasi keeps the hand-written thunk. The one
remaining hand-written entry is IS16l2ip, whose 'l2' argument (a 16-byte value
passed across two i64 parameters) maps one signature token to several C
parameters, which the generator cannot express yet.
SignatureMapper mixes two things: reflection over scanned assemblies, which
needs a LogAdapter and so drags in Microsoft.Build, and a pure mapping from
signature tokens to native types, which needs nothing. Make it partial and move
the pure half out, so a test can compile it directly instead of pulling MSBuild
into a compiler test assembly.
No behaviour change: the generator emits a byte-identical portable entrypoint
table and an identical interp-to-managed table afterwards.
The R2R-to-interpreter thunks are written by the WasmAppBuilder generator but
called by code crossgen2 emits, and nothing at run time can detect a
disagreement, so test that the two agree.
GeneratedThunkMatchesLoweredWasmSignature lowers a managed signature with
crossgen2 and requires the generator to produce the same wasm parameter arity
and types for the resulting key. That catches a missing hidden return buffer,
which is what returning the struct by value produces, but it cannot catch two
same-typed parameters being swapped: 'this' and retBuf are both i32, so a
transposition leaves the type sequence identical. ThunkParametersFollowCrossgen2Order
covers the positions separately, which is the only way that case is visible.
Both were checked by reintroducing each bug: the transposition fails 3 cases in
the ordering theory and none elsewhere, and dropping the return buffer fails 10.
The generated portable entrypoint thunks wrote every argument through
(int64_t), which converts a float or double to its integer value instead of
storing its bits: 1.5 arrived as 1. The interpreter reads those slots back as
ARG_F32/ARG_F64, so every floating point argument crossing an R2R to
interpreter call was corrupt.
This was a regression for Iidp and Ildp, whose hand-written thunks used a typed
'double args[1]' and stored the value correctly, and was wrong from the start
for the float and double shapes the generator discovered on its own.
The unit tests cannot see this: they compare parameter types and positions, not
the stores. The runtime test added alongside covers it.
The struct-returning shapes added to the pregenerated cookie list feed both
generators, and only the R2R-to-interpreter half was corrected: the
interpreter-to-R2R thunks still called through a pointer declared as returning
the struct by value, so the compiler inserted its own sret pointer at parameter
0, ahead of the stack pointer, while the R2R callee expects
(callersStackPointer, [this], retBuf, args..., portableEntrypoint).
Every parameter involved is an i32, so the mismatch passed call_indirect type
checking and corrupted memory instead. It showed up as an out-of-bounds access
during EventSource start-up, far from the call, and it broke tests that have
nothing to do with struct returns: WasmR2RStructAlignment passes on main, passes
with the P/Invoke table regenerated, and failed once the thunk table was
generated.
Native callees keep the by-value form, which is what their own C ABI gives them.
Methods marked BypassReadyToRun are skipped by crossgen2 and run interpreted
while the rest of the assembly is compiled, so a single test assembly can put a
thunk on a call in either direction. Cover the shapes the thunk table carries:
struct returns of 8, 12 and 16 bytes from both instance and static methods,
struct arguments, mixed float, double and long scalars, void, and an interpreted
method calling back into compiled code.
Every case checks a value rather than only that the call returned. Nothing here
traps when it goes wrong: the stack pointer, the return buffer, 'this' and every
by-reference argument are i32, so a thunk with its parameters in the wrong order
still passes call_indirect type checking and quietly returns bad data. The
callees are NoInlining so that an inlined callee cannot skip the transition and
leave the test passing without exercising anything.
This covers two bugs the unit tests structurally cannot reach, both found by
running it: float and double arguments stored through an integer cast, and the
interpreter-to-R2R struct return convention.
'l2' is a 16-byte value (Int128, UInt128, Decimal128) passed by value across two
i64 wasm parameters. SignatureMapper rejected the token outright, so IS16l2ip
had to stay hand-written: one signature token maps to several C parameters,
which the generator could not express.
Expand a multi-slot token into one parameter per slot in both directions, as
arg<n>Lo and arg<n>Hi, stored into consecutive transition block slots and read
back through consecutive ARG_I64 accessors. TokenToNativeType and TokenToArgType
still reject an unexpanded multi-slot token, so one cannot quietly collapse into
a single parameter -- the shape every parameter bug in this area has taken. 'V2'
and 'V4' now fail with a specific message instead: these thunks have no portable
spelling for a v128 and nothing generates one today.
The generated CallInterpreter_L2_I32_RetS16 is identical to the hand-written
thunk it replaces, which was itself verified against the wasm crossgen2 emits.
This empties the hand-written table, so it is removed. Browser is unaffected;
every thunk it uses is generated. wasi has no generated table yet, so it now has
no portable entrypoint thunks at all and a call needing one reports a missing
key. wasi had 17 before this series and needs its own generated table, which
requires a wasi testhost to scan.
The wasi portable entrypoint table was left behind when the generator took over
the browser one: wasi had 17 hand-written thunks on main, then 1, then none once
the multi-slot shape removed the last of them. Generate wasi's table too, so it
has the same 70 entries as browser, and drop the browser-only guards on the
CMake source entry, the extern declarations and the cache population.
The other wasi tables (interp-to-managed, pinvoke, reverse) are regenerated at
the same time; they were stale against the current scan set.
This restores wasi to the state it had before this series and no further. It
does not make R2R work there: an R2R image is a wasm module that has to be
instantiated at run time against the runtime's memory and indirect function
table, which only the JavaScript host does (libCorerun.js, host/assets.ts).
wasi has no equivalent, so its table stays latent until that exists.
Generated but not compiled locally: 'build.cmd -os wasi -subset clr' fails on a
Windows host because the cross-components build passes clang flags to cl.exe
(D8021: invalid numeric argument '/Werror'), which predates this change. The
table was produced from a managed-only 'clr.corelib+libs' build whose testhost
matches browser's exactly -- 181 assemblies, no difference in either direction.
CI's wasi leg is the first thing that will compile the file.
Every other platform block in configureplatform.cmake keys on CLR_CMAKE_HOST_OS. The wasi one keyed on CLR_CMAKE_TARGET_OS, so a wasi cross-components build - which compiles host tools with MSVC on Windows - still got CLR_CMAKE_HOST_UNIX=1 and CLR_CMAKE_HOST_ARCH=wasm. That handed cl.exe the clang flags from configurecompiler.cmake, failing with D8021 on /Werror.
…signature
A same-shaped method that runs interpreted must be enterable from R2R via a
function pointer, delegate, virtual slot, or GetMultiCallableAddrOfCode. That
thunk was previously rooted only when an R2R call site happened to share the
signature; root it per compiled method so the crossgen2 fallback covers shapes
beyond the generated table.
…e path
Adds R2R<->interpreter cases for float/double returns in both directions, the
'S1'/'S2' single-slot struct encodings the hand-written table lacked, and an
interpreted method reached through a delegate (GetMultiCallableAddrOfCode),
which needs the R2R-to-interpreter thunk independent of any direct call site.
The (byte)A / (short)B / (short)(A+C) expected values are constant expressions
that overflow a checked constant conversion; the runtime test build compiles
constants checked, so wrap them to match the methods' unchecked truncation.
The portable-entry-point thunk is the native entry point of an interpreted
method, used by any native caller reaching it through a materialized code
address - a delegate/ldftn, a vtable slot, or GetMultiCallableAddrOfCode - not
only R2R code, and so it is required even with no R2R present. Rename the
crossgen2 node WasmR2RToInterpreterThunkNode -> WasmNativeToInterpreterThunkNode
(and NodeFactory accessor) plus the surrounding comments/diagnostics/tests. The
runtime lookup key (LookupString 'I'+signature) is unchanged. The R2R format
helper READYTORUN_HELPER_R2RToInterpreter and the WasmInterpreterToR2RThunkNode
direction (which does target R2R code) keep their names.
…iveCode
The portable entry point must be made callable for any native caller reaching an
interpreted method through a materialized address - a delegate/ldftn, a vtable
slot, or GetMultiCallableAddrOfCode - not only R2R code, so name the API for what
it guarantees. Pure rename across the declaration, definition, all call sites, and
comments; no behavior change.
The thunk is the value stored in PortableEntryPoint._pActualCode - what
Init_WithInterpreterThunk(void* nativeEntryPoint) calls the native entry point -
so name it after what it is, distinct from the PortableEntryPoint data structure
that holds it. Bare 'native' was ambiguous next to the C-ABI reverse thunk;
'-to-interpreter' distinguishes it from the UnmanagedCallersOnly native entry
point. Renames WasmNativeToInterpreterThunkNode -> WasmNativeEntryPointToInterpreterThunkNode
(and NodeFactory accessor) plus comments/strings/tests. Runtime lookup key
(LookupString 'I'+signature) is unchanged. Existing runtime names using
PortableEntryPoint (GetPortableEntryPointToInterpreterThunk, the struct) are kept.
@pavelsavarapavelsavara changed the title [wasm] Generate the R2R-to-interpreter thunk table[wasm] Generate the native-entry-point-to-interpreter thunk tableSep 1, 2026
@pavelsavara
pavelsavara marked this pull request as ready for review September 1, 2026 15:05
CopilotAI lite review requested due to automatic review settings September 1, 2026 15:05
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 4 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.

CopilotAI 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.

🟡 Changes recommended

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR replaces hand-written wasm “native entrypoint → interpreter” thunks with generator-emitted tables, wires the generated thunk table into the CoreCLR wasm VM for both browser and wasi, and updates crossgen2 rooting so signature-shaped interpreted methods reached via materialized code pointers are covered. It also adds new ReadyToRun+wasm test coverage and includes a couple of Windows-host wasi build/config fixes.

Changes:

  • Generate and consume g_wasmGeneratedPortableEntryPointThunks (browser + wasi) instead of maintaining a hand-written table in helpers.cpp.
  • Update crossgen2 ReadyToRun compilation to root native-entry-point-to-interpreter thunks by signature, and rename the corresponding node/type.
  • Add wasm interpreter transition tests and extend WasmArgumentLayout unit tests to validate thunk lowering and parameter ordering.
File summaries
FileDescription
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csprojNew wasm-only R2R/interpreter transition test project configuration.
src/tests/readytorun/wasm/WasmInterpreterTransitions/WasmInterpreterTransitions.csRuntime test exercising both directions across scalar/struct/fp shapes and delegate entrypoint materialization.
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojAdds generator output path for portable-entrypoint thunk table emission.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.csSplits MSBuild-free token/type mapping for reuse in tests.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csMakes SignatureMapper partial and moves token helpers out to MSBuild-free file.
src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.csCentralizes thunk parameter ordering logic for generator + tests.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds pregenerated signatures and emits portable-entrypoint thunk tables when configured.
src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.csImplements emission of g_wasmGeneratedPortableEntryPointThunks and expands signature handling.
src/native/libs/build-native.cmdTreats wasi as cross-target on Windows native build script.
src/coreclr/build-runtime.cmdTreats wasi as cross-target on Windows CoreCLR build script.
eng/native/configureplatform.cmakeFixes host-wasi detection to avoid mixing target/host flags.
src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cppRegenerated wasm wasi pinvoke entry tables and counts.
src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for wasi.
src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cppRegenerated wasm browser pinvoke entry tables and counts.
src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cppRegenerated interpreter→managed thunk helpers and table entries for browser.
src/coreclr/vm/wasm/helpers.hppUpdates comment to reflect “native-entry-point-to-interpreter” terminology.
src/coreclr/vm/wasm/helpers.cppRemoves hand-written portable-entrypoint thunks and wires generated table + diagnostics.
src/coreclr/vm/wasm/callhelpers.hppDeclares generated portable-entrypoint thunk table symbols.
src/coreclr/vm/CMakeLists.txtEnsures callhelpers-portable-entrypoints.cpp is built into the shipped static lib for wasm.
src/coreclr/vm/prestub.cppUpdates comments and uses EnsurePortableEntryPointIsCallableFromNativeCode.
src/coreclr/vm/precode_portable.cppUpdates wasm comment terminology for portable entrypoint prestub behavior.
src/coreclr/vm/methodtable.cppEnsures portable entrypoints are callable from native code for cctor invocation.
src/coreclr/vm/method.hppRenames EnsurePortableEntryPointIsCallableFromR2R to ...FromNativeCode.
src/coreclr/vm/method.cppRenames implementation and updates comments describing native-call scenarios.
src/coreclr/vm/loaderallocator.hppUpdates comments around pending thunk resolution list.
src/coreclr/vm/jitinterface.cppEnsures helper entrypoints are callable from native code under portable entrypoints.
src/coreclr/vm/dllimport.cppEnsures IL stubs’ portable entrypoints are callable from native code.
src/coreclr/vm/comutilnative.cppEnsures finalizer portable entrypoints are callable from native code.
src/coreclr/vm/callhelpers.cppEnsures default ctor portable entrypoint is callable from native code.
src/coreclr/vm/assembly.cppEnsures managed entrypoint portable entrypoint is callable from native code.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/CorInfoImpl.ReadyToRun.csRoots both thunk directions by signature during wasm compilation and updates call-site thunk creation.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojRenames node file include to WasmNativeEntryPointToInterpreterThunkNode.cs.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.csRenames node cache and factory entrypoint for native-entry-point-to-interpreter thunks.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmNativeEntryPointToInterpreterThunkNode.csRenames and documents the thunk node; updates mangled name and dependency text.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/WasmInterpreterToR2RThunkNode.csUpdates comment reference to renamed native-entry-point thunk node.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.csAdds unit tests validating generated thunk parameter types and ordering.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojLinks MSBuild-free generator pieces into the ReadyToRun test project.
docs/design/coreclr/botr/clr-abi.mdUpdates documentation to reflect renamed runtime API and thunk role.
Review details
  • Files reviewed: 40/40 changed files
  • Comments generated: 5
  • Review effort level: Lite

Comment on lines +193 to +197
if (!isStructReturn)
w.WriteLine(isVoid ? " void * result = NULL;" : $" {retType} result = 0;");
string retBuffArg = isStructReturn ? "retBuf" : "(int8_t*)&result";
w.WriteLine($" ExecuteInterpretedMethodWithArgs_PortableEntryPoint(portableEntrypoint, &transitionBlock.block, {(slot > 0 ? "sizeof(transitionBlock.args)" : "0")}, {retBuffArg});");
w.WriteLine(isVoid ? " return;" : " return result;");
Comment on lines +11 to +13
<ItemGroup>
<Compile Include="$(MSBuildProjectName).cs" />
</ItemGroup>
Comment on lines +162 to +168
stores.Add(t switch
{
_ when IsStructToken(t) => $" memcpy(&transitionBlock.args[{slot}], arg{i}, {SignatureMapper.GetStructSize(t)});",
"f" => $" *(float*)&transitionBlock.args[{slot}] = arg{i};",
"d" => $" *(double*)&transitionBlock.args[{slot}] = arg{i};",
_ => $" transitionBlock.args[{slot}] = (int64_t)arg{i};",
});
Comment on lines 141 to +146
var m2n = new InterpToNativeGenerator(log);
m2n.Generate(cookies, InterpToNativeOutputPath);

if (!string.IsNullOrEmpty(PortableEntryPointOutputPath))
m2n.GeneratePortableEntryPoints(cookies, PortableEntryPointOutputPath);

Comment on lines 1189 to 1196
void* thunk = LookupPortableEntryPointThunk(keyBuffer);
#ifdef _DEBUG
if (thunk == NULL)
{
LOG((LF_STUBS, LL_INFO100000, "WASM R2R to interpreter call missing for key: %s\n", keyBuffer));
// Printed rather than only asserted: the caller's assert compiles out in release, where these
// gaps surface, and cannot carry the key. A miss leaves the entry point's table index 0 and
// traps later as "null function", far from here.
printf("WASM: no native-entry-point-to-interpreter thunk for signature key '%s'. Add it to pregeneratedInterpreterToNativeSignatures in ManagedToNativeGenerator and regenerate.\n", keyBuffer);
}

@jkotasjkotas 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.

None of this should be needed.

I would like to understand why the system is not working as expected: #132965 (comment)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRunos-browserBrowser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@pavelsavara@jkotas