Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7bb0abf
[wasm] Regenerate the browser P/Invoke table
pavelsavara Aug 29, 2026
efe9f71
[wasm] Generate the R2R-to-interpreter thunk table
pavelsavara Aug 29, 2026
40d4c53
[wasm] Split the token half of SignatureMapper into its own file
pavelsavara Aug 29, 2026
fa5b348
[wasm] Pin the generated thunks against crossgen2's lowering
pavelsavara Aug 29, 2026
a98bd0c
[wasm] Store float and double thunk arguments as themselves
pavelsavara Aug 29, 2026
dd12cb6
[wasm] Pass the return buffer explicitly on interpreter-to-R2R calls too
pavelsavara Aug 29, 2026
a08a693
[wasm] Add a runtime test for the R2R and interpreter transitions
pavelsavara Aug 29, 2026
1235f0f
[wasm] Generate the multi-slot 'l2' thunk shape
pavelsavara Aug 29, 2026
4f7ef82
[wasm] Generate the wasi call tables
pavelsavara Aug 29, 2026
ab84324
fix WASI build on windows
pavelsavara Aug 29, 2026
e88d97b
[wasm] Key CLR_CMAKE_HOST_WASI off the host OS, not the target OS
pavelsavara Aug 29, 2026
f93731b
[wasm] Root the R2R-to-interpreter thunk for every compiled method's …
pavelsavara Aug 31, 2026
0758283
[wasm] Cover float/double returns, 1-/2-byte structs, and the delegat…
pavelsavara Aug 31, 2026
add2eee
[wasm] Use unchecked for the narrowing S1/S2 assert constants
pavelsavara Aug 31, 2026
1c3c999
[wasm] Rename R2R-to-interpreter thunk to native-to-interpreter
pavelsavara Sep 1, 2026
50c08b6
[wasm] Rename EnsurePortableEntryPointIsCallableFromR2R to ...FromNat…
pavelsavara Sep 1, 2026
19b9572
[wasm] Name the thunk native-entry-point-to-interpreter, not native
pavelsavara Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/coreclr/botr/clr-abi.md
Original file line number Diff line number Diff line change
Expand Up @@ -869,7 +869,7 @@ i32.load offset=0 ;; load _pActualCode, a Wasm function table index
call_indirect <tableIndex> <sigIndex> (sig is: int32 (sp) arg0... argN-1 int32 (pep_ptr))
```

The first field of `PortableEntryPoint` (`_pActualCode`, offset 0) is the function table index used for the indirect call. When an R2R method body is loaded, the runtime sets `_pActualCode` to that body's function table index. When the target must run in the interpreter, `MethodDesc::EnsurePortableEntryPointIsCallableFromR2R` installs an R2R-to-interpreter thunk in `_pActualCode`.
The first field of `PortableEntryPoint` (`_pActualCode`, offset 0) is the function table index used for the indirect call. When an R2R method body is loaded, the runtime sets `_pActualCode` to that body's function table index. When the target must run in the interpreter, `MethodDesc::EnsurePortableEntryPointIsCallableFromNativeCode` installs a native-to-interpreter thunk in `_pActualCode`.

Virtual, interface, and delegate calls differ only in how they obtain `pep_ptr`; once the portable entrypoint address is available, they use the same final argument and `call_indirect` sequence. Direct managed R2R-to-R2R calls still use the portable-entrypoint calling convention so that the same call site can target either native R2R code or an interpreter thunk.

Expand Down
4 changes: 2 additions & 2 deletions eng/native/configureplatform.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -221,10 +221,10 @@ if(CLR_CMAKE_HOST_OS STREQUAL emscripten)
set(CLR_CMAKE_HOST_BROWSER 1)
endif(CLR_CMAKE_HOST_OS STREQUAL emscripten)

if(CLR_CMAKE_TARGET_OS STREQUAL wasi)
if(CLR_CMAKE_HOST_OS STREQUAL wasi)
set(CLR_CMAKE_HOST_WASI 1)
set(CLR_CMAKE_HOST_UNIX 1)
endif(CLR_CMAKE_TARGET_OS STREQUAL wasi)
endif(CLR_CMAKE_HOST_OS STREQUAL wasi)

#--------------------------------------------
# This repo builds two set of binaries
Expand Down
3 changes: 3 additions & 0 deletions src/coreclr/build-runtime.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,9 @@ if "%__TargetOS%"=="android" (
if "%__TargetOS%"=="browser" (
set __CrossTarget=1
)
if "%__TargetOS%"=="wasi" (
set __CrossTarget=1
)

if %__CrossTarget% EQU 0 (
call "%__RepoRootDir%\eng\native\version\copy_version_files.cmd"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@
<EmbeddedResource Include="TestCases/**/*.cs" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>

<!-- The wasm native-entry-point-to-interpreter thunks are emitted by these two files but called by code crossgen2
generates, so WasmArgumentLayoutTests checks the two agree. Only the MSBuild-free half of the
generator is linked; the rest of WasmAppBuilder needs a TaskLoggingHelper. -->
<ItemGroup>
<Compile Include="$(RepoRoot)src/tasks/WasmAppBuilder/coreclr/SignatureMapper.Tokens.cs" />
<Compile Include="$(RepoRoot)src/tasks/WasmAppBuilder/coreclr/PortableEntryPointThunkSignature.cs" />
</ItemGroup>

<Target Name="SetupCopyCrossgen2CompilationAssets"
BeforeTargets="AssignTargetPaths"
DependsOnTargets="ResolveProjectReferences"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
using Internal.TypeSystem;
using Internal.TypeSystem.Ecma;

using Microsoft.WebAssembly.Build.Tasks.CoreClr;

using Xunit;
using Xunit.Abstractions;

Expand Down Expand Up @@ -125,7 +127,7 @@ public void OtherSimdWidthsAreNotV128(string vectorType)

/// <summary>
/// The 'V' encoding says nothing about which vector type produced it, so raising must resolve the
/// same type regardless of what lowering saw first. The wasm R2R-to-interpreter thunk derives its
/// same type regardless of what lowering saw first. The wasm native-entry-point-to-interpreter thunk derives its
/// whole frame layout from the raised signature.
/// </summary>
[Theory]
Expand Down Expand Up @@ -201,7 +203,7 @@ public void MultiSlotTypesAreSplitAcrossWasmParameters(

/// <summary>
/// The elevation digit says nothing about which type produced it, so raising must resolve one
/// with the same size and alignment regardless of what lowering saw. The wasm R2R-to-interpreter
/// with the same size and alignment regardless of what lowering saw. The wasm native-entry-point-to-interpreter
/// thunk derives its whole frame layout from the raised signature.
/// </summary>
[Theory]
Expand Down Expand Up @@ -523,6 +525,195 @@ public void SingleFieldStructWrappingAMultiSlotScalarKeepsItsSlots()
Assert.Equal(OffsetsOf(context, int128), OffsetsOf(context, wrapper));
}

public static TheoryData<string, bool, string> ThunkShapes()
{
// (description, isStatic, expected signature key)
TheoryData<string, bool, string> data = new()
{
{ "void(int)", true, "vip" },
{ "int(int)", false, "iTip" },
{ "long(double)", true, "ldp" },
{ "int(float, double, long)", false, "iTfdlp" },
{ "void()", false, "vTp" },
// Struct returns: the shape both encoders previously disagreed on.
{ "S8()", true, "S8p" },
{ "S8(int)", false, "S8Tip" },
{ "S16(long, int)", true, "S16lip" },
{ "S12(S12, int)", false, "S12TS12ip" },
{ "void(S8)", false, "vTS8p" },
};

return data;
}

/// <summary>
/// The native-entry-point-to-interpreter thunks are written by the WasmAppBuilder generator but called by code
/// crossgen2 emits, so the two have to agree on the wasm signature behind every key. This checks
/// arity and types: a missing hidden return buffer — which is what returning the struct by value
/// produces, since the compiler then inserts its own pointer ahead of the stack pointer — or a
/// wrong scalar width shows up here. It cannot see two same-typed parameters swapped;
/// <see cref="ThunkParametersFollowCrossgen2Order"/> covers the order.
/// </summary>
[Theory]
[MemberData(nameof(ThunkShapes))]
public void GeneratedThunkMatchesLoweredWasmSignature(string description, bool isStatic, string expectedKey)
{
_output.WriteLine($"{description} => {expectedKey}");

ReadyToRunCompilerContext context = CreateWasmContext();
WasmSignature lowered = WasmLowering.GetSignature(MakeThunkSignature(context, description, isStatic), WasmLowering.LoweringFlags.None);

Assert.Equal(expectedKey, lowered.SignatureString);
Assert.Equal(lowered.FuncType.Params.Types.ToArray(), GetThunkWasmParameters(lowered.SignatureString));
}

/// <summary>
/// A generic context argument is an ordinary pointer slot that follows the return buffer, so it
/// encodes exactly like a leading <c>int</c> parameter and must lay out the same way.
/// </summary>
[Fact]
public void GenericContextArgumentFollowsTheReturnBuffer()
{
ReadyToRunCompilerContext context = CreateWasmContext();
MethodSignature signature = new(
MethodSignatureFlags.None,
genericParameterCount: 0,
returnType: MakeBlobOfSize(context, 8),
parameters: Array.Empty<TypeDesc>());

WasmSignature lowered = WasmLowering.GetSignature(signature, WasmLowering.LoweringFlags.HasGenericContextArg);

Assert.Equal("S8Tip", lowered.SignatureString);
Assert.Equal(lowered.FuncType.Params.Types.ToArray(), GetThunkWasmParameters(lowered.SignatureString));
}

public static TheoryData<string[], bool, string[]> ThunkParameterOrder()
{
// Transcribed from WasmNativeEntryPointToInterpreterThunkNode.EmitCode, which stores 'this' from the
// local after the stack pointer and then reads the buffer from
// retBufLocalIndex = 1 + (hasThis ? 1 : 0). A generic context is an ordinary slot that
// follows the buffer, so it is spelled like any other argument here.
return new TheoryData<string[], bool, string[]>
{
{ Array.Empty<string>(), true, new[] { "retBuf" } },
{ new[] { "i" }, true, new[] { "retBuf", "arg0" } },
{ new[] { "T" }, true, new[] { "arg0", "retBuf" } },
{ new[] { "T", "i" }, true, new[] { "arg0", "retBuf", "arg1" } },
{ new[] { "T", "i", "S8" }, true, new[] { "arg0", "retBuf", "arg1", "arg2" } },
{ new[] { "T", "i" }, false, new[] { "arg0", "arg1" } },
{ new[] { "i", "l" }, false, new[] { "arg0", "arg1" } },
};
}

/// <summary>
/// The transposition that shipped broken, and the reason it needs its own test: for an instance
/// method the hidden return buffer follows <c>this</c>, and both are <c>i32</c>, so getting the
/// order wrong leaves the wasm type sequence unchanged. Comparing lowered parameter types cannot
/// see it — only the positions can.
/// </summary>
[Theory]
[MemberData(nameof(ThunkParameterOrder))]
public void ThunkParametersFollowCrossgen2Order(string[] args, bool isStructReturn, string[] expectedNames)
{
Assert.Equal(
expectedNames,
PortableEntryPointThunkSignature.GetDeclaredParameters(args, isStructReturn).Select(static p => p.Name));
}

/// <summary>
/// Builds the signature named by <paramref name="description"/> in <see cref="ThunkShapes"/>.
/// </summary>
private static MethodSignature MakeThunkSignature(ReadyToRunCompilerContext context, string description, bool isStatic)
{
TypeDesc Int32() => context.GetWellKnownType(WellKnownType.Int32);

(TypeDesc Return, TypeDesc[] Parameters) shape = description switch
{
"void(int)" => (context.GetWellKnownType(WellKnownType.Void), new[] { Int32() }),
"int(int)" => (Int32(), new[] { Int32() }),
"long(double)" => (context.GetWellKnownType(WellKnownType.Int64), new[] { context.GetWellKnownType(WellKnownType.Double) }),
"int(float, double, long)" => (Int32(), new[]
{
context.GetWellKnownType(WellKnownType.Single),
context.GetWellKnownType(WellKnownType.Double),
context.GetWellKnownType(WellKnownType.Int64),
}),
"void()" => (context.GetWellKnownType(WellKnownType.Void), Array.Empty<TypeDesc>()),
"S8()" => (MakeBlobOfSize(context, 8), Array.Empty<TypeDesc>()),
"S8(int)" => (MakeBlobOfSize(context, 8), new[] { Int32() }),
"S16(long, int)" => (MakeBlobOfSize(context, 16), new[] { context.GetWellKnownType(WellKnownType.Int64), Int32() }),
"S12(S12, int)" => (MakeBlobOfSize(context, 12), new TypeDesc[] { MakeBlobOfSize(context, 12), Int32() }),
"void(S8)" => (context.GetWellKnownType(WellKnownType.Void), new TypeDesc[] { MakeBlobOfSize(context, 8) }),
_ => throw new ArgumentOutOfRangeException(nameof(description), description, null),
};

return new MethodSignature(
isStatic ? MethodSignatureFlags.Static : MethodSignatureFlags.None,
genericParameterCount: 0,
returnType: shape.Return,
parameters: shape.Parameters);
}

/// <summary>
/// A multi-field struct of the given size. It needs more than one field: a single-field struct
/// is lowered to the field's own type and would never reach the <c>S&lt;N&gt;</c> encoding.
/// </summary>
private static DefType MakeBlobOfSize(ReadyToRunCompilerContext context, int size)
{
TypeDesc int32 = context.GetWellKnownType(WellKnownType.Int32);
TypeDesc int64 = context.GetWellKnownType(WellKnownType.Int64);

DefType result = size switch
{
8 => MakeValueTuple(context, int32, int32),
12 => MakeValueTuple(context, int32, int32, int32),
16 => MakeValueTuple(context, int64, int64),
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null),
};

Assert.Equal(size, result.InstanceFieldSize.AsInt);
return result;
}

/// <summary>
/// The wasm parameters of the thunk the generator emits for <paramref name="signatureKey"/>.
/// The stack pointer comes from the WASM_CALLABLE_FUNC macro and the portable entrypoint is
/// appended after the declared arguments, so neither is part of the generator's own list.
/// </summary>
private static List<WasmValueType> GetThunkWasmParameters(string signatureKey)
{
List<string> tokens = SignatureMapper.ParseSignatureTokens(signatureKey);
string returnToken = tokens[0];

Assert.Equal("p", tokens[tokens.Count - 1]);
tokens.RemoveAt(tokens.Count - 1);
List<string> args = tokens.GetRange(1, tokens.Count - 1);

List<WasmValueType> parameters = new() { WasmValueType.I32 }; // callersStackPointer
foreach (PortableEntryPointThunkSignature.Parameter parameter in
PortableEntryPointThunkSignature.GetDeclaredParameters(args, PortableEntryPointThunkSignature.IsStructToken(returnToken)))
{
parameters.Add(NativeTypeToWasmType(parameter.NativeType));
}

parameters.Add(WasmValueType.I32); // portable entrypoint
return parameters;
}

/// <summary>
/// Maps a C type the generator emits onto the wasm type clang gives it on wasm32. Reading the
/// generator's own type strings, rather than re-deriving them from the signature, is what makes
/// this a check of the emitted thunk instead of a restatement of the encoding rules.
/// </summary>
private static WasmValueType NativeTypeToWasmType(string nativeType) => nativeType switch
{
"int32_t" or "int8_t*" or "PCODE" => WasmValueType.I32,
"int64_t" => WasmValueType.I64,
"float" => WasmValueType.F32,
"double" => WasmValueType.F64,
_ => throw new ArgumentOutOfRangeException(nameof(nativeType), nativeType, null),
};

private static DefType MakeValueTuple(ReadyToRunCompilerContext context, params TypeDesc[] fields) =>
((MetadataType)context.SystemModule.GetType(
"System"u8, System.Text.Encoding.UTF8.GetBytes($"ValueTuple`{fields.Length}")))
Expand Down Expand Up @@ -644,7 +835,7 @@ private static MethodSignature MakeStaticVoidSignature(TypeSystemContext context
}

/// <summary>
/// Runs the same ArgIterator the wasm R2R-to-interpreter thunk uses, returning each argument's
/// Runs the same ArgIterator the wasm native-entry-point-to-interpreter thunk uses, returning each argument's
/// offset relative to the start of the arguments area.
/// </summary>
private static List<int> GetArgumentOffsets(TypeSystemContext context, MethodSignature signature)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ protected override void EmitCode(NodeFactory factory, ref Wasm.WasmEmitter instr

// Build the arguments for the R2R call_indirect.
// Target R2R wasm params: ($sp, [this], [retbuf], explicit_params..., portableEntrypoint)
// (matches Compiler::lvaInitArgs / WasmR2RToInterpreterThunkNode local order.)
// (matches Compiler::lvaInitArgs / WasmNativeEntryPointToInterpreterThunkNode local order.)
// We track targetParamIndex to look up the correct wasm type for each arg.
int targetParamIndex = 0;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@
namespace ILCompiler.DependencyAnalysis.ReadyToRun
{
/// <summary>
/// A thunk that captures all arguments and dispatches to the interpreter via
/// READYTORUN_HELPER_R2RToInterpreter. This node is string-discoverable so the
/// runtime can find it by WasmSignature string at execution time.
/// The wasm funcref an interpreted method's portable entry point resolves to: a thunk that, under
/// the wasm managed calling convention, captures all arguments and dispatches into the interpreter.
/// Anything reaching the method through a materialized code pointer uses it - 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. This is the managed transition; the C-ABI reverse
/// (UnmanagedCallersOnly) thunk is a separate mechanism. It is wired through the
/// READYTORUN_HELPER_R2RToInterpreter helper cell and is string-discoverable so the runtime can find
/// it by WasmSignature string at execution time.
/// </summary>
public class WasmR2RToInterpreterThunkNode : StringDiscoverableAssemblyStubNode, INodeWithTypeSignature, ISymbolDefinitionNode, ISortableSymbolNode
public class WasmNativeEntryPointToInterpreterThunkNode : StringDiscoverableAssemblyStubNode, INodeWithTypeSignature, ISymbolDefinitionNode, ISortableSymbolNode
{
private readonly TypeSystemContext _context;
private readonly WasmSignature _wasmSignature;
Expand Down Expand Up @@ -80,7 +85,7 @@ private bool HasGenericContextBeforeAsync
}
}

public WasmR2RToInterpreterThunkNode(NodeFactory factory, WasmSignature wasmSignature)
public WasmNativeEntryPointToInterpreterThunkNode(NodeFactory factory, WasmSignature wasmSignature)
{
_context = factory.TypeSystemContext;
_wasmSignature = wasmSignature;
Expand All @@ -90,7 +95,7 @@ public WasmR2RToInterpreterThunkNode(NodeFactory factory, WasmSignature wasmSign

public override void AppendMangledName(NameMangler nameMangler, Utf8StringBuilder sb)
{
sb.Append("WasmR2RToInterpreterThunk("u8);
sb.Append("WasmNativeEntryPointToInterpreterThunk("u8);
sb.Append(_wasmSignature.SignatureString);
sb.Append(")"u8);
}
Expand All @@ -106,14 +111,14 @@ protected override string GetName(NodeFactory factory)

public override int CompareToImpl(ISortableNode other, CompilerComparer comparer)
{
WasmR2RToInterpreterThunkNode otherNode = (WasmR2RToInterpreterThunkNode)other;
WasmNativeEntryPointToInterpreterThunkNode otherNode = (WasmNativeEntryPointToInterpreterThunkNode)other;
return _wasmSignature.CompareTo(otherNode._wasmSignature);
}

protected override DependencyList ComputeNonRelocationBasedDependencies(NodeFactory factory)
{
DependencyList dependencies = base.ComputeNonRelocationBasedDependencies(factory);
dependencies.Add(_typeNode, "Wasm R2R to interpreter thunk requires type node");
dependencies.Add(_typeNode, "Wasm native-entry-point-to-interpreter thunk requires type node");
return dependencies;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,9 @@ private void CreateNodeCaches()
return new WasmImportThunkPortableEntrypoint(this, key.Import);
});

_wasmR2RToInterpreterThunks = new NodeCache<WasmSignature, WasmR2RToInterpreterThunkNode>(key =>
_wasmNativeEntryPointToInterpreterThunks = new NodeCache<WasmSignature, WasmNativeEntryPointToInterpreterThunkNode>(key =>
{
return new WasmR2RToInterpreterThunkNode(this, key);
return new WasmNativeEntryPointToInterpreterThunkNode(this, key);
});

_wasmInterpreterToR2RThunks = new NodeCache<WasmSignature, WasmInterpreterToR2RThunkNode>(key =>
Expand Down Expand Up @@ -929,10 +929,10 @@ public ISymbolDefinitionNode WasmImportThunkPortableEntrypoint(DelayLoadHelperIm
return _wasmImportThunkPortableEntrypoints.GetOrAdd(thunkKey);
}

private NodeCache<WasmSignature, WasmR2RToInterpreterThunkNode> _wasmR2RToInterpreterThunks;
public WasmR2RToInterpreterThunkNode WasmR2RToInterpreterThunk(WasmSignature wasmSignature)
private NodeCache<WasmSignature, WasmNativeEntryPointToInterpreterThunkNode> _wasmNativeEntryPointToInterpreterThunks;
public WasmNativeEntryPointToInterpreterThunkNode WasmNativeEntryPointToInterpreterThunk(WasmSignature wasmSignature)
{
return _wasmR2RToInterpreterThunks.GetOrAdd(wasmSignature);
return _wasmNativeEntryPointToInterpreterThunks.GetOrAdd(wasmSignature);
}

private NodeCache<WasmSignature, WasmInterpreterToR2RThunkNode> _wasmInterpreterToR2RThunks;
Expand Down
Loading
Loading