From 2ce391eec0ba0dcf7e4057a1c00f47d85b6c3e1f Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 28 Sep 2026 19:21:56 -0500 Subject: [PATCH 1/6] [wasm] Generate thunks for unmanaged calli signatures and allow thiscall The portable callhelpers generator only collected signatures from P/Invokes, UnmanagedCallersOnly methods and UnmanagedFunctionPointer delegates, so a calli through an unmanaged function pointer whose shape appeared nowhere else had no interp-to-native thunk and asserted in GetCookieForCalliSig. Scan method bodies for unmanaged calli and add their signatures. ComputeCalliSigThunk also rejected IMAGE_CEE_CS_CALLCONV_THISCALL before building a key; on wasm thiscall lowers the same as cdecl. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WasmArgumentLayoutTests.cs | 73 ++++++++++++++++- .../PortableCallHelpers/PInvokeCollector.cs | 78 +++++++++++++++++++ src/coreclr/vm/wasm/helpers.cpp | 1 + 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 4d7388a8ba0884..9420532823d550 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -785,17 +785,86 @@ public static class Exports } } + /// + /// The interpreter looks up a thunk for each unmanaged calli by signature, so the generator + /// has to emit one for every function pointer signature an app calls through, whatever the + /// calling convention spelling. + /// + [Fact] + public void PortableCallHelpersGeneratorEmitsThunksForUnmanagedCalliSites() + { + string source = """ + public struct FloatPair + { + public float X; + public float Y; + } + + public static unsafe class Calls + { + public static FloatPair MemberFunction(delegate* unmanaged[Cdecl, MemberFunction] fn) + => fn(null, 1); + + public static int ThisCall(delegate* unmanaged[Thiscall] fn) + => fn(null, 1, 2, 3); + + public static double Managed(delegate* fn) + => fn(1, 2, 3, 4, 5, 6); + + public static T Generic(delegate* unmanaged fn) + => fn(); + } + """; + + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(workingDirectory); + + try + { + string inputAssembly = CompileCallbackAssembly(source, Path.Combine(workingDirectory, "Calls.dll"), allowUnsafe: true); + 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: true)); + + Assert.True(exitCode == 0, log.ToString()); + + string thunks = File.ReadAllText(Path.Combine(outputDirectory, "callhelpers-interp-to-managed.cpp")); + Assert.Contains("\"MS8il\"", thunks); + Assert.Contains("\"Miiddd\"", thunks); + Assert.DoesNotContain("\"Mdiiiiii\"", thunks); + } + finally + { + try + { + Directory.Delete(workingDirectory, recursive: true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + } + } + } + /// /// 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. /// - private static string CompileCallbackAssembly(string source, string outputPath) + private static string CompileCallbackAssembly(string source, string outputPath, bool allowUnsafe = false) { CSharpCompilation compilation = CSharpCompilation.Create( Path.GetFileNameWithoutExtension(outputPath), new[] { CSharpSyntaxTree.ParseText(source) }, new[] { MetadataReference.CreateFromFile(TestPaths.SystemPrivateCoreLibPath) }, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: allowUnsafe)); EmitResult result = compilation.Emit(outputPath); Assert.True(result.Success, diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index f83b04e7a8b89c..b5b1f6aa0f8577 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -6,6 +6,7 @@ using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; +using Internal.IL; using Internal.JitInterface; using Internal.TypeSystem; using Internal.TypeSystem.Ecma; @@ -138,6 +139,7 @@ public void CollectPInvokes(List pinvokes, List ca CollectPInvokesForMethod(method); if (IsMethodCallback(method)) callbacks.Add(new PInvokeCallback(method)); + CollectUnmanagedCalliSignatures(signatures, method); } catch (Exception ex) when (ex is not LogAsErrorException) { @@ -178,6 +180,82 @@ void CollectPInvokesForMethod(EcmaMethod method) } } + /// + /// Adds the signature of every calli through an unmanaged function pointer in the + /// method's body. The interpreter calls those through the same thunks as P/Invokes, and a + /// function pointer's signature appears nowhere else the scan looks. + /// + private void CollectUnmanagedCalliSignatures(Dictionary signatures, EcmaMethod method) + { + if (method.IsPInvoke || method.IsAbstract) + return; + + MethodIL methodIL = EcmaMethodIL.Create(method); + if (methodIL is null) + return; + + ILReader reader = new ILReader(methodIL.GetILBytes()); + while (reader.HasNext) + { + ILOpcode opcode = reader.ReadILOpcode(); + if (opcode != ILOpcode.calli) + { + reader.Skip(opcode); + continue; + } + + if (methodIL.GetObject(reader.ReadILToken(), NotFoundBehavior.ReturnNull) is not MethodSignature calliSignature + || (calliSignature.Flags & MethodSignatureFlags.UnmanagedCallingConventionMask) is 0 or MethodSignatureFlags.CallingConventionVarargs) + { + continue; + } + + if (IsUnsupportedOnPlatform(method)) + return; + + // A signature over the enclosing generic parameters has no single native shape. + if (ContainsSignatureVariables(calliSignature)) + { + log.Verbose($"Skipping generic unmanaged calli in '{method.OwningType}.{method.Name.ToString()}'"); + continue; + } + + string signature; + try + { + // WasmLowering only infers the native lowering from the extensible 'unmanaged' + // convention, so name it for the cdecl, stdcall and thiscall encodings too. + signature = WasmLowering.GetSignature(calliSignature, WasmLowering.LoweringFlags.IsUnmanagedCallersOnly).SignatureString; + foreach (string token in InteropSignature.ParseSignatureTokens(signature)) + InteropSignature.TokenToNativeType(token); + } + catch (Exception ex) when (ex is InvalidSignatureCharException or LogAsErrorException or TypeSystemException) + { + // A P/Invoke with such a signature is a build error, but a calli is only a call + // site that may never run. Leave it to fail at run time rather than fail the build. + log.Verbose($"Skipping unmanaged calli in '{method.OwningType}.{method.Name.ToString()}': {ex.Message}"); + continue; + } + + if (signatures.TryAdd(signature, method)) + log.Verbose($"Adding calli signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); + } + + static bool ContainsSignatureVariables(MethodSignature signature) + { + if (signature.ReturnType.ContainsSignatureVariables()) + return true; + + for (int i = 0; i < signature.Length; i++) + { + if (signature[i].ContainsSignatureVariables()) + return true; + } + + return false; + } + } + private void AddSignature(Dictionary signatures, MethodDesc method, WasmLowering.LoweringFlags flags, string kind) { string signature = InteropSignature.GetMethodSignature(method, flags); diff --git a/src/coreclr/vm/wasm/helpers.cpp b/src/coreclr/vm/wasm/helpers.cpp index ceb910f5dd5b6f..7d0422bf777b39 100644 --- a/src/coreclr/vm/wasm/helpers.cpp +++ b/src/coreclr/vm/wasm/helpers.cpp @@ -1276,6 +1276,7 @@ namespace case IMAGE_CEE_CS_CALLCONV_DEFAULT: case IMAGE_CEE_CS_CALLCONV_C: case IMAGE_CEE_CS_CALLCONV_STDCALL: + case IMAGE_CEE_CS_CALLCONV_THISCALL: case IMAGE_CEE_CS_CALLCONV_FASTCALL: case IMAGE_CEE_CS_CALLCONV_UNMANAGED: break; From 73af018816cb024165ab52ef0b8432f2dda58e0c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 28 Sep 2026 20:03:34 -0500 Subject: [PATCH 2/6] Re-enable browser CoreCLR callconv MemberFunction/ThisCall tests Reverts the test disables added in #133571 now that unmanaged calli thunks are generated and thiscall is accepted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../callconv/CdeclMemberFunction/CdeclMemberFunctionTest.cs | 2 -- .../CdeclMemberFunction/CdeclMemberFunctionTest.csproj | 3 --- .../PlatformDefaultMemberFunctionTest.cs | 1 - .../PlatformDefaultMemberFunctionTest.csproj | 3 --- .../StdCallMemberFunction/StdCallMemberFunctionTest.cs | 2 -- .../StdCallMemberFunction/StdCallMemberFunctionTest.csproj | 3 --- src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.csproj | 5 ----- 7 files changed, 19 deletions(-) diff --git a/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.cs b/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.cs index 99d4ad905f22bd..038d5853e50f4d 100644 --- a/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.cs +++ b/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.cs @@ -7,7 +7,6 @@ using Xunit; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using TestLibrary; unsafe class CdeclMemberFunctionNative { @@ -68,7 +67,6 @@ public unsafe class CdeclMemberFunctionTest { [Fact] [ActiveIssue("https://github.com/dotnet/runtime/issues/50440", TestPlatforms.Windows, runtimes: TestRuntimes.Mono)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/131811", typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser), nameof(PlatformDetection.IsCoreCLR))] public static int TestEntryPoint() { try diff --git a/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.csproj b/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.csproj index a55d78d80bc6a8..00eaa467d6d71d 100644 --- a/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.csproj +++ b/src/tests/JIT/Directed/callconv/CdeclMemberFunction/CdeclMemberFunctionTest.csproj @@ -1,8 +1,5 @@ - - false true true diff --git a/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.cs b/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.cs index 8d8a03acc117c9..f8ed6ff7dc95a5 100644 --- a/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.cs +++ b/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.cs @@ -67,7 +67,6 @@ public enum E : uint public unsafe class PlatformDefaultMemberFunctionTest { [ActiveIssue("https://github.com/dotnet/runtime/issues/50440", TestPlatforms.Windows, runtimes: TestRuntimes.Mono)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/131811", typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser), nameof(PlatformDetection.IsCoreCLR))] [Fact] public static int TestEntryPoint() { diff --git a/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.csproj b/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.csproj index a55d78d80bc6a8..00eaa467d6d71d 100644 --- a/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.csproj +++ b/src/tests/JIT/Directed/callconv/PlatformDefaultMemberFunction/PlatformDefaultMemberFunctionTest.csproj @@ -1,8 +1,5 @@ - - false true true diff --git a/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.cs b/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.cs index 5bd075f8d54d0c..7b9cb8b7638229 100644 --- a/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.cs +++ b/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.cs @@ -7,7 +7,6 @@ using Xunit; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using TestLibrary; unsafe class StdCallMemberFunctionNative { @@ -68,7 +67,6 @@ public unsafe class StdCallMemberFunctionTest { [Fact] [ActiveIssue("https://github.com/dotnet/runtime/issues/50440", TestPlatforms.Windows, runtimes: TestRuntimes.Mono)] - [ActiveIssue("https://github.com/dotnet/runtime/issues/131811", typeof(PlatformDetection), nameof(PlatformDetection.IsBrowser), nameof(PlatformDetection.IsCoreCLR))] public static int TestEntryPoint() { try diff --git a/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.csproj b/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.csproj index a55d78d80bc6a8..00eaa467d6d71d 100644 --- a/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.csproj +++ b/src/tests/JIT/Directed/callconv/StdCallMemberFunction/StdCallMemberFunctionTest.csproj @@ -1,8 +1,5 @@ - - false true true diff --git a/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.csproj b/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.csproj index 9dc3602543121d..c943b6059364df 100644 --- a/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.csproj +++ b/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.csproj @@ -1,10 +1,5 @@ - - false - - true true true From 05ab272474934bffaf0a48fad573caeba8a95c25 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 28 Sep 2026 23:13:15 -0500 Subject: [PATCH 3/6] [wasm] Return by-reference structs through a hidden pointer in reverse thunks The wasm C ABI returns a struct that isn't a single scalar through a hidden leading pointer and no return value. The reverse thunk generator declared such returns as 'void *', so native code calling an [UnmanagedCallersOnly] method returning e.g. a two-float struct trapped with a function signature mismatch (and the interpreter would have written the struct into a 4-byte local). Take the sret pointer as the first parameter and pass it to the interpreter as the return buffer, matching the P/Invoke declaration path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../WasmArgumentLayoutTests.cs | 77 +++++++++++++++++++ .../PInvokeTableGenerator.cs | 26 +++++-- 2 files changed, 95 insertions(+), 8 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 9420532823d550..7f92dd8e47cc5e 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -854,6 +854,83 @@ public static T Generic(delegate* unmanaged fn) } } + /// + /// Native code calls a reverse thunk with the wasm C ABI, which returns a struct it cannot pass as a + /// single scalar through a hidden leading pointer instead of a return value. The thunk has to take + /// that pointer and hand it to the interpreter as the return buffer, or the indirect call from + /// native code traps with a signature mismatch. + /// + [Fact] + public void PortableCallHelpersGeneratorReturnsStructsThroughHiddenPointerInReverseThunks() + { + string source = """ + using System.Runtime.InteropServices; + + public struct FloatPair + { + public float X; + public float Y; + } + + public struct Width + { + public float Value; + } + + public static class Callbacks + { + [UnmanagedCallersOnly] + public static FloatPair GetPair(nint self, int unused) => default; + + [UnmanagedCallersOnly] + public static Width GetWidth(nint self) => default; + + [UnmanagedCallersOnly(EntryPoint = "export_pair")] + public static FloatPair ExportPair(int value) => default; + } + """; + + 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.True(exitCode == 0, log.ToString()); + + string reverse = File.ReadAllText(Path.Combine(outputDirectory, "callhelpers-reverse.cpp")); + Assert.Contains("static void Call_Callbacks__Callbacks_GetPair_I32_I32_RetS8(void * sret, void * arg0, int32_t arg1)", reverse); + Assert.Contains("static float Call_Callbacks__Callbacks_GetWidth_I32_RetF32(void * arg0)", reverse); + Assert.Contains("extern \"C\" void export_pair(void * sret, int32_t arg0)", reverse); + Assert.Contains("Call_Callbacks__Callbacks_ExportPair_I32_RetS8(sret, arg0);", reverse); + Assert.Contains("(int8_t*)sret, (PCODE)&Call_Callbacks__Callbacks_GetPair_I32_I32_RetS8", reverse); + Assert.DoesNotContain("RetS8(void * arg", reverse); + } + finally + { + try + { + Directory.Delete(workingDirectory, recursive: true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + } + } + } + /// /// 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. diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index db666466761c82..54bbf2a3ea7280 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -284,15 +284,25 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) ? $"\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")) : 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 struct the wasm C ABI returns by reference arrives as a hidden leading pointer to + // caller-owned storage, with no return value, so the interpreter writes straight into it. + bool returnsViaBuffer = IsPassedByReference(cb.ReturnType); + bool hasResult = !cb.IsVoid && !returnsViaBuffer; + string returnCType = returnsViaBuffer ? "void" : MapType(cb.ReturnType); + string retArg = returnsViaBuffer ? "(int8_t*)sret" : hasResult ? "(int8_t*)&result" : "nullptr"; + string parametersDeclaration = string.Join(", ", + (returnsViaBuffer ? ["void * sret"] : Array.Empty()) + .Concat(parameterCTypes.Select((p, i) => $"{p} arg{i}"))); + string arguments = string.Join(", ", + (returnsViaBuffer ? ["sret"] : Array.Empty()) + .Concat(Enumerable.Range(0, parameterCount).Select(i => $"arg{i}"))); string exportFunction = cb.IsExport ? $$""" - extern "C" {{MapType(cb.ReturnType)}} {{cb.EntryPoint}}({{parametersDeclaration}}) + extern "C" {{returnCType}} {{cb.EntryPoint}}({{parametersDeclaration}}) { - {{(cb.IsVoid ? "" : "return ")}}Call_{{cb.EntrySymbol}}({{arguments}}); + {{(hasResult ? "return " : "")}}Call_{{cb.EntrySymbol}}({{arguments}}); } """ : string.Empty; w.Write( @@ -300,16 +310,16 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr; static {{ - MapType(cb.ReturnType)}} Call_{{cb.EntrySymbol}}({{parametersDeclaration}}) + returnCType}} 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;" : "")}} - ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{(cb.IsVoid ? "nullptr" : "(int8_t*)&result")}}, (PCODE)&Call_{{cb.EntrySymbol}});{{ - (!cb.IsVoid ? $"{w.NewLine} return result;" : "")}} + (hasResult ? $"{w.NewLine}{w.NewLine} {returnCType} result;" : "")}} + ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{retArg}}, (PCODE)&Call_{{cb.EntrySymbol}});{{ + (hasResult ? $"{w.NewLine} return result;" : "")}} }{{exportFunction}} """); From b253ff39aa96d68dbca7edcb346e2ec81e5f83e1 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 28 Sep 2026 23:19:15 -0500 Subject: [PATCH 4/6] Skip ThisCall delegate-reverse cases on CoreCLR wasm Marshal.GetFunctionPointerForDelegate needs a stub created at run time, which CoreCLR on wasm cannot allocate. Keep the forward and UnmanagedCallersOnly cases running there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Directed/callconv/ThisCall/ThisCallTest.cs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs b/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs index c0cd4b09813020..f754eb1fb480fb 100644 --- a/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs +++ b/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs @@ -95,11 +95,16 @@ public static int TestEntryPoint() Test4ByteNonHFA(instance); TestEnum(instance); TestCLong(instance); - Test8ByteHFAReverse(); - Test4ByteHFAReverse(); - Test4ByteNonHFAReverse(); - TestEnumReverse(); - TestCLongReverse(); + // Marshal.GetFunctionPointerForDelegate needs a stub created at run time, which CoreCLR + // on wasm cannot allocate. + if (!(PlatformDetection.IsWasm && PlatformDetection.IsCoreCLR)) + { + Test8ByteHFAReverse(); + Test4ByteHFAReverse(); + Test4ByteNonHFAReverse(); + TestEnumReverse(); + TestCLongReverse(); + } Test8ByteHFAUnmanagedCallersOnly(); Test4ByteHFAUnmanagedCallersOnly(); Test4ByteNonHFAUnmanagedCallersOnly(); From 9930ff3d5f1326c13928377506df95173a0055a9 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 29 Sep 2026 11:45:58 +0200 Subject: [PATCH 5/6] Update src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs Co-authored-by: Jan Kotas --- src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs b/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs index f754eb1fb480fb..06dc917a6ae08b 100644 --- a/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs +++ b/src/tests/JIT/Directed/callconv/ThisCall/ThisCallTest.cs @@ -97,7 +97,7 @@ public static int TestEntryPoint() TestCLong(instance); // Marshal.GetFunctionPointerForDelegate needs a stub created at run time, which CoreCLR // on wasm cannot allocate. - if (!(PlatformDetection.IsWasm && PlatformDetection.IsCoreCLR)) + if (!PlatformDetection.IsWasm) { Test8ByteHFAReverse(); Test4ByteHFAReverse(); From 470aac15899bddf636117e5cd0bd303fa51cc85e Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Tue, 29 Sep 2026 11:04:44 -0500 Subject: [PATCH 6/6] [wasm] Infer native lowering from any unmanaged calling convention WasmLowering.GetSignature used HasFlag(UnmanagedCallingConvention) to detect unmanaged signatures, but the calling convention is an enum value in the low nibble, not a bitmask, so cdecl/stdcall/thiscall signatures were lowered as managed. Compare the masked value instead and drop the workaround in the calli collector. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tools/Common/JitInterface/WasmLowering.cs | 2 +- .../WasmArgumentLayoutTests.cs | 16 ++++++++++++++++ .../PortableCallHelpers/PInvokeCollector.cs | 4 +--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index d2aeae4e12c266..6cda8a7b3c1ff2 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -668,7 +668,7 @@ public enum LoweringFlags public static WasmSignature GetSignature(MethodSignature signature, LoweringFlags flags) { - if (!flags.HasFlag(LoweringFlags.IsUnmanagedCallersOnly) && signature.Flags.HasFlag(MethodSignatureFlags.UnmanagedCallingConvention)) + if ((signature.Flags & MethodSignatureFlags.UnmanagedCallingConventionMask) is not 0 and not MethodSignatureFlags.CallingConventionVarargs) { flags |= LoweringFlags.IsUnmanagedCallersOnly; } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 7f92dd8e47cc5e..d3f7c5e601e569 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -108,6 +108,22 @@ public void ClosedStaticDelegateSignaturePreservesHiddenArguments(WasmLowering.L Assert.True(lowered.FuncType.Returns.Types.IsEmpty); } + [Theory] + [InlineData(MethodSignatureFlags.None, "iip")] + [InlineData(MethodSignatureFlags.CallingConventionVarargs, "iip")] + [InlineData(MethodSignatureFlags.UnmanagedCallingConventionCdecl, "ii")] + [InlineData(MethodSignatureFlags.UnmanagedCallingConventionStdCall, "ii")] + [InlineData(MethodSignatureFlags.UnmanagedCallingConventionThisCall, "ii")] + [InlineData(MethodSignatureFlags.UnmanagedCallingConvention, "ii")] + public void SignatureCallingConventionSelectsLowering(MethodSignatureFlags callingConvention, string expectedSignature) + { + ReadyToRunCompilerContext context = CreateWasmContext(); + TypeDesc int32Type = context.GetWellKnownType(WellKnownType.Int32); + MethodSignature signature = new MethodSignature(callingConvention | MethodSignatureFlags.Static, 0, int32Type, [int32Type]); + + Assert.Equal(expectedSignature, WasmLowering.GetSignature(signature, WasmLowering.LoweringFlags.None).SignatureString); + } + [Theory] [InlineData(MethodSignatureFlags.None)] [InlineData(MethodSignatureFlags.CallingConventionVarargs)] diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index b5b1f6aa0f8577..4b619929dfb3e0 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -223,9 +223,7 @@ private void CollectUnmanagedCalliSignatures(Dictionary sign string signature; try { - // WasmLowering only infers the native lowering from the extensible 'unmanaged' - // convention, so name it for the cdecl, stdcall and thiscall encodings too. - signature = WasmLowering.GetSignature(calliSignature, WasmLowering.LoweringFlags.IsUnmanagedCallersOnly).SignatureString; + signature = WasmLowering.GetSignature(calliSignature, WasmLowering.LoweringFlags.None).SignatureString; foreach (string token in InteropSignature.ParseSignatureTokens(signature)) InteropSignature.TokenToNativeType(token); }