Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
54a5ce9
[wasm][coreclr] Dispatch R2R-compiled UnmanagedCallersOnly callbacks …
pavelsavara Sep 21, 2026
29a46ff
Merge branch 'main' into wasm_UCO_TO_R2R_dispatch
pavelsavara Sep 22, 2026
c895bf4
Merge branch 'main' into wasm_UCO_TO_R2R_dispatch
pavelsavara Sep 23, 2026
59cc113
Cache R2R entrypoint lookup in reverse thunks; tidy fallback whitespace
pavelsavara Sep 23, 2026
fa2adc5
Merge branch 'main' into wasm_UCO_TO_R2R_dispatch
pavelsavara Sep 23, 2026
82c18a9
Guard R2R reverse-thunk dispatch: skip by-ref returns, volatile-publi…
pavelsavara Sep 23, 2026
ea945e2
Provide VolatileLoad/VolatileStore in wasm callhelpers compat shim
pavelsavara Sep 23, 2026
03d1404
Test R2R native export callback dispatch on wasm
pavelsavara Sep 24, 2026
a3aceae
Harden wasm R2R reverse thunk dispatch
pavelsavara Sep 24, 2026
c0240ac
Expand wasm R2R native callback ABI coverage
pavelsavara Sep 24, 2026
64a68e1
Reject unsupported wasm callback struct returns
pavelsavara Sep 24, 2026
bc31a52
Reject unsupported wasm vector callbacks
pavelsavara Sep 24, 2026
dfdc276
Emit atomic wasm R2R callback cache operations
pavelsavara Sep 24, 2026
f350cdf
Compile test native sources without generated-helper flags
pavelsavara Sep 25, 2026
26268db
Skip wasm corerun for struct-returning callconv tests
pavelsavara Sep 25, 2026
323eaf2
Merge branch 'main' of https://github.com/dotnet/runtime into pr13435…
pavelsavara Sep 29, 2026
b6b2ac0
Emit wasm R2R callback dispatch for exported callbacks only
pavelsavara Sep 29, 2026
0628cb6
Scope R2R export-gating test to its own callback symbol
pavelsavara Sep 29, 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
27 changes: 21 additions & 6 deletions src/coreclr/hosts/corerun/wasm/testkit.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -229,12 +229,24 @@ function(corerun_kit_export)
# or, worse, hold per-configuration defaults the repo never actually uses.
# corerun's include directories are likewise not exported: the generated
# sources only need the headers shipped in the kit.
set(compileResponse "")
string(APPEND compileResponse "-O2\n")
string(APPEND compileResponse "-fwasm-exceptions\n")
string(APPEND compileResponse "-sWASM_LEGACY_EXCEPTIONS=0\n")
string(APPEND compileResponse "-msimd128\n")
string(APPEND compileResponse "-DGEN_PINVOKE=1\n")
# The user response is the flags every object linked into the test corerun must
# agree on -- the exception model above all, which wasm-ld rejects if it is
# mixed. It carries no generated-helper-only flags, mirroring the app build's
# split between user NativeFileReference sources and the crossgen2-emitted
# call tables (BrowserWasmApp.CoreCLR.targets: _EmccCFlags vs _EmccCFlagsGenerated).
set(userCompileResponse "")
string(APPEND userCompileResponse "-O2\n")
string(APPEND userCompileResponse "-fwasm-exceptions\n")
string(APPEND userCompileResponse "-sWASM_LEGACY_EXCEPTIONS=0\n")
string(APPEND userCompileResponse "-msimd128\n")
string(APPEND userCompileResponse "-DGEN_PINVOKE=1\n")

# The generated call tables additionally force-include coreclr_compat.h (which
# injects CoreCLR type/macro prereqs like MethodDesc/ULONG/LOG) and add the kit
# header search path. Those are deliberately kept off user sources: a raw .c/.cpp
# NativeFileReference must not inherit those typedefs/macros, which would collide
# with arbitrary user code.
set(compileResponse "${userCompileResponse}")
string(APPEND compileResponse "-I\"include\"\n")
string(APPEND compileResponse "-include\n\"include/coreclr_compat.h\"\n")

Expand All @@ -243,13 +255,16 @@ function(corerun_kit_export)
TARGET corerun_static)
file(GENERATE OUTPUT "${generatedDirectory}/corerun-compile.rsp" CONTENT "${compileResponse}"
TARGET corerun_static)
file(GENERATE OUTPUT "${generatedDirectory}/corerun-compile-user.rsp" CONTENT "${userCompileResponse}"
TARGET corerun_static)

foreach(library IN LISTS kitLibraries)
install(FILES "$<TARGET_FILE:${library}>" DESTINATION ${kitDestination} COMPONENT ${KIT_COMPONENT})
endforeach()
install(FILES ${kitFiles} DESTINATION ${kitDestination} COMPONENT ${KIT_COMPONENT})
install(FILES ${KIT_JS_LIBRARIES} "${KIT_EXTERN_POST_JS}" DESTINATION ${kitDestination} COMPONENT ${KIT_COMPONENT})
install(FILES "${generatedDirectory}/corerun-link.rsp" "${generatedDirectory}/corerun-compile.rsp"
"${generatedDirectory}/corerun-compile-user.rsp"
DESTINATION ${kitDestination} COMPONENT ${KIT_COMPONENT})

# Headers the generated call tables include, plus the compatibility header
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,87 @@ public void PortableCallHelpersGeneratorEncodesTypesTheSameWayInAndOutOfASignatu
Assert.Equal(tokens[1], InteropSignature.GetAbiToken(type));
}

[Theory]
[InlineData("int", "System.Int128", "l2")]
[InlineData("System.Int128", "int", "l2")]
[InlineData("int", "System.Runtime.Intrinsics.Vector128<int>", "V")]
[InlineData("System.Runtime.Intrinsics.Vector128<int>", "int", "V")]
public void PortableCallHelpersGeneratorRejectsUnsupportedSignatureTokens(
string returnType, string parameterType, string expectedToken)
{
string source = $$"""
using System;
using System.Runtime.InteropServices;

public static class Exports
{
[UnmanagedCallersOnly(EntryPoint = "callback")]
public static {{returnType}} Handle({{parameterType}} value) => default;
}
""";

AssertPortableCallHelpersGeneratorRejects(source, $"has unsupported signature token '{expectedToken}'");
}

[Fact]
public void PortableCallHelpersGeneratorRejectsHiddenReturnBufferCallbacks()
{
string source = """
using System.Runtime.InteropServices;

public struct Pair
{
public int First;
public int Second;
}

public static class Exports
{
[UnmanagedCallersOnly(EntryPoint = "callback")]
public static Pair Handle(int value) => default;
}
""";

AssertPortableCallHelpersGeneratorRejects(source, "uses a hidden return buffer");
}

private void AssertPortableCallHelpersGeneratorRejects(string source, string expectedError)
{
string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(workingDirectory);

try
{
string inputAssembly = CompileCallbackAssembly(source, Path.Combine(workingDirectory, "Callbacks.dll"));
var options = new PortableCallHelpersGeneratorOptions
{
OutputDirectory = Path.Combine(workingDirectory, "generated"),
TargetOS = "browser",
PInvokeModules = new[] { "libSystem.Native" },
};

var log = new StringWriter();
int exitCode = PortableCallHelpersGenerator.Run(
CreateWasmContext(inputAssembly), options, new Logger(log, isVerbose: false));

Assert.Equal(1, exitCode);
Assert.Contains(expectedError, log.ToString());
}
finally
{
// The type system maps an input assembly with FileShare.Read and never releases it - the
// context is not disposable - so on Windows the compiled input cannot be deleted while
// this process lives. Cleaning up is best effort rather than a second way to fail.
try
{
Directory.Delete(workingDirectory, recursive: true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
}
}
}

private const string CoreLibSimpleName = "System.Private.CoreLib";

/// <summary>
Expand Down Expand Up @@ -785,6 +866,75 @@ public static class Exports
}
}

/// <summary>
/// The generated wrapper only needs to reach the R2R native entrypoint for callbacks exported by
/// name: native code calls the extern "C" export (and thus the wrapper) directly. Every other
/// reverse thunk is handed out by the runtime, which already prefers R2R code before falling back to
/// the interpreter wrapper, so the dispatch is emitted for exports alone.
/// </summary>
[Theory]
[InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_export\")]", true)]
[InlineData("[UnmanagedCallersOnly]", false)]
public void PortableCallHelpersGeneratorEmitsR2RDispatchForExportsOnly(string attribute, bool expectR2RDispatch)
{
string source = $$"""
using System.Runtime.InteropServices;

public static class Exports
{
{{attribute}}
public static int Handle(int value) => value;
}
""";

string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(workingDirectory);

try
{
string inputAssembly = CompileCallbackAssembly(source, Path.Combine(workingDirectory, "Callbacks.dll"));
string outputDirectory = Path.Combine(workingDirectory, "generated");

var options = new PortableCallHelpersGeneratorOptions
{
OutputDirectory = outputDirectory,
TargetOS = "browser",
PInvokeModules = new[] { "libSystem.Native" },
};

var log = new StringWriter();
int exitCode = PortableCallHelpersGenerator.Run(
CreateWasmContext(inputAssembly), options, new Logger(log, isVerbose: false));

Assert.Equal(0, exitCode);
string reverseHelpers = File.ReadAllText(Path.Combine(outputDirectory, "callhelpers-reverse.cpp"));

// CoreLib is part of the scan context, so its own exported callbacks emit R2R dispatch too.
// Scope the assertions to this callback's mangled symbol to test the gating in isolation.
const string mySymbol = "R2RCode_Callbacks__Exports_Handle";
if (expectR2RDispatch)
{
Assert.Contains(mySymbol, reverseHelpers);
Assert.Contains($"__atomic_load_n(&{mySymbol}", reverseHelpers);
Assert.Contains($"__atomic_store_n(&{mySymbol}", reverseHelpers);
}
else
{
Assert.DoesNotContain(mySymbol, reverseHelpers);
}
}
finally
{
try
{
Directory.Delete(workingDirectory, recursive: true);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
}
}
}

/// <summary>
/// Builds an input assembly for the generator to scan. It references the same CoreLib the context
/// reads, so the attributes it applies are the ones the type system will resolve.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using System.Linq;
using System.Text;

using ILCompiler.DependencyAnalysis.Wasm;
using Internal.JitInterface;
using Internal.TypeSystem;
using Internal.TypeSystem.Ecma;
using Internal.TypeSystem.Interop;
Expand Down Expand Up @@ -233,8 +235,15 @@ public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
// the runtime's key for a method to its wrapper, and the runtime fills that static in as it
// hands the wrapper out. An export is not handed out that way, so it resolves the static
// itself, by name, on the first call.
// The R2R dispatch below is emitted only for callbacks exported by name, so its extern is too:
// native code reaches those wrappers directly, while every other reverse thunk is handed out by
// the runtime, which already prefers R2R code before falling back to the interpreter wrapper.
bool anyExport = callbacks.Any(cb => cb.IsExport);
string r2rExtern = anyExport
? w.NewLine + "extern \"C\" void* GetR2RNativeCodeForUnmanagedCallersOnly(MethodDesc* pMD);"
: string.Empty;
w.Write(
"""
$$"""
//
// GENERATED FILE, DON'T EDIT
// Generated by coreclr callhelpers generator
Expand All @@ -245,7 +254,7 @@ public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
// WASM-TODO: The method lookup would ideally be fully qualified assembly and then methodDef token.
// The current approach has limitations with overloaded methods.
extern "C" void LookupUnmanagedCallersOnlyMethodByName(const char* fullQualifiedTypeName, const char* methodName, MethodDesc** ppMD);
extern "C" void ExecuteInterpretedMethodFromUnmanaged(MethodDesc* pMD, int8_t* args, size_t argSize, int8_t* ret, PCODE callerIp);
extern "C" void ExecuteInterpretedMethodFromUnmanaged(MethodDesc* pMD, int8_t* args, size_t argSize, int8_t* ret, PCODE callerIp);{{r2rExtern}}

""");

Expand All @@ -254,6 +263,7 @@ public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
callbacks.Sort(new PInvokeCallbackComparer());
foreach (PInvokeCallback cb in callbacks)
{
RejectUnsupportedCallbackSignature(cb);
cb.EntrySymbol = FixedSymbolName(cb);

if (!callbackNames.Add(cb.EntrySymbol))
Expand Down Expand Up @@ -281,11 +291,47 @@ public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
// those instead. Every other type this emits reaches the slot unchanged through a cast.
bool CarriesBits(int i) => parameterCTypes[i] is "float" or "double";
string argsDeclaration = parameterCount > 0
? $"\n int64_t args[{parameterCount}] = {{ {string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => CarriesBits(i) ? "0" : $"(int64_t)arg{i}"))} }};\n"
+ string.Concat(Enumerable.Range(0, parameterCount).Where(CarriesBits).Select(i => $" memcpy(&args[{i}], &arg{i}, sizeof(arg{i}));\n"))
? $"\n\n int64_t args[{parameterCount}] = {{ {string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => CarriesBits(i) ? "0" : $"(int64_t)arg{i}"))} }};"
+ string.Concat(Enumerable.Range(0, parameterCount).Where(CarriesBits).Select(i => $"\n memcpy(&args[{i}], &arg{i}, sizeof(arg{i}));"))
: string.Empty;
string parametersDeclaration = string.Join(", ", parameterCTypes.Select((p, i) => $"{p} arg{i}"));
string arguments = string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => $"arg{i}"));

// A partial R2R image can compile an UnmanagedCallersOnly callback to native code. That R2R
// code is the directly-callable native entrypoint (same ABI as this wrapper's parameters), so
// dispatch to it and skip the interpreter/interp->R2R path entirely.
//
// Only callbacks exported by name need this here: native code holds the extern "C" export and
// calls this wrapper directly, so the wrapper itself has to reach the R2R entrypoint. Every
// other reverse thunk is handed out by the runtime through GetUnmanagedCallersOnlyThunk, which
// already returns the R2R native code directly and only falls back to this interpreter wrapper
// when the method has no native code -- so the dispatch would be dead code there.
string paramTypesOnly = string.Join(", ", parameterCTypes);
string r2rDispatch = cb.IsVoid
? $"((void(*)({paramTypesOnly}))r2r)({arguments});{w.NewLine} return;"
: $"return (({MapType(cb.ReturnType)}(*)({paramTypesOnly}))r2r)({arguments});";
Comment thread
pavelsavara marked this conversation as resolved.
// Cache the resolved entrypoint in a per-callback static, published with acquire/release
// atomics: these are native entry points that can be entered concurrently, and the value is
// computed identically on every call, so the racing read/write is benign but must not tear.
string r2rStaticDecl = cb.IsExport
? $"{w.NewLine}static void* R2RCode_{cb.EntrySymbol} = (void*)(intptr_t)-1;"
: string.Empty;
string r2rSection = cb.IsExport
? w.NewLine + $$"""
// Prefer the R2R native entrypoint when this callback was compiled (partial R2R).
// Resolve once and cache; a method's native-code availability is fixed after first prepare.
void* r2r = __atomic_load_n(&R2RCode_{{cb.EntrySymbol}}, __ATOMIC_ACQUIRE);
if (r2r == (void*)(intptr_t)-1)
{
r2r = GetR2RNativeCodeForUnmanagedCallersOnly(MD_{{cb.EntrySymbol}});
__atomic_store_n(&R2RCode_{{cb.EntrySymbol}}, r2r, __ATOMIC_RELEASE);
}
if (r2r != nullptr)
{
{{r2rDispatch}}
}
"""
: string.Empty;
string exportFunction = cb.IsExport ?
$$"""

Expand All @@ -298,16 +344,16 @@ public void EmitNativeToInterp(TextWriter w, List<PInvokeCallback> callbacks)
w.Write(
$$"""

static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr;
static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr;{{r2rStaticDecl}}
Comment thread
pavelsavara marked this conversation as resolved.
static {{
MapType(cb.ReturnType)}} Call_{{cb.EntrySymbol}}({{parametersDeclaration}})
{{{argsDeclaration}}
{
// Lazy lookup of MethodDesc for the function export scenario.
if (!MD_{{cb.EntrySymbol}})
{
LookupUnmanagedCallersOnlyMethodByName("{{cb.TypeFullName}}, {{cb.AssemblyName}}", "{{cb.MethodName}}", &MD_{{cb.EntrySymbol}});
}{{
(!cb.IsVoid ? $"{w.NewLine}{w.NewLine} {MapType(cb.ReturnType)} result;" : "")}}
}{{r2rSection}}{{argsDeclaration}}{{
(!cb.IsVoid ? $"{w.NewLine} {MapType(cb.ReturnType)} result;" : "")}}
ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{(cb.IsVoid ? "nullptr" : "(int8_t*)&result")}}, (PCODE)&Call_{{cb.EntrySymbol}});{{
(!cb.IsVoid ? $"{w.NewLine} return result;" : "")}}
}{{exportFunction}}
Expand Down Expand Up @@ -350,6 +396,43 @@ static void RejectAmbiguousExport(PInvokeCallback cb)
throw new LogAsErrorException(
$"Exported callback '{cb.EntryPoint}' cannot be resolved at run time: '{cb.TypeFullName}' declares more than one [UnmanagedCallersOnly] method named '{cb.MethodName}', and the runtime looks them up by name alone. Give them distinct names: {string.Join(", ", ambiguous)}");
}

static void RejectUnsupportedCallbackSignature(PInvokeCallback cb)
{
List<string> loweredTokens = InteropSignature.ParseSignatureTokens(
InteropSignature.GetMethodSignature(cb.Method, WasmLowering.LoweringFlags.IsUnmanagedCallersOnly));
string token = null;
for (int i = 1; i < loweredTokens.Count; i++)
{
if (IsUnsupportedToken(loweredTokens[i]))
{
token = loweredTokens[i];
break;
}
}

if (token is null && !cb.IsVoid)
{
string returnToken = InteropSignature.GetAbiToken(cb.ReturnType);
if (IsUnsupportedToken(returnToken))
token = returnToken;
}

if (token is not null)
{
throw new LogAsErrorException(
$"UnmanagedCallersOnly callback '{cb.Method}' has unsupported signature token '{token}', which the generated native wrapper does not support.");
}

if (!cb.IsVoid && IsPassedByReference(cb.ReturnType))
{
throw new LogAsErrorException(
$"UnmanagedCallersOnly callback '{cb.Method}' has return type '{cb.ReturnType}' that uses a hidden return buffer, which the generated native wrapper does not support.");
Comment thread
pavelsavara marked this conversation as resolved.
}

static bool IsUnsupportedToken(string token) =>
token is "V" || InteropSignature.IsMultiSlotToken(token);
}
}

private string CEntryPoint(PInvokeInfo pinvoke)
Expand Down
Loading
Loading