Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 R2R-to-interpreter thunks are emitted by these two files but called by code crossgen2

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.

Should these thunks be generated by crossgen instead? Crossgen knows the exact set of signatures that need R2R-to-interpreter thunk.

Or is this PR just adding more throw-away workarounds?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Or is this PR just adding more throw-away workarounds?

I want to unblock myself for end-to-end with blazor. This a blocker.
Specifically some hand written thunks are missing and some have bugs that corrupt memory.

Should these thunks be generated by crossgen instead?

Possibly. I see my problem as orthogonal to #131877

@jkotas jkotas Aug 30, 2026

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.

I see my problem as orthogonal to #131877

Right, #131877 is about interop thunks. Interop thunks can be discovered by simple IL analysis.

R2R-to-interpreter thunks need to be generated by crossgen2 as part of R2R compilation. They cannot be discovered by simple IL analysis due to generics. They need to be saved into R2R image, not as .c/cpp, so that the system can work without relinking.

crossgen2 has code to do all that. Introduced by #127483 (look for R2RToInterpreter), with number of follow up fixes. If there are bugs in the crossgen2 support, it would be better to spend time on fixing those bugs instead of adding more throw-away code in a wrong place.

The handwritten thunks that this PR is improving should be deleted instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The handwritten thunks that this PR is improving should be deleted instead.

I tried. The pure interp still need those thunks pre-generated, because it doesn't load any R2R yet.
I don't want to be blocked on that until we decide how to deal with app-only signatures and .o WASM writer.

See #132965 (comment) for more detail.

@pavelsavara pavelsavara Aug 31, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

If there are bugs in the crossgen2 support, it would be better to spend time on fixing those bugs

yes, on it.

Fix in CorInfoImpl.ReadyToRun.cs so far

The handwritten thunks that this PR is improving should be deleted instead.

This PR actually also deletes handwritten thunks and brings C code generator of them.

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 @@ -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 R2R-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 WasmR2RToInterpreterThunkNode.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
Original file line number Diff line number Diff line change
Expand Up @@ -862,14 +862,22 @@ public void CompileMethod(MethodWithGCInfo methodCodeNodeNeedingCode, Logger log
}
}

// For managed methods on Wasm, add an interpreter-to-R2R thunk so the
// interpreter can call into this R2R-compiled function.
// For managed methods on Wasm, add both interpreter transition thunks for this
// method's signature. The interpreter-to-R2R thunk lets the interpreter call into
// this R2R function. The R2R-to-interpreter thunk is keyed by signature, not method:
// a method of the same shape that runs interpreted must be enterable from R2R (via a
// function pointer, delegate, virtual slot, or the interpreter's own
// GetMultiCallableAddrOfCode path), and its thunk is not otherwise rooted unless an
// R2R call site happens to share the signature.
if (_compilation.NodeFactory.Target.IsWasm && !MethodBeingCompiled.IsUnmanagedCallersOnly)
{
WasmSignature wasmSig = WasmLowering.GetSignature(MethodBeingCompiled);
AddAdditionalDependency(
_compilation.NodeFactory.WasmInterpreterToR2RThunk(wasmSig),
"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?

}

var compilationResult = CompileMethodInternal(methodCodeNodeNeedingCode, methodIL);
Expand Down
5 changes: 5 additions & 0 deletions src/coreclr/vm/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,11 @@ elseif(CLR_CMAKE_TARGET_ARCH_WASM)
else()
set(WASM_THUNK_SUBDIR browser)
endif()
# Unlike the other generated call helpers, the portable entrypoint thunks are not regenerated
# per app, so they belong in the shipped static library rather than in cee_wks_gen.
list(APPEND VM_SOURCES_WKS_ARCH
${ARCH_SOURCES_DIR}/${WASM_THUNK_SUBDIR}/callhelpers-portable-entrypoints.cpp
)
set(VM_SOURCES_WKS_GEN
${ARCH_SOURCES_DIR}/${WASM_THUNK_SUBDIR}/callhelpers-interp-to-managed.cpp
${ARCH_SOURCES_DIR}/${WASM_THUNK_SUBDIR}/callhelpers-reverse.cpp
Expand Down
Loading
Loading