From ed840e21d82bd383434536372776c8f2d24b8bff Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 5 Aug 2026 13:38:41 +0200 Subject: [PATCH 01/72] Compute wasm struct sizes with crossgen2's type system The CoreCLR wasm P/Invoke generator computed ABI signatures from System.Reflection.MetadataLoadContext, which has no field-layout engine. Struct sizes therefore came from a 7-entry hardcoded table (s_knownStructSizes) and anything else was a hard error (WASM0067). Replace that table with crossgen2's own field-layout algorithms, so the S encoding is computed rather than looked up. The size crossgen2 encodes is TypeDesc.GetElementSize(), backed by Internal.TypeSystem. That is not a separable formula, so the change reuses the type system itself: - Split WasmTypes.cs and WasmLowering.cs so the ABI-lowering half no longer pulls in ILCompiler.ObjectWriter and Internal.JitInterface, and introduce IWasmTypeCacheContext to replace hard casts to CompilerTypeSystemContext. - Extract crossgen2's wasm-aware VectorOfTFieldLayoutAlgorithm from ReadyToRunCompilerContext.cs into its own file. It differs from ILC's copy in that Vector keeps 16-byte alignment on Wasm32, which only shows up in the layout of containing structs. - Add ILCompiler.Wasm.Lowering, a small tool with its own MetadataTypeSystemContext that links those algorithms. WasmAppBuilder multi-targets net11.0 and net472, and Sdk.targets.in loads the net472 copy under MSBuild.exe, where a netcoreapp type-system assembly cannot load. The tool therefore runs out of process and answers one metadata token per line. The task locates it by probing two paths relative to its own directory, which covers the in-tree, Helix and SDK pack layouts without any consumer passing a path. WasmLoweringParityTests loads both stacks side by side and asserts they agree on the formerly hardcoded structs, on every CoreLib value type, and on generic instantiations. Single-field structs with trailing padding now correctly encode as S; the old code recursed into the field and returned a primitive char. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- Directory.Build.props | 4 + .../CompilerTypeSystemContext.Wasm.cs | 2 +- .../Target_Wasm/WasmTypes.Encoding.cs | 144 +++++++++ .../Target_Wasm/WasmTypes.cs | 134 +------- .../Common/Compiler/IWasmTypeCacheContext.cs | 48 +++ .../JitInterface/WasmLowering.MethodDesc.cs | 43 +++ .../tools/Common/JitInterface/WasmLowering.cs | 45 +-- .../ILCompiler.Compiler.csproj | 2 + .../ILCompiler.ReadyToRun.Tests.csproj | 3 + .../WasmLoweringParityTests.cs | 299 ++++++++++++++++++ .../Compiler/ReadyToRunCompilerContext.cs | 127 -------- .../Compiler/VectorOfTFieldLayoutAlgorithm.cs | 137 ++++++++ .../ILCompiler.ReadyToRun.csproj | 4 + .../ILCompiler.RyuJit.csproj | 3 + .../ILCompiler.TypeSystem.csproj | 5 + .../ILCompiler.Wasm.Lowering.csproj | 45 +++ .../aot/ILCompiler.Wasm.Lowering/Program.cs | 121 +++++++ .../WasmAbiTypeResolver.cs | 78 +++++ .../WasmMetadataFieldLayoutAlgorithm.cs | 54 ++++ .../WasmTypeSystemContext.cs | 185 +++++++++++ ...rosoft.NET.Runtime.WebAssembly.Sdk.pkgproj | 8 + ...t.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj | 8 + .../WasmAppBuilder/IcallTableGenerator.cs | 8 +- .../WasmAppBuilder/WasmAppBuilder.csproj | 10 + .../coreclr/IWasmAbiTypeResolver.cs | 20 ++ .../coreclr/InternalCallSignatureCollector.cs | 9 +- .../coreclr/ManagedToNativeGenerator.cs | 83 ++++- .../coreclr/PInvokeCollector.cs | 8 +- .../coreclr/PInvokeTableGenerator.cs | 22 +- .../WasmAppBuilder/coreclr/SignatureMapper.cs | 76 ++--- .../coreclr/WasmAbiTypeResolver.cs | 222 +++++++++++++ 31 files changed, 1602 insertions(+), 355 deletions(-) create mode 100644 src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs create mode 100644 src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs create mode 100644 src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj create mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs create mode 100644 src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs create mode 100644 src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs diff --git a/Directory.Build.props b/Directory.Build.props index e10a9be6011df7..1cb776d6d7086a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -160,6 +160,10 @@ $([MSBuild]::NormalizePath('$(WasmAppBuilderDir)', 'WasmAppBuilder.dll')) $([MSBuild]::NormalizePath('$(WasmBuildTasksDir)', 'WasmBuildTasks.dll')) $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'WasmAppHost', 'wasm', '$(Configuration)')) + + $([MSBuild]::NormalizeDirectory('$(WasmAppBuilderDir)', 'ILCompiler.Wasm.Lowering')) $([MSBuild]::NormalizePath('$(WorkloadBuildTasksDir)', 'WorkloadBuildTasks.dll')) $([MSBuild]::NormalizePath('$(LibraryBuilderDir)', 'LibraryBuilder.dll')) $([MSBuild]::NormalizePath('$(MonoAOTCompilerDir)', 'MonoAOTCompiler.dll')) diff --git a/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs b/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs index ef6ff28c2769f6..d8eb9f1f12815d 100644 --- a/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs +++ b/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs @@ -7,7 +7,7 @@ namespace ILCompiler { - public partial class CompilerTypeSystemContext + public partial class CompilerTypeSystemContext : IWasmTypeCacheContext { private readonly object _structCacheLock = new object(); private readonly Dictionary _structsBySize = new Dictionary(); diff --git a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs new file mode 100644 index 00000000000000..5644ad08e3fc62 --- /dev/null +++ b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// Binary encoding, name mangling, and JIT interface conversions for the wasm type model. +// These are split out of WasmTypes.cs because they pull in the object writer, the name mangler, +// and the JIT interface, none of which a tool that only computes signatures can reference. + +using System; +using System.Diagnostics; + +using ILCompiler.ObjectWriter; +using Internal.JitInterface; + +namespace ILCompiler.DependencyAnalysis.Wasm +{ + public static partial class WasmValueTypeExtensions + { + public static WasmValueType FromCorInfoType(CorInfoWasmType ty) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan((int)ty, byte.MaxValue); + if (Enum.IsDefined(typeof(WasmValueType), (byte)ty)) + { + return (WasmValueType)ty; + } + else + { + throw new InvalidOperationException("Unsupported CorInfoWasmType: " + ty); + } + } + } + + public readonly partial struct WasmResultType + { + public int EncodeSize() + { + uint sizeLength = DwarfHelper.SizeOfULEB128((ulong)_types.Length); + return (int)(sizeLength + (uint)_types.Length); + } + + public int Encode(Span buffer) + { + int sizeLength = DwarfHelper.WriteULEB128(buffer, (ulong)_types.Length); + Span rest = buffer.Slice(sizeLength); + for (int i = 0; i < _types.Length; i++) + { + rest[i] = (byte)_types[i]; + } + return (int)(sizeLength + (uint)_types.Length); + } + + public void AppendMangledName(Internal.Text.Utf8StringBuilder sb, bool isReturn = false) + { + if (isReturn && _types.Length == 0) + { + sb.Append("v"); + return; + } + + foreach (var type in _types) + { + sb.Append(type switch + { + WasmValueType.V128 => 'V', + WasmValueType.F64 => 'd', + WasmValueType.F32 => 'f', + WasmValueType.I64 => 'j', + WasmValueType.I32 => 'i', + _ => throw new NotImplementedException($"Unknown WasmValueType: {type}"), + }); + } + } + } + + public partial struct WasmFuncType + { + public static WasmFuncType FromCorInfoSignature(CorInfoWasmType[] types) + { + WasmResultType rs; + if (types.Length == 0) + { + throw new ArgumentException("Signature must have at least one type for the return value"); + } + + // The first type is the return type + rs = types[0] switch + { + // "void" is actually encoded as an empty type list in Wasm + CorInfoWasmType.CORINFO_WASM_TYPE_VOID => new WasmResultType(Array.Empty()), + _ => new WasmResultType([WasmValueTypeExtensions.FromCorInfoType(types[0])]) + }; + + // The rest are parameter types + WasmResultType ps; + if (types.Length > 1) + { + WasmValueType[] paramTypes = new WasmValueType[types.Length - 1]; + int idx = 0; + foreach (CorInfoWasmType paramType in types.AsSpan().Slice(1)) + { + paramTypes[idx++] = WasmValueTypeExtensions.FromCorInfoType(paramType); + } + ps = new WasmResultType(paramTypes); + } + else + { + ps = new WasmResultType(Array.Empty()); + } + + return new WasmFuncType(ps, rs); + } + + public readonly int EncodeSize() + { + return 1 + _params.EncodeSize() + _returns.EncodeSize(); + } + + public readonly int Encode(Span buffer) + { + int totalSize = EncodeSize(); + buffer[0] = 0x60; // function type indicator + + int paramSize = _params.Encode(buffer.Slice(1)); + int returnSize = _returns.Encode(buffer.Slice(1 + paramSize)); + Debug.Assert(totalSize == 1 + paramSize + returnSize); + + return totalSize; + } + + public void AppendMangledName(NameMangler nameMangler, Internal.Text.Utf8StringBuilder sb) + { + sb.Append(nameMangler.CompilationUnitPrefix); + sb.Append("__wasmtype_"u8); + _returns.AppendMangledName(sb, isReturn: true); + _params.AppendMangledName(sb); + } + + public Internal.Text.Utf8String GetMangledName(NameMangler mangler) + { + Internal.Text.Utf8StringBuilder sb = new(); + AppendMangledName(mangler, sb); + return sb.ToUtf8String(); + } + } +} diff --git a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs index 30944c04ce70cf..7acb6e8d36d007 100644 --- a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs +++ b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs @@ -1,13 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +// This file holds the wasm type model that describes a signature. It is deliberately free of any +// dependency outside the type system so that it can be linked into tools that only need to compute +// signatures (see ILCompiler.Wasm.Lowering). Binary encoding, name mangling, and the JIT interface +// conversions live in WasmTypes.Encoding.cs. + using System; using System.Diagnostics; using System.Linq; -using ILCompiler.ObjectWriter; -using Internal.JitInterface; - namespace ILCompiler.DependencyAnalysis.Wasm { // For now, we only encode Wasm numeric value types. @@ -30,7 +32,7 @@ public enum WasmMutabilityType : byte Mut = 0x01 } - public static class WasmValueTypeExtensions + public static partial class WasmValueTypeExtensions { public static string ToTypeString(this WasmValueType valueType) { @@ -44,23 +46,10 @@ public static string ToTypeString(this WasmValueType valueType) _ => "unknown", }; } - - public static WasmValueType FromCorInfoType(CorInfoWasmType ty) - { - ArgumentOutOfRangeException.ThrowIfGreaterThan((int)ty, byte.MaxValue); - if (Enum.IsDefined(typeof(WasmValueType), (byte)ty)) - { - return (WasmValueType)ty; - } - else - { - throw new InvalidOperationException("Unsupported CorInfoWasmType: " + ty); - } - } } #nullable enable - public readonly struct WasmResultType : IEquatable, IComparable + public readonly partial struct WasmResultType : IEquatable, IComparable { private readonly WasmValueType[] _types; public ReadOnlySpan Types => _types; @@ -95,46 +84,7 @@ public override int GetHashCode() return code; } - public int EncodeSize() - { - uint sizeLength = DwarfHelper.SizeOfULEB128((ulong)_types.Length); - return (int)(sizeLength + (uint)_types.Length); - } - - public int Encode(Span buffer) - { - int sizeLength = DwarfHelper.WriteULEB128(buffer, (ulong)_types.Length); - Span rest = buffer.Slice(sizeLength); - for (int i = 0; i < _types.Length; i++) - { - rest[i] = (byte)_types[i]; - } - return (int)(sizeLength + (uint)_types.Length); - } - public int CompareTo(WasmResultType other) => MemoryExtensions.SequenceCompareTo(Types, other.Types); - - public void AppendMangledName(Internal.Text.Utf8StringBuilder sb, bool isReturn = false) - { - if (isReturn && _types.Length == 0) - { - sb.Append("v"); - return; - } - - foreach (var type in _types) - { - sb.Append(type switch - { - WasmValueType.V128 => 'V', - WasmValueType.F64 => 'd', - WasmValueType.F32 => 'f', - WasmValueType.I64 => 'j', - WasmValueType.I32 => 'i', - _ => throw new NotImplementedException($"Unknown WasmValueType: {type}"), - }); - } - } } public static class WasmResultTypeExtensions @@ -181,7 +131,7 @@ public int CompareTo(WasmSignature other) public static bool operator !=(WasmSignature left, WasmSignature right) => !left.Equals(right); } - public struct WasmFuncType : IEquatable, IComparable + public partial struct WasmFuncType : IEquatable, IComparable { private readonly WasmResultType _params; private readonly WasmResultType _returns; @@ -197,59 +147,6 @@ public WasmFuncType(WasmResultType paramTypes, WasmResultType returnTypes) _returns = returnTypes; } - public static WasmFuncType FromCorInfoSignature(CorInfoWasmType[] types) - { - WasmResultType rs; - if (types.Length == 0) - { - throw new ArgumentException("Signature must have at least one type for the return value"); - } - - // The first type is the return type - rs = types[0] switch - { - // "void" is actually encoded as an empty type list in Wasm - CorInfoWasmType.CORINFO_WASM_TYPE_VOID => new WasmResultType(Array.Empty()), - _ => new WasmResultType([WasmValueTypeExtensions.FromCorInfoType(types[0])]) - }; - - // The rest are parameter types - WasmResultType ps; - if (types.Length > 1) - { - WasmValueType[] paramTypes = new WasmValueType[types.Length - 1]; - int idx = 0; - foreach (CorInfoWasmType paramType in types.AsSpan().Slice(1)) - { - paramTypes[idx++] = WasmValueTypeExtensions.FromCorInfoType(paramType); - } - ps = new WasmResultType(paramTypes); - } - else - { - ps = new WasmResultType(Array.Empty()); - } - - return new WasmFuncType(ps, rs); - } - - public readonly int EncodeSize() - { - return 1 + _params.EncodeSize() + _returns.EncodeSize(); - } - - public readonly int Encode(Span buffer) - { - int totalSize = EncodeSize(); - buffer[0] = 0x60; // function type indicator - - int paramSize = _params.Encode(buffer.Slice(1)); - int returnSize = _returns.Encode(buffer.Slice(1 + paramSize)); - Debug.Assert(totalSize == 1 + paramSize + returnSize); - - return totalSize; - } - public bool Equals(WasmFuncType other) { return _params.Equals(other._params) && _returns.Equals(other._returns); @@ -293,20 +190,5 @@ public int CompareTo(WasmFuncType other) return paramComparison; return _returns.CompareTo(other._returns); } - - public void AppendMangledName(NameMangler nameMangler, Internal.Text.Utf8StringBuilder sb) - { - sb.Append(nameMangler.CompilationUnitPrefix); - sb.Append("__wasmtype_"u8); - _returns.AppendMangledName(sb, isReturn: true); - _params.AppendMangledName(sb); - } - - public Internal.Text.Utf8String GetMangledName(NameMangler mangler) - { - Internal.Text.Utf8StringBuilder sb = new(); - AppendMangledName(mangler, sb); - return sb.ToUtf8String(); - } } } diff --git a/src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs b/src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs new file mode 100644 index 00000000000000..44654599975b1c --- /dev/null +++ b/src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Internal.TypeSystem; + +namespace ILCompiler +{ + /// + /// The side table wasm signature lowering needs in order to be reversible. Lowering erases a + /// struct down to its size (the S<N> encoding) and an empty struct down to + /// 'e', so raising a signature back to a needs a real type + /// to hand back. Lowering records the types it saw here and raising looks them up. + /// + /// This is an interface rather than a direct reference + /// so that can be linked into tools that only + /// compute signatures and do not want the rest of the compiler. See ILCompiler.Wasm.Lowering. + /// + public interface IWasmTypeCacheContext + { + /// + /// The type the 'V' encoding raises to. All v128 types share the same wasm ABI + /// (16 bytes, 16-byte aligned), so any one of them round-trips 'V' identically. + /// + TypeDesc WasmV128Type { get; } + + /// + /// The first empty struct seen during lowering, or if there was none. + /// + TypeDesc CachedEmptyStruct { get; } + + /// + /// Records an empty struct seen during lowering. Only the first one is retained. + /// + void CacheEmptyStruct(TypeDesc type); + + /// + /// Records a struct seen during lowering, keyed by its element size. Only the first struct + /// encountered for a given size is retained. + /// + void CacheStructBySize(TypeDesc type); + + /// + /// Returns a previously cached struct of the given byte size, or if + /// no struct of that size has been cached. + /// + TypeDesc GetCachedStructOfSize(int size); + } +} diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs new file mode 100644 index 00000000000000..1782d6c7de38cf --- /dev/null +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// The MethodDesc-facing half of wasm signature lowering. It is split out of WasmLowering.cs because +// it needs the compiler's MethodDesc extension methods (Common/Compiler/TypeExtensions.cs and +// MethodExtensions.cs), which a tool that only lowers MethodSignatures does not want to link. + +using ILCompiler; +using ILCompiler.DependencyAnalysis.Wasm; + +using Internal.TypeSystem; + +namespace Internal.JitInterface +{ + public static partial class WasmLowering + { + /// + /// Gets the Wasm-level signature for a given MethodDesc. + /// + public static WasmSignature GetSignature(MethodDesc method) + { + return GetSignature(method.Signature, GetLoweringFlags(method)); + } + + public static LoweringFlags GetLoweringFlags(MethodDesc method) + { + LoweringFlags flags = 0; + if (method.RequiresInstMethodDescArg() || method.RequiresInstMethodTableArg()) + { + flags |= LoweringFlags.HasGenericContextArg; + } + if (method.IsAsyncCall()) + { + flags |= LoweringFlags.IsAsyncCall; + } + if (method.IsUnmanagedCallersOnly) + { + flags |= LoweringFlags.IsUnmanagedCallersOnly; + } + return flags; + } + } +} diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index 592ec82427ad16..73c555f52d2e88 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -207,7 +207,7 @@ public static WasmValueType LowerType(TypeDesc type) /// /// Maps a WasmValueType to its single-character signature encoding. /// - private static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch + internal static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch { WasmValueType.I32 => 'i', WasmValueType.I64 => 'l', @@ -223,7 +223,7 @@ public static WasmValueType LowerType(TypeDesc type) 'l' => context.GetWellKnownType(WellKnownType.Int64), 'f' => context.GetWellKnownType(WellKnownType.Single), 'd' => context.GetWellKnownType(WellKnownType.Double), - 'V' => ((CompilerTypeSystemContext)context).WasmV128Type, + 'V' => ((IWasmTypeCacheContext)context).WasmV128Type, _ => throw new InvalidOperationException($"Unknown signature char: {c}") }; @@ -254,7 +254,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy else if (sig[pos] == 'S') { int structSize = ParseStructSize(sig, ref pos); - returnType = ((CompilerTypeSystemContext)context).GetCachedStructOfSize(structSize); + returnType = ((IWasmTypeCacheContext)context).GetCachedStructOfSize(structSize); Debug.Assert(returnType is not null, $"No cached struct of size {structSize} for return type in signature '{sig}'"); } else @@ -303,7 +303,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy else if (c == 'e') { // Empty struct — include the cached empty struct type for roundtrip fidelity - TypeDesc emptyStruct = ((CompilerTypeSystemContext)context).CachedEmptyStruct; + TypeDesc emptyStruct = ((IWasmTypeCacheContext)context).CachedEmptyStruct; Debug.Assert(emptyStruct is not null, "Encountered 'e' in signature but no empty struct was cached during lowering"); parameters.Add(emptyStruct); pos++; @@ -311,7 +311,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy else if (c == 'S') { int structSize = ParseStructSize(sig, ref pos); - TypeDesc cachedStruct = ((CompilerTypeSystemContext)context).GetCachedStructOfSize(structSize); + TypeDesc cachedStruct = ((IWasmTypeCacheContext)context).GetCachedStructOfSize(structSize); Debug.Assert(cachedStruct is not null, $"No cached struct of size {structSize} for parameter in signature '{sig}'"); parameters.Add(cachedStruct); } @@ -346,7 +346,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy } /// - /// Gets the Wasm-level signature for a given MethodDesc. + /// Gets the Wasm-level signature for a given MethodSignature. /// The signature string format is documented in docs/design/coreclr/botr/readytorun-format.md /// (section "Wasm Signature String Encoding"). /// @@ -356,31 +356,6 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy /// For unmanaged callers only (reverse P/Invoke), the layout is simply the native signature /// which is just the lowered parameters+return. /// - /// - /// - public static WasmSignature GetSignature(MethodDesc method) - { - return GetSignature(method.Signature, GetLoweringFlags(method)); - } - - public static LoweringFlags GetLoweringFlags(MethodDesc method) - { - LoweringFlags flags = 0; - if (method.RequiresInstMethodDescArg() || method.RequiresInstMethodTableArg()) - { - flags |= LoweringFlags.HasGenericContextArg; - } - if (method.IsAsyncCall()) - { - flags |= LoweringFlags.IsAsyncCall; - } - if (method.IsUnmanagedCallersOnly) - { - flags |= LoweringFlags.IsUnmanagedCallersOnly; - } - return flags; - } - [Flags] public enum LoweringFlags { @@ -394,7 +369,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag { if (!flags.HasFlag(LoweringFlags.IsUnmanagedCallersOnly) && signature.Flags.HasFlag(MethodSignatureFlags.UnmanagedCallingConvention)) { - flags = flags | LoweringFlags.IsUnmanagedCallersOnly; + flags |= LoweringFlags.IsUnmanagedCallersOnly; } TypeDesc returnType = signature.ReturnType; @@ -426,7 +401,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag int returnSize = returnType.GetElementSize().AsInt; sigBuilder.Append('S'); sigBuilder.Append(returnSize); - ((CompilerTypeSystemContext)returnType.Context).CacheStructBySize(returnType); + ((IWasmTypeCacheContext)returnType.Context).CacheStructBySize(returnType); } } else if (loweredReturnType.IsVoid) @@ -499,7 +474,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag { // Empty struct — not emitted as a WebAssembly argument sigBuilder.Append('e'); - ((CompilerTypeSystemContext)signature.ReturnType.Context).CacheEmptyStruct(paramType); + ((IWasmTypeCacheContext)signature.ReturnType.Context).CacheEmptyStruct(paramType); continue; } @@ -508,7 +483,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag sigBuilder.Append('S'); sigBuilder.Append(paramSize); result.Add(pointerType); - ((CompilerTypeSystemContext)paramType.Context).CacheStructBySize(paramType); + ((IWasmTypeCacheContext)paramType.Context).CacheStructBySize(paramType); } else { diff --git a/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj b/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj index e26214457ac8c3..58ab89004b9190 100644 --- a/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj +++ b/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj @@ -350,6 +350,8 @@ + + diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj index 9969de865e5fe9..8881ae804dc9e7 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj @@ -24,6 +24,9 @@ + + + + + TypeSystem\Common\VersionResilientHashCode.cs diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj new file mode 100644 index 00000000000000..2ec12bf3c5469f --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj @@ -0,0 +1,45 @@ + + + Exe + ILCompiler.Wasm + ILCompiler.Wasm.Lowering + $(NetCoreAppToolCurrent) + disable + true + AnyCPU + false + false + false + Debug;Release;Checked + false + + $(WasmSignatureResolverDir) + + false + $(NoWarn);CA1859 + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs new file mode 100644 index 00000000000000..b0c51721a0c0cf --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; + +namespace ILCompiler.Wasm +{ + /// + /// A long-lived query server that answers wasm ABI questions about types, for build tasks that + /// cannot reference the type system directly. + /// + /// + /// This exists because the WebAssembly build tasks also run on .NET Framework MSBuild, where a + /// netcoreapp type system assembly cannot be loaded at all. Running it as a separate process keeps + /// one implementation of the ABI rules instead of a second, drifting one in the task. + /// + /// Loading the assembly closure is the expensive part, so the process stays up for the whole build + /// and answers queries on stdin rather than being spawned per type. + /// + /// Usage: + /// ILCompiler.Wasm.Lowering --targetos <browser|wasi> [--assembly <path>]... [@responsefile] + /// + /// Each stdin line is "<assemblySimpleName> <metadataToken>", where the token is the decimal or + /// 0x-prefixed hexadecimal metadata token of a type. Each reply line is either the ABI encoding + /// ('i', 'l', 'f', 'd', 'V' or "S<size>") or '!' followed by an error message. + /// + internal static class Program + { + private static int Main(string[] args) + { + string targetOS = null; + string systemModule = "System.Private.CoreLib"; + var assemblies = new List(); + + try + { + for (int i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "--targetos": + targetOS = args[++i]; + break; + case "--assembly": + assemblies.Add(args[++i]); + break; + case "--systemmodule": + systemModule = args[++i]; + break; + default: + if (args[i].StartsWith('@')) + { + assemblies.AddRange(File.ReadAllLines(args[i].Substring(1))); + break; + } + + Console.Error.WriteLine($"Unrecognized argument '{args[i]}'."); + return 1; + } + } + + if (targetOS is null) + { + Console.Error.WriteLine("Missing required argument --targetos."); + return 1; + } + + assemblies.RemoveAll(string.IsNullOrWhiteSpace); + var resolver = new WasmAbiTypeResolver(targetOS, assemblies, systemModule); + + // Tells the caller the closure loaded, so a startup failure is not mistaken for a + // failure of the first query. + Console.Out.WriteLine("ready"); + Console.Out.Flush(); + + Serve(resolver); + return 0; + } + catch (Exception ex) + { + Console.Error.WriteLine(ex.ToString()); + return 1; + } + } + + private static void Serve(WasmAbiTypeResolver resolver) + { + string line; + while ((line = Console.In.ReadLine()) is not null) + { + if (line.Length == 0) + continue; + + string reply; + try + { + int separator = line.LastIndexOf(' '); + if (separator < 0) + throw new FormatException($"Malformed query '{line}'; expected ' '."); + + string assemblyName = line.Substring(0, separator); + string tokenText = line.Substring(separator + 1); + int metadataToken = tokenText.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? int.Parse(tokenText.Substring(2), System.Globalization.NumberStyles.HexNumber) + : int.Parse(tokenText, System.Globalization.CultureInfo.InvariantCulture); + + reply = resolver.GetAbiToken(assemblyName, metadataToken); + } + catch (Exception ex) + { + reply = "!" + ex.Message.Replace('\r', ' ').Replace('\n', ' '); + } + + Console.Out.WriteLine(reply); + Console.Out.Flush(); + } + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs new file mode 100644 index 00000000000000..645e75e2c2c848 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; + +using Internal.JitInterface; +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace ILCompiler.Wasm +{ + /// + /// Answers "what does this type look like in a wasm ABI signature?" using the same lowering + /// crossgen2 uses, so callers that need to agree with compiled code do not have to reimplement it. + /// + /// + /// Types are identified by assembly simple name plus metadata token rather than by name. Name-based + /// lookup would have to reproduce nested-type and generic name mangling, and would silently pick + /// the wrong type when it got that wrong; a token cannot be ambiguous. + /// + public sealed class WasmAbiTypeResolver + { + private readonly WasmTypeSystemContext _context; + + public WasmAbiTypeResolver(string targetOS, IEnumerable assemblyPaths, string systemModuleName = "System.Private.CoreLib") + { + _context = new WasmTypeSystemContext(ParseTargetOS(targetOS)); + + foreach (string path in assemblyPaths) + { + _context.AddAssemblyPath(path); + } + + _context.SetSystemModule(_context.GetModuleForSimpleName(systemModuleName)); + } + + private static TargetOS ParseTargetOS(string targetOS) => targetOS?.ToLowerInvariant() switch + { + "browser" => TargetOS.Browser, + "wasi" => TargetOS.Wasi, + _ => throw new ArgumentException($"Unsupported wasm target OS '{targetOS}'.", nameof(targetOS)), + }; + + /// + /// Gets the signature encoding for a single type in parameter position: a primitive character + /// (i, l, f, d, V) or S<size> for a struct that is + /// passed by reference. + /// + /// Simple name of the assembly defining the type. + /// The type's metadata token (a TypeDef, TypeRef or TypeSpec token). + public string GetAbiToken(string assemblySimpleName, int metadataToken) + { + var module = (EcmaModule)_context.GetModuleForSimpleName(assemblySimpleName); + TypeDesc type = module.GetType(MetadataTokens.EntityHandle(metadataToken)); + + return GetAbiToken(type); + } + + /// + /// Gets the signature encoding for a type. Public so tests can drive it with types they + /// resolved themselves. + /// + public static string GetAbiToken(TypeDesc type) + { + TypeDesc loweredType = WasmLowering.LowerToAbiType(type); + if (loweredType is null) + { + // Passed by reference; the size is what the callee needs to know. + return string.Create(null, stackalloc char[16], $"S{type.GetElementSize().AsInt}"); + } + + return WasmLowering.WasmValueTypeToSigChar(WasmLowering.LowerType(loweredType)).ToString(); + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs new file mode 100644 index 00000000000000..7cab47b872a123 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Internal.TypeSystem; + +namespace ILCompiler.Wasm +{ + /// + /// Instance field layout matching what crossgen2 computes, so that a struct size resolved here + /// is the same size the compiler will encode into a wasm ABI signature. + /// + /// + /// This mirrors ReadyToRunMetadataFieldLayoutAlgorithm.ComputeInstanceFieldLayout. That type + /// cannot be reused directly because it also implements ReadyToRun static field layout, which drags + /// in the whole compilation-module-group and node-factory machinery. Only the instance side matters + /// for struct sizes, and it is small enough to mirror exactly. + /// + /// WasmLoweringParityTests compares this against a real ReadyToRunCompilerContext over + /// every value type in CoreLib, which is what keeps the mirroring honest if crossgen2 changes. + /// + internal sealed class WasmMetadataFieldLayoutAlgorithm : MetadataFieldLayoutAlgorithm + { + protected override ComputedInstanceFieldLayout ComputeInstanceFieldLayout(MetadataType type, int numInstanceFields) + { + ClassLayoutMetadata layoutMetadata = type.GetClassLayout(); + return layoutMetadata.Kind switch + { + MetadataLayoutKind.CStruct => ComputeCStructFieldLayout(type, numInstanceFields), + MetadataLayoutKind.CUnion => ComputeCUnionFieldLayout(type, numInstanceFields), + MetadataLayoutKind.Explicit => ComputeExplicitFieldLayout(type, numInstanceFields, layoutMetadata), + MetadataLayoutKind.Sequential when !type.ContainsGCPointers => ComputeSequentialFieldLayout(type, numInstanceFields, layoutMetadata), + _ => ComputeAutoFieldLayout(type, numInstanceFields, layoutMetadata), + }; + } + + protected override void PrepareRuntimeSpecificStaticFieldLayout(TypeSystemContext context, ref ComputedStaticFieldLayout layout) + { + layout.GcStatics.Size = context.Target.LayoutPointerSize; + layout.ThreadGcStatics.Size = context.Target.LayoutPointerSize; + } + + protected override void FinalizeRuntimeSpecificStaticFieldLayout(TypeSystemContext context, ref ComputedStaticFieldLayout layout) + { + if (layout.GcStatics.Size == context.Target.LayoutPointerSize) + { + layout.GcStatics.Size = LayoutInt.Zero; + } + if (layout.ThreadGcStatics.Size == context.Target.LayoutPointerSize) + { + layout.ThreadGcStatics.Size = LayoutInt.Zero; + } + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs new file mode 100644 index 00000000000000..9ca4456272c054 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs @@ -0,0 +1,185 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; + +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace ILCompiler.Wasm +{ + /// + /// A minimal configured the way crossgen2 configures itself + /// for a wasm target, so field layout - and therefore struct size - agrees with what the compiler + /// will encode into wasm ABI signatures. + /// + /// + /// Assemblies are resolved from an explicit list of file paths rather than from a probing path, so + /// callers get an error instead of a silently different answer when a reference is missing. + /// + public sealed class WasmTypeSystemContext : MetadataTypeSystemContext, IWasmTypeCacheContext + { + private readonly Dictionary _assemblyPaths = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _modules = new(StringComparer.OrdinalIgnoreCase); + + private readonly WasmMetadataFieldLayoutAlgorithm _metadataFieldLayout = new(); + private readonly VectorFieldLayoutAlgorithm _vectorFieldLayoutAlgorithm; + private readonly VectorOfTFieldLayoutAlgorithm _vectorOfTFieldLayoutAlgorithm; + private readonly Int128FieldLayoutAlgorithm _int128FieldLayoutAlgorithm; + private readonly DecimalFieldLayoutAlgorithm _decimalFieldLayoutAlgorithm; + private readonly TypeWithRepeatedFieldsFieldLayoutAlgorithm _typeWithRepeatedFieldsFieldLayoutAlgorithm; + private readonly MetadataRuntimeInterfacesAlgorithm _metadataRuntimeInterfacesAlgorithm = new(); + private readonly VirtualMethodAlgorithm _virtualMethodAlgorithm = new MetadataVirtualMethodAlgorithm(); + private ArrayOfTRuntimeInterfacesAlgorithm _arrayOfTRuntimeInterfacesAlgorithm; + + private readonly object _structCacheLock = new object(); + private readonly Dictionary _structsBySize = new Dictionary(); + private volatile TypeDesc _cachedEmptyStruct; + private volatile TypeDesc _wasmV128Type; + + public WasmTypeSystemContext(TargetOS targetOS) + : base(new TargetDetails(TargetArchitecture.Wasm32, targetOS, TargetAbi.NativeAot, SimdVectorLength.Vector128Bit)) + { + _vectorFieldLayoutAlgorithm = new VectorFieldLayoutAlgorithm(_metadataFieldLayout); + _vectorOfTFieldLayoutAlgorithm = new VectorOfTFieldLayoutAlgorithm(_metadataFieldLayout, _vectorFieldLayoutAlgorithm, "Vector128`1"u8); + _int128FieldLayoutAlgorithm = new Int128FieldLayoutAlgorithm(_metadataFieldLayout); + _decimalFieldLayoutAlgorithm = new DecimalFieldLayoutAlgorithm(_metadataFieldLayout); + _typeWithRepeatedFieldsFieldLayoutAlgorithm = new TypeWithRepeatedFieldsFieldLayoutAlgorithm(_metadataFieldLayout); + } + + /// + /// Registers an assembly file that may load. The last registration + /// for a given simple name wins, matching how a compiler command line treats duplicate inputs. + /// + public void AddAssemblyPath(string path) + { + _assemblyPaths[Path.GetFileNameWithoutExtension(path)] = path; + } + + public ModuleDesc GetModuleForSimpleName(string simpleName, bool throwIfNotFound = true) + { + if (_modules.TryGetValue(simpleName, out ModuleDesc existingModule)) + return existingModule; + + if (!_assemblyPaths.TryGetValue(simpleName, out string filePath)) + { + if (throwIfNotFound) + throw new FileNotFoundException($"Assembly '{simpleName}' was not among the assemblies provided to the wasm signature resolver."); + + return null; + } + + var peStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + ModuleDesc module = EcmaModule.Create(this, new PEReader(peStream), containingAssembly: null); + _modules.Add(simpleName, module); + return module; + } + + public override ModuleDesc ResolveAssembly(AssemblyNameInfo name, bool throwIfNotFound) + { + return GetModuleForSimpleName(name.Name, throwIfNotFound); + } + + public override FieldLayoutAlgorithm GetLayoutAlgorithmForType(DefType type) + { + if (type == UniversalCanonType) + return UniversalCanonLayoutAlgorithm.Instance; + + if (VectorOfTFieldLayoutAlgorithm.IsVectorOfTType(type)) + return _vectorOfTFieldLayoutAlgorithm; + + if (VectorFieldLayoutAlgorithm.IsVectorType(type)) + return _vectorFieldLayoutAlgorithm; + + if (Int128FieldLayoutAlgorithm.IsIntegerType(type)) + return _int128FieldLayoutAlgorithm; + + if (DecimalFieldLayoutAlgorithm.IsDecimalFloatingPointType(type)) + return _decimalFieldLayoutAlgorithm; + + if (type is TypeWithRepeatedFields) + return _typeWithRepeatedFieldsFieldLayoutAlgorithm; + + return _metadataFieldLayout; + } + + protected override RuntimeInterfacesAlgorithm GetRuntimeInterfacesAlgorithmForNonPointerArrayType(ArrayType type) + { + _arrayOfTRuntimeInterfacesAlgorithm ??= new ArrayOfTRuntimeInterfacesAlgorithm(SystemModule.GetType("System"u8, "Array`1"u8)); + return _arrayOfTRuntimeInterfacesAlgorithm; + } + + protected override RuntimeInterfacesAlgorithm GetRuntimeInterfacesAlgorithmForDefType(DefType type) + { + return _metadataRuntimeInterfacesAlgorithm; + } + + public override VirtualMethodAlgorithm GetVirtualMethodAlgorithmForType(TypeDesc type) + { + return _virtualMethodAlgorithm; + } + + // crossgen2 always runs with SharedGenericsMode.CanonicalReferenceTypes. + protected internal override Instantiation ConvertInstantiationToCanonForm(Instantiation instantiation, CanonicalFormKind kind, out bool changed) + => RuntimeDeterminedCanonicalizationAlgorithm.ConvertInstantiationToCanonForm(instantiation, kind, out changed); + + protected internal override TypeDesc ConvertToCanon(TypeDesc typeToConvert, CanonicalFormKind kind) + => RuntimeDeterminedCanonicalizationAlgorithm.ConvertToCanon(typeToConvert, kind); + + protected internal override TypeDesc ConvertToCanon(TypeDesc typeToConvert, ref CanonicalFormKind kind) + => RuntimeDeterminedCanonicalizationAlgorithm.ConvertToCanon(typeToConvert, ref kind); + + public override bool SupportsUniversalCanon => false; + public override bool SupportsCanon => true; + + public TypeDesc WasmV128Type + { + get + { + TypeDesc type = _wasmV128Type; + if (type is null) + { + var vector128 = (MetadataType)SystemModule.GetType("System.Runtime.Intrinsics"u8, "Vector128`1"u8); + _wasmV128Type = type = vector128.MakeInstantiatedType(GetWellKnownType(WellKnownType.Byte)); + } + + return type; + } + } + + public TypeDesc CachedEmptyStruct => _cachedEmptyStruct; + + public void CacheEmptyStruct(TypeDesc type) + { + _cachedEmptyStruct ??= type; + } + + public void CacheStructBySize(TypeDesc type) + { + int size = type.GetElementSize().AsInt; + if (size <= 0) + return; + + lock (_structCacheLock) + { + _structsBySize.TryAdd(size, type); + } + } + + public TypeDesc GetCachedStructOfSize(int size) + { + lock (_structCacheLock) + { + if (_structsBySize.TryGetValue(size, out TypeDesc result)) + return result; + } + + return null; + } + } +} diff --git a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj index adb60312fd57f6..7670a5cc382d34 100644 --- a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj +++ b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj @@ -9,6 +9,8 @@ + + @@ -46,9 +48,15 @@ <_WasmAppHostFiles Include="$(WasmAppHostDir)\*" TargetPath="WasmAppHost" /> + + + <_WasmSignatureResolverFiles Include="$(WasmSignatureResolverDir)*" TargetPath="tasks\ILCompiler.Wasm.Lowering" /> + + diff --git a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj index 7d09cb8f3147b8..adf650eed77a21 100644 --- a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj +++ b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj @@ -9,6 +9,8 @@ + + @@ -44,9 +46,15 @@ <_WasmAppHostFiles Include="$(WasmAppHostDir)\*" TargetPath="WasmAppHost" /> + + + <_WasmSignatureResolverFiles Include="$(WasmSignatureResolverDir)*" TargetPath="tasks\ILCompiler.Wasm.Lowering" /> + + diff --git a/src/tasks/WasmAppBuilder/IcallTableGenerator.cs b/src/tasks/WasmAppBuilder/IcallTableGenerator.cs index cc55684aef6e00..4fb5b3875c363b 100644 --- a/src/tasks/WasmAppBuilder/IcallTableGenerator.cs +++ b/src/tasks/WasmAppBuilder/IcallTableGenerator.cs @@ -26,6 +26,7 @@ internal sealed class IcallTableGenerator private readonly Func _fixupSymbolName; private bool _isCoreClr; + private readonly CoreClr.SignatureMapper? _coreClrSignatureMapper; // // Given the runtime generated icall table, and a set of assemblies, generate @@ -33,11 +34,12 @@ internal sealed class IcallTableGenerator // The runtime icall table should be generated using // mono --print-icall-table // - public IcallTableGenerator(string? runtimeIcallTableFile, Func fixupSymbolName, LogAdapter log, bool isCoreClr) + public IcallTableGenerator(string? runtimeIcallTableFile, Func fixupSymbolName, LogAdapter log, bool isCoreClr, CoreClr.SignatureMapper? coreClrSignatureMapper = null) { Log = log; _fixupSymbolName = fixupSymbolName; _isCoreClr = isCoreClr; + _coreClrSignatureMapper = coreClrSignatureMapper; if (runtimeIcallTableFile != null) ReadTable(runtimeIcallTableFile); } @@ -210,7 +212,9 @@ private void ProcessType(Type type) void AddSignature(Type type, MethodInfo method) { - string? signature = _isCoreClr ? CoreClr.SignatureMapper.MethodToSignature(method, Log) : Mono.SignatureMapper.MethodToSignature(method, Log); + string? signature = _isCoreClr + ? (_coreClrSignatureMapper ?? throw new LogAsErrorException("A CoreCLR signature mapper is required to generate icall signatures for CoreCLR.")).MethodToSignature(method) + : Mono.SignatureMapper.MethodToSignature(method, Log); if (signature == null) { throw new LogAsErrorException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); diff --git a/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj b/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj index 7408a0dfcb061b..2d9de39d5bfb93 100644 --- a/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj +++ b/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj @@ -52,6 +52,14 @@ + + + + + @@ -72,6 +80,8 @@ Assemblies="@(WasmPInvokeAssembly)" PInvokeModules="@(WasmPInvokeModule)" TargetOS="$(_RunGeneratorTargetOS)" + SignatureResolverPath="@(_WasmSignatureResolver)" + DotNetHostPath="$(DOTNET_HOST_PATH)" PInvokeOutputPath="$(GeneratorOutputPath)callhelpers-pinvoke.cpp" ReversePInvokeOutputPath="$(GeneratorOutputPath)callhelpers-reverse.cpp" InterpToNativeOutputPath="$(GeneratorOutputPath)callhelpers-interp-to-managed.cpp"> diff --git a/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs new file mode 100644 index 00000000000000..787252a21cabb0 --- /dev/null +++ b/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Reflection; + +namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; + +/// +/// Answers what a type looks like in a wasm ABI signature. +/// +internal interface IWasmAbiTypeResolver +{ + /// + /// Returns the signature encoding for in parameter position: a single + /// character for a type passed by value, or "S<size>" for a struct passed by reference. + /// + /// The type has no wasm ABI encoding, or could not be resolved. + string GetAbiToken(Type type); +} diff --git a/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs b/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs index eda8aedda299b4..3d37c75b581920 100644 --- a/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs @@ -16,8 +16,13 @@ internal sealed class InternalCallSignatureCollector { private readonly HashSet _signatures = new(); private readonly LogAdapter _log; + private readonly SignatureMapper _signatureMapper; - public InternalCallSignatureCollector(LogAdapter log) => _log = log; + public InternalCallSignatureCollector(LogAdapter log, SignatureMapper signatureMapper) + { + _log = log; + _signatureMapper = signatureMapper; + } public void ScanAssembly(Assembly asm) { @@ -36,7 +41,7 @@ private void ScanType(Type type) try { - string? signature = SignatureMapper.MethodToSignature(method, _log, includeThis: true); + string? signature = _signatureMapper.MethodToSignature(method, includeThis: true); if (signature is null) { _log.Warning("WASM0001", $"Could not generate signature for InternalCall method '{type.FullName}::{method.Name}'"); diff --git a/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs b/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs index f033d8b39ca3ed..f265ba05183e9f 100644 --- a/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs +++ b/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; @@ -44,8 +45,83 @@ public class ManagedToNativeGenerator : Task public string TargetOS { get; set; } = "browser"; + /// + /// Path to ILCompiler.Wasm.Lowering.dll, which computes struct sizes and ABI lowering using the + /// same type system crossgen2 uses; reflection alone cannot compute field layout. Defaults to + /// the copy shipped alongside this task. + /// + public string? SignatureResolverPath { get; set; } + + /// + /// Path to the dotnet host used to run the signature resolver. + /// + public string? DotNetHostPath { get; set; } + private static readonly string[] s_knownTargetOSes = new[] { "browser", "wasi" }; + /// + /// The resolver ships next to this task, in its own directory so its type system assemblies + /// cannot collide with the task's. Callers only need to set + /// when running against a layout that matches neither of the probed conventions. + /// + private string ResolveSignatureResolverPath() + { + if (!string.IsNullOrEmpty(SignatureResolverPath)) + return SignatureResolverPath!; + + string taskDir = Path.GetDirectoryName(typeof(ManagedToNativeGenerator).Assembly.Location)!; + + foreach (string candidate in GetSignatureResolverCandidates(taskDir)) + { + if (File.Exists(candidate)) + return Path.GetFullPath(candidate); + } + + throw new LogAsErrorException( + "Could not locate ILCompiler.Wasm.Lowering.dll, which is required to compute wasm ABI struct sizes. " + + $"Looked in: {string.Join(", ", GetSignatureResolverCandidates(taskDir))}. " + + "Set the SignatureResolverPath task parameter to its location."); + } + + private static IEnumerable GetSignatureResolverCandidates(string taskDir) + { + // In the repo and in the Helix payload the resolver is nested in the task's own directory, + // so it travels with whatever copies that directory. + yield return Path.Combine(taskDir, "ILCompiler.Wasm.Lowering", "ILCompiler.Wasm.Lowering.dll"); + + // In the SDK pack it sits beside the per-TFM task directories instead, since the .NET and + // .NET Framework copies of the task both launch the same .NET tool and need not duplicate it. + yield return Path.GetFullPath(Path.Combine(taskDir, "..", "ILCompiler.Wasm.Lowering", "ILCompiler.Wasm.Lowering.dll")); + } + + private string ResolveDotNetHostPath() + { + if (!string.IsNullOrEmpty(DotNetHostPath)) + return DotNetHostPath!; + + string? fromEnvironment = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); + if (!string.IsNullOrEmpty(fromEnvironment)) + return fromEnvironment!; + + // When MSBuild itself is running on the .NET host, reuse it rather than trusting PATH to + // turn up a compatible one. + try + { + string? currentProcess = Process.GetCurrentProcess().MainModule?.FileName; + if (!string.IsNullOrEmpty(currentProcess)) + { + string name = Path.GetFileNameWithoutExtension(currentProcess); + if (string.Equals(name, "dotnet", StringComparison.OrdinalIgnoreCase)) + return currentProcess!; + } + } + catch (Exception) + { + } + + return "dotnet"; + } + [Output] public string[]? FileWrites { get; private set; } @@ -93,8 +169,11 @@ private void ExecuteInternal(LogAdapter log) { Dictionary _symbolNameFixups = new(); List managedAssemblies = FilterOutUnmanagedBinaries(Assemblies); - var pinvoke = new PInvokeTableGenerator(FixupSymbolName, log, IsLibraryMode, TargetOS, WarnOnUnresolvedPInvokeModules); - var internalCallCollector = new InternalCallSignatureCollector(log); + + using var abiTypeResolver = new WasmAbiTypeResolver(ResolveDotNetHostPath(), ResolveSignatureResolverPath(), TargetOS, managedAssemblies, log); + var signatureMapper = new SignatureMapper(log, abiTypeResolver); + var pinvoke = new PInvokeTableGenerator(FixupSymbolName, log, IsLibraryMode, TargetOS, signatureMapper, WarnOnUnresolvedPInvokeModules); + var internalCallCollector = new InternalCallSignatureCollector(log, signatureMapper); var resolver = new PathAssemblyResolver(managedAssemblies); using var mlc = new MetadataLoadContext(resolver, "System.Private.CoreLib"); diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs index 150a2e50361454..119f54c82c8c20 100644 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs @@ -64,12 +64,14 @@ internal sealed class PInvokeCollector { private readonly Dictionary _typeUnsupportedOnPlatformCache = new(); private readonly Dictionary _assemblyUnsupportedOnPlatformCache = new(); private readonly string _targetOS; + private readonly SignatureMapper _signatureMapper; private LogAdapter Log { get; init; } - public PInvokeCollector(LogAdapter log, string targetOS) + public PInvokeCollector(LogAdapter log, string targetOS, SignatureMapper signatureMapper) { Log = log; _targetOS = targetOS; + _signatureMapper = signatureMapper; } public void CollectPInvokes(List pinvokes, List callbacks, HashSet signatures, Type type) @@ -94,7 +96,7 @@ public void CollectPInvokes(List pinvokes, List callba if (method != null) { - string? signature = SignatureMapper.MethodToSignature(method!, Log); + string? signature = _signatureMapper.MethodToSignature(method!); if (signature == null) throw new NotSupportedException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); @@ -116,7 +118,7 @@ void CollectPInvokesForMethod(MethodInfo method) var entrypoint = (string)dllimport.NamedArguments.First(arg => arg.MemberName == "EntryPoint").TypedValue.Value!; pinvokes.Add(new PInvoke(entrypoint, module, method, wasmLinkage)); - string? signature = SignatureMapper.MethodToSignature(method, Log); + string? signature = _signatureMapper.MethodToSignature(method); if (signature == null) { throw new NotSupportedException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs index 13d3a151e47874..51b78bfe662a89 100644 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs +++ b/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs @@ -24,14 +24,16 @@ internal sealed class PInvokeTableGenerator private readonly List pinvokes = new(); private readonly List callbacks = new(); private readonly PInvokeCollector _pinvokeCollector; + private readonly SignatureMapper _signatureMapper; private readonly bool _isLibraryMode; private readonly bool _warnOnUnresolvedModules; - public PInvokeTableGenerator(Func fixupSymbolName, LogAdapter log, bool isLibraryMode, string targetOS, bool warnOnUnresolvedModules = true) + public PInvokeTableGenerator(Func fixupSymbolName, LogAdapter log, bool isLibraryMode, string targetOS, SignatureMapper signatureMapper, bool warnOnUnresolvedModules = true) { Log = log; _fixupSymbolName = fixupSymbolName; - _pinvokeCollector = new(log, targetOS); + _signatureMapper = signatureMapper; + _pinvokeCollector = new(log, targetOS, signatureMapper); _isLibraryMode = isLibraryMode; _warnOnUnresolvedModules = warnOnUnresolvedModules; } @@ -352,7 +354,7 @@ private static bool TryIsMethodGetParametersSupported(MethodInfo method, [NotNul var realReturnType = method.ReturnType; var realParameterTypes = method.GetParameters().Select(p => MapType(p.ParameterType)).ToList(); - SignatureMapper.TypeToChar(realReturnType, Log, out bool resultIsByRef); + _signatureMapper.TypeToChar(realReturnType, out bool resultIsByRef); if (resultIsByRef) { realReturnType = typeof(void); realParameterTypes.Insert(0, "void *"); @@ -445,7 +447,7 @@ private void EmitNativeToInterp(StreamWriter w, List callbacks) callbacks = callbacks.OrderBy(c => c, new PInvokeCallbackComparer()).ToList(); foreach (var cb in callbacks) { - cb.EntrySymbol = FixedSymbolName(cb, Log); + cb.EntrySymbol = FixedSymbolName(cb); if (callbackNames.Contains(cb.EntrySymbol)) { @@ -508,7 +510,7 @@ private void EmitNativeToInterp(StreamWriter w, List callbacks) const ReverseThunkMapEntry g_ReverseThunks[] = { - {{callbacks.Join($",{w.NewLine}", cb => ThunkMapEntryLine(cb, Log))}} + {{callbacks.Join($",{w.NewLine}", ThunkMapEntryLine)}} }; const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks) / sizeof(g_ReverseThunks[0]); @@ -516,18 +518,18 @@ private void EmitNativeToInterp(StreamWriter w, List callbacks) """); } - private string FixedSymbolName(PInvokeCallback cb, LogAdapter Log) + private string FixedSymbolName(PInvokeCallback cb) { - var paramTypes = cb.Parameters.Length > 0 ? cb.Parameters.Join("_", (info, i) => SignatureMapper.TypeToNameType(info.ParameterType, Log)).ToString() : "Void"; - var sig = $"{paramTypes}_Ret{SignatureMapper.TypeToNameType(cb.ReturnType, Log)}"; + var paramTypes = cb.Parameters.Length > 0 ? cb.Parameters.Join("_", (info, i) => _signatureMapper.TypeToNameType(info.ParameterType)).ToString() : "Void"; + var sig = $"{paramTypes}_Ret{_signatureMapper.TypeToNameType(cb.ReturnType)}"; return _fixupSymbolName($"{cb.EntryName}_{sig}"); } - private string ThunkMapEntryLine(PInvokeCallback cb, LogAdapter Log) + private string ThunkMapEntryLine(PInvokeCallback cb) { - var fsName = FixedSymbolName(cb, Log); + var fsName = FixedSymbolName(cb); return $" {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{fsName}, (void*)&Call_{cb.EntrySymbol} }} }}"; } diff --git a/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs b/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs index d532991bdd6e52..2a5426fb7ff647 100644 --- a/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs +++ b/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs @@ -12,31 +12,24 @@ namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; // Computes Wasm signature strings from reflection metadata. // The signature string format is documented in docs/design/coreclr/botr/readytorun-format.md // (section "Wasm Signature String Encoding"). -internal static class SignatureMapper +internal sealed class SignatureMapper { - // Hardcoded struct sizes for types that crossgen2 encodes as S. - // The fully general case is handled by crossgen2's type system; these - // cover the small set of multi-field structs that appear in InternalCall - // and PInvoke signatures. - private static readonly Dictionary s_knownStructSizes = new() + private readonly LogAdapter _log; + private readonly IWasmAbiTypeResolver _resolver; + + public SignatureMapper(LogAdapter log, IWasmAbiTypeResolver resolver) { - ["System.Runtime.CompilerServices.QCallModule"] = 8, - ["System.Runtime.CompilerServices.QCallAssembly"] = 8, - ["System.Runtime.CompilerServices.QCallTypeHandle"] = 8, - ["System.GC+GCHeapHardLimitInfo"] = 64, - // Used by WBT tests - ["WasmAppBuilderTestsPairStruct"] = 8, - ["WasmAppBuilderTests.S"] = 8, - ["WasmAppBuilderTests.Test+S"] = 8, - }; + _log = log; + _resolver = resolver; + } - internal static char? TypeToChar(Type t, LogAdapter log, out bool isByRefStruct, out int structSize, int depth = 0) + internal char? TypeToChar(Type t, out bool isByRefStruct, out int structSize, int depth = 0) { isByRefStruct = false; structSize = 0; if (depth > 5) { - log.Warning("WASM0064", $"Unbounded recursion detected through parameter type '{t.Name}'"); + _log.Warning("WASM0064", $"Unbounded recursion detected through parameter type '{t.Name}'"); return null; } @@ -80,7 +73,7 @@ internal static class SignatureMapper else if (t.IsEnum) { Type underlyingType = t.GetEnumUnderlyingType(); - c = TypeToChar(underlyingType, log, out _, out structSize, ++depth); + c = TypeToChar(underlyingType, out _, out structSize, ++depth); } else if (t.IsPointer) c = 'i'; @@ -88,47 +81,36 @@ internal static class SignatureMapper c = 'i'; else if (t.IsValueType) { - var fields = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (fields.Length == 1) + // Reflection cannot compute field layout, so the ABI encoding of a struct - including + // whether it collapses to a single primitive - comes from the compiler's type system. + string token = _resolver.GetAbiToken(t); + if (token[0] == 'S') { - Type fieldType = fields[0].FieldType; - return TypeToChar(fieldType, log, out isByRefStruct, out structSize, ++depth); + structSize = int.Parse(token.Substring(1)); + isByRefStruct = true; + c = 'S'; } else { - string fullName = t.FullName ?? t.Name; - if (s_knownStructSizes.TryGetValue(fullName, out int size)) - { - structSize = size; - } - else - { - log.Error("WASM0067", - $"SignatureMapper: unknown multi-field struct '{fullName}' (fields: {fields.Length}) — add its size to s_knownStructSizes in SignatureMapper.cs"); - return null; - } - - c = 'S'; + c = token[0]; } - - isByRefStruct = true; } else - log.Warning("WASM0065", $"Unsupported parameter type '{t.Name}'"); + _log.Warning("WASM0065", $"Unsupported parameter type '{t.Name}'"); return c; } - internal static char? TypeToChar(Type t, LogAdapter log, out bool isByRefStruct, int depth = 0) - => TypeToChar(t, log, out isByRefStruct, out _, depth); + internal char? TypeToChar(Type t, out bool isByRefStruct, int depth = 0) + => TypeToChar(t, out isByRefStruct, out _, depth); /// /// Builds the multi-char token for a type in the signature string. /// For most types this is a single character; for multi-field structs it is "S<N>". /// - private static string? TypeToSignatureToken(Type t, LogAdapter log, out bool isByRefStruct) + private string? TypeToSignatureToken(Type t, out bool isByRefStruct) { - char? c = TypeToChar(t, log, out isByRefStruct, out int structSize); + char? c = TypeToChar(t, out isByRefStruct, out int structSize); if (c is null) return null; @@ -138,9 +120,9 @@ internal static class SignatureMapper return c.Value.ToString(); } - public static string? MethodToSignature(MethodInfo method, LogAdapter log, bool includeThis = false) + public string? MethodToSignature(MethodInfo method, bool includeThis = false) { - string? returnToken = TypeToSignatureToken(method.ReturnType, log, out bool resultIsByRef); + string? returnToken = TypeToSignatureToken(method.ReturnType, out bool resultIsByRef); if (returnToken is null) return null; @@ -163,7 +145,7 @@ internal static class SignatureMapper foreach (var parameter in method.GetParameters()) { - string? paramToken = TypeToSignatureToken(parameter.ParameterType, log, out _); + string? paramToken = TypeToSignatureToken(parameter.ParameterType, out _); if (paramToken is null) return null; @@ -257,9 +239,9 @@ public static int TokenToSlotCount(string token) public static string CharToNameType(char c) => TokenToNameType(c.ToString()); public static string CharToArgType(char c) => TokenToArgType(c.ToString()); - public static string TypeToNameType(Type t, LogAdapter log) + public string TypeToNameType(Type t) { - char? c = TypeToChar(t, log, out _); + char? c = TypeToChar(t, out _); if (c is null) throw new InvalidSignatureCharException('?'); diff --git a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs new file mode 100644 index 00000000000000..ebaa121794da06 --- /dev/null +++ b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text; +using Microsoft.Build.Framework; + +namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; + +/// +/// Resolves wasm ABI encodings by delegating to the ILCompiler.Wasm.Lowering tool, which shares its +/// lowering and field layout code with crossgen2. +/// +/// +/// The generated helpers have to agree with compiled code exactly - a struct whose size is off by one +/// produces a call that reads the wrong stack slots at runtime - so the sizes come from the compiler's +/// own type system rather than from reflection, which has no field layout engine. +/// +/// The tool runs out of process because this task also runs under .NET Framework MSBuild, which cannot +/// load a netcoreapp type system assembly. It is started once and reused for every query. +/// +internal sealed class WasmAbiTypeResolver : IWasmAbiTypeResolver, IDisposable +{ + private readonly string _dotnetHostPath; + private readonly string _toolPath; + private readonly string _targetOS; + private readonly IReadOnlyList _assemblies; + private readonly LogAdapter _log; + private readonly Dictionary<(string Assembly, int Token), string> _cache = new(); + + private Process? _process; + private string? _responseFilePath; + private readonly StringBuilder _stderr = new(); + + public WasmAbiTypeResolver(string dotnetHostPath, string toolPath, string targetOS, IReadOnlyList assemblies, LogAdapter log) + { + _dotnetHostPath = dotnetHostPath; + _toolPath = toolPath; + _targetOS = targetOS; + _assemblies = assemblies; + _log = log; + } + + public string GetAbiToken(Type type) + { + if (type.IsConstructedGenericType || type.IsGenericParameter || type.ContainsGenericParameters) + { + throw new LogAsErrorException( + $"Cannot compute the wasm ABI encoding of generic type '{type.FullName ?? type.Name}'. " + + "Generic types are not addressable by metadata token, so the size of an instantiation cannot be resolved."); + } + + string assemblyName = type.Module.Assembly.GetName().Name + ?? throw new LogAsErrorException($"Type '{type.FullName ?? type.Name}' comes from an assembly with no simple name."); + int metadataToken = type.MetadataToken; + + var key = (assemblyName, metadataToken); + if (_cache.TryGetValue(key, out string? cached)) + return cached; + + string reply = Query($"{assemblyName} 0x{metadataToken:x8}"); + if (reply[0] == '!') + { + throw new LogAsErrorException( + $"Could not compute the wasm ABI encoding of '{type.FullName ?? type.Name}': {reply.Substring(1)}"); + } + + _cache[key] = reply; + return reply; + } + + private string Query(string request) + { + Process process = EnsureStarted(); + process.StandardInput.WriteLine(request); + process.StandardInput.Flush(); + + string? reply = process.StandardOutput.ReadLine(); + if (reply is null) + { + throw new LogAsErrorException( + $"The wasm signature resolver ('{_toolPath}') exited unexpectedly while resolving '{request}'. {ReadStandardError(process)}"); + } + + return reply; + } + + private Process EnsureStarted() + { + if (_process is not null) + return _process; + + if (!File.Exists(_toolPath)) + { + throw new LogAsErrorException( + $"The wasm signature resolver was not found at '{_toolPath}'. Set the SignatureResolverPath task parameter to the path of ILCompiler.Wasm.Lowering.dll."); + } + + // A response file keeps the command line under the platform limit; the framework alone is + // ~170 assemblies and an app can add many more. + _responseFilePath = Path.GetTempFileName(); + File.WriteAllLines(_responseFilePath, _assemblies, Encoding.UTF8); + + var startInfo = new ProcessStartInfo(_dotnetHostPath) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + // ProcessStartInfo.ArgumentList is not available on .NET Framework, which this task also + // targets, so the command line is quoted by hand. + Arguments = $"exec {Quote(_toolPath)} --targetos {_targetOS} {Quote("@" + _responseFilePath)}", + }; + + _log.LogMessage(MessageImportance.Low, $"Starting wasm signature resolver: {_dotnetHostPath} {startInfo.Arguments}"); + + Process process; + try + { + process = Process.Start(startInfo) + ?? throw new LogAsErrorException($"Failed to start the wasm signature resolver '{_toolPath}'."); + } + catch (Exception ex) when (ex is not LogAsErrorException) + { + throw new LogAsErrorException($"Failed to start the wasm signature resolver '{_toolPath}': {ex.Message}"); + } + + // Take ownership before the handshake so a failure below still goes through Dispose. An + // orphaned tool would hold open file handles on every assembly in the closure, which on + // Windows blocks a subsequent build from overwriting them. + _process = process; + + // stderr has to be drained continuously: the tool would otherwise block once the pipe + // buffer filled, while this side blocks reading stdout. + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + lock (_stderr) + { + _stderr.AppendLine(e.Data); + } + } + }; + process.BeginErrorReadLine(); + + string? ready = process.StandardOutput.ReadLine(); + if (ready != "ready") + { + throw new LogAsErrorException( + $"The wasm signature resolver '{_toolPath}' failed to load the assembly closure. {ReadStandardError(process)}"); + } + + return process; + } + + private static readonly char[] s_charsNeedingQuotes = new[] { ' ', '"', '\t' }; + + private static string Quote(string argument) + { + if (argument.Length > 0 && argument.IndexOfAny(s_charsNeedingQuotes) < 0) + return argument; + + return "\"" + argument.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + + private string ReadStandardError(Process process) + { + // Give the asynchronous reader a moment to flush what the tool wrote before it died, + // but never block the build waiting on a process that is still alive. + try + { + process.WaitForExit(2000); + } + catch (Exception) + { + } + + lock (_stderr) + { + return _stderr.ToString().Trim(); + } + } + + public void Dispose() + { + if (_process is not null) + { + try + { + // Closing stdin ends the tool's read loop, letting it exit on its own. + _process.StandardInput.Close(); + if (!_process.WaitForExit(5000)) + _process.Kill(); + } + catch (Exception ex) + { + _log.LogMessage(MessageImportance.Low, $"Failed to shut down the wasm signature resolver: {ex.Message}"); + } + + _process.Dispose(); + _process = null; + } + + if (_responseFilePath is not null) + { + try + { + File.Delete(_responseFilePath); + } + catch (IOException) + { + } + + _responseFilePath = null; + } + } +} From 83792db0702b7e7cf6730f2029b293110a405a92 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 5 Aug 2026 14:25:43 +0200 Subject: [PATCH 02/72] Ask the wasm lowering for whole method signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator asked the resolver one question per parameter type, naming each type by metadata token. A token names a TypeDef row, so a constructed generic — a TypeSpec, which has no row — could not be named at all: Nullable and Nullable both report the token of Nullable`1. The generator therefore refused generic types outright. Ask for the whole signature per method instead. Parameter types then come out of the method's signature blob, where instantiations are spelled in full, and the string is produced by WasmLowering.GetSignature — the same call crossgen2 makes — rather than by a second encoder here that had to be kept in agreement with it by hand. The stdin protocol grows a verb: 't' for the existing per-type query, 'm' for a method plus its lowering flags. Fields are parsed right to left so the assembly name, being the leftover, may contain spaces. Two call sites needed care. The lowering appends the trailing 'p' and the instance 'T' only for a managed signature, so InternalCall scanning passes None and drops its manual += "p", while P/Invoke and icall scanning pass IsUnmanagedCallersOnly and get neither. Both scans now skip open generics, which have no single signature. That was previously a warning for InternalCalls, and for a generic delegate carrying UnmanagedFunctionPointerAttribute it silently encoded the type parameter itself as a pointer — right only by accident, and now a hard error from the lowering, on a path with no catch. Regenerating src/coreclr/vm/wasm/ produces byte-identical output. The parity test gains a sweep of 35,236 CoreLib method signatures through both stacks, 12,270 of which name a constructed generic type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmLoweringParityTests.cs | 124 ++++++++++++++++++ .../aot/ILCompiler.Wasm.Lowering/Program.cs | 71 ++++++++-- .../WasmAbiTypeResolver.cs | 45 ++++++- .../coreclr/IWasmAbiTypeResolver.cs | 12 +- .../coreclr/InternalCallSignatureCollector.cs | 12 +- .../coreclr/PInvokeCollector.cs | 9 ++ .../WasmAppBuilder/coreclr/SignatureMapper.cs | 56 ++------ .../coreclr/WasmAbiTypeResolver.cs | 30 ++++- .../coreclr/WasmLoweringFlags.cs | 31 +++++ 9 files changed, 319 insertions(+), 71 deletions(-) create mode 100644 src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs index 31c83fc7a7c2b6..27d7514349f728 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs @@ -24,6 +24,7 @@ using Crossgen2Lowering = crossgen2::Internal.JitInterface.WasmLowering; using Crossgen2LoweringFlags = crossgen2::Internal.JitInterface.WasmLowering.LoweringFlags; using WasmResolverContext = wasmlowering::ILCompiler.Wasm.WasmTypeSystemContext; +using WasmResolver = wasmlowering::ILCompiler.Wasm.WasmAbiTypeResolver; using WasmResolverLowering = wasmlowering::Internal.JitInterface.WasmLowering; using WasmResolverLoweringFlags = wasmlowering::Internal.JitInterface.WasmLowering.LoweringFlags; @@ -195,6 +196,123 @@ private static DefType Instantiate(ModuleDesc module, string @namespace, string return definition.MakeInstantiatedType(argument); } + /// + /// Sweeps CoreLib's methods through both stacks by MethodDef token. This is the seam the + /// generator actually uses — it asks for a whole signature per method rather than for one + /// parameter type at a time — so a disagreement here is a disagreement in generated code. + /// + /// + /// Signatures also reach further than the per-type sweep above can. Parameter types are read out + /// of the method's signature blob, so a constructed generic such as Nullable<int> + /// resolves here despite having no metadata token of its own to be asked about. + /// + [Theory] + [InlineData((int)Crossgen2LoweringFlags.None)] + [InlineData((int)Crossgen2LoweringFlags.IsUnmanagedCallersOnly)] + public void ResolverAgreesWithCrossgen2ForCoreLibMethodSignatures(int flags) + { + Crossgen2Context crossgen2Context = CreateCrossgen2Context(); + WasmResolver resolver = CreateResolver(); + + var crossgen2CoreLib = (EcmaModule)crossgen2Context.SystemModule; + + List mismatches = new(); + int compared = 0; + int namingConstructedGenerics = 0; + + foreach (MethodDefinitionHandle handle in crossgen2CoreLib.MetadataReader.MethodDefinitions) + { + if (!TryGetComparableMethod(crossgen2CoreLib, handle, out MethodDesc method)) + continue; + + string crossgen2Signature; + try + { + crossgen2Signature = Crossgen2Lowering.GetSignature(method.Signature, (Crossgen2LoweringFlags)flags).SignatureString; + } + catch (TypeSystemException) + { + // Agreeing to throw is not the parity this test is about. + continue; + } + + string resolverSignature; + try + { + resolverSignature = resolver.GetMethodSignature("System.Private.CoreLib", MetadataTokens.GetToken(handle), flags); + } + catch (TypeSystemException e) + { + // crossgen2 answered and the resolver did not: the resolver is the production path, + // so this is a divergence, not something to quietly leave out of the comparison. + mismatches.Add($"{method}: crossgen2 '{crossgen2Signature}' vs resolver threw {e.GetType().Name}: {e.Message}"); + continue; + } + + compared++; + if (NamesConstructedGenericType(method.Signature)) + namingConstructedGenerics++; + + if (crossgen2Signature != resolverSignature) + mismatches.Add($"{method}: crossgen2 '{crossgen2Signature}' vs resolver '{resolverSignature}'"); + } + + _output.WriteLine($"Compared {compared} CoreLib method signatures, {namingConstructedGenerics} of them naming a constructed generic type."); + + Assert.True(compared > 1000, $"Expected to compare a meaningful number of methods, but only saw {compared}."); + Assert.True(namingConstructedGenerics > 0, "Expected to cover methods naming constructed generic types, since resolving those is the reason signatures are queried per method."); + Assert.Empty(mismatches); + } + + private static bool TryGetComparableMethod(EcmaModule module, MethodDefinitionHandle handle, out MethodDesc method) + { + method = null!; + + try + { + MethodDesc candidate = module.GetMethod(handle); + + // A signature variable stands for whatever type the instantiation supplies, so it has no + // ABI of its own; only fully concrete signatures describe how a call is really made. + if (candidate.HasInstantiation || candidate.OwningType.HasInstantiation) + return false; + + // Touch the signature up front so a module that cannot be read fails the same way on + // both stacks rather than only on the one that got there first. + _ = candidate.Signature; + + method = candidate; + return true; + } + catch (TypeSystemException) + { + return false; + } + catch (BadImageFormatException) + { + return false; + } + } + + /// + /// True when the signature names a constructed generic type, the case a per-type token query + /// cannot express: Nullable<int> and Nullable<long> share the metadata + /// token of Nullable<T> and would be indistinguishable over the wire. + /// + private static bool NamesConstructedGenericType(MethodSignature signature) + { + if (signature.ReturnType is InstantiatedType) + return true; + + for (int i = 0; i < signature.Length; i++) + { + if (signature[i] is InstantiatedType) + return true; + } + + return false; + } + private static string GetCrossgen2Signature(Crossgen2Context context, TypeDesc parameterType) { return Crossgen2Lowering.GetSignature( @@ -286,6 +404,12 @@ private WasmResolverContext CreateResolverContext() return context; } + /// + /// Builds the resolver the way the standalone tool does, so the token round-trip the generator + /// depends on is part of what gets tested. + /// + private WasmResolver CreateResolver() => new("browser", new[] { CoreLibPath }); + private string CoreLibPath { get diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs index b0c51721a0c0cf..2b83e48a6ef571 100644 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs @@ -22,9 +22,17 @@ namespace ILCompiler.Wasm /// Usage: /// ILCompiler.Wasm.Lowering --targetos <browser|wasi> [--assembly <path>]... [@responsefile] /// - /// Each stdin line is "<assemblySimpleName> <metadataToken>", where the token is the decimal or - /// 0x-prefixed hexadecimal metadata token of a type. Each reply line is either the ABI encoding - /// ('i', 'l', 'f', 'd', 'V' or "S<size>") or '!' followed by an error message. + /// Each stdin line is one query, and each reply line is either the answer or '!' followed by an + /// error message. Two query forms are supported: + /// + /// t <assemblySimpleName> <typeToken> + /// Replies with the ABI encoding of a type in parameter position ('i', 'l', 'f', 'd', 'V' or + /// "S<size>"). + /// + /// m <assemblySimpleName> <methodToken> <loweringFlags> + /// Replies with the full signature string of a method. + /// + /// Tokens are decimal or 0x-prefixed hexadecimal; flags are a decimal LoweringFlags value. /// internal static class Program { @@ -96,17 +104,7 @@ private static void Serve(WasmAbiTypeResolver resolver) string reply; try { - int separator = line.LastIndexOf(' '); - if (separator < 0) - throw new FormatException($"Malformed query '{line}'; expected ' '."); - - string assemblyName = line.Substring(0, separator); - string tokenText = line.Substring(separator + 1); - int metadataToken = tokenText.StartsWith("0x", StringComparison.OrdinalIgnoreCase) - ? int.Parse(tokenText.Substring(2), System.Globalization.NumberStyles.HexNumber) - : int.Parse(tokenText, System.Globalization.CultureInfo.InvariantCulture); - - reply = resolver.GetAbiToken(assemblyName, metadataToken); + reply = Answer(resolver, line); } catch (Exception ex) { @@ -117,5 +115,50 @@ private static void Serve(WasmAbiTypeResolver resolver) Console.Out.Flush(); } } + + private static string Answer(WasmAbiTypeResolver resolver, string query) + { + if (query.Length < 2 || query[1] != ' ') + throw new FormatException($"Malformed query '{query}'; expected a 't' or 'm' verb."); + + string rest = query.Substring(2); + + // Parsed right to left so that the assembly name, which is whatever is left over, is not + // assumed to be free of spaces. + switch (query[0]) + { + case 't': + { + (string assemblyName, int typeToken) = SplitToken(rest, query); + return resolver.GetAbiToken(assemblyName, typeToken); + } + + case 'm': + { + (string head, int flags) = SplitToken(rest, query); + (string assemblyName, int methodToken) = SplitToken(head, query); + return resolver.GetMethodSignature(assemblyName, methodToken, flags); + } + + default: + throw new FormatException($"Unrecognized query verb '{query[0]}'."); + } + } + + private static (string Head, int Value) SplitToken(string text, string query) + { + int separator = text.LastIndexOf(' '); + if (separator < 0) + throw new FormatException($"Malformed query '{query}'; not enough fields."); + + return (text.Substring(0, separator), ParseToken(text.Substring(separator + 1))); + } + + private static int ParseToken(string text) + { + return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? int.Parse(text.Substring(2), System.Globalization.NumberStyles.HexNumber) + : int.Parse(text, System.Globalization.CultureInfo.InvariantCulture); + } } } diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs index 645e75e2c2c848..c1cf476e3532a5 100644 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs +++ b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs @@ -13,13 +13,16 @@ namespace ILCompiler.Wasm { /// - /// Answers "what does this type look like in a wasm ABI signature?" using the same lowering - /// crossgen2 uses, so callers that need to agree with compiled code do not have to reimplement it. + /// Answers "what does this look like in a wasm ABI signature?" for methods and for individual + /// types, using the same lowering crossgen2 uses, so callers that need to agree with compiled + /// code do not have to reimplement it. /// /// - /// Types are identified by assembly simple name plus metadata token rather than by name. Name-based - /// lookup would have to reproduce nested-type and generic name mangling, and would silently pick - /// the wrong type when it got that wrong; a token cannot be ambiguous. + /// Methods and types are identified by assembly simple name plus metadata token rather than by + /// name. Name-based lookup would have to reproduce nested-type and generic name mangling, and + /// would silently pick the wrong member when it got that wrong; a token cannot be ambiguous. + /// Note that this makes constructed generics unnameable — they have no token — which is why + /// whole signatures are queried per method rather than a token at a time. /// public sealed class WasmAbiTypeResolver { @@ -59,6 +62,38 @@ public string GetAbiToken(string assemblySimpleName, int metadataToken) return GetAbiToken(type); } + /// + /// Gets the full wasm signature string for a method, using the same lowering the compiler + /// applies to the code that will implement or call it. + /// + /// + /// Preferred over per-parameter queries: the parameter + /// types come from the method's signature blob, so generic instantiations resolve here even + /// though they have no metadata token of their own and cannot be named over the wire. + /// + /// Simple name of the assembly defining the method. + /// The method's MethodDef token. + /// A value. + public string GetMethodSignature(string assemblySimpleName, int methodToken, int flags) + { + // The caller keeps its own copy of LoweringFlags, because a build task cannot reference + // the type system. Reject bits this build does not define rather than letting a copy that + // has drifted ahead silently ask for a lowering that is not the one it means. + const int KnownFlags = (int)(WasmLowering.LoweringFlags.HasGenericContextArg + | WasmLowering.LoweringFlags.IsAsyncCall + | WasmLowering.LoweringFlags.IsUnmanagedCallersOnly); + + if ((flags & ~KnownFlags) != 0) + { + throw new ArgumentOutOfRangeException(nameof(flags), $"Unknown wasm lowering flags 0x{flags:x}; this build understands 0x{KnownFlags:x}."); + } + + var module = (EcmaModule)_context.GetModuleForSimpleName(assemblySimpleName); + MethodDesc method = module.GetMethod(MetadataTokens.EntityHandle(methodToken)); + + return WasmLowering.GetSignature(method.Signature, (WasmLowering.LoweringFlags)flags).SignatureString; + } + /// /// Gets the signature encoding for a type. Public so tests can drive it with types they /// resolved themselves. diff --git a/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs index 787252a21cabb0..10d71ac1bf5fc0 100644 --- a/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs +++ b/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs @@ -7,7 +7,7 @@ namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; /// -/// Answers what a type looks like in a wasm ABI signature. +/// Answers what a type or method looks like in a wasm ABI signature. /// internal interface IWasmAbiTypeResolver { @@ -17,4 +17,14 @@ internal interface IWasmAbiTypeResolver /// /// The type has no wasm ABI encoding, or could not be resolved. string GetAbiToken(Type type); + + /// + /// Returns the full signature string for . + /// + /// + /// Resolved from the method's own metadata rather than by asking about each parameter type in + /// turn, so generic instantiations work and the string comes from the same code the compiler uses. + /// + /// The method has no wasm ABI signature, or could not be resolved. + string GetMethodSignature(MethodInfo method, WasmLoweringFlags flags); } diff --git a/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs b/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs index 3d37c75b581920..d074a25e8a83c7 100644 --- a/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs @@ -39,8 +39,18 @@ private void ScanType(Type type) if ((method.GetMethodImplementationFlags() & MethodImplAttributes.InternalCall) == 0) continue; + // An uninstantiated generic has no single signature to generate a thunk from, because + // its parameters stand for whatever the instantiation supplies. + if (method.ContainsGenericParameters) + { + _log.Warning("WASM0001", $"Skipping generic InternalCall method '{type.FullName}::{method.Name}', which has no single signature"); + continue; + } + try { + // A managed signature: the lowering adds the 'T' for an instance method and the + // trailing 'p' for the portable entry point parameter. string? signature = _signatureMapper.MethodToSignature(method, includeThis: true); if (signature is null) { @@ -48,8 +58,6 @@ private void ScanType(Type type) continue; } - signature += "p"; - if (_signatures.Add(signature)) _log.LogMessage(MessageImportance.Low, $"Adding InternalCall signature {signature} for method '{type.FullName}.{method.Name}'"); } diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs index 119f54c82c8c20..a5494206c1f325 100644 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs +++ b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs @@ -92,6 +92,15 @@ public void CollectPInvokes(List pinvokes, List callba if (HasAttribute(type, "System.Runtime.InteropServices.UnmanagedFunctionPointerAttribute")) { + // Each instantiation of an open generic delegate would marshal differently, so there is + // no single native signature to emit a thunk for. The encoding this used to produce came + // from mapping the type parameter itself, which was only ever right by accident. + if (type.ContainsGenericParameters) + { + Log.Warning("WASM0001", $"Skipping generic function pointer delegate '{type.FullName}', which has no single native signature"); + return; + } + var method = type.GetMethod("Invoke"); if (method != null) diff --git a/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs b/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs index 2a5426fb7ff647..2fc5b19721847d 100644 --- a/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs +++ b/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text; namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; @@ -105,54 +104,21 @@ public SignatureMapper(LogAdapter log, IWasmAbiTypeResolver resolver) => TypeToChar(t, out isByRefStruct, out _, depth); /// - /// Builds the multi-char token for a type in the signature string. - /// For most types this is a single character; for multi-field structs it is "S<N>". + /// Returns the wasm signature string for a method. /// - private string? TypeToSignatureToken(Type t, out bool isByRefStruct) - { - char? c = TypeToChar(t, out isByRefStruct, out int structSize); - if (c is null) - return null; - - if (c == 'S' && structSize > 0) - return $"S{structSize}"; - - return c.Value.ToString(); - } - + /// + /// Delegates to the compiler's own lowering rather than building the string from + /// . That resolves each parameter from the method's signature blob, so + /// generic instantiations work, and it keeps one implementation of the encoding instead of a + /// second one here that has to be kept in agreement with compiled code. + /// public string? MethodToSignature(MethodInfo method, bool includeThis = false) { - string? returnToken = TypeToSignatureToken(method.ReturnType, out bool resultIsByRef); - if (returnToken is null) - return null; - - var sb = new StringBuilder(); - - if (resultIsByRef) - { - // Struct return — encode as S (the return type token already has the size) - sb.Append(returnToken); - } - else - { - sb.Append(returnToken); - } - - if (includeThis && !method.IsStatic) - { - sb.Append('T'); - } - - foreach (var parameter in method.GetParameters()) - { - string? paramToken = TypeToSignatureToken(parameter.ParameterType, out _); - if (paramToken is null) - return null; - - sb.Append(paramToken); - } + // A managed signature is what picks up the 'T' for an instance method and the trailing 'p'; + // everything else describes a native function. + WasmLoweringFlags flags = includeThis ? WasmLoweringFlags.None : WasmLoweringFlags.IsUnmanagedCallersOnly; - return sb.ToString(); + return _resolver.GetMethodSignature(method, flags); } /// diff --git a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs index ebaa121794da06..e0e6dced1400ab 100644 --- a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs +++ b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs @@ -30,7 +30,8 @@ internal sealed class WasmAbiTypeResolver : IWasmAbiTypeResolver, IDisposable private readonly string _targetOS; private readonly IReadOnlyList _assemblies; private readonly LogAdapter _log; - private readonly Dictionary<(string Assembly, int Token), string> _cache = new(); + private readonly Dictionary<(string Assembly, int Token), string> _typeCache = new(); + private readonly Dictionary<(string Assembly, int Token, WasmLoweringFlags Flags), string> _methodCache = new(); private Process? _process; private string? _responseFilePath; @@ -59,17 +60,38 @@ public string GetAbiToken(Type type) int metadataToken = type.MetadataToken; var key = (assemblyName, metadataToken); - if (_cache.TryGetValue(key, out string? cached)) + if (_typeCache.TryGetValue(key, out string? cached)) return cached; - string reply = Query($"{assemblyName} 0x{metadataToken:x8}"); + string reply = Query($"t {assemblyName} 0x{metadataToken:x8}"); if (reply[0] == '!') { throw new LogAsErrorException( $"Could not compute the wasm ABI encoding of '{type.FullName ?? type.Name}': {reply.Substring(1)}"); } - _cache[key] = reply; + _typeCache[key] = reply; + return reply; + } + + public string GetMethodSignature(MethodInfo method, WasmLoweringFlags flags) + { + string assemblyName = method.Module.Assembly.GetName().Name + ?? throw new LogAsErrorException($"Method '{method.Name}' comes from an assembly with no simple name."); + int metadataToken = method.MetadataToken; + + var key = (assemblyName, metadataToken, flags); + if (_methodCache.TryGetValue(key, out string? cached)) + return cached; + + string reply = Query($"m {assemblyName} 0x{metadataToken:x8} {(int)flags}"); + if (reply[0] == '!') + { + throw new LogAsErrorException( + $"Could not compute the wasm signature of '{method.DeclaringType?.FullName}::{method.Name}': {reply.Substring(1)}"); + } + + _methodCache[key] = reply; return reply; } diff --git a/src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs b/src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs new file mode 100644 index 00000000000000..c2c9fd8ed0be75 --- /dev/null +++ b/src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; + +/// +/// Mirrors Internal.JitInterface.WasmLowering.LoweringFlags, which this task cannot reference +/// because the type system it lives in does not load on .NET Framework MSBuild. The values are passed +/// through to the signature resolver unchanged, so they must stay in sync. +/// +[Flags] +internal enum WasmLoweringFlags +{ + /// + /// A managed call. The signature gains a 'T' for an instance method and a trailing 'p' for the + /// portable entry point parameter. + /// + None = 0x0, + + HasGenericContextArg = 0x1, + + IsAsyncCall = 0x2, + + /// + /// A native signature: the lowered parameters and return value with no managed calling convention + /// additions. Used for P/Invoke targets and reverse P/Invoke entry points. + /// + IsUnmanagedCallersOnly = 0x4, +} From 05b975a7bb2ab6cc4a7a965577fe489b20c709cb Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 5 Aug 2026 23:07:00 +0200 Subject: [PATCH 03/72] Answer wasm ABI queries from crossgen2 instead of a separate tool The WasmAppBuilder generator needs struct sizes to build the signature strings that describe P/Invokes to the interpreter, and metadata alone does not give them. The previous commits added a standalone ILCompiler.Wasm.Lowering tool for that, which meant extracting parts of crossgen2 into shareable sources so a second host could link them. Jan Kotas pointed out that crossgen2 already exposes exactly this: it computes wasm signatures during compilation and always has. The tool added no capability, only a second host for an API that already existed. So this replaces it with a --wasm-abi-query mode on crossgen2 and reverts every extraction that existed to serve the tool. What is left in src/coreclr/tools is the query mode itself plus its wiring, and one word in WasmLowering.cs widening the encoding table from private to internal. crossgen2 is built by the 'clr' subset already, so it is present wherever the generator runs; the old tool was in no subset at all, which is why three library-test legs could not find it. Query mode configures a compilation group before answering, because the ReadyToRun field layout algorithm asks the group whether a derived type needs its base offset aligned and a struct holding a reference reaches that path. All inputs go in one version bubble: the alignment exists to keep offsets baked into precompiled code valid, and the interpreter computes layout itself. Regenerating the CoreCLR helpers through this mode reproduces the committed output byte for byte, using the published, trimmed, single-file crossgen2 apphost. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- Directory.Build.props | 4 - .../CompilerTypeSystemContext.Wasm.cs | 2 +- .../Target_Wasm/WasmTypes.Encoding.cs | 144 ------ .../Target_Wasm/WasmTypes.cs | 134 +++++- .../Common/Compiler/IWasmTypeCacheContext.cs | 48 -- .../JitInterface/WasmLowering.MethodDesc.cs | 43 -- .../tools/Common/JitInterface/WasmLowering.cs | 45 +- .../ILCompiler.Compiler.csproj | 2 - .../ILCompiler.ReadyToRun.Tests.csproj | 3 - .../WasmArgumentLayoutTests.cs | 101 +++++ .../WasmLoweringParityTests.cs | 423 ------------------ .../Compiler/ReadyToRunCompilerContext.cs | 127 ++++++ .../Compiler/VectorOfTFieldLayoutAlgorithm.cs | 137 ------ .../ILCompiler.ReadyToRun.csproj | 5 +- .../JitInterface/WasmAbiQuery.cs | 208 +++++++++ .../ILCompiler.RyuJit.csproj | 3 - .../ILCompiler.TypeSystem.csproj | 5 - .../ILCompiler.Wasm.Lowering.csproj | 45 -- .../aot/ILCompiler.Wasm.Lowering/Program.cs | 164 ------- .../WasmAbiTypeResolver.cs | 113 ----- .../WasmMetadataFieldLayoutAlgorithm.cs | 54 --- .../WasmTypeSystemContext.cs | 185 -------- .../aot/crossgen2/Crossgen2RootCommand.cs | 3 + src/coreclr/tools/aot/crossgen2/Program.cs | 11 +- .../aot/crossgen2/Properties/Resources.resx | 3 + src/libraries/sendtohelix-browser.targets | 6 + .../build/BrowserWasmApp.CoreCLR.targets | 9 + ...rosoft.NET.Runtime.WebAssembly.Sdk.pkgproj | 8 - ...t.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj | 8 - src/mono/wasi/build/WasiApp.CoreCLR.targets | 9 + .../Common/BuildEnvironment.cs | 2 + .../Common/EnvironmentVariables.cs | 1 + .../Wasm.Build.Tests/Wasm.Build.Tests.csproj | 2 + .../data/Local.Directory.Build.props | 1 + .../data/RunScriptTemplate.sh | 3 + .../WasmAppBuilder/WasmAppBuilder.csproj | 18 +- .../coreclr/ManagedToNativeGenerator.cs | 51 +-- .../coreclr/WasmAbiTypeResolver.cs | 53 ++- .../generate-coreclr-helpers.sh | 6 +- 39 files changed, 715 insertions(+), 1474 deletions(-) delete mode 100644 src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs delete mode 100644 src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs delete mode 100644 src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs delete mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs delete mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs delete mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj delete mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs delete mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs delete mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs delete mode 100644 src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs diff --git a/Directory.Build.props b/Directory.Build.props index 1cb776d6d7086a..e10a9be6011df7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -160,10 +160,6 @@ $([MSBuild]::NormalizePath('$(WasmAppBuilderDir)', 'WasmAppBuilder.dll')) $([MSBuild]::NormalizePath('$(WasmBuildTasksDir)', 'WasmBuildTasks.dll')) $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'WasmAppHost', 'wasm', '$(Configuration)')) - - $([MSBuild]::NormalizeDirectory('$(WasmAppBuilderDir)', 'ILCompiler.Wasm.Lowering')) $([MSBuild]::NormalizePath('$(WorkloadBuildTasksDir)', 'WorkloadBuildTasks.dll')) $([MSBuild]::NormalizePath('$(LibraryBuilderDir)', 'LibraryBuilder.dll')) $([MSBuild]::NormalizePath('$(MonoAOTCompilerDir)', 'MonoAOTCompiler.dll')) diff --git a/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs b/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs index d8eb9f1f12815d..ef6ff28c2769f6 100644 --- a/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs +++ b/src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs @@ -7,7 +7,7 @@ namespace ILCompiler { - public partial class CompilerTypeSystemContext : IWasmTypeCacheContext + public partial class CompilerTypeSystemContext { private readonly object _structCacheLock = new object(); private readonly Dictionary _structsBySize = new Dictionary(); diff --git a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs deleted file mode 100644 index 5644ad08e3fc62..00000000000000 --- a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs +++ /dev/null @@ -1,144 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -// Binary encoding, name mangling, and JIT interface conversions for the wasm type model. -// These are split out of WasmTypes.cs because they pull in the object writer, the name mangler, -// and the JIT interface, none of which a tool that only computes signatures can reference. - -using System; -using System.Diagnostics; - -using ILCompiler.ObjectWriter; -using Internal.JitInterface; - -namespace ILCompiler.DependencyAnalysis.Wasm -{ - public static partial class WasmValueTypeExtensions - { - public static WasmValueType FromCorInfoType(CorInfoWasmType ty) - { - ArgumentOutOfRangeException.ThrowIfGreaterThan((int)ty, byte.MaxValue); - if (Enum.IsDefined(typeof(WasmValueType), (byte)ty)) - { - return (WasmValueType)ty; - } - else - { - throw new InvalidOperationException("Unsupported CorInfoWasmType: " + ty); - } - } - } - - public readonly partial struct WasmResultType - { - public int EncodeSize() - { - uint sizeLength = DwarfHelper.SizeOfULEB128((ulong)_types.Length); - return (int)(sizeLength + (uint)_types.Length); - } - - public int Encode(Span buffer) - { - int sizeLength = DwarfHelper.WriteULEB128(buffer, (ulong)_types.Length); - Span rest = buffer.Slice(sizeLength); - for (int i = 0; i < _types.Length; i++) - { - rest[i] = (byte)_types[i]; - } - return (int)(sizeLength + (uint)_types.Length); - } - - public void AppendMangledName(Internal.Text.Utf8StringBuilder sb, bool isReturn = false) - { - if (isReturn && _types.Length == 0) - { - sb.Append("v"); - return; - } - - foreach (var type in _types) - { - sb.Append(type switch - { - WasmValueType.V128 => 'V', - WasmValueType.F64 => 'd', - WasmValueType.F32 => 'f', - WasmValueType.I64 => 'j', - WasmValueType.I32 => 'i', - _ => throw new NotImplementedException($"Unknown WasmValueType: {type}"), - }); - } - } - } - - public partial struct WasmFuncType - { - public static WasmFuncType FromCorInfoSignature(CorInfoWasmType[] types) - { - WasmResultType rs; - if (types.Length == 0) - { - throw new ArgumentException("Signature must have at least one type for the return value"); - } - - // The first type is the return type - rs = types[0] switch - { - // "void" is actually encoded as an empty type list in Wasm - CorInfoWasmType.CORINFO_WASM_TYPE_VOID => new WasmResultType(Array.Empty()), - _ => new WasmResultType([WasmValueTypeExtensions.FromCorInfoType(types[0])]) - }; - - // The rest are parameter types - WasmResultType ps; - if (types.Length > 1) - { - WasmValueType[] paramTypes = new WasmValueType[types.Length - 1]; - int idx = 0; - foreach (CorInfoWasmType paramType in types.AsSpan().Slice(1)) - { - paramTypes[idx++] = WasmValueTypeExtensions.FromCorInfoType(paramType); - } - ps = new WasmResultType(paramTypes); - } - else - { - ps = new WasmResultType(Array.Empty()); - } - - return new WasmFuncType(ps, rs); - } - - public readonly int EncodeSize() - { - return 1 + _params.EncodeSize() + _returns.EncodeSize(); - } - - public readonly int Encode(Span buffer) - { - int totalSize = EncodeSize(); - buffer[0] = 0x60; // function type indicator - - int paramSize = _params.Encode(buffer.Slice(1)); - int returnSize = _returns.Encode(buffer.Slice(1 + paramSize)); - Debug.Assert(totalSize == 1 + paramSize + returnSize); - - return totalSize; - } - - public void AppendMangledName(NameMangler nameMangler, Internal.Text.Utf8StringBuilder sb) - { - sb.Append(nameMangler.CompilationUnitPrefix); - sb.Append("__wasmtype_"u8); - _returns.AppendMangledName(sb, isReturn: true); - _params.AppendMangledName(sb); - } - - public Internal.Text.Utf8String GetMangledName(NameMangler mangler) - { - Internal.Text.Utf8StringBuilder sb = new(); - AppendMangledName(mangler, sb); - return sb.ToUtf8String(); - } - } -} diff --git a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs index 7acb6e8d36d007..30944c04ce70cf 100644 --- a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs +++ b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs @@ -1,15 +1,13 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -// This file holds the wasm type model that describes a signature. It is deliberately free of any -// dependency outside the type system so that it can be linked into tools that only need to compute -// signatures (see ILCompiler.Wasm.Lowering). Binary encoding, name mangling, and the JIT interface -// conversions live in WasmTypes.Encoding.cs. - using System; using System.Diagnostics; using System.Linq; +using ILCompiler.ObjectWriter; +using Internal.JitInterface; + namespace ILCompiler.DependencyAnalysis.Wasm { // For now, we only encode Wasm numeric value types. @@ -32,7 +30,7 @@ public enum WasmMutabilityType : byte Mut = 0x01 } - public static partial class WasmValueTypeExtensions + public static class WasmValueTypeExtensions { public static string ToTypeString(this WasmValueType valueType) { @@ -46,10 +44,23 @@ public static string ToTypeString(this WasmValueType valueType) _ => "unknown", }; } + + public static WasmValueType FromCorInfoType(CorInfoWasmType ty) + { + ArgumentOutOfRangeException.ThrowIfGreaterThan((int)ty, byte.MaxValue); + if (Enum.IsDefined(typeof(WasmValueType), (byte)ty)) + { + return (WasmValueType)ty; + } + else + { + throw new InvalidOperationException("Unsupported CorInfoWasmType: " + ty); + } + } } #nullable enable - public readonly partial struct WasmResultType : IEquatable, IComparable + public readonly struct WasmResultType : IEquatable, IComparable { private readonly WasmValueType[] _types; public ReadOnlySpan Types => _types; @@ -84,7 +95,46 @@ public override int GetHashCode() return code; } + public int EncodeSize() + { + uint sizeLength = DwarfHelper.SizeOfULEB128((ulong)_types.Length); + return (int)(sizeLength + (uint)_types.Length); + } + + public int Encode(Span buffer) + { + int sizeLength = DwarfHelper.WriteULEB128(buffer, (ulong)_types.Length); + Span rest = buffer.Slice(sizeLength); + for (int i = 0; i < _types.Length; i++) + { + rest[i] = (byte)_types[i]; + } + return (int)(sizeLength + (uint)_types.Length); + } + public int CompareTo(WasmResultType other) => MemoryExtensions.SequenceCompareTo(Types, other.Types); + + public void AppendMangledName(Internal.Text.Utf8StringBuilder sb, bool isReturn = false) + { + if (isReturn && _types.Length == 0) + { + sb.Append("v"); + return; + } + + foreach (var type in _types) + { + sb.Append(type switch + { + WasmValueType.V128 => 'V', + WasmValueType.F64 => 'd', + WasmValueType.F32 => 'f', + WasmValueType.I64 => 'j', + WasmValueType.I32 => 'i', + _ => throw new NotImplementedException($"Unknown WasmValueType: {type}"), + }); + } + } } public static class WasmResultTypeExtensions @@ -131,7 +181,7 @@ public int CompareTo(WasmSignature other) public static bool operator !=(WasmSignature left, WasmSignature right) => !left.Equals(right); } - public partial struct WasmFuncType : IEquatable, IComparable + public struct WasmFuncType : IEquatable, IComparable { private readonly WasmResultType _params; private readonly WasmResultType _returns; @@ -147,6 +197,59 @@ public WasmFuncType(WasmResultType paramTypes, WasmResultType returnTypes) _returns = returnTypes; } + public static WasmFuncType FromCorInfoSignature(CorInfoWasmType[] types) + { + WasmResultType rs; + if (types.Length == 0) + { + throw new ArgumentException("Signature must have at least one type for the return value"); + } + + // The first type is the return type + rs = types[0] switch + { + // "void" is actually encoded as an empty type list in Wasm + CorInfoWasmType.CORINFO_WASM_TYPE_VOID => new WasmResultType(Array.Empty()), + _ => new WasmResultType([WasmValueTypeExtensions.FromCorInfoType(types[0])]) + }; + + // The rest are parameter types + WasmResultType ps; + if (types.Length > 1) + { + WasmValueType[] paramTypes = new WasmValueType[types.Length - 1]; + int idx = 0; + foreach (CorInfoWasmType paramType in types.AsSpan().Slice(1)) + { + paramTypes[idx++] = WasmValueTypeExtensions.FromCorInfoType(paramType); + } + ps = new WasmResultType(paramTypes); + } + else + { + ps = new WasmResultType(Array.Empty()); + } + + return new WasmFuncType(ps, rs); + } + + public readonly int EncodeSize() + { + return 1 + _params.EncodeSize() + _returns.EncodeSize(); + } + + public readonly int Encode(Span buffer) + { + int totalSize = EncodeSize(); + buffer[0] = 0x60; // function type indicator + + int paramSize = _params.Encode(buffer.Slice(1)); + int returnSize = _returns.Encode(buffer.Slice(1 + paramSize)); + Debug.Assert(totalSize == 1 + paramSize + returnSize); + + return totalSize; + } + public bool Equals(WasmFuncType other) { return _params.Equals(other._params) && _returns.Equals(other._returns); @@ -190,5 +293,20 @@ public int CompareTo(WasmFuncType other) return paramComparison; return _returns.CompareTo(other._returns); } + + public void AppendMangledName(NameMangler nameMangler, Internal.Text.Utf8StringBuilder sb) + { + sb.Append(nameMangler.CompilationUnitPrefix); + sb.Append("__wasmtype_"u8); + _returns.AppendMangledName(sb, isReturn: true); + _params.AppendMangledName(sb); + } + + public Internal.Text.Utf8String GetMangledName(NameMangler mangler) + { + Internal.Text.Utf8StringBuilder sb = new(); + AppendMangledName(mangler, sb); + return sb.ToUtf8String(); + } } } diff --git a/src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs b/src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs deleted file mode 100644 index 44654599975b1c..00000000000000 --- a/src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Internal.TypeSystem; - -namespace ILCompiler -{ - /// - /// The side table wasm signature lowering needs in order to be reversible. Lowering erases a - /// struct down to its size (the S<N> encoding) and an empty struct down to - /// 'e', so raising a signature back to a needs a real type - /// to hand back. Lowering records the types it saw here and raising looks them up. - /// - /// This is an interface rather than a direct reference - /// so that can be linked into tools that only - /// compute signatures and do not want the rest of the compiler. See ILCompiler.Wasm.Lowering. - /// - public interface IWasmTypeCacheContext - { - /// - /// The type the 'V' encoding raises to. All v128 types share the same wasm ABI - /// (16 bytes, 16-byte aligned), so any one of them round-trips 'V' identically. - /// - TypeDesc WasmV128Type { get; } - - /// - /// The first empty struct seen during lowering, or if there was none. - /// - TypeDesc CachedEmptyStruct { get; } - - /// - /// Records an empty struct seen during lowering. Only the first one is retained. - /// - void CacheEmptyStruct(TypeDesc type); - - /// - /// Records a struct seen during lowering, keyed by its element size. Only the first struct - /// encountered for a given size is retained. - /// - void CacheStructBySize(TypeDesc type); - - /// - /// Returns a previously cached struct of the given byte size, or if - /// no struct of that size has been cached. - /// - TypeDesc GetCachedStructOfSize(int size); - } -} diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs deleted file mode 100644 index 1782d6c7de38cf..00000000000000 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -// The MethodDesc-facing half of wasm signature lowering. It is split out of WasmLowering.cs because -// it needs the compiler's MethodDesc extension methods (Common/Compiler/TypeExtensions.cs and -// MethodExtensions.cs), which a tool that only lowers MethodSignatures does not want to link. - -using ILCompiler; -using ILCompiler.DependencyAnalysis.Wasm; - -using Internal.TypeSystem; - -namespace Internal.JitInterface -{ - public static partial class WasmLowering - { - /// - /// Gets the Wasm-level signature for a given MethodDesc. - /// - public static WasmSignature GetSignature(MethodDesc method) - { - return GetSignature(method.Signature, GetLoweringFlags(method)); - } - - public static LoweringFlags GetLoweringFlags(MethodDesc method) - { - LoweringFlags flags = 0; - if (method.RequiresInstMethodDescArg() || method.RequiresInstMethodTableArg()) - { - flags |= LoweringFlags.HasGenericContextArg; - } - if (method.IsAsyncCall()) - { - flags |= LoweringFlags.IsAsyncCall; - } - if (method.IsUnmanagedCallersOnly) - { - flags |= LoweringFlags.IsUnmanagedCallersOnly; - } - return flags; - } - } -} diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index 73c555f52d2e88..2fc450790871bc 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -207,6 +207,8 @@ public static WasmValueType LowerType(TypeDesc type) /// /// Maps a WasmValueType to its single-character signature encoding. /// + // internal rather than private so the wasm ABI query mode can answer single-type questions + // with the same encoding table the signature builder below uses. internal static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch { WasmValueType.I32 => 'i', @@ -223,7 +225,7 @@ public static WasmValueType LowerType(TypeDesc type) 'l' => context.GetWellKnownType(WellKnownType.Int64), 'f' => context.GetWellKnownType(WellKnownType.Single), 'd' => context.GetWellKnownType(WellKnownType.Double), - 'V' => ((IWasmTypeCacheContext)context).WasmV128Type, + 'V' => ((CompilerTypeSystemContext)context).WasmV128Type, _ => throw new InvalidOperationException($"Unknown signature char: {c}") }; @@ -254,7 +256,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy else if (sig[pos] == 'S') { int structSize = ParseStructSize(sig, ref pos); - returnType = ((IWasmTypeCacheContext)context).GetCachedStructOfSize(structSize); + returnType = ((CompilerTypeSystemContext)context).GetCachedStructOfSize(structSize); Debug.Assert(returnType is not null, $"No cached struct of size {structSize} for return type in signature '{sig}'"); } else @@ -303,7 +305,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy else if (c == 'e') { // Empty struct — include the cached empty struct type for roundtrip fidelity - TypeDesc emptyStruct = ((IWasmTypeCacheContext)context).CachedEmptyStruct; + TypeDesc emptyStruct = ((CompilerTypeSystemContext)context).CachedEmptyStruct; Debug.Assert(emptyStruct is not null, "Encountered 'e' in signature but no empty struct was cached during lowering"); parameters.Add(emptyStruct); pos++; @@ -311,7 +313,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy else if (c == 'S') { int structSize = ParseStructSize(sig, ref pos); - TypeDesc cachedStruct = ((IWasmTypeCacheContext)context).GetCachedStructOfSize(structSize); + TypeDesc cachedStruct = ((CompilerTypeSystemContext)context).GetCachedStructOfSize(structSize); Debug.Assert(cachedStruct is not null, $"No cached struct of size {structSize} for parameter in signature '{sig}'"); parameters.Add(cachedStruct); } @@ -346,7 +348,7 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy } /// - /// Gets the Wasm-level signature for a given MethodSignature. + /// Gets the Wasm-level signature for a given MethodDesc. /// The signature string format is documented in docs/design/coreclr/botr/readytorun-format.md /// (section "Wasm Signature String Encoding"). /// @@ -356,6 +358,31 @@ public static MethodSignature RaiseSignature(WasmSignature wasmSignature, TypeSy /// For unmanaged callers only (reverse P/Invoke), the layout is simply the native signature /// which is just the lowered parameters+return. /// + /// + /// + public static WasmSignature GetSignature(MethodDesc method) + { + return GetSignature(method.Signature, GetLoweringFlags(method)); + } + + public static LoweringFlags GetLoweringFlags(MethodDesc method) + { + LoweringFlags flags = 0; + if (method.RequiresInstMethodDescArg() || method.RequiresInstMethodTableArg()) + { + flags |= LoweringFlags.HasGenericContextArg; + } + if (method.IsAsyncCall()) + { + flags |= LoweringFlags.IsAsyncCall; + } + if (method.IsUnmanagedCallersOnly) + { + flags |= LoweringFlags.IsUnmanagedCallersOnly; + } + return flags; + } + [Flags] public enum LoweringFlags { @@ -369,7 +396,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag { if (!flags.HasFlag(LoweringFlags.IsUnmanagedCallersOnly) && signature.Flags.HasFlag(MethodSignatureFlags.UnmanagedCallingConvention)) { - flags |= LoweringFlags.IsUnmanagedCallersOnly; + flags = flags | LoweringFlags.IsUnmanagedCallersOnly; } TypeDesc returnType = signature.ReturnType; @@ -401,7 +428,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag int returnSize = returnType.GetElementSize().AsInt; sigBuilder.Append('S'); sigBuilder.Append(returnSize); - ((IWasmTypeCacheContext)returnType.Context).CacheStructBySize(returnType); + ((CompilerTypeSystemContext)returnType.Context).CacheStructBySize(returnType); } } else if (loweredReturnType.IsVoid) @@ -474,7 +501,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag { // Empty struct — not emitted as a WebAssembly argument sigBuilder.Append('e'); - ((IWasmTypeCacheContext)signature.ReturnType.Context).CacheEmptyStruct(paramType); + ((CompilerTypeSystemContext)signature.ReturnType.Context).CacheEmptyStruct(paramType); continue; } @@ -483,7 +510,7 @@ public static WasmSignature GetSignature(MethodSignature signature, LoweringFlag sigBuilder.Append('S'); sigBuilder.Append(paramSize); result.Add(pointerType); - ((IWasmTypeCacheContext)paramType.Context).CacheStructBySize(paramType); + ((CompilerTypeSystemContext)paramType.Context).CacheStructBySize(paramType); } else { diff --git a/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj b/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj index 58ab89004b9190..e26214457ac8c3 100644 --- a/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj +++ b/src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj @@ -350,8 +350,6 @@ - - diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj index 8881ae804dc9e7..9969de865e5fe9 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj @@ -24,9 +24,6 @@ - - - - - TypeSystem\Common\VersionResilientHashCode.cs diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj deleted file mode 100644 index 2ec12bf3c5469f..00000000000000 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj +++ /dev/null @@ -1,45 +0,0 @@ - - - Exe - ILCompiler.Wasm - ILCompiler.Wasm.Lowering - $(NetCoreAppToolCurrent) - disable - true - AnyCPU - false - false - false - Debug;Release;Checked - false - - $(WasmSignatureResolverDir) - - false - $(NoWarn);CA1859 - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs deleted file mode 100644 index 2b83e48a6ef571..00000000000000 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs +++ /dev/null @@ -1,164 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.IO; - -namespace ILCompiler.Wasm -{ - /// - /// A long-lived query server that answers wasm ABI questions about types, for build tasks that - /// cannot reference the type system directly. - /// - /// - /// This exists because the WebAssembly build tasks also run on .NET Framework MSBuild, where a - /// netcoreapp type system assembly cannot be loaded at all. Running it as a separate process keeps - /// one implementation of the ABI rules instead of a second, drifting one in the task. - /// - /// Loading the assembly closure is the expensive part, so the process stays up for the whole build - /// and answers queries on stdin rather than being spawned per type. - /// - /// Usage: - /// ILCompiler.Wasm.Lowering --targetos <browser|wasi> [--assembly <path>]... [@responsefile] - /// - /// Each stdin line is one query, and each reply line is either the answer or '!' followed by an - /// error message. Two query forms are supported: - /// - /// t <assemblySimpleName> <typeToken> - /// Replies with the ABI encoding of a type in parameter position ('i', 'l', 'f', 'd', 'V' or - /// "S<size>"). - /// - /// m <assemblySimpleName> <methodToken> <loweringFlags> - /// Replies with the full signature string of a method. - /// - /// Tokens are decimal or 0x-prefixed hexadecimal; flags are a decimal LoweringFlags value. - /// - internal static class Program - { - private static int Main(string[] args) - { - string targetOS = null; - string systemModule = "System.Private.CoreLib"; - var assemblies = new List(); - - try - { - for (int i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "--targetos": - targetOS = args[++i]; - break; - case "--assembly": - assemblies.Add(args[++i]); - break; - case "--systemmodule": - systemModule = args[++i]; - break; - default: - if (args[i].StartsWith('@')) - { - assemblies.AddRange(File.ReadAllLines(args[i].Substring(1))); - break; - } - - Console.Error.WriteLine($"Unrecognized argument '{args[i]}'."); - return 1; - } - } - - if (targetOS is null) - { - Console.Error.WriteLine("Missing required argument --targetos."); - return 1; - } - - assemblies.RemoveAll(string.IsNullOrWhiteSpace); - var resolver = new WasmAbiTypeResolver(targetOS, assemblies, systemModule); - - // Tells the caller the closure loaded, so a startup failure is not mistaken for a - // failure of the first query. - Console.Out.WriteLine("ready"); - Console.Out.Flush(); - - Serve(resolver); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine(ex.ToString()); - return 1; - } - } - - private static void Serve(WasmAbiTypeResolver resolver) - { - string line; - while ((line = Console.In.ReadLine()) is not null) - { - if (line.Length == 0) - continue; - - string reply; - try - { - reply = Answer(resolver, line); - } - catch (Exception ex) - { - reply = "!" + ex.Message.Replace('\r', ' ').Replace('\n', ' '); - } - - Console.Out.WriteLine(reply); - Console.Out.Flush(); - } - } - - private static string Answer(WasmAbiTypeResolver resolver, string query) - { - if (query.Length < 2 || query[1] != ' ') - throw new FormatException($"Malformed query '{query}'; expected a 't' or 'm' verb."); - - string rest = query.Substring(2); - - // Parsed right to left so that the assembly name, which is whatever is left over, is not - // assumed to be free of spaces. - switch (query[0]) - { - case 't': - { - (string assemblyName, int typeToken) = SplitToken(rest, query); - return resolver.GetAbiToken(assemblyName, typeToken); - } - - case 'm': - { - (string head, int flags) = SplitToken(rest, query); - (string assemblyName, int methodToken) = SplitToken(head, query); - return resolver.GetMethodSignature(assemblyName, methodToken, flags); - } - - default: - throw new FormatException($"Unrecognized query verb '{query[0]}'."); - } - } - - private static (string Head, int Value) SplitToken(string text, string query) - { - int separator = text.LastIndexOf(' '); - if (separator < 0) - throw new FormatException($"Malformed query '{query}'; not enough fields."); - - return (text.Substring(0, separator), ParseToken(text.Substring(separator + 1))); - } - - private static int ParseToken(string text) - { - return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) - ? int.Parse(text.Substring(2), System.Globalization.NumberStyles.HexNumber) - : int.Parse(text, System.Globalization.CultureInfo.InvariantCulture); - } - } -} diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs deleted file mode 100644 index c1cf476e3532a5..00000000000000 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs +++ /dev/null @@ -1,113 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Reflection.Metadata; -using System.Reflection.Metadata.Ecma335; - -using Internal.JitInterface; -using Internal.TypeSystem; -using Internal.TypeSystem.Ecma; - -namespace ILCompiler.Wasm -{ - /// - /// Answers "what does this look like in a wasm ABI signature?" for methods and for individual - /// types, using the same lowering crossgen2 uses, so callers that need to agree with compiled - /// code do not have to reimplement it. - /// - /// - /// Methods and types are identified by assembly simple name plus metadata token rather than by - /// name. Name-based lookup would have to reproduce nested-type and generic name mangling, and - /// would silently pick the wrong member when it got that wrong; a token cannot be ambiguous. - /// Note that this makes constructed generics unnameable — they have no token — which is why - /// whole signatures are queried per method rather than a token at a time. - /// - public sealed class WasmAbiTypeResolver - { - private readonly WasmTypeSystemContext _context; - - public WasmAbiTypeResolver(string targetOS, IEnumerable assemblyPaths, string systemModuleName = "System.Private.CoreLib") - { - _context = new WasmTypeSystemContext(ParseTargetOS(targetOS)); - - foreach (string path in assemblyPaths) - { - _context.AddAssemblyPath(path); - } - - _context.SetSystemModule(_context.GetModuleForSimpleName(systemModuleName)); - } - - private static TargetOS ParseTargetOS(string targetOS) => targetOS?.ToLowerInvariant() switch - { - "browser" => TargetOS.Browser, - "wasi" => TargetOS.Wasi, - _ => throw new ArgumentException($"Unsupported wasm target OS '{targetOS}'.", nameof(targetOS)), - }; - - /// - /// Gets the signature encoding for a single type in parameter position: a primitive character - /// (i, l, f, d, V) or S<size> for a struct that is - /// passed by reference. - /// - /// Simple name of the assembly defining the type. - /// The type's metadata token (a TypeDef, TypeRef or TypeSpec token). - public string GetAbiToken(string assemblySimpleName, int metadataToken) - { - var module = (EcmaModule)_context.GetModuleForSimpleName(assemblySimpleName); - TypeDesc type = module.GetType(MetadataTokens.EntityHandle(metadataToken)); - - return GetAbiToken(type); - } - - /// - /// Gets the full wasm signature string for a method, using the same lowering the compiler - /// applies to the code that will implement or call it. - /// - /// - /// Preferred over per-parameter queries: the parameter - /// types come from the method's signature blob, so generic instantiations resolve here even - /// though they have no metadata token of their own and cannot be named over the wire. - /// - /// Simple name of the assembly defining the method. - /// The method's MethodDef token. - /// A value. - public string GetMethodSignature(string assemblySimpleName, int methodToken, int flags) - { - // The caller keeps its own copy of LoweringFlags, because a build task cannot reference - // the type system. Reject bits this build does not define rather than letting a copy that - // has drifted ahead silently ask for a lowering that is not the one it means. - const int KnownFlags = (int)(WasmLowering.LoweringFlags.HasGenericContextArg - | WasmLowering.LoweringFlags.IsAsyncCall - | WasmLowering.LoweringFlags.IsUnmanagedCallersOnly); - - if ((flags & ~KnownFlags) != 0) - { - throw new ArgumentOutOfRangeException(nameof(flags), $"Unknown wasm lowering flags 0x{flags:x}; this build understands 0x{KnownFlags:x}."); - } - - var module = (EcmaModule)_context.GetModuleForSimpleName(assemblySimpleName); - MethodDesc method = module.GetMethod(MetadataTokens.EntityHandle(methodToken)); - - return WasmLowering.GetSignature(method.Signature, (WasmLowering.LoweringFlags)flags).SignatureString; - } - - /// - /// Gets the signature encoding for a type. Public so tests can drive it with types they - /// resolved themselves. - /// - public static string GetAbiToken(TypeDesc type) - { - TypeDesc loweredType = WasmLowering.LowerToAbiType(type); - if (loweredType is null) - { - // Passed by reference; the size is what the callee needs to know. - return string.Create(null, stackalloc char[16], $"S{type.GetElementSize().AsInt}"); - } - - return WasmLowering.WasmValueTypeToSigChar(WasmLowering.LowerType(loweredType)).ToString(); - } - } -} diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs deleted file mode 100644 index 7cab47b872a123..00000000000000 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using Internal.TypeSystem; - -namespace ILCompiler.Wasm -{ - /// - /// Instance field layout matching what crossgen2 computes, so that a struct size resolved here - /// is the same size the compiler will encode into a wasm ABI signature. - /// - /// - /// This mirrors ReadyToRunMetadataFieldLayoutAlgorithm.ComputeInstanceFieldLayout. That type - /// cannot be reused directly because it also implements ReadyToRun static field layout, which drags - /// in the whole compilation-module-group and node-factory machinery. Only the instance side matters - /// for struct sizes, and it is small enough to mirror exactly. - /// - /// WasmLoweringParityTests compares this against a real ReadyToRunCompilerContext over - /// every value type in CoreLib, which is what keeps the mirroring honest if crossgen2 changes. - /// - internal sealed class WasmMetadataFieldLayoutAlgorithm : MetadataFieldLayoutAlgorithm - { - protected override ComputedInstanceFieldLayout ComputeInstanceFieldLayout(MetadataType type, int numInstanceFields) - { - ClassLayoutMetadata layoutMetadata = type.GetClassLayout(); - return layoutMetadata.Kind switch - { - MetadataLayoutKind.CStruct => ComputeCStructFieldLayout(type, numInstanceFields), - MetadataLayoutKind.CUnion => ComputeCUnionFieldLayout(type, numInstanceFields), - MetadataLayoutKind.Explicit => ComputeExplicitFieldLayout(type, numInstanceFields, layoutMetadata), - MetadataLayoutKind.Sequential when !type.ContainsGCPointers => ComputeSequentialFieldLayout(type, numInstanceFields, layoutMetadata), - _ => ComputeAutoFieldLayout(type, numInstanceFields, layoutMetadata), - }; - } - - protected override void PrepareRuntimeSpecificStaticFieldLayout(TypeSystemContext context, ref ComputedStaticFieldLayout layout) - { - layout.GcStatics.Size = context.Target.LayoutPointerSize; - layout.ThreadGcStatics.Size = context.Target.LayoutPointerSize; - } - - protected override void FinalizeRuntimeSpecificStaticFieldLayout(TypeSystemContext context, ref ComputedStaticFieldLayout layout) - { - if (layout.GcStatics.Size == context.Target.LayoutPointerSize) - { - layout.GcStatics.Size = LayoutInt.Zero; - } - if (layout.ThreadGcStatics.Size == context.Target.LayoutPointerSize) - { - layout.ThreadGcStatics.Size = LayoutInt.Zero; - } - } - } -} diff --git a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs b/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs deleted file mode 100644 index 9ca4456272c054..00000000000000 --- a/src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs +++ /dev/null @@ -1,185 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using System.Reflection.Metadata; -using System.Reflection.PortableExecutable; - -using Internal.TypeSystem; -using Internal.TypeSystem.Ecma; - -namespace ILCompiler.Wasm -{ - /// - /// A minimal configured the way crossgen2 configures itself - /// for a wasm target, so field layout - and therefore struct size - agrees with what the compiler - /// will encode into wasm ABI signatures. - /// - /// - /// Assemblies are resolved from an explicit list of file paths rather than from a probing path, so - /// callers get an error instead of a silently different answer when a reference is missing. - /// - public sealed class WasmTypeSystemContext : MetadataTypeSystemContext, IWasmTypeCacheContext - { - private readonly Dictionary _assemblyPaths = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _modules = new(StringComparer.OrdinalIgnoreCase); - - private readonly WasmMetadataFieldLayoutAlgorithm _metadataFieldLayout = new(); - private readonly VectorFieldLayoutAlgorithm _vectorFieldLayoutAlgorithm; - private readonly VectorOfTFieldLayoutAlgorithm _vectorOfTFieldLayoutAlgorithm; - private readonly Int128FieldLayoutAlgorithm _int128FieldLayoutAlgorithm; - private readonly DecimalFieldLayoutAlgorithm _decimalFieldLayoutAlgorithm; - private readonly TypeWithRepeatedFieldsFieldLayoutAlgorithm _typeWithRepeatedFieldsFieldLayoutAlgorithm; - private readonly MetadataRuntimeInterfacesAlgorithm _metadataRuntimeInterfacesAlgorithm = new(); - private readonly VirtualMethodAlgorithm _virtualMethodAlgorithm = new MetadataVirtualMethodAlgorithm(); - private ArrayOfTRuntimeInterfacesAlgorithm _arrayOfTRuntimeInterfacesAlgorithm; - - private readonly object _structCacheLock = new object(); - private readonly Dictionary _structsBySize = new Dictionary(); - private volatile TypeDesc _cachedEmptyStruct; - private volatile TypeDesc _wasmV128Type; - - public WasmTypeSystemContext(TargetOS targetOS) - : base(new TargetDetails(TargetArchitecture.Wasm32, targetOS, TargetAbi.NativeAot, SimdVectorLength.Vector128Bit)) - { - _vectorFieldLayoutAlgorithm = new VectorFieldLayoutAlgorithm(_metadataFieldLayout); - _vectorOfTFieldLayoutAlgorithm = new VectorOfTFieldLayoutAlgorithm(_metadataFieldLayout, _vectorFieldLayoutAlgorithm, "Vector128`1"u8); - _int128FieldLayoutAlgorithm = new Int128FieldLayoutAlgorithm(_metadataFieldLayout); - _decimalFieldLayoutAlgorithm = new DecimalFieldLayoutAlgorithm(_metadataFieldLayout); - _typeWithRepeatedFieldsFieldLayoutAlgorithm = new TypeWithRepeatedFieldsFieldLayoutAlgorithm(_metadataFieldLayout); - } - - /// - /// Registers an assembly file that may load. The last registration - /// for a given simple name wins, matching how a compiler command line treats duplicate inputs. - /// - public void AddAssemblyPath(string path) - { - _assemblyPaths[Path.GetFileNameWithoutExtension(path)] = path; - } - - public ModuleDesc GetModuleForSimpleName(string simpleName, bool throwIfNotFound = true) - { - if (_modules.TryGetValue(simpleName, out ModuleDesc existingModule)) - return existingModule; - - if (!_assemblyPaths.TryGetValue(simpleName, out string filePath)) - { - if (throwIfNotFound) - throw new FileNotFoundException($"Assembly '{simpleName}' was not among the assemblies provided to the wasm signature resolver."); - - return null; - } - - var peStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); - ModuleDesc module = EcmaModule.Create(this, new PEReader(peStream), containingAssembly: null); - _modules.Add(simpleName, module); - return module; - } - - public override ModuleDesc ResolveAssembly(AssemblyNameInfo name, bool throwIfNotFound) - { - return GetModuleForSimpleName(name.Name, throwIfNotFound); - } - - public override FieldLayoutAlgorithm GetLayoutAlgorithmForType(DefType type) - { - if (type == UniversalCanonType) - return UniversalCanonLayoutAlgorithm.Instance; - - if (VectorOfTFieldLayoutAlgorithm.IsVectorOfTType(type)) - return _vectorOfTFieldLayoutAlgorithm; - - if (VectorFieldLayoutAlgorithm.IsVectorType(type)) - return _vectorFieldLayoutAlgorithm; - - if (Int128FieldLayoutAlgorithm.IsIntegerType(type)) - return _int128FieldLayoutAlgorithm; - - if (DecimalFieldLayoutAlgorithm.IsDecimalFloatingPointType(type)) - return _decimalFieldLayoutAlgorithm; - - if (type is TypeWithRepeatedFields) - return _typeWithRepeatedFieldsFieldLayoutAlgorithm; - - return _metadataFieldLayout; - } - - protected override RuntimeInterfacesAlgorithm GetRuntimeInterfacesAlgorithmForNonPointerArrayType(ArrayType type) - { - _arrayOfTRuntimeInterfacesAlgorithm ??= new ArrayOfTRuntimeInterfacesAlgorithm(SystemModule.GetType("System"u8, "Array`1"u8)); - return _arrayOfTRuntimeInterfacesAlgorithm; - } - - protected override RuntimeInterfacesAlgorithm GetRuntimeInterfacesAlgorithmForDefType(DefType type) - { - return _metadataRuntimeInterfacesAlgorithm; - } - - public override VirtualMethodAlgorithm GetVirtualMethodAlgorithmForType(TypeDesc type) - { - return _virtualMethodAlgorithm; - } - - // crossgen2 always runs with SharedGenericsMode.CanonicalReferenceTypes. - protected internal override Instantiation ConvertInstantiationToCanonForm(Instantiation instantiation, CanonicalFormKind kind, out bool changed) - => RuntimeDeterminedCanonicalizationAlgorithm.ConvertInstantiationToCanonForm(instantiation, kind, out changed); - - protected internal override TypeDesc ConvertToCanon(TypeDesc typeToConvert, CanonicalFormKind kind) - => RuntimeDeterminedCanonicalizationAlgorithm.ConvertToCanon(typeToConvert, kind); - - protected internal override TypeDesc ConvertToCanon(TypeDesc typeToConvert, ref CanonicalFormKind kind) - => RuntimeDeterminedCanonicalizationAlgorithm.ConvertToCanon(typeToConvert, ref kind); - - public override bool SupportsUniversalCanon => false; - public override bool SupportsCanon => true; - - public TypeDesc WasmV128Type - { - get - { - TypeDesc type = _wasmV128Type; - if (type is null) - { - var vector128 = (MetadataType)SystemModule.GetType("System.Runtime.Intrinsics"u8, "Vector128`1"u8); - _wasmV128Type = type = vector128.MakeInstantiatedType(GetWellKnownType(WellKnownType.Byte)); - } - - return type; - } - } - - public TypeDesc CachedEmptyStruct => _cachedEmptyStruct; - - public void CacheEmptyStruct(TypeDesc type) - { - _cachedEmptyStruct ??= type; - } - - public void CacheStructBySize(TypeDesc type) - { - int size = type.GetElementSize().AsInt; - if (size <= 0) - return; - - lock (_structCacheLock) - { - _structsBySize.TryAdd(size, type); - } - } - - public TypeDesc GetCachedStructOfSize(int size) - { - lock (_structCacheLock) - { - if (_structsBySize.TryGetValue(size, out TypeDesc result)) - return result; - } - - return null; - } - } -} diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 644fe3d92b02ff..3ef3f9d3631e83 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -98,6 +98,8 @@ internal class Crossgen2RootCommand : RootCommand new("--jitpath") { Description = SR.JitPathOption }; public Option PrintReproInstructions { get; } = new("--print-repro-instructions") { Description = SR.PrintReproInstructionsOption }; + public Option WasmAbiQuery { get; } = + new("--wasm-abi-query") { Description = SR.WasmAbiQueryOption }; public Option SingleMethodTypeName { get; } = new("--singlemethodtypename") { Description = SR.SingleMethodTypeName }; public Option SingleMethodName { get; } = @@ -201,6 +203,7 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(TargetOS); Options.Add(JitPath); Options.Add(PrintReproInstructions); + Options.Add(WasmAbiQuery); Options.Add(SingleMethodTypeName); Options.Add(SingleMethodName); Options.Add(SingleMethodIndex); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index 691add6dd78b2a..2b780d8894f627 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -39,6 +39,7 @@ internal sealed class Program private readonly bool _singleFileCompilation; private readonly bool _outNearInput; private readonly string _outputFilePath; + private readonly bool _wasmAbiQuery; public Program(Crossgen2RootCommand command) { @@ -47,6 +48,7 @@ public Program(Crossgen2RootCommand command) _singleFileCompilation = Get(command.SingleFileCompilation); _outNearInput = Get(command.OutNearInput); _outputFilePath = Get(command.OutputFilePath); + _wasmAbiQuery = Get(command.WasmAbiQuery); if (Get(command.WaitForDebugger)) { @@ -68,7 +70,9 @@ private void ConfigureImageBase(TargetDetails targetDetails) public int Run() { - if (_outputFilePath == null && !_outNearInput) + // Query mode answers questions about the input assemblies and writes no image, so the + // output arguments the compilation path requires do not apply. + if (_outputFilePath == null && !_outNearInput && !_wasmAbiQuery) throw new CommandLineException(SR.MissingOutputFile); if (_singleFileCompilation && !_outNearInput) @@ -275,6 +279,11 @@ public int Run() _typeSystemContext.SetSystemModule((EcmaModule)_typeSystemContext.GetModuleForSimpleName(systemModuleName)); ReadyToRunCompilerContext typeSystemContext = _typeSystemContext; + if (_wasmAbiQuery) + { + return WasmAbiQuery.Run(typeSystemContext, Console.In, Console.Out); + } + if (_singleFileCompilation) { var singleCompilationInputFilePaths = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index 8a34770d35e215..9914585d5558cb 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -285,6 +285,9 @@ Target OS for cross compilation + + Answer wasm ABI signature queries on stdin instead of compiling + Target OS is not supported diff --git a/src/libraries/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index 918ce5dbcc0503..fb26106268e807 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -188,6 +188,7 @@ + @@ -197,6 +198,7 @@ + @@ -293,6 +295,10 @@ + + diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 0e8b915873fa2d..0c2d3d132eb1c9 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -638,6 +638,13 @@ <_WasmManagedAssemblies Include="$(_CoreLibPath)" /> + + + <_WasmAbiQueryExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmAbiQueryExeSuffix)')) + + diff --git a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj index 7670a5cc382d34..adb60312fd57f6 100644 --- a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj +++ b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj @@ -9,8 +9,6 @@ - - @@ -48,15 +46,9 @@ <_WasmAppHostFiles Include="$(WasmAppHostDir)\*" TargetPath="WasmAppHost" /> - - - <_WasmSignatureResolverFiles Include="$(WasmSignatureResolverDir)*" TargetPath="tasks\ILCompiler.Wasm.Lowering" /> - - diff --git a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj index adf650eed77a21..7d09cb8f3147b8 100644 --- a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj +++ b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj @@ -9,8 +9,6 @@ - - @@ -46,15 +44,9 @@ <_WasmAppHostFiles Include="$(WasmAppHostDir)\*" TargetPath="WasmAppHost" /> - - - <_WasmSignatureResolverFiles Include="$(WasmSignatureResolverDir)*" TargetPath="tasks\ILCompiler.Wasm.Lowering" /> - - diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 9415a5ab7f9ca4..035c2e73a12e59 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -157,6 +157,13 @@ <_WasiIgnoredPInvokeModules Include="libSystem.Globalization.Native" Condition="'$(InvariantGlobalization)' == 'true'" /> + + + <_WasmAbiQueryExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmAbiQueryExeSuffix)')) + + diff --git a/src/mono/wasm/Wasm.Build.Tests/Common/BuildEnvironment.cs b/src/mono/wasm/Wasm.Build.Tests/Common/BuildEnvironment.cs index baa00b3ac746e6..c39fff4d1260eb 100644 --- a/src/mono/wasm/Wasm.Build.Tests/Common/BuildEnvironment.cs +++ b/src/mono/wasm/Wasm.Build.Tests/Common/BuildEnvironment.cs @@ -161,6 +161,8 @@ public BuildEnvironment() EnvVars["BROWSER_BUILD_TARGETS_DIR"] = EnvironmentVariables.BrowserBuildTargetsDir; if (!string.IsNullOrEmpty(EnvironmentVariables.WasmAppBuilderTasksAssemblyPath)) EnvVars["WASM_APP_BUILDER_TASKS_ASSEMBLY_PATH"] = EnvironmentVariables.WasmAppBuilderTasksAssemblyPath; + if (!string.IsNullOrEmpty(EnvironmentVariables.WasmAbiQueryCrossgen2Path)) + EnvVars["WASM_ABI_QUERY_CROSSGEN2_PATH"] = EnvironmentVariables.WasmAbiQueryCrossgen2Path; if (!string.IsNullOrEmpty(EnvironmentVariables.EmsdkPath)) EnvVars["EMSDK_PATH"] = EnvironmentVariables.EmsdkPath; if (!string.IsNullOrEmpty(EnvironmentVariables.MinipalIncludeDir)) diff --git a/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs b/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs index a5d772394474c9..7b57e69ab06010 100644 --- a/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs +++ b/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs @@ -31,6 +31,7 @@ internal static class EnvironmentVariables internal static readonly string? RepositoryEngineeringDir = Environment.GetEnvironmentVariable("REPOSITORY_ENGINEERING_DIR"); internal static readonly string? BrowserBuildTargetsDir = Environment.GetEnvironmentVariable("BROWSER_BUILD_TARGETS_DIR"); internal static readonly string? WasmAppBuilderTasksAssemblyPath = Environment.GetEnvironmentVariable("WASM_APP_BUILDER_TASKS_ASSEMBLY_PATH"); + internal static readonly string? WasmAbiQueryCrossgen2Path = Environment.GetEnvironmentVariable("WASM_ABI_QUERY_CROSSGEN2_PATH"); internal static readonly string? EmsdkPath = Environment.GetEnvironmentVariable("EMSDK_PATH"); // WASM-TODO https://github.com/dotnet/runtime/issues/128362 diff --git a/src/mono/wasm/Wasm.Build.Tests/Wasm.Build.Tests.csproj b/src/mono/wasm/Wasm.Build.Tests/Wasm.Build.Tests.csproj index ca0506d2f81cdc..96bc97795aae67 100644 --- a/src/mono/wasm/Wasm.Build.Tests/Wasm.Build.Tests.csproj +++ b/src/mono/wasm/Wasm.Build.Tests/Wasm.Build.Tests.csproj @@ -159,7 +159,9 @@ + + - - - + + + <_WasmAbiQueryCrossgen2Path Condition="'$(_WasmAbiQueryCrossgen2Path)' == ''">$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(ExeSuffix)')) + + + @@ -80,7 +82,7 @@ Assemblies="@(WasmPInvokeAssembly)" PInvokeModules="@(WasmPInvokeModule)" TargetOS="$(_RunGeneratorTargetOS)" - SignatureResolverPath="@(_WasmSignatureResolver)" + Crossgen2Path="$(_WasmAbiQueryCrossgen2Path)" DotNetHostPath="$(DOTNET_HOST_PATH)" PInvokeOutputPath="$(GeneratorOutputPath)callhelpers-pinvoke.cpp" ReversePInvokeOutputPath="$(GeneratorOutputPath)callhelpers-reverse.cpp" diff --git a/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs b/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs index f265ba05183e9f..24dc5afe46a8ef 100644 --- a/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs +++ b/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs @@ -46,52 +46,33 @@ public class ManagedToNativeGenerator : Task public string TargetOS { get; set; } = "browser"; /// - /// Path to ILCompiler.Wasm.Lowering.dll, which computes struct sizes and ABI lowering using the - /// same type system crossgen2 uses; reflection alone cannot compute field layout. Defaults to - /// the copy shipped alongside this task. + /// Path to crossgen2, which is run in its --wasm-abi-query mode to compute struct sizes + /// and ABI lowering. Reflection alone cannot compute field layout, and the answers have to be the + /// ones the compiler itself would produce. /// - public string? SignatureResolverPath { get; set; } + /// + /// Query mode does not load the JIT, so this does not have to be a wasm-targeting crossgen2. + /// + public string? Crossgen2Path { get; set; } /// - /// Path to the dotnet host used to run the signature resolver. + /// Path to the dotnet host, used when points at an IL-only build of + /// crossgen2 rather than an apphost. /// public string? DotNetHostPath { get; set; } private static readonly string[] s_knownTargetOSes = new[] { "browser", "wasi" }; - /// - /// The resolver ships next to this task, in its own directory so its type system assemblies - /// cannot collide with the task's. Callers only need to set - /// when running against a layout that matches neither of the probed conventions. - /// - private string ResolveSignatureResolverPath() + private string ResolveCrossgen2Path() { - if (!string.IsNullOrEmpty(SignatureResolverPath)) - return SignatureResolverPath!; - - string taskDir = Path.GetDirectoryName(typeof(ManagedToNativeGenerator).Assembly.Location)!; - - foreach (string candidate in GetSignatureResolverCandidates(taskDir)) + if (string.IsNullOrEmpty(Crossgen2Path)) { - if (File.Exists(candidate)) - return Path.GetFullPath(candidate); + throw new LogAsErrorException( + "The Crossgen2Path task parameter is required: computing the wasm ABI struct sizes for the " + + "generated helpers needs crossgen2's type system."); } - throw new LogAsErrorException( - "Could not locate ILCompiler.Wasm.Lowering.dll, which is required to compute wasm ABI struct sizes. " + - $"Looked in: {string.Join(", ", GetSignatureResolverCandidates(taskDir))}. " + - "Set the SignatureResolverPath task parameter to its location."); - } - - private static IEnumerable GetSignatureResolverCandidates(string taskDir) - { - // In the repo and in the Helix payload the resolver is nested in the task's own directory, - // so it travels with whatever copies that directory. - yield return Path.Combine(taskDir, "ILCompiler.Wasm.Lowering", "ILCompiler.Wasm.Lowering.dll"); - - // In the SDK pack it sits beside the per-TFM task directories instead, since the .NET and - // .NET Framework copies of the task both launch the same .NET tool and need not duplicate it. - yield return Path.GetFullPath(Path.Combine(taskDir, "..", "ILCompiler.Wasm.Lowering", "ILCompiler.Wasm.Lowering.dll")); + return Crossgen2Path!; } private string ResolveDotNetHostPath() @@ -170,7 +151,7 @@ private void ExecuteInternal(LogAdapter log) Dictionary _symbolNameFixups = new(); List managedAssemblies = FilterOutUnmanagedBinaries(Assemblies); - using var abiTypeResolver = new WasmAbiTypeResolver(ResolveDotNetHostPath(), ResolveSignatureResolverPath(), TargetOS, managedAssemblies, log); + using var abiTypeResolver = new WasmAbiTypeResolver(ResolveDotNetHostPath(), ResolveCrossgen2Path(), TargetOS, managedAssemblies, log); var signatureMapper = new SignatureMapper(log, abiTypeResolver); var pinvoke = new PInvokeTableGenerator(FixupSymbolName, log, IsLibraryMode, TargetOS, signatureMapper, WarnOnUnresolvedPInvokeModules); var internalCallCollector = new InternalCallSignatureCollector(log, signatureMapper); diff --git a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs index e0e6dced1400ab..f5953660c79bf2 100644 --- a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs +++ b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs @@ -12,21 +12,26 @@ namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; /// -/// Resolves wasm ABI encodings by delegating to the ILCompiler.Wasm.Lowering tool, which shares its -/// lowering and field layout code with crossgen2. +/// Resolves wasm ABI encodings by asking crossgen2, running in its --wasm-abi-query mode. /// /// /// The generated helpers have to agree with compiled code exactly - a struct whose size is off by one /// produces a call that reads the wrong stack slots at runtime - so the sizes come from the compiler's /// own type system rather than from reflection, which has no field layout engine. /// -/// The tool runs out of process because this task also runs under .NET Framework MSBuild, which cannot -/// load a netcoreapp type system assembly. It is started once and reused for every query. +/// crossgen2 answers rather than a purpose-built tool so that there is exactly one implementation of +/// the wasm lowering rules and one type system configuration. Query mode never loads the JIT, so the +/// crossgen2 used here does not have to be the wasm-targeting one; the target is selected by the +/// --targetos and --targetarch arguments. +/// +/// It runs out of process because this task also runs under .NET Framework MSBuild, which cannot load +/// a netcoreapp type system assembly. Loading the assembly closure is the expensive part, so the +/// process is started once and reused for every query. /// internal sealed class WasmAbiTypeResolver : IWasmAbiTypeResolver, IDisposable { private readonly string _dotnetHostPath; - private readonly string _toolPath; + private readonly string _crossgen2Path; private readonly string _targetOS; private readonly IReadOnlyList _assemblies; private readonly LogAdapter _log; @@ -37,10 +42,10 @@ internal sealed class WasmAbiTypeResolver : IWasmAbiTypeResolver, IDisposable private string? _responseFilePath; private readonly StringBuilder _stderr = new(); - public WasmAbiTypeResolver(string dotnetHostPath, string toolPath, string targetOS, IReadOnlyList assemblies, LogAdapter log) + public WasmAbiTypeResolver(string dotnetHostPath, string crossgen2Path, string targetOS, IReadOnlyList assemblies, LogAdapter log) { _dotnetHostPath = dotnetHostPath; - _toolPath = toolPath; + _crossgen2Path = crossgen2Path; _targetOS = targetOS; _assemblies = assemblies; _log = log; @@ -105,7 +110,7 @@ private string Query(string request) if (reply is null) { throw new LogAsErrorException( - $"The wasm signature resolver ('{_toolPath}') exited unexpectedly while resolving '{request}'. {ReadStandardError(process)}"); + $"crossgen2 ('{_crossgen2Path}') exited unexpectedly while resolving '{request}'. {ReadStandardError(process)}"); } return reply; @@ -116,10 +121,11 @@ private Process EnsureStarted() if (_process is not null) return _process; - if (!File.Exists(_toolPath)) + if (!File.Exists(_crossgen2Path)) { throw new LogAsErrorException( - $"The wasm signature resolver was not found at '{_toolPath}'. Set the SignatureResolverPath task parameter to the path of ILCompiler.Wasm.Lowering.dll."); + $"crossgen2 was not found at '{_crossgen2Path}'. It computes the wasm ABI struct sizes the generated " + + "helpers need. Set the Crossgen2Path task parameter to its location."); } // A response file keeps the command line under the platform limit; the framework alone is @@ -127,7 +133,20 @@ private Process EnsureStarted() _responseFilePath = Path.GetTempFileName(); File.WriteAllLines(_responseFilePath, _assemblies, Encoding.UTF8); - var startInfo = new ProcessStartInfo(_dotnetHostPath) + // The assemblies are passed as crossgen2's positional inputs rather than as references so + // that its "no input files" check is satisfied; query mode writes no image, so nothing is + // compiled for them. + string arguments = $"--wasm-abi-query --targetos {_targetOS} --targetarch wasm {Quote("@" + _responseFilePath)}"; + + // crossgen2 normally ships as an apphost, but an IL-only build is run through the muxer. + string executable = _crossgen2Path; + if (_crossgen2Path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + executable = _dotnetHostPath; + arguments = $"exec {Quote(_crossgen2Path)} {arguments}"; + } + + var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false, RedirectStandardInput = true, @@ -135,20 +154,20 @@ private Process EnsureStarted() RedirectStandardError = true, // ProcessStartInfo.ArgumentList is not available on .NET Framework, which this task also // targets, so the command line is quoted by hand. - Arguments = $"exec {Quote(_toolPath)} --targetos {_targetOS} {Quote("@" + _responseFilePath)}", + Arguments = arguments, }; - _log.LogMessage(MessageImportance.Low, $"Starting wasm signature resolver: {_dotnetHostPath} {startInfo.Arguments}"); + _log.LogMessage(MessageImportance.Low, $"Starting wasm ABI query: {executable} {arguments}"); Process process; try { process = Process.Start(startInfo) - ?? throw new LogAsErrorException($"Failed to start the wasm signature resolver '{_toolPath}'."); + ?? throw new LogAsErrorException($"Failed to start crossgen2 '{_crossgen2Path}'."); } catch (Exception ex) when (ex is not LogAsErrorException) { - throw new LogAsErrorException($"Failed to start the wasm signature resolver '{_toolPath}': {ex.Message}"); + throw new LogAsErrorException($"Failed to start crossgen2 '{_crossgen2Path}': {ex.Message}"); } // Take ownership before the handshake so a failure below still goes through Dispose. An @@ -174,7 +193,7 @@ private Process EnsureStarted() if (ready != "ready") { throw new LogAsErrorException( - $"The wasm signature resolver '{_toolPath}' failed to load the assembly closure. {ReadStandardError(process)}"); + $"crossgen2 '{_crossgen2Path}' failed to load the assembly closure. {ReadStandardError(process)}"); } return process; @@ -221,7 +240,7 @@ public void Dispose() } catch (Exception ex) { - _log.LogMessage(MessageImportance.Low, $"Failed to shut down the wasm signature resolver: {ex.Message}"); + _log.LogMessage(MessageImportance.Low, $"Failed to shut down the wasm ABI query process: {ex.Message}"); } _process.Dispose(); diff --git a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh b/src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh index 917b737b0a83a6..5f314201088482 100755 --- a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh +++ b/src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh @@ -78,8 +78,10 @@ run_generator() { echo "[$target_os] Scan path: $scan_path" echo "[$target_os] Output path: $output_dir" echo "Running generator for $target_os..." - echo "./dotnet.sh build /t:RunGenerator /p:RuntimeFlavor=CoreCLR /p:TargetOS=$target_os /p:GeneratorOutputPath=$output_dir /p:AssembliesScanPath=$scan_path src/tasks/WasmAppBuilder/WasmAppBuilder.csproj" - ./dotnet.sh build /t:RunGenerator /p:RuntimeFlavor=CoreCLR "/p:TargetOS=$target_os" "/p:GeneratorOutputPath=$output_dir" "/p:AssembliesScanPath=$scan_path" src/tasks/WasmAppBuilder/WasmAppBuilder.csproj + # RuntimeConfiguration selects which built crossgen2 answers the ABI queries; it has to match the + # configuration the scanned assemblies came from. + echo "./dotnet.sh build /t:RunGenerator /p:RuntimeFlavor=CoreCLR /p:TargetOS=$target_os /p:RuntimeConfiguration=$configuration /p:GeneratorOutputPath=$output_dir /p:AssembliesScanPath=$scan_path src/tasks/WasmAppBuilder/WasmAppBuilder.csproj" + ./dotnet.sh build /t:RunGenerator /p:RuntimeFlavor=CoreCLR "/p:TargetOS=$target_os" "/p:RuntimeConfiguration=$configuration" "/p:GeneratorOutputPath=$output_dir" "/p:AssembliesScanPath=$scan_path" src/tasks/WasmAppBuilder/WasmAppBuilder.csproj } # Resolve scan paths (allow overrides). From d4fa401eaac03e82b372093136cb7750e0f49a1c Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 6 Aug 2026 12:36:00 +0200 Subject: [PATCH 04/72] Acquire crossgen2 through the wasm-tools workload The wasm P/Invoke generator asks crossgen2 for the ABI signature of each P/Invoke it finds. In the repo crossgen2 comes from the build output, but out of repo -- relinking from a restored SDK -- nothing resolved it, so $(Crossgen2Path) reached the task empty and the build failed. The SDK does resolve a crossgen2 pack, but only when PublishReadyToRun is set, which a wasm CoreCLR app never sets. So declare the existing Microsoft.NETCore.App.Crossgen2. pack in the wasm-tools workload manifest instead, and ship an Sdk/Sdk.props inside that pack so the import defines $(Crossgen2ToolPath). Query mode never loads the JIT, so the host-targeting pack answers wasm questions correctly; regenerating the browser helpers through the NativeAOT-built pack binary reproduces the committed output byte for byte. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Microsoft.NETCore.App/Crossgen2/Sdk.props | 18 ++++++++++++++++++ .../Microsoft.NETCore.App.Crossgen2.props | 4 ++++ .../build/BrowserWasmApp.CoreCLR.targets | 8 +++++++- .../WorkloadManifest.json.in | 17 ++++++++++++++++- .../WorkloadManifest.targets.in | 8 +++++++- src/mono/wasi/build/WasiApp.CoreCLR.targets | 9 ++++++++- 6 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/installer/pkg/sfx/Microsoft.NETCore.App/Crossgen2/Sdk.props diff --git a/src/installer/pkg/sfx/Microsoft.NETCore.App/Crossgen2/Sdk.props b/src/installer/pkg/sfx/Microsoft.NETCore.App/Crossgen2/Sdk.props new file mode 100644 index 00000000000000..a5cdb6c55a638d --- /dev/null +++ b/src/installer/pkg/sfx/Microsoft.NETCore.App/Crossgen2/Sdk.props @@ -0,0 +1,18 @@ + + + + <_Crossgen2PackExeSuffix Condition="$([MSBuild]::IsOSPlatform('Windows'))">.exe + $([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '..', 'tools', 'crossgen2$(_Crossgen2PackExeSuffix)')) + + diff --git a/src/installer/pkg/sfx/Microsoft.NETCore.App/Microsoft.NETCore.App.Crossgen2.props b/src/installer/pkg/sfx/Microsoft.NETCore.App/Microsoft.NETCore.App.Crossgen2.props index a400c42adf25c7..cdec0b7f158ee7 100644 --- a/src/installer/pkg/sfx/Microsoft.NETCore.App/Microsoft.NETCore.App.Crossgen2.props +++ b/src/installer/pkg/sfx/Microsoft.NETCore.App/Microsoft.NETCore.App.Crossgen2.props @@ -18,6 +18,10 @@ Include="$(CrossgenPublishProject)" OutputItemType="_RawCrossgenPublishFiles" ReferenceOutputAssembly="false" /> + + + + load the JIT, so a host-targeting crossgen2 answers wasm questions correctly. In the repo it + comes from the build output; outside it, from the crossgen2 pack the wasm-tools workload + acquires, which sets $(Crossgen2ToolPath) from its Sdk.props. --> <_WasmAbiQueryExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmAbiQueryExeSuffix)')) + $(Crossgen2ToolPath) + + + the CoreCLR runtime pack via its own KnownRuntimePack. + + crossgen2 is imported for its type system, not to compile ReadyToRun images: the wasm P/Invoke + generator asks it for the ABI signatures of the P/Invokes it finds, which encode struct sizes + that cannot be derived from metadata alone. The SDK only resolves its own KnownCrossgen2Pack + when PublishReadyToRun is set, so the pack is acquired through the workload instead. --> + diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 035c2e73a12e59..f804eea98ccbda 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -158,12 +158,19 @@ + load the JIT, so a host-targeting crossgen2 answers wasm questions correctly. In the repo it + comes from the build output; outside it, from a crossgen2 pack that sets $(Crossgen2ToolPath). + The wasi-experimental workload does not acquire that pack today, so out-of-repo wasi relink + needs $(WasmAbiQueryCrossgen2Path) or $(Crossgen2ToolPath) to be set explicitly. --> <_WasmAbiQueryExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmAbiQueryExeSuffix)')) + $(Crossgen2ToolPath) + + Date: Thu, 6 Aug 2026 13:41:49 +0200 Subject: [PATCH 05/72] Stage the host crossgen2 pack for wasm workload testing Installing wasm-tools for testing pulls every pack the manifest declares, from a feed populated by the wasm build legs. None of them produce a crossgen2 pack: a pack is named for the machine that *runs* the tool, so building the regular pack project for a wasm target would yield Microsoft.NETCore.App.Crossgen2.browser-wasm -- a crossgen2 that runs in the browser. Subsets.props excludes it for that reason, correctly. The Host variant pins the RID to the build host instead, which is exactly the pack the workload resolves. Build it from the CoreCLR browser-wasm leg, which already has the CoreCLR artifacts it needs, and stage its nupkg alongside the runtime pack. Opt-in via BuildCrossgen2HostPackForWorkloadTesting so the official build is untouched -- it already publishes this pack from the host platform legs, and a second copy would collide on package id. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- eng/Subsets.props | 12 ++++++++++++ .../common/templates/browser-wasm-build-tests.yml | 13 ++++++++----- .../runtime-extra-platforms-wasm.yml | 2 +- eng/pipelines/runtime.yml | 2 +- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/eng/Subsets.props b/eng/Subsets.props index 337bca6fa63caf..9d00f04b609fb5 100644 --- a/eng/Subsets.props +++ b/eng/Subsets.props @@ -751,6 +751,18 @@ In non-VMR builds, downstream repos can use the crossgen2 built for the target host SDK from another build leg, but in the VMR we need to provide one to use. --> + + + diff --git a/eng/pipelines/common/templates/browser-wasm-build-tests.yml b/eng/pipelines/common/templates/browser-wasm-build-tests.yml index 88f99633ea54d5..a2c9e5a32bcab4 100644 --- a/eng/pipelines/common/templates/browser-wasm-build-tests.yml +++ b/eng/pipelines/common/templates/browser-wasm-build-tests.yml @@ -92,9 +92,10 @@ jobs: TargetFolder: '$(Build.SourcesDirectory)/artifacts' CleanTargetFolder: false - # Download the CoreCLR runtime pack. The wasm-tools workload manifest now includes - # the CoreCLR browser-wasm runtime pack, so installing the workload for testing - # requires the pack to be present in the local package feed. + # Download the CoreCLR runtime pack and the host crossgen2 pack. The wasm-tools workload + # manifest includes both, so installing the workload for testing requires them to be + # present in the local package feed. Only pipelines that build the CoreCLR browser-wasm + # runtime (e.g. runtime.yml) can stage them here. - ${{ if eq(parameters.includeCoreClrRuntimePack, true) }}: - task: DownloadPipelineArtifact@2 displayName: Download built nugets for CoreCLR runtime @@ -104,10 +105,12 @@ jobs: targetPath: '$(Build.SourcesDirectory)/artifacts/BuildArtifacts_browser_wasm_$(_hostedOs)_Release_CoreCLR' - task: CopyFiles@2 - displayName: Copy CoreCLR runtime pack + displayName: Copy CoreCLR runtime pack and host crossgen2 pack inputs: SourceFolder: '$(Build.SourcesDirectory)/artifacts/BuildArtifacts_browser_wasm_$(_hostedOs)_Release_CoreCLR' - Contents: packages/$(_BuildConfig)/Shipping/Microsoft.NETCore.App.Runtime.browser-wasm.* + Contents: | + packages/$(_BuildConfig)/Shipping/Microsoft.NETCore.App.Runtime.browser-wasm.* + packages/$(_BuildConfig)/Shipping/Microsoft.NETCore.App.Crossgen2.* TargetFolder: '$(Build.SourcesDirectory)/artifacts' CleanTargetFolder: false diff --git a/eng/pipelines/extra-platforms/runtime-extra-platforms-wasm.yml b/eng/pipelines/extra-platforms/runtime-extra-platforms-wasm.yml index 8efd2d83a95181..af9fdae42696fe 100644 --- a/eng/pipelines/extra-platforms/runtime-extra-platforms-wasm.yml +++ b/eng/pipelines/extra-platforms/runtime-extra-platforms-wasm.yml @@ -199,7 +199,7 @@ jobs: - browser_wasm_win jobParameters: nameSuffix: CoreCLR - buildArgs: -s clr+libs+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:InstallWorkloadForTesting=false + buildArgs: -s clr+libs+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:InstallWorkloadForTesting=false /p:BuildCrossgen2HostPackForWorkloadTesting=true timeoutInMinutes: 120 postBuildSteps: - template: /eng/pipelines/common/wasm-post-build-steps.yml diff --git a/eng/pipelines/runtime.yml b/eng/pipelines/runtime.yml index 7ca641af5d93f7..b00ffaaab77eb7 100644 --- a/eng/pipelines/runtime.yml +++ b/eng/pipelines/runtime.yml @@ -125,7 +125,7 @@ extends: - browser_wasm_win jobParameters: nameSuffix: CoreCLR - buildArgs: -s clr+libs+libs.tests+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:TestWasmBuildTests=true /p:ArchiveTests=true /p:InstallWorkloadForTesting=false + buildArgs: -s clr+libs+libs.tests+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:TestWasmBuildTests=true /p:ArchiveTests=true /p:InstallWorkloadForTesting=false /p:BuildCrossgen2HostPackForWorkloadTesting=true timeoutInMinutes: 120 postBuildSteps: - template: /eng/pipelines/common/wasm-post-build-steps.yml From 529567a30bb7c9767a589761e39d93b636bc8aca Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 6 Aug 2026 19:23:21 +0200 Subject: [PATCH 06/72] Fix two CI failures in the crossgen2 query path Query mode configured its compilation group with every input assembly but left composite mode off, which is only legal for a single-assembly set. Checked builds asserted on it; the unit tests missed it because their harness feeds exactly one assembly, while a real build passes the whole app closure. Composite mode is what "many inputs, one compilation unit" means, and that unit is what makes the group report layout without inserting cross-bubble alignment. The new test passes a second assembly and reproduces the assert without the fix. The Wasm.Build.Tests leg then failed sending to Helix: it declares crossgen2 as a correlation payload, but that leg builds only the test project and takes everything else from the CoreCLR leg's artifact, which did not carry crossgen2. Ship it with the other build tooling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../common/wasm-post-build-steps.yml | 5 +++ .../WasmArgumentLayoutTests.cs | 33 ++++++++++++++++--- .../JitInterface/WasmAbiQuery.cs | 4 +++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/eng/pipelines/common/wasm-post-build-steps.yml b/eng/pipelines/common/wasm-post-build-steps.yml index 8637dd280c6c08..d198252b0e3c73 100644 --- a/eng/pipelines/common/wasm-post-build-steps.yml +++ b/eng/pipelines/common/wasm-post-build-steps.yml @@ -8,6 +8,10 @@ parameters: steps: + # The wasm CoreCLR generator shells out to crossgen2 for P/Invoke struct sizes while building test + # apps, so the Wasm.Build.Tests leg ships it to Helix as a correlation payload. That leg builds only + # the test project and takes everything else from here, so crossgen2 has to travel in these + # artifacts. The glob matches nothing for Mono, which has no crossgen2 to send. - task: CopyFiles@2 displayName: Copy artifacts needed for running WBT condition: and(succeeded(), ${{ parameters.publishArtifactsForWorkload }}) @@ -21,6 +25,7 @@ steps: bin/WorkloadBuildTasks/** bin/installer.tasks/** bin/Crossgen2Tasks/** + bin/coreclr/${{ parameters.osGroup }}.wasm.*/*/crossgen2/** TargetFolder: '$(Build.StagingDirectory)/IntermediateArtifacts' CleanTargetFolder: true diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 9f707153e62205..9bb90a8fd396b9 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -223,6 +223,24 @@ public void WasmAbiQueryRejectsQueriesItCannotAnswer(string query, string expect Assert.Contains(expectedMessage, reply); } + /// + /// Real builds hand query mode the whole app closure, not one assembly. The compilation group it + /// configures has to accept that: a multi-assembly set is only legal in composite mode, and a + /// group built without it asserts in checked builds and lays out nothing in any build. + /// + [Fact] + public void WasmAbiQueryAcceptsMoreThanOneInputAssembly() + { + // Any second real assembly will do; the queries below still target CoreLib. This one is + // guaranteed to exist because it is the assembly currently executing. + string extraInput = typeof(WasmArgumentLayoutTests).Assembly.Location; + Assert.True(File.Exists(extraInput), $"test assembly not found at '{extraInput}'"); + + ReadyToRunCompilerContext context = CreateWasmContext(extraInput); + + Assert.Equal(new[] { "S16" }, RunQueries(context, TypeQuery(context, "Guid"))); + } + private const string CoreLibSimpleName = "System.Private.CoreLib"; private static EcmaType GetSystemType(ReadyToRunCompilerContext context, string typeName) @@ -254,9 +272,10 @@ private static string[] RunQueries(ReadyToRunCompilerContext context, params str /// /// Configures a type system context the way crossgen2 does for - /// --targetarch wasm --targetos browser. + /// --targetarch wasm --targetos browser. Extra input assemblies stand in for the rest of an + /// app closure, which a real build always supplies alongside CoreLib. /// - private ReadyToRunCompilerContext CreateWasmContext() + private ReadyToRunCompilerContext CreateWasmContext(params string[] extraInputAssemblyPaths) { string coreLibPath = new TestPaths(_output).SystemPrivateCoreLibPath; Assert.True(File.Exists(coreLibPath), $"System.Private.CoreLib.dll not found at '{coreLibPath}'"); @@ -264,14 +283,20 @@ private ReadyToRunCompilerContext CreateWasmContext() InstructionSetSupport instructionSetSupport = new(default, default, TargetArchitecture.Wasm32); TargetDetails target = new(TargetArchitecture.Wasm32, TargetOS.Browser, TargetAbi.NativeAot, instructionSetSupport.GetVectorTSimdVector()); + Dictionary inputFilePaths = new(StringComparer.OrdinalIgnoreCase) { { CoreLibSimpleName, coreLibPath } }; + foreach (string path in extraInputAssemblyPaths) + { + inputFilePaths.Add(Path.GetFileNameWithoutExtension(path), path); + } + // Wasm cannot generate code at runtime, matching what crossgen2's Program computes for this target. ReadyToRunCompilerContext context = new(target, SharedGenericsMode.CanonicalReferenceTypes, bubbleIncludesCoreModule: true, targetAllowsRuntimeCodeGeneration: false, instructionSetSupport, oldTypeSystemContext: null) { - InputFilePaths = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "System.Private.CoreLib", coreLibPath } }, + InputFilePaths = inputFilePaths, ReferenceFilePaths = new Dictionary(StringComparer.OrdinalIgnoreCase), }; - EcmaModule coreLib = (EcmaModule)context.GetModuleForSimpleName("System.Private.CoreLib"); + EcmaModule coreLib = (EcmaModule)context.GetModuleForSimpleName(CoreLibSimpleName); context.SetSystemModule(coreLib); // The R2R field layout algorithm reaches into the compilation group to decide whether base diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs index 1c914e0effd2ba..c66deac5c54510 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs @@ -105,6 +105,10 @@ private static void ConfigureCompilationGroup(ReadyToRunCompilerContext context) context.SetCompilationGroup(new ReadyToRunSingleAssemblyCompilationModuleGroup(new ReadyToRunCompilationModuleGroupConfig { Context = context, + // "Many inputs, one output unit" is what composite mode means, and it is what makes + // the group treat every input as a single compilation unit. Without it the group + // asserts on a compilation set larger than one assembly. + IsCompositeBuildMode = true, IsInputBubble = true, CompilationModuleSet = modules, VersionBubbleModuleSet = modules, From fa67c502570b826fe6f3a15a02190bd318e18a68 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 17 Aug 2026 20:18:27 +0200 Subject: [PATCH 07/72] [wasm] Generate CoreCLR call helpers with crossgen2 The CoreClr.ManagedToNativeGenerator MSBuild task inspected assemblies through MetadataLoadContext, which cannot answer wasm ABI questions. It faked struct sizes with a hardcoded table (s_knownStructSizes) and a hand-rolled WasmAbiTypeResolver, so any struct not in the table produced wrong argument lowering. Move the whole generator into crossgen2 as a new --wasm-generate-callhelpers mode. There it runs on Internal.TypeSystem and calls the compiler's own WasmLowering, so struct sizes and argument lowering are exactly what the compiler emits. The ~590 lines of hand-rolled ABI code (SignatureMapper, WasmAbiTypeResolver, IWasmAbiTypeResolver, WasmLoweringFlags) are deleted along with the task itself. The regenerated helpers are byte-identical to the checked-in baselines except for five added System.String..ctor interp-to-managed thunks: MetadataType.GetMethods() returns constructors, which reflection's Type.GetMethods(BindingFlags...) structurally never does. Because the mode answers ABI questions, it rejects anything but a wasm target rather than silently emitting host layouts, and normalizes the target OS name so platform attributes match however --targetos was spelled. Also drops code that was already dead in the task: the WASM0063 literal field check (BindingFlags.Instance never returns static fields), the IsLibraryMode flag, the unread m2n_cache.txt, and an isCoreClr branch in the Mono IcallTableGenerator whose only caller passed false. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- docs/workflow/wasm-documentation.md | 2 +- .../WasmArgumentLayoutTests.cs | 114 ++-- .../ILCompiler.ReadyToRun.csproj | 8 +- .../JitInterface/WasmAbiQuery.cs | 212 ------ .../WasmInternalCallSignatureCollector.cs | 86 +++ .../Wasm/WasmInteropGenerator.cs | 172 +++++ .../Wasm/WasmInteropSignature.cs | 187 ++++++ .../Wasm/WasmInterpToNativeGenerator.cs | 185 +++++ .../Wasm/WasmPInvokeCollector.cs | 384 +++++++++++ .../Wasm/WasmPInvokeTableGenerator.cs | 478 +++++++++++++ .../Wasm/WasmTypeNames.cs | 45 ++ .../aot/crossgen2/Crossgen2RootCommand.cs | 15 +- src/coreclr/tools/aot/crossgen2/Program.cs | 30 +- .../aot/crossgen2/Properties/Resources.resx | 16 +- .../browser/callhelpers-interp-to-managed.cpp | 40 ++ .../vm/wasm}/generate-coreclr-helpers.cmd | 25 +- .../vm/wasm}/generate-coreclr-helpers.md | 31 +- .../vm/wasm}/generate-coreclr-helpers.sh | 46 +- .../wasi/callhelpers-interp-to-managed.cpp | 40 ++ .../vm/wasm/wasi/callhelpers-pinvoke.cpp | 2 +- src/libraries/sendtohelix-browser.targets | 6 +- .../build/BrowserWasmApp.CoreCLR.targets | 75 ++- src/mono/browser/build/coreclr_compat.h | 2 +- src/mono/wasi/build/WasiApp.CoreCLR.targets | 74 +- .../Common/BuildEnvironment.cs | 4 +- .../Common/EnvironmentVariables.cs | 2 +- .../Wasm.Build.Tests/Wasm.Build.Tests.csproj | 8 +- .../data/Local.Directory.Build.props | 4 +- .../data/RunScriptTemplate.sh | 4 +- src/mono/wasm/build/WasmApp.Common.targets | 1 - .../WasmAppBuilder/IcallTableGenerator.cs | 16 +- .../WasmAppBuilder/WasmAppBuilder.csproj | 41 -- .../coreclr/IWasmAbiTypeResolver.cs | 30 - .../coreclr/InternalCallSignatureCollector.cs | 70 -- .../coreclr/InterpToNativeGenerator.cs | 222 ------ .../coreclr/ManagedToNativeGenerator.cs | 241 ------- .../coreclr/PInvokeCollector.cs | 409 ----------- .../coreclr/PInvokeTableGenerator.cs | 633 ------------------ .../WasmAppBuilder/coreclr/SignatureMapper.cs | 268 -------- .../coreclr/WasmAbiTypeResolver.cs | 263 -------- .../coreclr/WasmLoweringFlags.cs | 31 - .../mono/ManagedToNativeGenerator.cs | 2 +- 42 files changed, 1915 insertions(+), 2609 deletions(-) delete mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs create mode 100644 src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs rename src/{tasks/WasmAppBuilder => coreclr/vm/wasm}/generate-coreclr-helpers.cmd (70%) rename src/{tasks/WasmAppBuilder => coreclr/vm/wasm}/generate-coreclr-helpers.md (61%) rename src/{tasks/WasmAppBuilder => coreclr/vm/wasm}/generate-coreclr-helpers.sh (64%) delete mode 100644 src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs delete mode 100644 src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs diff --git a/docs/workflow/wasm-documentation.md b/docs/workflow/wasm-documentation.md index 5e95f96038bdb2..604561e7b0e494 100644 --- a/docs/workflow/wasm-documentation.md +++ b/docs/workflow/wasm-documentation.md @@ -51,7 +51,7 @@ For debugging instructions including VS Code and Chrome DevTools setup, see the ### Running coreclr callhelpers generator -After building the runtime, use the `generate-coreclr-helpers` script for your platform (`.cmd` or `.sh`) in `src/tasks/WasmAppBuilder` to [re]generate the call helpers in `src/coreclr/vm/wasm`. +After building the runtime, use the `generate-coreclr-helpers` script for your platform (`.cmd` or `.sh`) in `src/coreclr/vm/wasm` to [re]generate the call helpers in `src/coreclr/vm/wasm`. ## Features and Configuration diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 91d16ae557809d..7bcdd7dd54b5b4 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -7,14 +7,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection.Metadata.Ecma335; using crossgen2::ILCompiler; using crossgen2::ILCompiler.DependencyAnalysis.ReadyToRun; using crossgen2::ILCompiler.DependencyAnalysis.Wasm; +using crossgen2::ILCompiler.Wasm; using crossgen2::Internal.CallingConvention; using crossgen2::Internal.JitInterface; -using crossgen2::Internal.Text; using ILCompiler.ReadyToRun.Tests.TestCasesRunner; @@ -545,7 +544,7 @@ private static MethodSignature MakeProbeSignature(ReadyToRunCompilerContext cont /// - /// The type query answers with the encoding of a type in parameter position. These are the three + /// The generator encodes a type in parameter position with a single token. These are the three /// shapes that encoding exists to tell apart: a multi-field struct, which goes by reference and /// carries its size; a single-field wrapper, which is passed as the field it wraps; and a /// primitive. @@ -554,20 +553,20 @@ private static MethodSignature MakeProbeSignature(ReadyToRunCompilerContext cont [InlineData("Guid", "S16")] [InlineData("DateTime", "l")] [InlineData("Int32", "i")] - public void WasmAbiQueryAnswersTypeQueries(string typeName, string expected) + public void WasmInteropGeneratorEncodesTypesTheWayTheCompilerLowersThem(string typeName, string expected) { ReadyToRunCompilerContext context = CreateWasmContext(); - Assert.Equal(new[] { expected }, RunQueries(context, TypeQuery(context, typeName))); + Assert.Equal(expected, WasmInteropSignature.GetAbiToken(GetSystemType(context, typeName))); } /// /// A struct that holds a reference lays out through the auto-layout path, which asks the - /// compilation group whether the base offset needs aligning. Query mode is not a compilation, so + /// compilation group whether the base offset needs aligning. Generation is not a compilation, so /// it has to configure a group itself for that question to have an answer at all. /// [Fact] - public void WasmAbiQueryComputesLayoutOfStructsHoldingReferences() + public void WasmInteropGeneratorComputesLayoutOfStructsHoldingReferences() { ReadyToRunCompilerContext context = CreateWasmContext(); var type = GetSystemType(context, "RuntimeTypeHandle"); @@ -576,89 +575,94 @@ public void WasmAbiQueryComputesLayoutOfStructsHoldingReferences() Assert.True(type.ContainsGCPointers, $"{type} was chosen because it holds a reference"); // One field the size of the whole struct: lowered to that field, a reference, passed as i32. - Assert.Equal(new[] { "i" }, RunQueries(context, TypeQuery(context, "RuntimeTypeHandle"))); + Assert.Equal("i", WasmInteropSignature.GetAbiToken(type)); } /// - /// Parameter types come from the signature blob rather than being named one by one, because a - /// generic instantiation has no metadata token of its own and so cannot be named over the wire. - /// The answer has to be the signature the compiler itself would lower the method to. + /// The thunk a method gets is keyed by its lowered signature, so the generator has to encode a + /// method exactly as the compiler lowers it. Anything else and the interpreter calls through a + /// thunk built for a different shape. /// [Fact] - public void WasmAbiQueryAnswersMethodQueriesLikeTheCompiler() + public void WasmInteropGeneratorEncodesMethodsLikeTheCompiler() { ReadyToRunCompilerContext context = CreateWasmContext(); - var method = (EcmaMethod)GetSystemType(context, "DateTime").GetMethod(new Utf8String("AddTicks"), null); + var method = (EcmaMethod)GetSystemType(context, "DateTime").GetMethod("AddTicks"u8, null); string expected = WasmLowering.GetSignature(method.Signature, WasmLowering.LoweringFlags.None).SignatureString; _output.WriteLine($"{method} lowers to '{expected}'"); - string query = $"m {CoreLibSimpleName} 0x{MetadataTokens.GetToken(method.Handle):x8} 0"; - Assert.Equal(new[] { expected }, RunQueries(context, query)); + Assert.Equal(expected, WasmInteropSignature.GetMethodSignature(method, includeThis: true)); } /// - /// The caller cannot reference the type system, so it keeps its own copy of the lowering flags and - /// its own idea of the wire format. A copy that has drifted has to be told, rather than quietly - /// handed a lowering that is not the one it asked for. + /// Void has no lowering of its own - the compiler never sees it in a position that needs one - + /// but it is still what a thunk returns, so the generator has to encode it. /// - [Theory] - [InlineData("x System.Private.CoreLib 1", "Unrecognized query verb")] - [InlineData("t System.Private.CoreLib", "not enough fields")] - [InlineData("m System.Private.CoreLib 0x06000001 0x40000000", "Unknown wasm lowering flags")] - public void WasmAbiQueryRejectsQueriesItCannotAnswer(string query, string expectedMessage) + [Fact] + public void WasmInteropGeneratorEncodesVoid() { - string reply = RunQueries(CreateWasmContext(), query)[0]; + ReadyToRunCompilerContext context = CreateWasmContext(); - Assert.StartsWith("!", reply); - Assert.Contains(expectedMessage, reply); + Assert.Equal("v", WasmInteropSignature.GetAbiToken(context.GetWellKnownType(WellKnownType.Void))); } /// - /// Real builds hand query mode the whole app closure, not one assembly. The compilation group it - /// configures has to accept that: a multi-assembly set is only legal in composite mode, and a + /// Real builds hand the generator the whole app closure, not one assembly. The compilation group + /// it configures has to accept that: a multi-assembly set is only legal in composite mode, and a /// group built without it asserts in checked builds and lays out nothing in any build. /// [Fact] - public void WasmAbiQueryAcceptsMoreThanOneInputAssembly() + public void WasmInteropGeneratorAcceptsMoreThanOneInputAssembly() { - // Any second real assembly will do; the queries below still target CoreLib. This one is - // guaranteed to exist because it is the assembly currently executing. + // Any second real assembly will do. This one is guaranteed to exist because it is the + // assembly currently executing. string extraInput = typeof(WasmArgumentLayoutTests).Assembly.Location; Assert.True(File.Exists(extraInput), $"test assembly not found at '{extraInput}'"); ReadyToRunCompilerContext context = CreateWasmContext(extraInput); + string outputDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - Assert.Equal(new[] { "S16" }, RunQueries(context, TypeQuery(context, "Guid"))); - } - - private const string CoreLibSimpleName = "System.Private.CoreLib"; + try + { + var options = new WasmInteropGeneratorOptions + { + OutputDirectory = outputDirectory, + TargetOS = "browser", + PInvokeModules = new[] { "libSystem.Native" }, + WarnOnUnresolvedPInvokeModules = false, + }; - private static EcmaType GetSystemType(ReadyToRunCompilerContext context, string typeName) - { - return (EcmaType)context.SystemModule.GetType(new Utf8String("System"), new Utf8String(typeName)); - } + Assert.Equal(0, WasmInteropGenerator.Run(context, options, new Logger(TextWriter.Null, isVerbose: false))); - private static string TypeQuery(ReadyToRunCompilerContext context, string typeName) - { - EcmaType type = GetSystemType(context, typeName); + foreach (string fileName in new[] + { + WasmInteropGenerator.PInvokeFileName, + WasmInteropGenerator.ReversePInvokeFileName, + WasmInteropGenerator.InterpToNativeFileName, + }) + { + string path = Path.Combine(outputDirectory, fileName); + Assert.True(File.Exists(path), $"{fileName} was not generated"); + Assert.NotEmpty(File.ReadAllText(path)); + } - return $"t {CoreLibSimpleName} 0x{MetadataTokens.GetToken(type.Handle):x8}"; + // The statically linked module has to resolve to direct calls, which is the whole point + // of naming it on the command line. + Assert.Contains("SystemNative_", File.ReadAllText(Path.Combine(outputDirectory, WasmInteropGenerator.PInvokeFileName))); + } + finally + { + if (Directory.Exists(outputDirectory)) + Directory.Delete(outputDirectory, recursive: true); + } } - /// - /// Runs the query loop over in-memory streams and returns the replies, minus the readiness line - /// that precedes them. - /// - private static string[] RunQueries(ReadyToRunCompilerContext context, params string[] queries) - { - StringWriter output = new(); - Assert.Equal(0, WasmAbiQuery.Run(context, new StringReader(string.Join(Environment.NewLine, queries)), output)); - - string[] replies = output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - Assert.Equal("ready", replies[0]); + private const string CoreLibSimpleName = "System.Private.CoreLib"; - return replies[1..]; + private static EcmaType GetSystemType(ReadyToRunCompilerContext context, string typeName) + { + return (EcmaType)context.SystemModule.GetType("System"u8, System.Text.Encoding.UTF8.GetBytes(typeName)); } /// diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index 2a905d41b6ec02..25e4641894c16f 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -375,7 +375,13 @@ - + + + + + + + diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs deleted file mode 100644 index c66deac5c54510..00000000000000 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs +++ /dev/null @@ -1,212 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Reflection.Metadata.Ecma335; - -using Internal.JitInterface; -using Internal.TypeSystem; -using Internal.TypeSystem.Ecma; - -namespace ILCompiler -{ - /// - /// Answers wasm ABI signature questions on stdin, for build tasks that need to agree with what - /// the compiler will emit but cannot reference the type system themselves. - /// - /// - /// The WebAssembly build tasks compute the signature strings that describe P/Invokes to the - /// interpreter. Those strings encode struct sizes, which cannot be derived from metadata alone, - /// so the task asks the compiler rather than keeping a second implementation of the layout rules - /// that would be free to drift. - /// - /// This runs as a mode of crossgen2 rather than as its own tool so that there is exactly one - /// wasm lowering implementation and one type system configuration. Loading the assembly closure - /// is the expensive part, so the process stays up for the whole build and answers queries on - /// stdin instead of being spawned per method. - /// - /// Usage: - /// crossgen2 --wasm-abi-query --targetos <browser|wasi> --targetarch wasm <assembly>... - /// - /// Each stdin line is one query, and each reply line is either the answer or '!' followed by an - /// error message. Two query forms are supported: - /// - /// t <assemblySimpleName> <typeToken> - /// Replies with the ABI encoding of a type in parameter position ('i', 'l', 'f', 'd', 'V' or - /// "S<size>"). - /// - /// m <assemblySimpleName> <methodToken> <loweringFlags> - /// Replies with the full signature string of a method. Preferred over per-parameter type - /// queries: the parameter types come from the method's signature blob, so generic - /// instantiations resolve even though they have no metadata token of their own and so cannot - /// be named over the wire. - /// - /// Tokens are decimal or 0x-prefixed hexadecimal; flags are a decimal LoweringFlags value. - /// - public static class WasmAbiQuery - { - public static int Run(ReadyToRunCompilerContext context, TextReader input, TextWriter output) - { - ConfigureCompilationGroup(context); - - // Tells the caller the closure loaded, so a startup failure is not mistaken for a - // failure of the first query. - output.WriteLine("ready"); - output.Flush(); - - string line; - while ((line = input.ReadLine()) is not null) - { - if (line.Length == 0) - continue; - - string reply; - try - { - reply = Answer(context, line); - } - catch (Exception ex) - { - // A malformed query is the caller's fault and the message says everything; anything - // else is a bug in here, and whoever reads the build log needs the stack to act on it. - string detail = ex is FormatException or ArgumentException ? ex.Message : ex.ToString(); - reply = "!" + detail.Replace('\r', ' ').Replace('\n', ' '); - } - - output.WriteLine(reply); - output.Flush(); - } - - return 0; - } - - /// - /// The ReadyToRun field layout algorithm asks the compilation group whether a derived type - /// needs its base offset aligned, so a context without a group throws before computing any - /// layout. - /// - /// - /// Every input goes into a single version bubble. The alignment the group would otherwise - /// introduce exists to keep offsets baked into precompiled code valid across a version - /// boundary, and there is no precompiled code here: the interpreter loads these assemblies - /// and computes their layout itself. One bubble is what reports that layout. - /// - private static void ConfigureCompilationGroup(ReadyToRunCompilerContext context) - { - List modules = new(); - foreach (string simpleName in context.InputFilePaths.Keys) - { - modules.Add(context.GetModuleForSimpleName(simpleName)); - } - - context.SetCompilationGroup(new ReadyToRunSingleAssemblyCompilationModuleGroup(new ReadyToRunCompilationModuleGroupConfig - { - Context = context, - // "Many inputs, one output unit" is what composite mode means, and it is what makes - // the group treat every input as a single compilation unit. Without it the group - // asserts on a compilation set larger than one assembly. - IsCompositeBuildMode = true, - IsInputBubble = true, - CompilationModuleSet = modules, - VersionBubbleModuleSet = modules, - CrossModuleInlineable = Array.Empty(), - InstructionSetSupport = context.InstructionSetSupport, - })); - } - - private static string Answer(CompilerTypeSystemContext context, string query) - { - if (query.Length < 2 || query[1] != ' ') - throw new FormatException($"Malformed query '{query}'; expected a 't' or 'm' verb."); - - string rest = query.Substring(2); - - // Parsed right to left so that the assembly name, which is whatever is left over, is not - // assumed to be free of spaces. - switch (query[0]) - { - case 't': - { - (string assemblyName, int typeToken) = SplitToken(rest, query); - TypeDesc type = GetModule(context, assemblyName).GetType(MetadataTokens.EntityHandle(typeToken)); - - return GetAbiToken(type); - } - - case 'm': - { - (string head, int flags) = SplitToken(rest, query); - (string assemblyName, int methodToken) = SplitToken(head, query); - - return GetMethodSignature(context, assemblyName, methodToken, flags); - } - - default: - throw new FormatException($"Unrecognized query verb '{query[0]}'."); - } - } - - private static string GetMethodSignature(CompilerTypeSystemContext context, string assemblySimpleName, int methodToken, int flags) - { - // The caller keeps its own copy of LoweringFlags, because a build task cannot reference - // the type system. Reject bits this build does not define rather than letting a copy that - // has drifted ahead silently ask for a lowering that is not the one it means. - const int KnownFlags = (int)(WasmLowering.LoweringFlags.HasGenericContextArg - | WasmLowering.LoweringFlags.IsAsyncCall - | WasmLowering.LoweringFlags.IsUnmanagedCallersOnly); - - if ((flags & ~KnownFlags) != 0) - { - throw new ArgumentOutOfRangeException(nameof(flags), $"Unknown wasm lowering flags 0x{flags:x}; this build understands 0x{KnownFlags:x}."); - } - - MethodDesc method = GetModule(context, assemblySimpleName).GetMethod(MetadataTokens.EntityHandle(methodToken)); - - return WasmLowering.GetSignature(method.Signature, (WasmLowering.LoweringFlags)flags).SignatureString; - } - - /// - /// Gets the signature encoding for a type in parameter position: a primitive character - /// (i, l, f, d, V) or S<size> for a struct that - /// is passed by reference. - /// - private static string GetAbiToken(TypeDesc type) - { - TypeDesc loweredType = WasmLowering.LowerToAbiType(type); - if (loweredType is null) - { - // Passed by reference; the size is what the callee needs to know. - return string.Create(null, stackalloc char[16], $"S{type.GetElementSize().AsInt}"); - } - - return WasmLowering.WasmValueTypeToSigChar(WasmLowering.LowerType(loweredType)).ToString(); - } - - private static EcmaModule GetModule(CompilerTypeSystemContext context, string assemblySimpleName) - { - // Resolved by simple name plus metadata token rather than by name: name-based lookup - // would have to reproduce nested-type and generic name mangling, and would silently pick - // the wrong member when it got that wrong, whereas a token cannot be ambiguous. - return context.GetModuleForSimpleName(assemblySimpleName); - } - - private static (string Head, int Value) SplitToken(string text, string query) - { - int separator = text.LastIndexOf(' '); - if (separator < 0) - throw new FormatException($"Malformed query '{query}'; not enough fields."); - - return (text.Substring(0, separator), ParseToken(text.Substring(separator + 1))); - } - - private static int ParseToken(string text) - { - return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) - ? int.Parse(text.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) - : int.Parse(text, CultureInfo.InvariantCulture); - } - } -} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs new file mode 100644 index 00000000000000..5b9c50bc2c7ed6 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; + +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace ILCompiler.Wasm +{ + /// + /// Reports a condition that should fail the build, with a message that is complete on its own. + /// + internal sealed class LogAsErrorException(string message) : Exception(message); + + /// + /// Emits generator diagnostics in the canonical MSBuild format, so that a build driving + /// crossgen2 through Exec still reports them with their codes. + /// + internal sealed class WasmInteropLogger(Logger logger) + { + private readonly HashSet _reportedInfo = []; + + public void Warning(string code, string message) + => logger.LogMessage($"crossgen2 : warning {code}: {message}"); + + /// + /// Reports an informational diagnostic once per distinct message, so that a type used by + /// many signatures does not produce the same line repeatedly. + /// + public void InfoHigh(string code, string message) + { + if (_reportedInfo.Add($"{code}:{message}")) + logger.LogMessage($"crossgen2 : message {code}: {message}"); + } + + public void Verbose(string message) + { + if (logger.IsVerbose) + logger.LogMessage(message); + } + } + + /// + /// Scans assemblies for methods marked with MethodImplAttributes.InternalCall and + /// collects the portable entry point signatures the interpreter-to-native thunks are generated + /// from. + /// + internal sealed class WasmInternalCallSignatureCollector(WasmInteropLogger log) + { + private readonly HashSet _signatures = []; + + public IEnumerable Signatures => _signatures; + + public void ScanType(EcmaType type) + { + foreach (MethodDesc method in type.GetMethods()) + { + if (!method.IsInternalCall) + continue; + + // An uninstantiated generic has no single signature to generate a thunk from, because + // its parameters stand for whatever the instantiation supplies. + if (method.HasInstantiation || method.OwningType.HasInstantiation) + { + log.Warning("WASM0001", $"Skipping generic InternalCall method '{type}::{method.Name.ToString()}', which has no single signature"); + continue; + } + + try + { + // A managed signature: the lowering adds the 'T' for an instance method and the + // trailing 'p' for the portable entry point parameter. + string signature = WasmInteropSignature.GetMethodSignature(method, includeThis: true); + if (_signatures.Add(signature)) + log.Verbose($"Adding InternalCall signature {signature} for method '{type}.{method.Name.ToString()}'"); + } + catch (Exception ex) when (ex is not LogAsErrorException) + { + log.Warning("WASM0001", $"Could not get signature for InternalCall method '{type}::{method.Name.ToString()}' because '{ex.Message}'"); + } + } + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs new file mode 100644 index 00000000000000..9418d33b4d40a5 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace ILCompiler.Wasm +{ + /// + /// Options for , mirroring the command line. + /// + public sealed class WasmInteropGeneratorOptions + { + public string OutputDirectory { get; init; } + public IReadOnlyList PInvokeModules { get; init; } = []; + public IReadOnlyList IgnoredPInvokeModules { get; init; } = []; + public string TargetOS { get; init; } + public bool WarnOnUnresolvedPInvokeModules { get; init; } = true; + } + + /// + /// Generates the C++ interop helpers the wasm interpreter needs: the static P/Invoke resolution + /// table, the native-to-managed reverse thunks, and the interpreter-to-native call thunks. + /// + /// + /// This runs inside crossgen2 because the files it writes encode the wasm ABI - struct sizes, + /// alignment, and how each type is passed - which cannot be derived from metadata alone. Doing + /// it here means there is exactly one implementation of the lowering (, + /// shared with compiled code) rather than a second one in a build task that would be free to + /// drift from it. + /// + /// Usage: + /// crossgen2 --wasm-generate-callhelpers <dir> --targetos <browser|wasi> --targetarch wasm \ + /// --wasm-pinvoke-module <name>... <assembly>... + /// + public static class WasmInteropGenerator + { + public const string PInvokeFileName = "callhelpers-pinvoke.cpp"; + public const string ReversePInvokeFileName = "callhelpers-reverse.cpp"; + public const string InterpToNativeFileName = "callhelpers-interp-to-managed.cpp"; + + public static int Run(ReadyToRunCompilerContext context, WasmInteropGeneratorOptions options, Logger logger) + { + var log = new WasmInteropLogger(logger); + + try + { + Generate(context, options, log); + return 0; + } + catch (LogAsErrorException ex) + { + logger.LogMessage($"crossgen2 : error : {ex.Message}"); + return 1; + } + } + + private static void Generate(ReadyToRunCompilerContext context, WasmInteropGeneratorOptions options, WasmInteropLogger log) + { + ConfigureCompilationGroup(context); + + var collector = new WasmPInvokeCollector(log, options.TargetOS); + var internalCallCollector = new WasmInternalCallSignatureCollector(log); + + List pinvokes = []; + List callbacks = []; + HashSet signatures = []; + + foreach (string simpleName in context.InputFilePaths.Keys) + { + EcmaModule module = context.GetModuleForSimpleName(simpleName); + + // Only System.Private.CoreLib is scanned for InternalCall methods: all the ones that + // are used are defined there, scanning everything is expensive, and doing so can hit + // failures on assemblies that are not tested for it. + bool scanInternalCalls = module == context.SystemModule; + + log.Verbose($"Scanning {simpleName} for pinvokes{(scanInternalCalls ? " and InternalCall methods" : "")}"); + + foreach (MetadataType type in module.GetAllTypes()) + { + if (type is not EcmaType ecmaType) + continue; + + collector.CollectPInvokes(pinvokes, callbacks, signatures, ecmaType); + + if (scanInternalCalls) + internalCallCollector.ScanType(ecmaType); + } + } + + var generator = new WasmPInvokeTableGenerator(log, options.WarnOnUnresolvedPInvokeModules); + + WriteIfDifferent(Path.Combine(options.OutputDirectory, PInvokeFileName), log, + w => generator.EmitPInvokeTable(w, options.PInvokeModules, options.IgnoredPInvokeModules, pinvokes)); + + WriteIfDifferent(Path.Combine(options.OutputDirectory, ReversePInvokeFileName), log, + w => generator.EmitNativeToInterp(w, callbacks)); + + // Pregenerated signatures for commonly used shapes used by R2R code to reduce duplication + // in generated R2R binaries. Currently none, but can be added here as needed in the future. + string[] pregeneratedInterpreterToNativeSignatures = []; + + IEnumerable cookies = signatures + .Concat(internalCallCollector.Signatures) + .Concat(pregeneratedInterpreterToNativeSignatures); + + WriteIfDifferent(Path.Combine(options.OutputDirectory, InterpToNativeFileName), log, + w => WasmInterpToNativeGenerator.Emit(w, cookies)); + } + + /// + /// Writes generated content to only when it differs from what is + /// already there, so that an unchanged file keeps its timestamp and does not retrigger the + /// native build that consumes it. + /// + private static void WriteIfDifferent(string path, WasmInteropLogger log, Action emit) + { + var buffer = new StringWriter { NewLine = Environment.NewLine }; + emit(buffer); + string content = buffer.ToString(); + + if (File.Exists(path) && File.ReadAllText(path) == content) + { + log.Verbose($"{path} is unchanged."); + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); + File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + log.Verbose($"Generated {path}."); + } + + /// + /// The ReadyToRun field layout algorithm asks the compilation group whether a derived type + /// needs its base offset aligned, so a context without a group throws before computing any + /// layout. + /// + /// + /// Every input goes into a single version bubble. The alignment the group would otherwise + /// introduce exists to keep offsets baked into precompiled code valid across a version + /// boundary, and there is no precompiled code here: the interpreter loads these assemblies + /// and computes their layout itself. One bubble is what reports that layout. + /// + private static void ConfigureCompilationGroup(ReadyToRunCompilerContext context) + { + List modules = context.InputFilePaths.Keys + .Select(simpleName => context.GetModuleForSimpleName(simpleName)) + .ToList(); + + context.SetCompilationGroup(new ReadyToRunSingleAssemblyCompilationModuleGroup(new ReadyToRunCompilationModuleGroupConfig + { + Context = context, + // "Many inputs, one output unit" is what composite mode means, and it is what makes + // the group treat every input as a single compilation unit. Without it the group + // asserts on a compilation set larger than one assembly. + IsCompositeBuildMode = true, + IsInputBubble = true, + CompilationModuleSet = modules, + VersionBubbleModuleSet = modules, + CrossModuleInlineable = [], + InstructionSetSupport = context.InstructionSetSupport, + })); + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs new file mode 100644 index 00000000000000..2d406cbaa69d4d --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs @@ -0,0 +1,187 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; + +using Internal.JitInterface; +using Internal.TypeSystem; + +namespace ILCompiler.Wasm +{ + /// + /// Thrown when a signature token has no representation in the generated C. + /// + internal sealed class InvalidSignatureCharException(char c) + : Exception($"Can't handle signature '{c}'") + { + public char Char { get; } = c; + } + + /// + /// Maps between the wasm signature strings produced by and the C + /// declarations the generated interop files are built from. + /// + /// + /// The signature string format is documented in docs/design/coreclr/botr/readytorun-format.md + /// (section "Wasm Signature String Encoding"). is the single + /// implementation of the encoding; everything here either produces a string through it or + /// consumes one it produced. + /// + internal static class WasmInteropSignature + { + /// + /// Returns the wasm signature string for a method. + /// + /// The method to encode. + /// + /// for a managed signature, which picks up the leading 'T' for an + /// instance method and the trailing 'p' for the portable entry point argument. + /// describes a native function. + /// + public static string GetMethodSignature(MethodDesc method, bool includeThis) + { + WasmLowering.LoweringFlags flags = includeThis + ? WasmLowering.LoweringFlags.None + : WasmLowering.LoweringFlags.IsUnmanagedCallersOnly; + + return WasmLowering.GetSignature(method.Signature, flags).SignatureString; + } + + /// + /// Gets the signature encoding for a type in parameter position: a primitive character + /// (i, l, f, d, V), a multi-slot token, or + /// S<size>/A<size> for a struct that is passed by reference. + /// + public static string GetAbiToken(TypeDesc type) + { + if (type.IsVoid) + return "v"; + + TypeDesc loweredType = WasmLowering.LowerToAbiType(type); + if (loweredType is null) + { + // Passed by reference; the size is what the callee needs to know. 'A' marks a struct + // whose alignment exceeds a stack slot, matching what WasmLowering.GetSignature emits + // so a type gets the same token here as it does inside a method signature. + Debug.Assert(type is DefType, "LowerToAbiType only returns null for aggregates"); + char kind = CorInfoImpl.GetClassAlignmentRequirementStatic((DefType)type) > 8 ? 'A' : 'S'; + return string.Create(CultureInfo.InvariantCulture, $"{kind}{type.GetElementSize().AsInt}"); + } + + return WasmLowering.WasmValueTypeToSigChar(WasmLowering.LowerType(loweredType)).ToString(); + } + + /// + /// Parses a signature string into individual tokens. Single-char types produce one-char + /// tokens; struct encodings produce multi-char tokens like "S8" or "A32", and a multi-slot + /// parameter produces a two-char token like "l2" or "V4". The 'a' and 'p' suffixes are + /// included as their own tokens. + /// + public static List ParseSignatureTokens(string signature) + { + List tokens = []; + int i = 0; + while (i < signature.Length) + { + if (signature[i] is 'S' or 'A') + { + int start = i; + i++; // skip 'S'/'A' + while (i < signature.Length && char.IsDigit(signature[i])) + i++; + tokens.Add(signature.Substring(start, i - start)); + } + else if (signature[i] is 'l' or 'V' && i + 1 < signature.Length && char.IsDigit(signature[i + 1])) + { + tokens.Add(signature.Substring(i, 2)); + i += 2; + } + else + { + tokens.Add(signature[i].ToString()); + i++; + } + } + + return tokens; + } + + /// + /// True for a token describing a type passed by value across several wasm parameters + /// ("l2", "V2", "V4"). Interop signatures do not use these today. + /// + public static bool IsMultiSlotToken(string token) + => token.Length == 2 && token[0] is 'l' or 'V' && char.IsDigit(token[1]); + + private static void RejectMultiSlotToken(string token) + { + if (IsMultiSlotToken(token)) + throw new NotSupportedException($"Multi-slot signature token '{token}' is not supported in interop thunks"); + } + + public static string TokenToNativeType(string token) + { + RejectMultiSlotToken(token); + return token[0] switch + { + 'v' => "void", + 'i' => "int32_t", + 'l' => "int64_t", + 'f' => "float", + 'd' => "double", + 'S' or 'A' or 'T' => "int32_t", + 'p' => "PCODE", + _ => throw new InvalidSignatureCharException(token[0]) + }; + } + + public static string TokenToNameType(string token) + { + RejectMultiSlotToken(token); + return token[0] switch + { + 'v' => "Void", + 'i' => "I32", + 'l' => "I64", + 'f' => "F32", + 'd' => "F64", + 'S' or 'A' => token, + 'T' => "This", + 'p' => "PE", + _ => throw new InvalidSignatureCharException(token[0]) + }; + } + + public static string TokenToArgType(string token) + { + RejectMultiSlotToken(token); + return token[0] switch + { + 'i' or 'T' => "ARG_I32", + 'l' => "ARG_I64", + 'f' => "ARG_F32", + 'd' => "ARG_F64", + 'S' or 'A' => "ARG_IND", + _ => throw new InvalidSignatureCharException(token[0]) + }; + } + + /// + /// Returns the number of INTERP_STACK_SLOT_SIZE slots consumed by a token. Struct tokens + /// consume max((size + 7) / 8, 1) slots; all others consume 1. + /// + public static int TokenToSlotCount(string token) + { + if (token[0] is not ('S' or 'A') || token.Length < 2) + return 1; + + return Math.Max((GetStructSize(token) + 7) / 8, 1); + } + + public static int GetStructSize(string token) + => int.Parse(token.AsSpan(1), CultureInfo.InvariantCulture); + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs new file mode 100644 index 00000000000000..ee9567e24ea082 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs @@ -0,0 +1,185 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace ILCompiler.Wasm +{ + /// + /// Generates the g_wasmThunks array and CallFunc_* functions used by the CoreCLR + /// interpreter to call native code on wasm. + /// + /// + /// The generated code has to stay in sync with the CoreCLR runtime code that consumes these + /// thunks and call functions. + /// + internal static class WasmInterpToNativeGenerator + { + public static void Emit(TextWriter w, IEnumerable cookies) + { + string[] signatures = cookies.Distinct().ToArray(); + Array.Sort(signatures, StringComparer.Ordinal); + + // Collect unique struct return sizes so we can emit typedefs + var structReturnSizes = new SortedSet(); + foreach (string signature in signatures) + { + string returnToken = WasmInteropSignature.ParseSignatureTokens(signature)[0]; + if (returnToken[0] == 'S' && returnToken.Length > 1) + structReturnSizes.Add(WasmInteropSignature.GetStructSize(returnToken)); + } + + w.Write( + """ + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // + + // + // GENERATED FILE, DON'T EDIT + // Generated by coreclr InterpToNativeGenerator + // + + #include + #include + + // Arguments are passed on the stack with each argument aligned to INTERP_STACK_SLOT_SIZE. + #define ARG_ADDR(i) (pArgs + (i * INTERP_STACK_SLOT_SIZE)) + #define ARG_IND(i) ((int32_t)((int32_t*)ARG_ADDR(i))) + #define ARG_I32(i) (*(int32_t*)ARG_ADDR(i)) + #define ARG_I64(i) (*(int64_t*)ARG_ADDR(i)) + #define ARG_F32(i) (*(float*)ARG_ADDR(i)) + #define ARG_F64(i) (*(double*)ARG_ADDR(i)) + + """); + + // Emit typedefs for struct return types so emcc generates the correct sret ABI + foreach (int size in structReturnSizes) + w.WriteLine($"typedef struct {{ char d[{size}]; }} wasm_ret_S{size};"); + + w.Write( + """ + + namespace + { + """); + + foreach (string signature in signatures) + { + try + { + List tokens = WasmInteropSignature.ParseSignatureTokens(signature); + string returnToken = tokens[0]; + (bool isVoid, string nativeType) result = Result(returnToken); + bool isPortableEntryPointCall = IsPortableEntryPointCall(tokens); + if (isPortableEntryPointCall) + { + // Portable entrypoints have an extra hidden parameter for the portable entrypoint + // context, so adjust the signature and result accordingly for the call function. + tokens.RemoveAt(tokens.Count - 1); + } + + RemoveAsyncCallMarker(tokens); + + List args = Args(tokens); + string argTypes = string.Join(", ", args.Select(WasmInteropSignature.TokenToNativeType)); + + string portableEntryPointComma = args.Count > 0 ? ", " : ""; + string portableEntrypointDeclaration = isPortableEntryPointCall ? portableEntryPointComma + "PCODE" : ""; + string portableEntrypointParam = isPortableEntryPointCall ? portableEntryPointComma + "pPortableEntryPoint" : ""; + string portableEntrypointStackDeclaration = isPortableEntryPointCall ? "int*, " : ""; + string portableEntrypointStackParam = isPortableEntryPointCall ? "&framePointer, " : ""; + string portableEntrypointPointerRD = isPortableEntryPointCall ? "*" : ""; + w.Write( + $$""" + + {{(isPortableEntryPointCall ? "NOINLINE " : "")}}static void {{CallFuncName(args, WasmInteropSignature.TokenToNameType(returnToken), isPortableEntryPointCall)}}(PCODE {{(isPortableEntryPointCall ? "pPortableEntryPoint" : "pcode")}}, int8_t* pArgs, int8_t* pRet) + {{{(isPortableEntryPointCall ? "\n alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK;" : "")}} + {{result.nativeType}} (*fptr)({{portableEntrypointStackDeclaration}}{{argTypes}}{{portableEntrypointDeclaration}}) = {{portableEntrypointPointerRD}}({{result.nativeType}} ({{portableEntrypointPointerRD}}*)({{portableEntrypointStackDeclaration}}{{argTypes}}{{portableEntrypointDeclaration}})){{(isPortableEntryPointCall ? "(pPortableEntryPoint)" : "pcode")}}; + {{(result.isVoid ? "" : $"*(({result.nativeType}*)pRet) = ")}}(*fptr)({{portableEntrypointStackParam}}{{string.Join(", ", ArgsWithSlotOffsets(args))}}{{portableEntrypointParam}}); + } + + """); + } + catch (InvalidSignatureCharException e) + { + throw new LogAsErrorException($"Element '{e.Char}' of signature '{signature}' can't be handled by managed2native generator"); + } + } + + w.Write( + $$""" + } + + const StringToWasmSigThunk g_wasmThunks[] = { + {{string.Join($",{w.NewLine}", signatures.Select(ThunkEntry))}} + }; + + const size_t g_wasmThunksCount = sizeof(g_wasmThunks) / sizeof(g_wasmThunks[0]); + + """); + + static string ThunkEntry(string signature) + { + List tokens = WasmInteropSignature.ParseSignatureTokens(signature); + bool isPortableEntryPointCall = IsPortableEntryPointCall(tokens); + if (isPortableEntryPointCall) + tokens.RemoveAt(tokens.Count - 1); + RemoveAsyncCallMarker(tokens); + + string name = CallFuncName(Args(tokens), WasmInteropSignature.TokenToNameType(tokens[0]), isPortableEntryPointCall); + return $" {{ \"M{signature}\", (void*)&{name} }}"; + } + + static List Args(List tokens) + => tokens.Count > 1 ? tokens.GetRange(1, tokens.Count - 1) : []; + + static List ArgsWithSlotOffsets(List args) + { + List result = []; + int slot = 0; + foreach (string token in args) + { + if (token[0] == 'A') + slot = (slot + 1) & ~1; + + result.Add($"{WasmInteropSignature.TokenToArgType(token)}({slot})"); + slot += WasmInteropSignature.TokenToSlotCount(token); + } + + return result; + } + + static (bool IsVoid, string NativeType) Result(string returnToken) + { + // For struct returns, use the typedef so emcc generates the correct sret ABI + if (returnToken[0] == 'S' && returnToken.Length > 1) + return (false, $"wasm_ret_S{WasmInteropSignature.GetStructSize(returnToken)}"); + + return (returnToken == "v", WasmInteropSignature.TokenToNativeType(returnToken)); + } + + static bool IsPortableEntryPointCall(List tokens) + => tokens.Count > 0 && tokens[^1] == "p"; + + static void RemoveAsyncCallMarker(List tokens) + { + int asyncMarkerIndex = tokens.IndexOf("a"); + if (asyncMarkerIndex >= 0) + tokens.RemoveAt(asyncMarkerIndex); + } + } + + private static string CallFuncName(List args, string result, bool isPortableEntryPointCall) + { + string paramTypes = args.Count > 0 + ? string.Join("_", args.Select(WasmInteropSignature.TokenToNameType)) + : "Void"; + + return $"CallFunc_{paramTypes}_Ret{result}{(isPortableEntryPointCall ? "_PE" : "")}"; + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs new file mode 100644 index 00000000000000..104a81b9b123a7 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs @@ -0,0 +1,384 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; + +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace ILCompiler.Wasm +{ + /// + /// A P/Invoke discovered while scanning the input assemblies. + /// + internal sealed class WasmPInvoke(string entryPoint, string module, EcmaMethod method, bool wasmLinkage) + : IEquatable + { + public string EntryPoint { get; } = entryPoint; + public string Module { get; } = module; + public EcmaMethod Method { get; } = method; + public bool WasmLinkage { get; } = wasmLinkage; + public bool Skip { get; set; } + + /// A stable identity for de-duplicating declarations of the same import. + private string Identity => $"{EntryPoint}!{Module}!{Method.OwningType}::{Method.Name.ToString()}{Method.Signature}"; + + public bool Equals(WasmPInvoke other) + => other is not null && string.Equals(Identity, other.Identity, StringComparison.Ordinal); + + public override bool Equals(object obj) => Equals(obj as WasmPInvoke); + + public override int GetHashCode() => Identity.GetHashCode(StringComparison.Ordinal); + + public override string ToString() => $"{{ EntryPoint: {EntryPoint}, Module: {Module}, Method: {Method}, Skip: {Skip} }}"; + } + + /// + /// A managed method callable from native code, discovered while scanning the input assemblies. + /// + internal sealed class WasmPInvokeCallback + { + public WasmPInvokeCallback(EcmaMethod method) + { + Method = method; + var type = (EcmaType)method.OwningType; + + TypeName = type.Name.ToString(); + TypeFullName = WasmTypeNames.GetFullName(type); + AssemblyName = ((EcmaAssembly)type.Module).GetName().Name; + + // Nested types: the runtime reverse-thunk key (vm/wasm/helpers.cpp GetHashCode -> + // GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so match that + // here or the emitted g_ReverseThunks key won't be found at lookup time (#130129). + // This key drops the enclosing-type chain, so nested types with the same simple name in + // different namespaces collide; the duplicate-key check in WasmPInvokeTableGenerator + // (EmitNativeToInterp) turns that into a build error. + // Tracked by https://github.com/dotnet/runtime/issues/130739. + Namespace = type.ContainingType is not null ? string.Empty : type.Namespace.ToString(); + MethodName = method.Name.ToString(); + ReturnType = method.Signature.ReturnType; + IsVoid = ReturnType.IsVoid; + Token = (uint)MetadataTokens.GetToken(method.Handle); + + // FIXME: this is a hack, we need to encode this better and allow reflection in the interp case + // but either way it needs to match the key generated in get_native_to_interp since the key is + // used to look up the interp entry function. It must be unique for each callback runtime errors + // can occur since it is used to look up the index in the wasm_native_to_interp_ftndescs and + // the signature of the interp entry function must match the native signature + // + // the key also needs to survive being encoded in C literals, if in doubt + // add something like "\U0001F412" to the key on both the managed and unmanaged side + Key = $"{MethodName}#{method.Signature.Length}:{AssemblyName}:{Namespace}:{TypeName}"; + + if (method.GetDecodedCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute") + is CustomAttributeValue attribute) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Name == "EntryPoint" && argument.Value is string entryPoint) + { + EntryPoint = entryPoint; + IsExport = true; + break; + } + } + } + } + + /// + /// The name of the type that declares the callback, as the runtime spells it. Nested types + /// report an empty namespace here, which is what the reverse-thunk key expects. + /// + public string EntryName => $"{AssemblyName}_{Namespace}_{TypeName}_{MethodName}"; + + public MethodSignature Parameters => Method.Signature; + public string EntryPoint { get; } + public EcmaMethod Method { get; } + public string EntrySymbol { get; set; } + public string AssemblyName { get; } + public string TypeName { get; } + public string TypeFullName { get; } + public string Namespace { get; } + public string MethodName { get; } + public TypeDesc ReturnType { get; } + public bool IsExport { get; } + public bool IsVoid { get; } + public uint Token { get; } + public string Key { get; } + } + + internal sealed class WasmPInvokeCallbackComparer : IComparer + { + public int Compare(WasmPInvokeCallback x, WasmPInvokeCallback y) + { + int compare = string.Compare(x.Key, y.Key, StringComparison.Ordinal); + return compare != 0 ? compare : (int)(x.Token - y.Token); + } + } + + /// + /// Scans assemblies for the interop surface the wasm interpreter needs thunks for: P/Invokes, + /// methods callable from native code, native function pointer signatures, and InternalCalls. + /// + internal sealed class WasmPInvokeCollector(WasmInteropLogger log, string targetOS) + { + private readonly Dictionary _assemblyDisableRuntimeMarshalling = []; + private readonly Dictionary _typeUnsupportedOnPlatform = []; + private readonly Dictionary _assemblyUnsupportedOnPlatform = []; + private readonly Dictionary _blittable = []; + + public void CollectPInvokes(List pinvokes, List callbacks, HashSet signatures, EcmaType type) + { + foreach (MethodDesc methodDesc in type.GetMethods()) + { + var method = (EcmaMethod)methodDesc; + try + { + CollectPInvokesForMethod(method); + if (DoesMethodHaveCallbacks(method)) + callbacks.Add(new WasmPInvokeCallback(method)); + } + catch (Exception ex) when (ex is not LogAsErrorException) + { + log.Warning("WASM0001", $"Could not get pinvoke, or callbacks for method '{type}::{method.Name.ToString()}' because '{ex}'"); + } + } + + if (type.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedFunctionPointerAttribute")) + { + // Each instantiation of an open generic delegate would marshal differently, so there is + // no single native signature to emit a thunk for. The encoding this used to produce came + // from mapping the type parameter itself, which was only ever right by accident. + if (type.HasInstantiation) + { + log.Warning("WASM0001", $"Skipping generic function pointer delegate '{type}', which has no single native signature"); + return; + } + + MethodDesc invokeMethod = type.GetMethod("Invoke"u8, null); + if (invokeMethod is not null) + AddSignature(signatures, invokeMethod, includeThis: false, "pinvoke"); + } + + void CollectPInvokesForMethod(EcmaMethod method) + { + if (!method.IsPInvoke) + return; + + if (IsUnsupportedOnPlatform(method)) + return; + + PInvokeMetadata metadata = method.GetPInvokeMethodMetadata(); + bool wasmLinkage = method.HasCustomAttribute("System.Runtime.InteropServices", "WasmImportLinkageAttribute"); + + pinvokes.Add(new WasmPInvoke(metadata.Name, metadata.Module, method, wasmLinkage)); + + AddSignature(signatures, method, includeThis: false, "pinvoke"); + } + } + + private void AddSignature(HashSet signatures, MethodDesc method, bool includeThis, string kind) + { + string signature = WasmInteropSignature.GetMethodSignature(method, includeThis); + if (signatures.Add(signature)) + log.Verbose($"Adding {kind} signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); + } + + private bool DoesMethodHaveCallbacks(EcmaMethod method) + { + if (!MethodHasCallbackAttributes(method)) + return false; + + if (IsUnsupportedOnPlatform(method)) + return false; + + if (HasAssemblyDisableRuntimeMarshallingAttribute((EcmaAssembly)method.Module)) + return true; + + // No DisableRuntimeMarshalling attribute, so check if the params/ret-type are blittable + MethodSignature signature = method.Signature; + if (!signature.ReturnType.IsVoid && !IsBlittable(signature.ReturnType)) + throw new LogAsErrorException($"The return type '{signature.ReturnType}' of pinvoke callback method '{method}' needs to be blittable."); + + foreach (TypeDesc parameterType in signature) + { + if (!IsBlittable(parameterType)) + throw new LogAsErrorException($"Parameter types of pinvoke callback method '{method}' needs to be blittable."); + } + + return true; + } + + private static bool MethodHasCallbackAttributes(EcmaMethod method) + => method.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute") + || HasAttributeByName(method, "MonoPInvokeCallbackAttribute"); + + /// + /// Matches an attribute by its simple name in any namespace, for attributes that are + /// declared by user code rather than by the framework. + /// + private static bool HasAttributeByName(EcmaMethod method, string attributeName) + { + MetadataReader reader = method.MetadataReader; + foreach (CustomAttributeHandle handle in reader.GetMethodDefinition(method.Handle).GetCustomAttributes()) + { + if (reader.GetAttributeNamespaceAndName(handle, out _, out StringHandle name) + && reader.StringComparer.Equals(name, attributeName)) + { + return true; + } + } + + return false; + } + + private bool HasAssemblyDisableRuntimeMarshallingAttribute(EcmaAssembly assembly) + { + if (!_assemblyDisableRuntimeMarshalling.TryGetValue(assembly, out bool value)) + { + _assemblyDisableRuntimeMarshalling[assembly] = value = + assembly.HasAssemblyCustomAttribute("System.Runtime.CompilerServices", "DisableRuntimeMarshallingAttribute"); + } + + return value; + } + + private bool IsUnsupportedOnPlatform(EcmaMethod method) + => EvaluatePlatformAttributes(method.GetDecodedCustomAttributes) switch + { + PlatformSupport.Unsupported => true, + PlatformSupport.Supported => false, + _ => IsUnsupportedOnPlatform(method.OwningType) + }; + + private bool IsUnsupportedOnPlatform(TypeDesc type) + { + if (type is not EcmaType ecmaType) + return false; + + if (_typeUnsupportedOnPlatform.TryGetValue(type, out bool cached)) + return cached; + + bool value = EvaluatePlatformAttributes(ecmaType.GetDecodedCustomAttributes) switch + { + PlatformSupport.Unsupported => true, + PlatformSupport.Supported => false, + _ when ecmaType.ContainingType is not null => IsUnsupportedOnPlatform(ecmaType.ContainingType), + _ => IsAssemblyUnsupportedOnPlatform((EcmaAssembly)ecmaType.Module) + }; + + _typeUnsupportedOnPlatform[type] = value; + return value; + } + + private bool IsAssemblyUnsupportedOnPlatform(EcmaAssembly assembly) + { + if (!_assemblyUnsupportedOnPlatform.TryGetValue(assembly, out bool value)) + { + _assemblyUnsupportedOnPlatform[assembly] = + value = EvaluatePlatformAttributes(assembly.GetDecodedCustomAttributes) == PlatformSupport.Unsupported; + } + + return value; + } + + private enum PlatformSupport + { + Unknown, // No platform attributes were observed at this scope + Supported, // Explicitly supported here (target appears in a SupportedOSPlatform list) + Unsupported, // Explicitly unsupported here (target matches UnsupportedOSPlatform, or + // SupportedOSPlatform is present and does not list the target) + } + + private PlatformSupport EvaluatePlatformAttributes( + Func>> getAttributes) + { + const string Namespace = "System.Runtime.Versioning"; + + foreach (var attribute in getAttributes(Namespace, "UnsupportedOSPlatformAttribute")) + { + if (MatchesTargetOS(attribute)) + return PlatformSupport.Unsupported; + } + + bool hasSupportedOSPlatform = false; + foreach (var attribute in getAttributes(Namespace, "SupportedOSPlatformAttribute")) + { + if (attribute.FixedArguments.Length == 0) + continue; + + hasSupportedOSPlatform = true; + if (MatchesTargetOS(attribute)) + return PlatformSupport.Supported; + } + + return hasSupportedOSPlatform ? PlatformSupport.Unsupported : PlatformSupport.Unknown; + + bool MatchesTargetOS(CustomAttributeValue attribute) + => attribute.FixedArguments.Length > 0 + && attribute.FixedArguments[0].Value?.ToString() == targetOS; + } + + /// + /// Whether a type can be handed to native code as-is. Results are cached so that a type used + /// by many P/Invokes only produces one diagnostic. + /// + public bool IsBlittable(TypeDesc type) + { + if (_blittable.TryGetValue(type, out bool blittable)) + return blittable; + + bool result = IsBlittableUncached(type); + _blittable[type] = result; + return result; + } + + private bool IsBlittableUncached(TypeDesc type) + { + if (type.IsPrimitive || type.IsByRef || type.IsPointer || type.IsEnum || type is FunctionPointerType) + return true; + + // HACK: SkiaSharp has pinvokes that rely on this + if (type is EcmaType delegateType + && delegateType.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedFunctionPointerAttribute")) + return true; + + if (type is MetadataType nonBlittableMarker && nonBlittableMarker.Name.StringEquals("__NonBlittableTypeForAutomatedTests__")) + return false; + + if (!type.IsValueType) + { + log.InfoHigh("WASM0060", $"Type {type} is not blittable: Not a ValueType"); + return false; + } + + var metadataType = (MetadataType)type; + List fields = []; + foreach (FieldDesc field in metadataType.GetFields()) + { + if (!field.IsStatic) + fields.Add(field); + } + + if (!metadataType.IsSequentialLayout && fields.Count > 1) + { + log.InfoHigh("WASM0061", $"Type {type} is not blittable: LayoutKind is not Sequential"); + return false; + } + + foreach (FieldDesc field in fields) + { + if (!IsBlittable(field.FieldType)) + { + log.InfoHigh("WASM0062", $"Type {type} is not blittable: Field {field.Name.ToString()} is not blittable"); + return false; + } + } + + return true; + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs new file mode 100644 index 00000000000000..0372cfb76147d0 --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs @@ -0,0 +1,478 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +using Internal.TypeSystem; +using Internal.TypeSystem.Ecma; + +namespace ILCompiler.Wasm +{ + /// + /// Emits the static P/Invoke resolution table and the native-to-interpreter reverse thunks. + /// + internal sealed class WasmPInvokeTableGenerator(WasmInteropLogger log, bool warnOnUnresolvedModules) + { + public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, IEnumerable ignoredPInvokeModules, List pinvokes) + { + var ignoredModules = new HashSet(ignoredPInvokeModules, StringComparer.Ordinal); + var modules = new SortedDictionary(StringComparer.Ordinal); + foreach (string module in pinvokeModules) + { + if (!ignoredModules.Contains(module)) + modules[module] = module; + } + + foreach (string module in ignoredModules.OrderBy(module => module, StringComparer.Ordinal)) + log.Verbose($"Ignoring PInvoke module {module}"); + + foreach (WasmPInvoke pinvoke in pinvokes) + { + if (modules.ContainsKey(pinvoke.Module) || ignoredModules.Contains(pinvoke.Module)) + continue; + + // Handle special modules, and add them to the list of modules otherwise, skip them + // and throw an exception at runtime if they are called. + if (pinvoke.WasmLinkage) + { + // WasmLinkage means we need to import the module + modules.Add(pinvoke.Module, pinvoke.Module); + log.Verbose($"Adding module {pinvoke.Module} for WasmImportLinkage"); + } + else if (pinvoke.Module == "*") + { + // Special case for * module to indicate static linking without specifying the module + modules.Add(pinvoke.Module, pinvoke.Module); + log.Verbose($"Adding module {pinvoke.Module} for static linking"); + } + else if (pinvoke.Module != "QCall") + { + // Unresolved module: not statically linked, ignored, [WasmImportLinkage], "*" or QCall. + // By design we skip it and throw at runtime if it is ever called. For hand-authored + // apps this is likely a bug, so warn; consumers scanning untrimmed closures full of + // cross-platform interop (library-test bundles) disable the warning to avoid failing + // the build under warn-as-error for P/Invokes that are never called on wasm. + if (warnOnUnresolvedModules) + { + log.Warning("WASM0066", $"PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' is not in the list of allowed modules. It is also not a specially treated module."); + } + else if (ignoredModules.Add(pinvoke.Module)) + { + log.Verbose($"Skipping unresolved PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' (not statically linked on wasm; will throw if called)."); + } + } + } + + w.WriteLine( + """ + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // + + // + // GENERATED FILE, DON'T EDIT + // Generated by coreclr callhelpers generator + // + + #include + #include + + extern "C" { + """); + + var pinvokesGroupedByEntryPoint = pinvokes + .Where(l => modules.ContainsKey(l.Module)) + .OrderBy(l => l.EntryPoint, StringComparer.Ordinal) + .GroupBy(CEntryPoint, StringComparer.Ordinal); + + foreach (IGrouping group in pinvokesGroupedByEntryPoint) + { + WasmPInvoke[] candidates = group.Distinct().ToArray(); + WasmPInvoke first = candidates[0]; + if (ShouldTreatAsVariadic(candidates)) + { + string imports = string.Join(Environment.NewLine, + candidates.Select( + p => $" {p.Method} (in [{((EcmaAssembly)p.Method.Module).GetName().Name}] {p.Method.OwningType})")); + log.Warning("WASM0001", $"Found a native function ({first.EntryPoint}) with varargs in {first.Module}." + + " Calling such functions is not supported, and will fail at runtime." + + $" Managed DllImports: {Environment.NewLine}{imports}"); + + foreach (WasmPInvoke candidate in candidates) + candidate.Skip = true; + + continue; + } + + var decls = new HashSet(); + foreach (WasmPInvoke candidate in candidates) + { + string decl = GenPInvokeDecl(candidate); + if (decls.Add(decl)) + w.WriteLine(decl); + } + } + + w.Write( + """ + } // extern "C" + + """); + + var moduleImports = new Dictionary>(); + foreach (string module in modules.Keys) + { + // the order here is not important, because we use hash tables, we want it to be stable though + List imports = pinvokes + .Where(l => l.Module == module && !l.Skip) + .OrderBy(l => l.EntryPoint, StringComparer.Ordinal) + .GroupBy(d => d.EntryPoint, StringComparer.Ordinal) + .Select(l => + { + WasmPInvoke p = l.First(); + // Runtime resolver looks up by managed EntryPoint. + // [WasmImportLinkage] mangles the C symbol per module, + // so emit the entry-point string explicitly rather than + // stringifying the mangled name via DllImportEntry. + if (p.WasmLinkage) + return $" {{ \"{EscapeLiteral(p.EntryPoint)}\", (void*)&{CEntryPoint(p)} }}, // {ListRefs(l)}{w.NewLine}"; + return $" DllImportEntry({CEntryPoint(p)}) // {ListRefs(l)}{w.NewLine}"; + }) + .ToList(); + + moduleImports[module] = imports; + w.Write( + $$""" + + static const Entry s_{{FixupSymbolName(module)}} [] = { + {{string.Concat(imports)}}}; + + """); + } + + w.Write( + $$""" + + typedef struct PInvokeTable { + const char* LibraryName; + const Entry* Entries; + size_t EntryCount; + } PInvokeTable; + + static PInvokeTable s_PInvokeTables[] = { + {{string.Join($",{w.NewLine} ", modules.Keys.Select(m => $"{{\"{EscapeLiteral(m)}\", s_{FixupSymbolName(m)}, {moduleImports[m].Count}}}"))}} + }; + const size_t s_PInvokeTablesCount = sizeof(s_PInvokeTables) / sizeof(s_PInvokeTables[0]); + + const void* callhelpers_pinvoke_override(const char* library_name, const char* entry_point_name) + { + for (size_t i = 0; i < s_PInvokeTablesCount; i++) + { + if (strcmp(library_name, s_PInvokeTables[i].LibraryName) == 0) + { + LOG((LF_INTEROP, LL_INFO1000, "Wasm callhelpers PInvoke override for: lib: %s, entry: %s \n", library_name, entry_point_name)); + return minipal_resolve_dllimport(s_PInvokeTables[i].Entries, s_PInvokeTables[i].EntryCount, entry_point_name); + } + } + + return nullptr; + } + + """); + + static bool ShouldTreatAsVariadic(WasmPInvoke[] candidates) + { + if (candidates.Length < 2) + return false; + + // Detect possible vararg entrypoint usage, where the same entrypoint is used with + // different numbers of arguments. + int firstNumArgs = candidates[0].Method.Signature.Length; + return candidates.Skip(1).Any(c => c.Method.Signature.Length != firstNumArgs); + } + + static string ListRefs(IGrouping l) + => string.Join(", ", l.Select(c => ((EcmaAssembly)c.Method.Module).GetName().Name).Distinct().OrderBy(n => n)); + } + + public void EmitNativeToInterp(TextWriter w, List callbacks) + { + // Generate native->interp entry functions + // These are called by native code, so they need to obtain + // the interp entry function/arg from a global array + // They also need to have a signature matching what the + // native code expects, which is the native signature + // of the delegate invoke in the [MonoPInvokeCallback] + // or [UnmanagedCallersOnly] attribute. + // Only blittable parameter/return types are supposed. + w.Write( + """ + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // + + // + // GENERATED FILE, DON'T EDIT + // Generated by coreclr callhelpers generator + // + + #include + + // 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); + + """); + + var callbackNames = new HashSet(); + var keys = new HashSet(); + int callbackIndex = 0; + callbacks = callbacks.Order(new WasmPInvokeCallbackComparer()).ToList(); + foreach (WasmPInvokeCallback cb in callbacks) + { + cb.EntrySymbol = FixedSymbolName(cb); + + if (!callbackNames.Add(cb.EntrySymbol)) + throw new LogAsErrorException($"Two callbacks with the same symbol '{cb.EntrySymbol}' are not supported."); + + if (!keys.Add(cb.Key)) + throw new LogAsErrorException($"Two callbacks with the same Name and number of arguments '{cb.Key}' are not supported."); + + int parameterCount = cb.Parameters.Length; + string argsArgs = parameterCount > 0 ? "(int8_t*)args, sizeof(args)" : "nullptr, 0"; + string argsDeclaration = parameterCount > 0 + ? $"\n int64_t args[{parameterCount}] = {{ {string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => $"(int64_t)arg{i}"))} }};\n" + : string.Empty; + string parametersDeclaration = string.Join(", ", ParameterTypes(cb.Parameters).Select((p, i) => $"{MapType(p)} arg{i}")); + string arguments = string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => $"arg{i}")); + string exportFunction = cb.IsExport ? + $$""" + + + extern "C" {{MapType(cb.ReturnType)}} {{cb.EntryPoint}}({{parametersDeclaration}}) + { + {{(cb.IsVoid ? "" : "return ")}}Call_{{cb.EntrySymbol}}({{arguments}}); + } + """ : string.Empty; + w.Write( + $$""" + + static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr; + 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;" : "")}} + ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{(cb.IsVoid ? "nullptr" : "(int8_t*)&result")}}, (PCODE)&Call_{{cb.EntrySymbol}});{{ + (!cb.IsVoid ? $"{w.NewLine} return result;" : "")}} + }{{exportFunction}} + + """); + callbackIndex++; + } + + w.Write( + $$""" + + const ReverseThunkMapEntry g_ReverseThunks[] = + { + {{string.Join($",{w.NewLine}", callbacks.Select(ThunkMapEntryLine))}} + }; + + const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks) / sizeof(g_ReverseThunks[0]); + + """); + } + + private string CEntryPoint(WasmPInvoke pinvoke) + { + if (pinvoke.WasmLinkage) + { + // We mangle the name to avoid collisions with symbols in other modules + string namespaceName = WasmTypeNames.GetNamespace(pinvoke.Method.OwningType); + return FixupSymbolName($"{namespaceName}#{pinvoke.Module}#{pinvoke.EntryPoint}"); + } + + return FixupSymbolName(pinvoke.EntryPoint); + } + + private string GenPInvokeDecl(WasmPInvoke pinvoke) + { + MethodSignature signature = pinvoke.Method.Signature; + TypeDesc returnType = signature.ReturnType; + List parameterTypes = ParameterTypes(signature).Select(MapType).ToList(); + + if (IsReturnedByReference(returnType)) + { + returnType = pinvoke.Method.Context.GetWellKnownType(WellKnownType.Void); + parameterTypes.Insert(0, "void *"); + } + + string importAttributes = pinvoke.WasmLinkage + ? $"__attribute__((import_module(\"{EscapeLiteral(pinvoke.Module)}\"),import_name(\"{EscapeLiteral(pinvoke.EntryPoint)}\"))) " + : ""; + string externKeyword = pinvoke.WasmLinkage ? "extern " : ""; + + return $" {importAttributes}{externKeyword}{MapType(returnType)} {CEntryPoint(pinvoke)} ({string.Join(", ", parameterTypes)});"; + } + + private string FixedSymbolName(WasmPInvokeCallback cb) + { + string paramTypes = cb.Parameters.Length > 0 + ? string.Join("_", ParameterTypes(cb.Parameters).Select(TypeToNameType)) + : "Void"; + + return FixupSymbolName($"{cb.EntryName}_{paramTypes}_Ret{TypeToNameType(cb.ReturnType)}"); + } + + private string ThunkMapEntryLine(WasmPInvokeCallback cb) + => $" {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{FixedSymbolName(cb)}, (void*)&Call_{cb.EntrySymbol} }} }}"; + + /// + /// Whether a struct return is turned into a hidden by-reference first argument, which the C + /// declaration has to spell out because the generated code calls the import directly. + /// + private static bool IsReturnedByReference(TypeDesc type) + { + if (!type.IsValueType || type.IsPrimitive || type.IsEnum || type is FunctionPointerType) + return false; + + return WasmInteropSignature.GetAbiToken(type)[0] is 'S' or 'A'; + } + + private static string TypeToNameType(TypeDesc type) + { + if (!type.IsValueType || type.IsPointer || type.IsByRef || type is FunctionPointerType) + return "I32"; + + if (type.IsEnum) + return TypeToNameType(type.UnderlyingType); + + return WasmInteropSignature.TokenToNameType(WasmInteropSignature.GetAbiToken(type)); + } + + private static string MapType(TypeDesc type) => type.Category switch + { + TypeFlags.Void => "void", + TypeFlags.Double => "double", + TypeFlags.Single => "float", + TypeFlags.Int64 => "int64_t", + TypeFlags.UInt64 => "uint64_t", + TypeFlags.Int32 or TypeFlags.Int16 or TypeFlags.Char or TypeFlags.Boolean or TypeFlags.SByte => "int32_t", + TypeFlags.UInt32 or TypeFlags.UInt16 or TypeFlags.Byte => "uint32_t", + TypeFlags.IntPtr or TypeFlags.UIntPtr => "void *", + _ => PickCTypeNameForUnknownType(type) + }; + + private static string PickCTypeNameForUnknownType(TypeDesc type) + { + // Pass objects by-reference (their address by-value), and pointers and function pointers + // by-value. + if (!type.IsValueType || type.IsPointer || type is FunctionPointerType) + return "void *"; + + if (type.IsEnum) + return MapType(type.UnderlyingType); + + // https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md#function-signatures + // Any struct or union that recursively (including through nested structs, unions, and arrays) + // contains just a single scalar value and is not specified to have greater than natural alignment. + // FIXME: Handle the scenario where there are fields of struct types that contain no members + FieldDesc singleField = null; + foreach (FieldDesc field in ((MetadataType)type).GetFields()) + { + if (field.IsStatic) + continue; + + if (singleField is not null) + return "void *"; + + singleField = field; + } + + return singleField is not null ? MapType(singleField.FieldType) : "void *"; + } + + private static readonly char[] s_charsToReplace = ['.', '-', '+', '<', '>']; + + /// is indexable but not enumerable; this makes it LINQ-friendly. + private static IEnumerable ParameterTypes(MethodSignature signature) + { + for (int i = 0; i < signature.Length; i++) + yield return signature[i]; + } + + /// + /// Rewrites a name into something that can be used as a C identifier, reversibly enough that + /// two different names cannot collide. + /// + private static string FixupSymbolName(string name) + { + var sb = new StringBuilder(); + foreach (byte b in Encoding.UTF8.GetBytes(name)) + { + if (b is (>= (byte)'0' and <= (byte)'9') or (>= (byte)'a' and <= (byte)'z') or (>= (byte)'A' and <= (byte)'Z') or (byte)'_') + sb.Append((char)b); + else if (Array.IndexOf(s_charsToReplace, (char)b) >= 0) + sb.Append('_'); + else + sb.Append(CultureInfo.InvariantCulture, $"_{b:X}_"); + } + + return sb.ToString(); + } + + private static string EscapeLiteral(string input) + { + if (input is null) + return string.Empty; + + var sb = new StringBuilder(); + for (int i = 0; i < input.Length; i++) + { + char c = input[i]; + + sb.Append(c switch + { + '\\' => "\\\\", + '\"' => "\\\"", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + // take special care with surrogate pairs to avoid + // potential decoding issues in generated C literals + _ when char.IsHighSurrogate(c) && i + 1 < input.Length && char.IsLowSurrogate(input[i + 1]) + => $"\\U{char.ConvertToUtf32(c, input[++i]):X8}", + _ when char.IsControl(c) || c > 127 + => $"\\u{(int)c:X4}", + _ => c.ToString() + }); + } + + return sb.ToString(); + } + + /// + /// Equivalent to ULONG HashString(LPCWSTR szStr) in the CoreCLR runtime, + /// src/coreclr/inc/utilcode.h. + /// + private static uint HashString(string str) + { + uint hash = 5381; + foreach (char c in str) + hash = ((hash << 5) + hash) ^ c; + + return hash; + } + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs new file mode 100644 index 00000000000000..a776eacace27fa --- /dev/null +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Internal.TypeSystem; + +namespace ILCompiler.Wasm +{ + /// + /// Formats type names the way reports them. The runtime looks + /// callbacks up by these names and the emitted symbols embed them, so they have to match + /// reflection rather than the type system, which spells nested types differently. + /// + internal static class WasmTypeNames + { + /// + /// The name would report, with nested types joined by '+'. + /// + public static string GetFullName(MetadataType type) + { + if (type.ContainingType is MetadataType containingType) + return $"{GetFullName(containingType)}+{type.Name.ToString()}"; + + string name = type.Name.ToString(); + string ns = GetNamespace(type); + + return string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + /// + /// The namespace would report. The type system stores it + /// only on the outermost type, while reflection reports the enclosing namespace for nested + /// types too. + /// + public static string GetNamespace(TypeDesc type) + { + if (type is not MetadataType metadataType) + return string.Empty; + + while (metadataType.ContainingType is MetadataType containingType) + metadataType = containingType; + + return metadataType.Namespace.ToString(); + } + } +} diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 3ef3f9d3631e83..fea071723f569f 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -98,8 +98,14 @@ internal class Crossgen2RootCommand : RootCommand new("--jitpath") { Description = SR.JitPathOption }; public Option PrintReproInstructions { get; } = new("--print-repro-instructions") { Description = SR.PrintReproInstructionsOption }; - public Option WasmAbiQuery { get; } = - new("--wasm-abi-query") { Description = SR.WasmAbiQueryOption }; + public Option WasmGenerateCallHelpers { get; } = + new("--wasm-generate-callhelpers") { Description = SR.WasmGenerateCallHelpersOption }; + public Option WasmPInvokeModule { get; } = + new("--wasm-pinvoke-module") { Description = SR.WasmPInvokeModuleOption }; + public Option WasmIgnoredPInvokeModule { get; } = + new("--wasm-ignored-pinvoke-module") { Description = SR.WasmIgnoredPInvokeModuleOption }; + public Option WasmNoWarnUnresolvedPInvokeModules { get; } = + new("--wasm-no-warn-unresolved-pinvoke-modules") { Description = SR.WasmNoWarnUnresolvedPInvokeModulesOption }; public Option SingleMethodTypeName { get; } = new("--singlemethodtypename") { Description = SR.SingleMethodTypeName }; public Option SingleMethodName { get; } = @@ -203,7 +209,10 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(TargetOS); Options.Add(JitPath); Options.Add(PrintReproInstructions); - Options.Add(WasmAbiQuery); + Options.Add(WasmGenerateCallHelpers); + Options.Add(WasmPInvokeModule); + Options.Add(WasmIgnoredPInvokeModule); + Options.Add(WasmNoWarnUnresolvedPInvokeModules); Options.Add(SingleMethodTypeName); Options.Add(SingleMethodName); Options.Add(SingleMethodIndex); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index 2b780d8894f627..90a13fbaecc216 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -39,7 +39,7 @@ internal sealed class Program private readonly bool _singleFileCompilation; private readonly bool _outNearInput; private readonly string _outputFilePath; - private readonly bool _wasmAbiQuery; + private readonly string _wasmGenerateCallHelpers; public Program(Crossgen2RootCommand command) { @@ -48,7 +48,7 @@ public Program(Crossgen2RootCommand command) _singleFileCompilation = Get(command.SingleFileCompilation); _outNearInput = Get(command.OutNearInput); _outputFilePath = Get(command.OutputFilePath); - _wasmAbiQuery = Get(command.WasmAbiQuery); + _wasmGenerateCallHelpers = Get(command.WasmGenerateCallHelpers); if (Get(command.WaitForDebugger)) { @@ -70,9 +70,9 @@ private void ConfigureImageBase(TargetDetails targetDetails) public int Run() { - // Query mode answers questions about the input assemblies and writes no image, so the + // Interop generation mode reads the input assemblies and writes source files, so the // output arguments the compilation path requires do not apply. - if (_outputFilePath == null && !_outNearInput && !_wasmAbiQuery) + if (_outputFilePath == null && !_outNearInput && _wasmGenerateCallHelpers is null) throw new CommandLineException(SR.MissingOutputFile); if (_singleFileCompilation && !_outNearInput) @@ -82,6 +82,15 @@ public int Run() (TargetArchitecture targetArchitecture, TargetOS targetOS, TargetAbi targetAbi) = Helpers.GetTargetSpec(Get(_command.TargetArchitecture), Get(_command.TargetOS)); + + // The interop generator answers ABI questions (struct sizes, argument lowering) through the + // same type system the compiler uses, so an unspecified target would silently produce host + // layouts. Reject anything but a wasm target instead of emitting subtly wrong helpers. + if (_wasmGenerateCallHelpers is not null + && (targetArchitecture != TargetArchitecture.Wasm32 || targetOS is not (TargetOS.Browser or TargetOS.Wasi))) + { + throw new CommandLineException(SR.WasmGenerateCallHelpersRequiresWasmTarget); + } bool targetAllowsRuntimeCodeGeneration = GetTargetAllowsRuntimeCodeGeneration(targetOS, targetArchitecture); // Crossgen2 is partial AOT and its pre-compiled methods can be thrown away at runtime if @@ -279,9 +288,18 @@ public int Run() _typeSystemContext.SetSystemModule((EcmaModule)_typeSystemContext.GetModuleForSimpleName(systemModuleName)); ReadyToRunCompilerContext typeSystemContext = _typeSystemContext; - if (_wasmAbiQuery) + if (_wasmGenerateCallHelpers is not null) { - return WasmAbiQuery.Run(typeSystemContext, Console.In, Console.Out); + return Wasm.WasmInteropGenerator.Run(typeSystemContext, new Wasm.WasmInteropGeneratorOptions + { + OutputDirectory = _wasmGenerateCallHelpers, + PInvokeModules = Get(_command.WasmPInvokeModule), + IgnoredPInvokeModules = Get(_command.WasmIgnoredPInvokeModule), + // The normalized name, so that platform attributes match regardless of how + // --targetos was spelled on the command line. + TargetOS = targetOS.ToString().ToLowerInvariant(), + WarnOnUnresolvedPInvokeModules = !Get(_command.WasmNoWarnUnresolvedPInvokeModules), + }, logger); } if (_singleFileCompilation) diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index 9914585d5558cb..378d3a184efdfe 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -285,8 +285,20 @@ Target OS for cross compilation - - Answer wasm ABI signature queries on stdin instead of compiling + + Generate the wasm call helper sources into the given directory instead of compiling + + + Name of a statically linked native module P/Invokes may resolve against + + + Name of a native module to leave out of the generated P/Invoke table + + + Do not warn about P/Invokes to modules that are not statically linked + + + --wasm-generate-callhelpers requires --targetarch wasm together with --targetos browser or --targetos wasi Target OS is not supported diff --git a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp index dc7af3ffa4ea3a..6bfccf16e8a064 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp @@ -510,6 +510,41 @@ namespace (*fptr)(ARG_IND(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), ARG_I32(5), ARG_I32(6), ARG_I32(7), ARG_I32(8), ARG_I32(9), ARG_I32(10), ARG_I32(11)); } + NOINLINE static void CallFunc_This_S8_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_IND(1), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), pPortableEntryPoint); + } + NOINLINE static void CallFunc_This_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) { alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; @@ -741,6 +776,11 @@ const StringToWasmSigThunk g_wasmThunks[] = { { "MvS8iiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiiiiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_RetVoid }, + { "MvTS8p", (void*)&CallFunc_This_S8_RetVoid_PE }, + { "MvTiiiip", (void*)&CallFunc_This_I32_I32_I32_I32_RetVoid_PE }, + { "MvTiiip", (void*)&CallFunc_This_I32_I32_I32_RetVoid_PE }, + { "MvTiip", (void*)&CallFunc_This_I32_I32_RetVoid_PE }, + { "MvTip", (void*)&CallFunc_This_I32_RetVoid_PE }, { "MvTp", (void*)&CallFunc_This_RetVoid_PE }, { "Mvdddddddddii", (void*)&CallFunc_F64_F64_F64_F64_F64_F64_F64_F64_F64_I32_I32_RetVoid }, { "Mvdi", (void*)&CallFunc_F64_I32_RetVoid }, diff --git a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.cmd b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd similarity index 70% rename from src/tasks/WasmAppBuilder/generate-coreclr-helpers.cmd rename to src/coreclr/vm/wasm/generate-coreclr-helpers.cmd index 3cf780d5c5659c..09de81adad5ee9 100644 --- a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.cmd +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd @@ -6,9 +6,9 @@ set "configuration=Debug" set "browser_scan_path_override=" set "wasi_scan_path_override=" -:: Get the repo root (script is in src/tasks/WasmAppBuilder). +:: Get the repo root (script is in src/coreclr/vm/wasm). :: This must be computed before argument parsing, because SHIFT also shifts %0. -for %%I in ("%~dp0..\..\..") do set "repo_root=%%~fI" +for %%I in ("%~dp0..\..\..\..") do set "repo_root=%%~fI" set "usage=Usage: %~nx0 [options]" set "usage=!usage!^ @@ -75,6 +75,18 @@ echo Repo root: %repo_root% cd /d "%repo_root%" +:: The generator lives in crossgen2 and uses its type system to compute the wasm ABI. Generation +:: does not load the JIT, so the host-targeting crossgen2 answers wasm questions correctly. Its +:: configuration has to match the one the scanned assemblies came from. +set crossgen2=%repo_root%\artifacts\bin\coreclr\windows.x64.%configuration%\crossgen2\crossgen2.dll + +:: Modules the runtime links statically; a P/Invoke into any of them resolves to a direct call. +set pinvoke_module_args=--wasm-pinvoke-module libSystem.Native ^ + --wasm-pinvoke-module libSystem.Native.Browser ^ + --wasm-pinvoke-module libSystem.IO.Compression.Native ^ + --wasm-pinvoke-module libSystem.Globalization.Native ^ + --wasm-pinvoke-module libSystem.Runtime.InteropServices.JavaScript.Native + :: Resolve scan paths (allow overrides). if not "%browser_scan_path_override%"=="" ( set browser_scan_path=%browser_scan_path_override% @@ -109,11 +121,16 @@ if not exist "%scan_path%" ( exit /b 1 ) +if not exist "%crossgen2%" ( + echo Error: crossgen2 was not found at: %crossgen2% + echo Please build the clr subset first using: .\build.cmd clr -c %configuration% + exit /b 1 +) + echo [%target_os%] Scan path: %scan_path% echo [%target_os%] Output path: %output_dir% echo Running generator for %target_os%... -echo dotnet.cmd build /t:RunGenerator /p:RuntimeFlavor=CoreCLR /p:TargetOS=%target_os% /p:GeneratorOutputPath=%output_dir% /p:AssembliesScanPath=%scan_path% src\tasks\WasmAppBuilder\WasmAppBuilder.csproj -call .\dotnet.cmd build /t:RunGenerator /p:RuntimeFlavor=CoreCLR /p:TargetOS=%target_os% /p:GeneratorOutputPath=%output_dir% /p:AssembliesScanPath=%scan_path% src\tasks\WasmAppBuilder\WasmAppBuilder.csproj +call .\dotnet.cmd "%crossgen2%" --targetos %target_os% --targetarch wasm --wasm-generate-callhelpers "%output_dir%" --wasm-no-warn-unresolved-pinvoke-modules %pinvoke_module_args% "%scan_path%*.dll" if errorlevel 1 ( echo Generator failed for %target_os%! diff --git a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.md b/src/coreclr/vm/wasm/generate-coreclr-helpers.md similarity index 61% rename from src/tasks/WasmAppBuilder/generate-coreclr-helpers.md rename to src/coreclr/vm/wasm/generate-coreclr-helpers.md index 4cc261bffc9ed8..32a6b484d98682 100644 --- a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.md +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.md @@ -2,10 +2,14 @@ The `generate-coreclr-helpers.cmd` (Windows) and `generate-coreclr-helpers.sh` (Linux/macOS) scripts in this directory regenerate the checked-in CoreCLR call-helper source files used by the -WebAssembly runtime. They run the `RunGenerator` target in -[`WasmAppBuilder.csproj`](./WasmAppBuilder.csproj), which invokes the -`ManagedToNativeGenerator` MSBuild task to scan the managed framework assemblies and emit the -native P/Invoke, reverse-P/Invoke, and interpreter-to-managed call helpers. +WebAssembly runtime. They run crossgen2 in `--wasm-generate-callhelpers` mode, which scans the managed +framework assemblies and emits the native P/Invoke, reverse-P/Invoke, and interpreter-to-managed +call helpers. The generator lives in +[`ILCompiler.ReadyToRun/Wasm`](../../tools/aot/ILCompiler.ReadyToRun/Wasm) so it can use crossgen2's +type system to compute the wasm ABI layout of the structs that cross the boundary. + +The relink targets for browser and wasi apps run the same crossgen2 mode over the app's own +assembly closure, so these checked-in files and a relinked app are produced by one code path. The scripts generate **both** WebAssembly variations: @@ -23,9 +27,10 @@ Each run emits three files into the output directory: ## What needs to be built first The generator scans the **managed framework assemblies** in the `testhost` folder produced by a -`clr+libs` build. Because the scripts generate both the `browser` and `wasi` variations, you must -build **both** WebAssembly flavors before running them. The first build of either flavor also -downloads and provisions the Emscripten SDK (emsdk) automatically. +`clr+libs` build, and runs the crossgen2 built by the `clr` subset for your **host** platform. +Because the scripts generate both the `browser` and `wasi` variations, you must build **both** +WebAssembly flavors before running them. The first build of either flavor also downloads and +provisions the Emscripten SDK (emsdk) automatically. From the repository root: @@ -45,10 +50,10 @@ Notes: - Use a matching `-c ` for the configuration you intend to pass to the generator script (the script derives the scan path from the configuration name). -- The `WasmAppBuilder` task itself is built on demand by the generator script (the `RunGenerator` - target depends on `Build`), so you do not need to build it separately. -- If a required `testhost` scan path is missing, the script stops and prints the exact - `build` command needed to produce it. +- Generation does not load the JIT, so the host-targeting crossgen2 from a plain `clr` build + answers wasm questions correctly; no wasm-targeting crossgen2 is needed. +- If a required `testhost` scan path or crossgen2 is missing, the script stops and prints the + exact `build` command needed to produce it. ## Running the generator @@ -56,12 +61,12 @@ Once both flavors are built, run the script from anywhere (it resolves the repo **Windows:** ```cmd -src\tasks\WasmAppBuilder\generate-coreclr-helpers.cmd -c Debug +src\coreclr\vm\wasm\generate-coreclr-helpers.cmd -c Debug ``` **Linux/macOS:** ```bash -src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh -c Debug +src/coreclr/vm/wasm/generate-coreclr-helpers.sh -c Debug ``` ### Options diff --git a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh similarity index 64% rename from src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh rename to src/coreclr/vm/wasm/generate-coreclr-helpers.sh index 5f314201088482..8fcde25f21ac5f 100755 --- a/src/tasks/WasmAppBuilder/generate-coreclr-helpers.sh +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh @@ -53,9 +53,9 @@ case "$config_lower" in ;; esac -# Get the repo root (script is in src/tasks/WasmAppBuilder) +# Get the repo root (script is in src/coreclr/vm/wasm) script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/../../.." && pwd)" +repo_root="$(cd "$script_dir/../../../.." && pwd)" echo "Configuration: $configuration" echo "Repo root: $repo_root" @@ -75,15 +75,49 @@ run_generator() { exit 1 fi + if [[ ! -f "$crossgen2" ]]; then + echo "Error: crossgen2 was not found at: $crossgen2" + echo "Please build the clr subset first using: ./build.sh clr -c $configuration" + exit 1 + fi + echo "[$target_os] Scan path: $scan_path" echo "[$target_os] Output path: $output_dir" echo "Running generator for $target_os..." - # RuntimeConfiguration selects which built crossgen2 answers the ABI queries; it has to match the - # configuration the scanned assemblies came from. - echo "./dotnet.sh build /t:RunGenerator /p:RuntimeFlavor=CoreCLR /p:TargetOS=$target_os /p:RuntimeConfiguration=$configuration /p:GeneratorOutputPath=$output_dir /p:AssembliesScanPath=$scan_path src/tasks/WasmAppBuilder/WasmAppBuilder.csproj" - ./dotnet.sh build /t:RunGenerator /p:RuntimeFlavor=CoreCLR "/p:TargetOS=$target_os" "/p:RuntimeConfiguration=$configuration" "/p:GeneratorOutputPath=$output_dir" "/p:AssembliesScanPath=$scan_path" src/tasks/WasmAppBuilder/WasmAppBuilder.csproj + + local args=( + --targetos "$target_os" + --targetarch wasm + --wasm-generate-callhelpers "$output_dir" + --wasm-no-warn-unresolved-pinvoke-modules + ) + local module + for module in "${pinvoke_modules[@]}"; do + args+=(--wasm-pinvoke-module "$module") + done + + ./dotnet.sh "$crossgen2" "${args[@]}" "$scan_path"*.dll } +# Modules the runtime links statically; a P/Invoke into any of them resolves to a direct call. +pinvoke_modules=( + libSystem.Native + libSystem.Native.Browser + libSystem.IO.Compression.Native + libSystem.Globalization.Native + libSystem.Runtime.InteropServices.JavaScript.Native +) + +# The generator lives in crossgen2 and uses its type system to compute the wasm ABI. Generation +# does not load the JIT, so the host-targeting crossgen2 answers wasm questions correctly. Its +# configuration has to match the one the scanned assemblies came from. +crossgen2="$repo_root/artifacts/bin/coreclr/$(uname -s | tr '[:upper:]' '[:lower:]').$(uname -m).$configuration/crossgen2/crossgen2.dll" +case "$(uname -s)" in + Darwin) crossgen2="${crossgen2/darwin./osx.}" ;; +esac +crossgen2="${crossgen2/aarch64./arm64.}" +crossgen2="${crossgen2/x86_64./x64.}" + # Resolve scan paths (allow overrides). if [[ -n "$browser_scan_path_override" ]]; then browser_scan_path="$browser_scan_path_override" diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp index 0033e59ea2469e..e13326d784da4a 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp @@ -510,6 +510,41 @@ namespace (*fptr)(ARG_IND(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), ARG_I32(5), ARG_I32(6), ARG_I32(7), ARG_I32(8), ARG_I32(9), ARG_I32(10), ARG_I32(11)); } + NOINLINE static void CallFunc_This_S8_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_IND(1), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), pPortableEntryPoint); + } + + NOINLINE static void CallFunc_This_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) + { + alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; + void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); + (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), pPortableEntryPoint); + } + NOINLINE static void CallFunc_This_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) { alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; @@ -717,6 +752,11 @@ const StringToWasmSigThunk g_wasmThunks[] = { { "MvS8iiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiiiiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_RetVoid }, + { "MvTS8p", (void*)&CallFunc_This_S8_RetVoid_PE }, + { "MvTiiiip", (void*)&CallFunc_This_I32_I32_I32_I32_RetVoid_PE }, + { "MvTiiip", (void*)&CallFunc_This_I32_I32_I32_RetVoid_PE }, + { "MvTiip", (void*)&CallFunc_This_I32_I32_RetVoid_PE }, + { "MvTip", (void*)&CallFunc_This_I32_RetVoid_PE }, { "MvTp", (void*)&CallFunc_This_RetVoid_PE }, { "Mvdiip", (void*)&CallFunc_F64_I32_I32_RetVoid_PE }, { "Mvfiip", (void*)&CallFunc_F32_I32_I32_RetVoid_PE }, diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp index e128a77b7bd640..dc57848a7d6cbb 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp @@ -344,7 +344,7 @@ static const Entry s_libSystem_Native [] = { DllImportEntry(SystemNative_GetCpuUtilization) // System.Private.CoreLib DllImportEntry(SystemNative_GetCwd) // System.Private.CoreLib DllImportEntry(SystemNative_GetDefaultSearchOrderPseudoHandle) // System.Private.CoreLib - DllImportEntry(SystemNative_GetErrNo) // System.Net.NameResolution, System.Private.CoreLib + DllImportEntry(SystemNative_GetErrNo) // System.Private.CoreLib DllImportEntry(SystemNative_GetHostEntryForName) // System.Net.NameResolution DllImportEntry(SystemNative_GetHostName) // System.Net.NameResolution DllImportEntry(SystemNative_GetIPv4Address) // System.Net.Primitives, System.Net.Sockets diff --git a/src/libraries/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index fb26106268e807..88a0edd0a71310 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -188,7 +188,7 @@ - + @@ -198,7 +198,7 @@ - + @@ -339,7 +339,7 @@ diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index f981e8a46c4c28..5c221b84a95ef8 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -79,15 +79,11 @@ - + - @@ -638,33 +631,47 @@ <_WasmManagedAssemblies Include="$(_CoreLibPath)" /> - + + + <_WasmInteropGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmInteropGeneratorExeSuffix)')) + $(Crossgen2ToolPath) + + + + - <_WasmAbiQueryExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe - $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmAbiQueryExeSuffix)')) - $(Crossgen2ToolPath) + <_WasmInteropGeneratorRsp>$(_WasmIntermediateOutputPath)wasm-interop-generator.rsp + + <_WasmInteropGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(WasmInteropGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(WasmInteropGeneratorPath)" + <_WasmInteropGeneratorCommand Condition="'$(_WasmInteropGeneratorCommand)' == ''">"$(WasmInteropGeneratorPath)" - - - - - + + + <_WasmInteropGeneratorArg Include="--targetos" /> + <_WasmInteropGeneratorArg Include="browser" /> + <_WasmInteropGeneratorArg Include="--targetarch" /> + <_WasmInteropGeneratorArg Include="wasm" /> + <_WasmInteropGeneratorArg Include="--wasm-generate-callhelpers" /> + <_WasmInteropGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> + <_WasmInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasmPInvokeModules.Identity)" /> + <_WasmInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasmIgnoredPInvokeModules.Identity)" /> + <_WasmInteropGeneratorArg Include="--wasm-no-warn-unresolved-pinvoke-modules" Condition="'$(WasmWarnOnUnresolvedPInvokeModules)' == 'false'" /> + <_WasmInteropGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> + + + + + + + + + <_WasmSourceFileToCompileGenerated Remove="@(_WasmSourceFileToCompileGenerated)" /> @@ -711,7 +718,7 @@ - + , ) assume are already in scope from // the in-tree CoreCLR PCH (vm/common.h). The generated .cpp files still diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index f804eea98ccbda..f481fd84eae8b3 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -6,7 +6,7 @@ Performs a per-app native link of the shipping wasihost corehost (libWasiHost.a, from src/native/corehost/wasihost) so reverse P/Invoke thunks for the app/test [UnmanagedCallersOnly] methods are covered (the baked libcoreclr_gen_static.a - only covers framework top-level UCO callbacks). Runs ManagedToNativeGenerator + only covers framework top-level UCO callbacks). Runs the crossgen2 wasm interop generator (TargetOS=wasi) over the bundle, compiles the generated callhelpers with the wasi-sdk clang, and links libWasiHost.a from the runtime-pack static archives + the app callhelper .o (replacing libcoreclr_gen_static.a) via wasm-component-ld. The wasi:http import that @@ -21,11 +21,6 @@ WasmAppRuntimeFlavor=Mono default. --> - - CoreCLR false @@ -108,7 +103,6 @@ <_WasiPInvokeTablePath>$(_WasiRelinkObjDir)callhelpers-pinvoke.cpp <_WasiReversePInvokeTablePath>$(_WasiRelinkObjDir)callhelpers-reverse.cpp <_WasiInterpToNativeTablePath>$(_WasiRelinkObjDir)callhelpers-interp-to-managed.cpp - <_WasiM2NCachePath>$(_WasiRelinkObjDir)m2n_cache.txt + + + <_WasmInteropGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmInteropGeneratorExeSuffix)')) + $(Crossgen2ToolPath) + + + + - <_WasmAbiQueryExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe - $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmAbiQueryExeSuffix)')) - $(Crossgen2ToolPath) + <_WasiInteropGeneratorRsp>$(_WasiRelinkObjDir)wasm-interop-generator.rsp + + <_WasiInteropGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(WasmInteropGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(WasmInteropGeneratorPath)" + <_WasiInteropGeneratorCommand Condition="'$(_WasiInteropGeneratorCommand)' == ''">"$(WasmInteropGeneratorPath)" - - - - - + + + <_WasiInteropGeneratorArg Include="--targetos" /> + <_WasiInteropGeneratorArg Include="wasi" /> + <_WasiInteropGeneratorArg Include="--targetarch" /> + <_WasiInteropGeneratorArg Include="wasm" /> + <_WasiInteropGeneratorArg Include="--wasm-generate-callhelpers" /> + <_WasiInteropGeneratorArg Include="$(_WasiRelinkObjDir)" /> + <_WasiInteropGeneratorArg Include="--wasm-no-warn-unresolved-pinvoke-modules" /> + <_WasiInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasiPInvokeModules.Identity)" /> + <_WasiInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasiIgnoredPInvokeModules.Identity)" /> + <_WasiInteropGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> + + + + + + + + + diff --git a/src/mono/wasm/Wasm.Build.Tests/data/Local.Directory.Build.props b/src/mono/wasm/Wasm.Build.Tests/data/Local.Directory.Build.props index 35442917b78f5f..a1f0f910a14fb9 100644 --- a/src/mono/wasm/Wasm.Build.Tests/data/Local.Directory.Build.props +++ b/src/mono/wasm/Wasm.Build.Tests/data/Local.Directory.Build.props @@ -4,7 +4,7 @@ (before it is imported in .targets) so its import chain resolves: RepositoryEngineeringDir -> eng/ (locally) or payload build/eng/ (Helix) - WasmAppBuilderTasksAssemblyPath -> UsingTask for EmccCompile / ManagedToNativeGenerator + WasmAppBuilderTasksAssemblyPath -> UsingTask for EmccCompile RuntimeFlavor -> drives conditional logic in native.wasm.targets These are supplied via BuildEnvironment.EnvVars when the test harness invokes dotnet. @@ -16,6 +16,6 @@ $([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetFullPath('$(REPOSITORY_ENGINEERING_DIR)')))) $([System.IO.Path]::GetFullPath('$(WASM_APP_BUILDER_TASKS_ASSEMBLY_PATH)')) - $([System.IO.Path]::GetFullPath('$(WASM_ABI_QUERY_CROSSGEN2_PATH)')) + $([System.IO.Path]::GetFullPath('$(WASM_INTEROP_GENERATOR_PATH)')) \ No newline at end of file diff --git a/src/mono/wasm/Wasm.Build.Tests/data/RunScriptTemplate.sh b/src/mono/wasm/Wasm.Build.Tests/data/RunScriptTemplate.sh index a82aba4ef15c2a..269b7a4ae7d726 100644 --- a/src/mono/wasm/Wasm.Build.Tests/data/RunScriptTemplate.sh +++ b/src/mono/wasm/Wasm.Build.Tests/data/RunScriptTemplate.sh @@ -65,8 +65,8 @@ function set_env_vars() if [[ -n "$WASM_APP_BUILDER_TASKS_ASSEMBLY_PATH" ]]; then export WASM_APP_BUILDER_TASKS_ASSEMBLY_PATH fi - if [[ -n "$WASM_ABI_QUERY_CROSSGEN2_PATH" ]]; then - export WASM_ABI_QUERY_CROSSGEN2_PATH + if [[ -n "$WASM_INTEROP_GENERATOR_PATH" ]]; then + export WASM_INTEROP_GENERATOR_PATH fi if [[ -n "$EMSDK_PATH" ]]; then export EMSDK_PATH diff --git a/src/mono/wasm/build/WasmApp.Common.targets b/src/mono/wasm/build/WasmApp.Common.targets index 9cff34e8753a3b..5835ac039256f3 100644 --- a/src/mono/wasm/build/WasmApp.Common.targets +++ b/src/mono/wasm/build/WasmApp.Common.targets @@ -118,7 +118,6 @@ - diff --git a/src/tasks/WasmAppBuilder/IcallTableGenerator.cs b/src/tasks/WasmAppBuilder/IcallTableGenerator.cs index 4fb5b3875c363b..e070ff654d9002 100644 --- a/src/tasks/WasmAppBuilder/IcallTableGenerator.cs +++ b/src/tasks/WasmAppBuilder/IcallTableGenerator.cs @@ -25,21 +25,16 @@ internal sealed class IcallTableGenerator private LogAdapter Log { get; set; } private readonly Func _fixupSymbolName; - private bool _isCoreClr; - private readonly CoreClr.SignatureMapper? _coreClrSignatureMapper; - // // Given the runtime generated icall table, and a set of assemblies, generate // a smaller linked icall table mapping tokens to C function names // The runtime icall table should be generated using // mono --print-icall-table // - public IcallTableGenerator(string? runtimeIcallTableFile, Func fixupSymbolName, LogAdapter log, bool isCoreClr, CoreClr.SignatureMapper? coreClrSignatureMapper = null) + public IcallTableGenerator(string? runtimeIcallTableFile, Func fixupSymbolName, LogAdapter log) { Log = log; _fixupSymbolName = fixupSymbolName; - _isCoreClr = isCoreClr; - _coreClrSignatureMapper = coreClrSignatureMapper; if (runtimeIcallTableFile != null) ReadTable(runtimeIcallTableFile); } @@ -212,13 +207,8 @@ private void ProcessType(Type type) void AddSignature(Type type, MethodInfo method) { - string? signature = _isCoreClr - ? (_coreClrSignatureMapper ?? throw new LogAsErrorException("A CoreCLR signature mapper is required to generate icall signatures for CoreCLR.")).MethodToSignature(method) - : Mono.SignatureMapper.MethodToSignature(method, Log); - if (signature == null) - { - throw new LogAsErrorException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); - } + string signature = Mono.SignatureMapper.MethodToSignature(method, Log) + ?? throw new LogAsErrorException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); if (_signatures.Add(signature)) Log.LogMessage(MessageImportance.Low, $"Adding icall signature {signature} for method '{type.FullName}.{method.Name}'"); diff --git a/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj b/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj index ad2cf8b6c07cf1..abcb0ee49024bd 100644 --- a/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj +++ b/src/tasks/WasmAppBuilder/WasmAppBuilder.csproj @@ -49,46 +49,5 @@ - - - - - - <_WasmAbiQueryCrossgen2Path Condition="'$(_WasmAbiQueryCrossgen2Path)' == ''">$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(ExeSuffix)')) - - - - - - - - - - - - - - - - <_RunGeneratorTargetOS Condition="'$(TargetOS)' == ''">browser - <_RunGeneratorTargetOS Condition="'$(TargetOS)' != ''">$(TargetOS) - - - - - - - diff --git a/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs deleted file mode 100644 index 10d71ac1bf5fc0..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Reflection; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -/// -/// Answers what a type or method looks like in a wasm ABI signature. -/// -internal interface IWasmAbiTypeResolver -{ - /// - /// Returns the signature encoding for in parameter position: a single - /// character for a type passed by value, or "S<size>" for a struct passed by reference. - /// - /// The type has no wasm ABI encoding, or could not be resolved. - string GetAbiToken(Type type); - - /// - /// Returns the full signature string for . - /// - /// - /// Resolved from the method's own metadata rather than by asking about each parameter type in - /// turn, so generic instantiations work and the string comes from the same code the compiler uses. - /// - /// The method has no wasm ABI signature, or could not be resolved. - string GetMethodSignature(MethodInfo method, WasmLoweringFlags flags); -} diff --git a/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs b/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs deleted file mode 100644 index d074a25e8a83c7..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Reflection; -using Microsoft.Build.Framework; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -// -// Scans assemblies for methods marked with MethodImplAttributes.InternalCall -// and generates portable entry point signatures for the interpreter-to-native thunks. -// -internal sealed class InternalCallSignatureCollector -{ - private readonly HashSet _signatures = new(); - private readonly LogAdapter _log; - private readonly SignatureMapper _signatureMapper; - - public InternalCallSignatureCollector(LogAdapter log, SignatureMapper signatureMapper) - { - _log = log; - _signatureMapper = signatureMapper; - } - - public void ScanAssembly(Assembly asm) - { - foreach (Type type in asm.GetTypes()) - ScanType(type); - } - - public IEnumerable GetSignatures() => _signatures; - - private void ScanType(Type type) - { - foreach (var method in type.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) - { - if ((method.GetMethodImplementationFlags() & MethodImplAttributes.InternalCall) == 0) - continue; - - // An uninstantiated generic has no single signature to generate a thunk from, because - // its parameters stand for whatever the instantiation supplies. - if (method.ContainsGenericParameters) - { - _log.Warning("WASM0001", $"Skipping generic InternalCall method '{type.FullName}::{method.Name}', which has no single signature"); - continue; - } - - try - { - // A managed signature: the lowering adds the 'T' for an instance method and the - // trailing 'p' for the portable entry point parameter. - string? signature = _signatureMapper.MethodToSignature(method, includeThis: true); - if (signature is null) - { - _log.Warning("WASM0001", $"Could not generate signature for InternalCall method '{type.FullName}::{method.Name}'"); - continue; - } - - if (_signatures.Add(signature)) - _log.LogMessage(MessageImportance.Low, $"Adding InternalCall signature {signature} for method '{type.FullName}.{method.Name}'"); - } - catch (Exception ex) when (ex is not LogAsErrorException) - { - _log.Warning("WASM0001", $"Could not get signature for InternalCall method '{type.FullName}::{method.Name}' because '{ex.Message}'"); - } - } - } -} diff --git a/src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.cs b/src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.cs deleted file mode 100644 index e5183182df8088..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/InterpToNativeGenerator.cs +++ /dev/null @@ -1,222 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.IO; -using System.Linq; -using System.Text; -using System.Collections.Generic; -using System.Globalization; -using Microsoft.Build.Utilities; -using Microsoft.Build.Framework; -using System.Diagnostics.CodeAnalysis; - -using JoinedString; -// -// This class generates the g_wasmThunks array and CallFunc_* functions used by the CoreCLR interpreter to call native code on WASM. -// The generated code should be kept in sync with the corresponding CoreCLR runtime code that consumes these thunks and call functions. -// - -#nullable enable - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -internal sealed class InterpToNativeGenerator -{ - private LogAdapter Log { get; set; } - - public InterpToNativeGenerator(LogAdapter log) => Log = log; - - public void Generate(IEnumerable cookies, string outputPath) - { - using TempFileName tmpFileName = new(); - using (var w = File.CreateText(tmpFileName.Path)) - { - Emit(w, cookies); - } - - if (Utils.CopyIfDifferent(tmpFileName.Path, outputPath, useHash: false)) - Log.LogMessage(MessageImportance.Low, $"Generating managed2native table to '{outputPath}'."); - else - Log.LogMessage(MessageImportance.Low, $"Managed2native table in {outputPath} is unchanged."); - } - - private static string SignatureToArguments(string signature) - { - var tokens = SignatureMapper.ParseSignatureTokens(signature); - if (tokens.Count <= 1) - return "void"; - - return string.Join(", ", tokens.Skip(1).Select(static t => SignatureMapper.TokenToNativeType(t))); - } - - private static string CallFuncName(IEnumerable args, string result, bool isPortableEntryPointCall) - { - var paramTypes = args.Any() ? string.Join("_", args.Select(static t => SignatureMapper.TokenToNameType(t))) : "Void"; - - return $"CallFunc_{paramTypes}_Ret{result}{(isPortableEntryPointCall ? "_PE" : "")}"; - } - - private static void Emit(StreamWriter w, IEnumerable cookies) - { - // Use OrderBy because Order() is not available on .NET Framework - var signatures = cookies.OrderBy(c => c).Distinct().ToArray(); - Array.Sort(signatures, StringComparer.Ordinal); - - // Collect unique struct return sizes so we can emit typedefs - var structReturnSizes = new SortedSet(); - foreach (var sig in signatures) - { - var toks = SignatureMapper.ParseSignatureTokens(sig); - if (toks[0][0] == 'S' && toks[0].Length > 1) - structReturnSizes.Add(SignatureMapper.GetStructSize(toks[0])); - } - - w.Write( - """ - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. - // - - // - // GENERATED FILE, DON'T EDIT - // Generated by coreclr InterpToNativeGenerator - // - - #include - #include - - // Arguments are passed on the stack with each argument aligned to INTERP_STACK_SLOT_SIZE. - #define ARG_ADDR(i) (pArgs + (i * INTERP_STACK_SLOT_SIZE)) - #define ARG_IND(i) ((int32_t)((int32_t*)ARG_ADDR(i))) - #define ARG_I32(i) (*(int32_t*)ARG_ADDR(i)) - #define ARG_I64(i) (*(int64_t*)ARG_ADDR(i)) - #define ARG_F32(i) (*(float*)ARG_ADDR(i)) - #define ARG_F64(i) (*(double*)ARG_ADDR(i)) - - """); - - // Emit typedefs for struct return types so emcc generates the correct sret ABI - foreach (var size in structReturnSizes) - { - w.WriteLine($"typedef struct {{ char d[{size}]; }} wasm_ret_S{size};"); - } - - w.Write( - """ - - namespace - { - """); - - foreach (var signatureValue in signatures) - { - string signature = signatureValue; - try - { - var tokens = SignatureMapper.ParseSignatureTokens(signature); - string returnToken = tokens[0]; - var result = Result(returnToken); - bool isPortableEntryPointCall = IsPortableEntryPointCall(tokens); - if (isPortableEntryPointCall) - { - // Portable entrypoints have an extra hidden parameter for the portable entrypoint context, so we need to adjust the signature and result accordingly for the call function generation - tokens.RemoveAt(tokens.Count - 1); - } - - RemoveAsyncCallMarker(tokens); - - var args = Args(tokens); - - var portableEntryPointComma = args.Count > 0 ? ", " : ""; - var portableEntrypointDeclaration = isPortableEntryPointCall ? portableEntryPointComma + "PCODE" : ""; - var portableEntrypointParam = isPortableEntryPointCall ? portableEntryPointComma + "pPortableEntryPoint" : ""; - var portableEntrypointStackDeclaration = isPortableEntryPointCall ? "int*, " : ""; - var portableEntrypointStackParam = isPortableEntryPointCall ? "&framePointer, " : ""; - var portableEntrypointPointerRD = isPortableEntryPointCall ? "*" : ""; - w.Write( - $$""" - - {{(isPortableEntryPointCall ? "NOINLINE " : "")}}static void {{CallFuncName(args, SignatureMapper.TokenToNameType(returnToken), isPortableEntryPointCall)}}(PCODE {{(isPortableEntryPointCall ? "pPortableEntryPoint" : "pcode")}}, int8_t* pArgs, int8_t* pRet) - {{{(isPortableEntryPointCall ? "\n alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK;" : "")}} - {{result.nativeType}} (*fptr)({{portableEntrypointStackDeclaration}}{{string.Join(", ", args.Select(static t => SignatureMapper.TokenToNativeType(t)))}}{{portableEntrypointDeclaration}}) = {{portableEntrypointPointerRD}}({{result.nativeType}} ({{portableEntrypointPointerRD}}*)({{portableEntrypointStackDeclaration}}{{string.Join(", ", args.Select(static t => SignatureMapper.TokenToNativeType(t)))}}{{portableEntrypointDeclaration}})){{(isPortableEntryPointCall ? "(pPortableEntryPoint)" : "pcode")}}; - {{(result.isVoid ? "" : "*" + "((" + result.nativeType + "*)pRet) = ")}}(*fptr)({{portableEntrypointStackParam}}{{string.Join(", ", ArgsWithSlotOffsets(args))}}{{portableEntrypointParam}}); - } - - """); - } - catch (InvalidSignatureCharException e) - { - throw new LogAsErrorException($"Element '{e.Char}' of signature '{signature}' can't be handled by managed2native generator"); - } - } - - w.Write( - $$""" - } - - const StringToWasmSigThunk g_wasmThunks[] = { - {{signatures.Join($",{w.NewLine}", signature => - { - string initialSignature = signature; - var tokens = SignatureMapper.ParseSignatureTokens(signature); - bool isPortableEntryPointCall = IsPortableEntryPointCall(tokens); - if (isPortableEntryPointCall) - tokens.RemoveAt(tokens.Count - 1); - RemoveAsyncCallMarker(tokens); - return $" {{ \"M{initialSignature}\", (void*)&{CallFuncName(Args(tokens), SignatureMapper.TokenToNameType(tokens[0]), isPortableEntryPointCall)} }}"; - } - )}} - }; - - const size_t g_wasmThunksCount = sizeof(g_wasmThunks) / sizeof(g_wasmThunks[0]); - - """); - - static List Args(List tokens) - { - return tokens.Count > 1 ? tokens.GetRange(1, tokens.Count - 1) : new List(); - } - - static List ArgsWithSlotOffsets(List args) - { - var result = new List(); - int slot = 0; - foreach (var token in args) - { - if (token[0] == 'A') - { - slot = (slot + 1) & ~1; - } - - result.Add($"{SignatureMapper.TokenToArgType(token)}({slot})"); - slot += SignatureMapper.TokenToSlotCount(token); - } - - return result; - } - - static (bool isVoid, string nativeType) Result(string returnToken) - { - // For struct returns, use the typedef so emcc generates the correct sret ABI - if (returnToken[0] == 'S' && returnToken.Length > 1) - return (false, $"wasm_ret_S{SignatureMapper.GetStructSize(returnToken)}"); - return new(returnToken == "v", SignatureMapper.TokenToNativeType(returnToken)); - } - - static bool IsPortableEntryPointCall(List tokens) - { - return tokens.Count > 0 && tokens[tokens.Count - 1] == "p"; - } - - static bool RemoveAsyncCallMarker(List tokens) - { - int asyncMarkerIndex = tokens.IndexOf("a"); - if (asyncMarkerIndex < 0) - return false; - - tokens.RemoveAt(asyncMarkerIndex); - return true; - } - } -} diff --git a/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs b/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs deleted file mode 100644 index 24dc5afe46a8ef..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs +++ /dev/null @@ -1,241 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Reflection; -using System.Text; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -public class ManagedToNativeGenerator : Task -{ - [Required] - public string[] Assemblies { get; set; } = Array.Empty(); - - [Required, NotNull] - public string[]? PInvokeModules { get; set; } - - public string[] IgnoredPInvokeModules { get; set; } = Array.Empty(); - - [Required, NotNull] - public string? PInvokeOutputPath { get; set; } - - [Required, NotNull] - public string? ReversePInvokeOutputPath { get; set; } - - [Required, NotNull] - public string? InterpToNativeOutputPath { get; set; } - public string? CacheFilePath { get; set; } - - public bool IsLibraryMode { get; set; } - - // When true (default), a P/Invoke to a module that isn't statically linked, ignored, - // [WasmImportLinkage], "*" or QCall produces a WASM0066 warning. Consumers that scan - // untrimmed closures full of cross-platform interop (e.g. library-test bundles) set this - // false so the expected "unresolved module, skip and throw-if-called" case is logged as a - // message instead of a build-breaking (under warn-as-error) warning. - public bool WarnOnUnresolvedPInvokeModules { get; set; } = true; - - public string TargetOS { get; set; } = "browser"; - - /// - /// Path to crossgen2, which is run in its --wasm-abi-query mode to compute struct sizes - /// and ABI lowering. Reflection alone cannot compute field layout, and the answers have to be the - /// ones the compiler itself would produce. - /// - /// - /// Query mode does not load the JIT, so this does not have to be a wasm-targeting crossgen2. - /// - public string? Crossgen2Path { get; set; } - - /// - /// Path to the dotnet host, used when points at an IL-only build of - /// crossgen2 rather than an apphost. - /// - public string? DotNetHostPath { get; set; } - - private static readonly string[] s_knownTargetOSes = new[] { "browser", "wasi" }; - - private string ResolveCrossgen2Path() - { - if (string.IsNullOrEmpty(Crossgen2Path)) - { - throw new LogAsErrorException( - "The Crossgen2Path task parameter is required: computing the wasm ABI struct sizes for the " + - "generated helpers needs crossgen2's type system."); - } - - return Crossgen2Path!; - } - - private string ResolveDotNetHostPath() - { - if (!string.IsNullOrEmpty(DotNetHostPath)) - return DotNetHostPath!; - - string? fromEnvironment = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); - if (!string.IsNullOrEmpty(fromEnvironment)) - return fromEnvironment!; - - // When MSBuild itself is running on the .NET host, reuse it rather than trusting PATH to - // turn up a compatible one. - try - { - string? currentProcess = Process.GetCurrentProcess().MainModule?.FileName; - if (!string.IsNullOrEmpty(currentProcess)) - { - string name = Path.GetFileNameWithoutExtension(currentProcess); - if (string.Equals(name, "dotnet", StringComparison.OrdinalIgnoreCase)) - return currentProcess!; - } - } - catch (Exception) - { - } - - return "dotnet"; - } - - [Output] - public string[]? FileWrites { get; private set; } - - public override bool Execute() - { - if (Assemblies!.Length == 0) - { - Log.LogError($"{nameof(ManagedToNativeGenerator)}.{nameof(Assemblies)} cannot be empty"); - return false; - } - - if (PInvokeModules!.Length == 0) - { - Log.LogError($"{nameof(ManagedToNativeGenerator)}.{nameof(PInvokeModules)} cannot be empty"); - return false; - } - - if (string.IsNullOrWhiteSpace(TargetOS)) - { - Log.LogError($"{nameof(ManagedToNativeGenerator)}.{nameof(TargetOS)} cannot be empty; expected one of: {string.Join(", ", s_knownTargetOSes)}"); - return false; - } - - TargetOS = TargetOS.Trim().ToLowerInvariant(); - if (Array.IndexOf(s_knownTargetOSes, TargetOS) < 0) - { - Log.LogError($"{nameof(ManagedToNativeGenerator)}.{nameof(TargetOS)} '{TargetOS}' is not recognized; expected one of: {string.Join(", ", s_knownTargetOSes)}"); - return false; - } - - try - { - var logAdapter = new LogAdapter(Log); - ExecuteInternal(logAdapter); - return !Log.HasLoggedErrors; - } - catch (LogAsErrorException e) - { - Log.LogError(e.Message); - return false; - } - } - - private void ExecuteInternal(LogAdapter log) - { - Dictionary _symbolNameFixups = new(); - List managedAssemblies = FilterOutUnmanagedBinaries(Assemblies); - - using var abiTypeResolver = new WasmAbiTypeResolver(ResolveDotNetHostPath(), ResolveCrossgen2Path(), TargetOS, managedAssemblies, log); - var signatureMapper = new SignatureMapper(log, abiTypeResolver); - var pinvoke = new PInvokeTableGenerator(FixupSymbolName, log, IsLibraryMode, TargetOS, signatureMapper, WarnOnUnresolvedPInvokeModules); - var internalCallCollector = new InternalCallSignatureCollector(log, signatureMapper); - - var resolver = new PathAssemblyResolver(managedAssemblies); - using var mlc = new MetadataLoadContext(resolver, "System.Private.CoreLib"); - foreach (string asmPath in managedAssemblies) - { - log.LogMessage(MessageImportance.Low, $"Loading {asmPath} to scan for pinvokes and InternalCall methods"); - Assembly asm = mlc.LoadFromAssemblyPath(asmPath); - pinvoke.ScanAssembly(asm); - - if (asmPath.Contains("System.Private.CoreLib", StringComparison.OrdinalIgnoreCase)) - { - // Only scan System.Private.CoreLib, as all used InternalCall methods should be defined there, - // and scanning all assemblies can be expensive, and can trigger failures which should be avoided. - // System.Private.CoreLib is tested such that this should never fail on that binary. - internalCallCollector.ScanAssembly(asm); - } - } - - // Pregenerated signatures for commonly used shapes used by R2R code to reduce duplication in generated R2R binaries. - // The signatures should be in the form of a string where the first character represents the return type and the - // following characters represent the argument types. The type characters should match those used by the - // SignatureMapper.CharToNativeType method. - string[] pregeneratedInterpreterToNativeSignatures = Array.Empty(); // Currently none, but can be added here as needed in the future. - - IEnumerable cookies = pinvoke.Generate(PInvokeModules, IgnoredPInvokeModules, PInvokeOutputPath, ReversePInvokeOutputPath); - cookies = cookies.Concat(internalCallCollector.GetSignatures()); - cookies = cookies.Concat(pregeneratedInterpreterToNativeSignatures); - - var m2n = new InterpToNativeGenerator(log); - m2n.Generate(cookies, InterpToNativeOutputPath); - - if (!string.IsNullOrEmpty(CacheFilePath)) - { - IEnumerable cacheLines = PInvokeModules - .Select(module => $"module:{module}") - .Concat(IgnoredPInvokeModules.Select(module => $"ignored:{module}")); - File.WriteAllLines(CacheFilePath, cacheLines, Encoding.UTF8); - } - - List fileWritesList = new() { PInvokeOutputPath, InterpToNativeOutputPath }; - if (!string.IsNullOrEmpty(CacheFilePath)) - fileWritesList.Add(CacheFilePath); - - FileWrites = fileWritesList.ToArray(); - - string FixupSymbolName(string name) - { - if (_symbolNameFixups.TryGetValue(name, out string? fixedName)) - return fixedName; - - fixedName = Utils.FixupSymbolName(name); - _symbolNameFixups[name] = fixedName; - return fixedName; - } - } - - private List FilterOutUnmanagedBinaries(string[] assemblies) - { - List managedAssemblies = new(assemblies.Length); - foreach (string asmPath in Assemblies) - { - if (!File.Exists(asmPath)) - throw new LogAsErrorException($"Cannot find assembly {asmPath}"); - - try - { - if (!Utils.IsManagedAssembly(asmPath)) - { - Log.LogMessage(MessageImportance.Low, $"Skipping unmanaged {asmPath}."); - continue; - } - } - catch (Exception ex) - { - Log.LogMessage(MessageImportance.Low, $"Failed to read assembly {asmPath}: {ex}"); - throw new LogAsErrorException($"Failed to read assembly {asmPath}: {ex.Message}"); - } - - managedAssemblies.Add(asmPath); - } - - return managedAssemblies; - } -} diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs deleted file mode 100644 index a5494206c1f325..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs +++ /dev/null @@ -1,409 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Collections.Generic; -using System; -using System.Linq; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using Microsoft.Build.Tasks; -using JoinedString; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -#pragma warning disable CA1067 -#pragma warning disable CS0649 -internal sealed class PInvoke : IEquatable -#pragma warning restore CA1067 -{ - public PInvoke(string entryPoint, string module, MethodInfo method, bool wasmLinkage) - { - EntryPoint = entryPoint; - Module = module; - Method = method; - WasmLinkage = wasmLinkage; - } - - public string EntryPoint; - public string Module; - public MethodInfo Method; - public bool Skip; - public bool WasmLinkage; - - public bool Equals(PInvoke? other) - => other != null && - string.Equals(EntryPoint, other.EntryPoint, StringComparison.Ordinal) && - string.Equals(Module, other.Module, StringComparison.Ordinal) && - string.Equals(Method.ToString(), other.Method.ToString(), StringComparison.Ordinal); - - public override string ToString() => $"{{ EntryPoint: {EntryPoint}, Module: {Module}, Method: {Method}, Skip: {Skip} }}"; -} -#pragma warning restore CS0649 - -internal sealed class PInvokeComparer : IEqualityComparer -{ - public bool Equals(PInvoke? x, PInvoke? y) - { - if (x == null && y == null) - return true; - if (x == null || y == null) - return false; - - return x.Equals(y); - } - - public int GetHashCode(PInvoke pinvoke) - => $"{pinvoke.EntryPoint}{pinvoke.Module}{pinvoke.Method}".GetHashCode(); -} - - -internal sealed class PInvokeCollector { - private readonly Dictionary _assemblyDisableRuntimeMarshallingAttributeCache = new(); - private readonly Dictionary _typeUnsupportedOnPlatformCache = new(); - private readonly Dictionary _assemblyUnsupportedOnPlatformCache = new(); - private readonly string _targetOS; - private readonly SignatureMapper _signatureMapper; - private LogAdapter Log { get; init; } - - public PInvokeCollector(LogAdapter log, string targetOS, SignatureMapper signatureMapper) - { - Log = log; - _targetOS = targetOS; - _signatureMapper = signatureMapper; - } - - public void CollectPInvokes(List pinvokes, List callbacks, HashSet signatures, Type type) - { - foreach (var method in type.GetMethods(BindingFlags.DeclaredOnly | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) - { - try - { - CollectPInvokesForMethod(method); - if (DoesMethodHaveCallbacks(method, Log)) - callbacks.Add(new PInvokeCallback(method)); - } - catch (Exception ex) when (ex is not LogAsErrorException) - { - Log.Warning("WASM0001", $"Could not get pinvoke, or callbacks for method '{type.FullName}::{method.Name}' because '{ex}'"); - } - } - - if (HasAttribute(type, "System.Runtime.InteropServices.UnmanagedFunctionPointerAttribute")) - { - // Each instantiation of an open generic delegate would marshal differently, so there is - // no single native signature to emit a thunk for. The encoding this used to produce came - // from mapping the type parameter itself, which was only ever right by accident. - if (type.ContainsGenericParameters) - { - Log.Warning("WASM0001", $"Skipping generic function pointer delegate '{type.FullName}', which has no single native signature"); - return; - } - - var method = type.GetMethod("Invoke"); - - if (method != null) - { - string? signature = _signatureMapper.MethodToSignature(method!); - if (signature == null) - throw new NotSupportedException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); - - if (signatures.Add(signature)) - Log.LogMessage(MessageImportance.Low, $"Adding pinvoke signature {signature} for method '{type.FullName}.{method.Name}'"); - } - } - - void CollectPInvokesForMethod(MethodInfo method) - { - if ((method.Attributes & MethodAttributes.PinvokeImpl) != 0) - { - if (IsUnsupportedOnPlatform(method)) - return; - - var dllimport = method.CustomAttributes.First(attr => attr.AttributeType.Name == "DllImportAttribute"); - var wasmLinkage = method.CustomAttributes.Any(attr => attr.AttributeType.Name == "WasmImportLinkageAttribute"); - var module = (string)dllimport.ConstructorArguments[0].Value!; - var entrypoint = (string)dllimport.NamedArguments.First(arg => arg.MemberName == "EntryPoint").TypedValue.Value!; - pinvokes.Add(new PInvoke(entrypoint, module, method, wasmLinkage)); - - string? signature = _signatureMapper.MethodToSignature(method); - if (signature == null) - { - throw new NotSupportedException($"Unsupported parameter type in method '{type.FullName}.{method.Name}'"); - } - - if (signatures.Add(signature)) - Log.LogMessage(MessageImportance.Low, $"Adding pinvoke signature {signature} for method '{type.FullName}.{method.Name}'"); - } - } - - bool DoesMethodHaveCallbacks(MethodInfo method, LogAdapter log) - { - if (!MethodHasCallbackAttributes(method)) - return false; - - if (IsUnsupportedOnPlatform(method)) - return false; - - if (TryIsMethodGetParametersUnsupported(method, out string? reason)) - { - Log.Warning("WASM0001", $"Skipping callback '{method.DeclaringType!.FullName}::{method.Name}' because '{reason}'."); - return false; - } - - if (method.DeclaringType != null && HasAssemblyDisableRuntimeMarshallingAttribute(method.DeclaringType.Assembly)) - return true; - - // No DisableRuntimeMarshalling attribute, so check if the params/ret-type are - // blittable - bool isVoid = method.ReturnType.FullName == "System.Void"; - if (!isVoid && !IsBlittable(method.ReturnType, log)) - Error($"The return type '{method.ReturnType.FullName}' of pinvoke callback method '{method}' needs to be blittable."); - - foreach (var p in method.GetParameters()) - { - if (!IsBlittable(p.ParameterType, log)) - Error("Parameter types of pinvoke callback method '" + method + "' needs to be blittable."); - } - - return true; - } - - static bool MethodHasCallbackAttributes(MethodInfo method) - { - foreach (CustomAttributeData cattr in CustomAttributeData.GetCustomAttributes(method)) - { - try - { - if (cattr.AttributeType.FullName == "System.Runtime.InteropServices.UnmanagedCallersOnlyAttribute" || - cattr.AttributeType.Name == "MonoPInvokeCallbackAttribute") - { - return true; - } - } - catch - { - // Assembly not found, ignore - } - } - - return false; - } - } - - public static bool IsBlittable(Type type, LogAdapter log) => PInvokeTableGenerator.IsBlittable(type, log); - - private static void Error(string msg) => throw new LogAsErrorException(msg); - - internal static bool HasAttribute(MemberInfo element, params string[] attributeNames) => PInvokeTableGenerator.HasAttribute(element, attributeNames); - - private static bool TryIsMethodGetParametersUnsupported(MethodInfo method, [NotNullWhen(true)] out string? reason) - { - try - { - method.GetParameters(); - } - catch (NotSupportedException nse) - { - reason = nse.Message; - return true; - } - catch - { - // not concerned with other exceptions - } - - reason = null; - return false; - } - - private bool HasAssemblyDisableRuntimeMarshallingAttribute(Assembly assembly) - { - if (!_assemblyDisableRuntimeMarshallingAttributeCache.TryGetValue(assembly, out var value)) - { - _assemblyDisableRuntimeMarshallingAttributeCache[assembly] = value = assembly - .GetCustomAttributesData() - .Any(d => d.AttributeType.Name == "DisableRuntimeMarshallingAttribute"); - } - - return value; - } - - private bool IsUnsupportedOnPlatform(MethodInfo method) - { - PlatformSupport methodResult = EvaluatePlatformAttributes(CustomAttributeData.GetCustomAttributes(method)); - if (methodResult == PlatformSupport.Unsupported) - return true; - if (methodResult == PlatformSupport.Supported) - return false; - - return IsUnsupportedOnPlatform(method.DeclaringType); - } - - private bool IsUnsupportedOnPlatform(Type? type) - { - if (type is null) - return false; - - if (_typeUnsupportedOnPlatformCache.TryGetValue(type, out bool cached)) - return cached; - - bool value; - PlatformSupport typeResult = EvaluatePlatformAttributes(CustomAttributeData.GetCustomAttributes(type)); - if (typeResult == PlatformSupport.Unsupported) - { - value = true; - } - else if (typeResult == PlatformSupport.Supported) - { - value = false; - } - else if (type.DeclaringType is not null) - { - value = IsUnsupportedOnPlatform(type.DeclaringType); - } - else - { - value = IsAssemblyUnsupportedOnPlatform(type.Assembly); - } - - _typeUnsupportedOnPlatformCache[type] = value; - return value; - } - - private bool IsAssemblyUnsupportedOnPlatform(Assembly assembly) - { - if (!_assemblyUnsupportedOnPlatformCache.TryGetValue(assembly, out bool value)) - { - PlatformSupport asmResult = EvaluatePlatformAttributes(assembly.GetCustomAttributesData()); - value = asmResult == PlatformSupport.Unsupported; - _assemblyUnsupportedOnPlatformCache[assembly] = value; - } - - return value; - } - - private enum PlatformSupport - { - Unknown, // No platform attributes were observed at this scope - Supported, // Explicitly supported here (target appears in a SupportedOSPlatform list) - Unsupported, // Explicitly unsupported here (target matches UnsupportedOSPlatform, or - // SupportedOSPlatform is present and does not list the target) - } - - private PlatformSupport EvaluatePlatformAttributes(IList attrs) - { - bool hasSupportedOSPlatform = false; - bool hasSupportedTarget = false; - foreach (CustomAttributeData cattr in attrs) - { - try - { - if (cattr.AttributeType.FullName == "System.Runtime.Versioning.UnsupportedOSPlatformAttribute" && - cattr.ConstructorArguments.Count > 0 && - cattr.ConstructorArguments[0].Value?.ToString() == _targetOS) - { - return PlatformSupport.Unsupported; - } - if (cattr.AttributeType.FullName == "System.Runtime.Versioning.SupportedOSPlatformAttribute" && - cattr.ConstructorArguments.Count > 0) - { - hasSupportedOSPlatform = true; - if (cattr.ConstructorArguments[0].Value?.ToString() == _targetOS) - hasSupportedTarget = true; - } - } - catch - { - // Assembly not found, ignore - } - } - - if (hasSupportedOSPlatform) - return hasSupportedTarget ? PlatformSupport.Supported : PlatformSupport.Unsupported; - - return PlatformSupport.Unknown; - } -} - -internal sealed class PInvokeCallbackComparer : IComparer -{ - public int Compare(PInvokeCallback? x, PInvokeCallback? y) - { - int compare = string.Compare(x!.Key, y!.Key, StringComparison.Ordinal); - return compare != 0 ? compare : (int)(x.Token - y.Token); - } -} - -#pragma warning disable CS0649 -internal sealed class PInvokeCallback -{ - public PInvokeCallback(MethodInfo method) - { - Method = method; - var t = method.DeclaringType!; - TypeName = t.Name!; - TypeFullName = t.FullName!; - AssemblyName = t.Module!.Assembly!.GetName()!.Name!; - AssemblyFQName = t.Module!.Assembly!.GetName()!.FullName!; - // Nested types: the runtime reverse-thunk key (vm/wasm/helpers.cpp GetHashCode -> - // GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so match that - // here or the emitted g_ReverseThunks key won't be found at lookup time (#130129). - // This key drops the enclosing-type chain, so nested types with the same simple name in - // different namespaces collide; the duplicate-key check in PInvokeTableGenerator - // (EmitNativeToInterp) turns that into a build error. - // Tracked by https://github.com/dotnet/runtime/issues/130739. - Namespace = t.IsNested ? string.Empty : t.Namespace; - MethodName = method.Name!; - ReturnType = method.ReturnType!; - IsVoid = ReturnType.Name == "Void"; - Token = (uint)method.MetadataToken; - - // FIXME: this is a hack, we need to encode this better and allow reflection in the interp case - // but either way it needs to match the key generated in get_native_to_interp since the key is - // used to look up the interp entry function. It must be unique for each callback runtime errors - // can occur since it is used to look up the index in the wasm_native_to_interp_ftndescs and - // the signature of the interp entry function must match the native signature - // - // the key also needs to survive being encoded in C literals, if in doubt - // add something like "\U0001F412" to the key on both the managed and unmanaged side - Key = $"{MethodName}#{Method.GetParameters().Length}:{AssemblyName}:{Namespace}:{TypeName}"; - - IsExport = false; - foreach (var attr in method.CustomAttributes) - { - if (attr.AttributeType.Name == "UnmanagedCallersOnlyAttribute") - { - foreach (var arg in attr.NamedArguments) - { - if (arg.MemberName == "EntryPoint") - { - EntryPoint = arg.TypedValue.Value!.ToString(); - IsExport = true; - return; - } - } - } - } - } - - public string EntryName => $"{AssemblyName}_{Namespace}_{TypeName}_{MethodName}"; - - public ParameterInfo[] Parameters => Method.GetParameters(); - public string? EntryPoint { get; } - public MethodInfo Method { get; } - public string? EntrySymbol { get; set; } - public string AssemblyName { get; } - public string AssemblyFQName { get; } - public string TypeName { get; } - public string TypeFullName { get; } - public string? Namespace { get;} - public string MethodName { get; } - public Type ReturnType { get;} - public bool IsExport { get; } - public bool IsVoid { get; } - public uint Token { get; } - public string Key { get; } -} -#pragma warning restore CS0649 diff --git a/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs b/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs deleted file mode 100644 index 51b78bfe662a89..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs +++ /dev/null @@ -1,633 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.RegularExpressions; -using System.Reflection; -using System.Runtime.InteropServices; -using Microsoft.Build.Framework; -using Microsoft.Build.Utilities; -using JoinedString; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -internal sealed class PInvokeTableGenerator -{ - private LogAdapter Log { get; set; } - private readonly Func _fixupSymbolName; - private readonly HashSet signatures = new(); - private readonly List pinvokes = new(); - private readonly List callbacks = new(); - private readonly PInvokeCollector _pinvokeCollector; - private readonly SignatureMapper _signatureMapper; - private readonly bool _isLibraryMode; - private readonly bool _warnOnUnresolvedModules; - - public PInvokeTableGenerator(Func fixupSymbolName, LogAdapter log, bool isLibraryMode, string targetOS, SignatureMapper signatureMapper, bool warnOnUnresolvedModules = true) - { - Log = log; - _fixupSymbolName = fixupSymbolName; - _signatureMapper = signatureMapper; - _pinvokeCollector = new(log, targetOS, signatureMapper); - _isLibraryMode = isLibraryMode; - _warnOnUnresolvedModules = warnOnUnresolvedModules; - } - - public void ScanAssembly(Assembly asm) - { - foreach (Type type in asm.GetTypes()) - _pinvokeCollector.CollectPInvokes(pinvokes, callbacks, signatures, type); - } - - public IEnumerable Generate(string[] pinvokeModules, string[] ignoredPInvokeModules, string outputPathPInvoke, string outputPathReversePInvoke) - { - var ignoredModules = new HashSet(ignoredPInvokeModules, StringComparer.Ordinal); - var modules = new SortedDictionary(StringComparer.Ordinal); - foreach (var module in pinvokeModules) - { - if (!ignoredModules.Contains(module)) - modules[module] = module; - } - - foreach (var module in ignoredModules.OrderBy(module => module, StringComparer.Ordinal)) - Log.LogMessage(MessageImportance.Low, $"Ignoring PInvoke module {module}"); - - using TempFileName tmpFileNamePInvoke = new(); - using (var w = new JoinedStringStreamWriter(tmpFileNamePInvoke.Path, false)) - { - EmitPInvokeTable(w, modules, ignoredModules, pinvokes); - } - - using TempFileName tmpFileNameReversePInvoke = new(); - using (var w = new JoinedStringStreamWriter(tmpFileNameReversePInvoke.Path, false)) - { - EmitNativeToInterp(w, callbacks); - } - - if (Utils.CopyIfDifferent(tmpFileNamePInvoke.Path, outputPathPInvoke, useHash: false)) - Log.LogMessage(MessageImportance.Low, $"Generating pinvoke table to '{outputPathPInvoke}'."); - else - Log.LogMessage(MessageImportance.Low, $"PInvoke table in {outputPathPInvoke} is unchanged."); - - if (Utils.CopyIfDifferent(tmpFileNameReversePInvoke.Path, outputPathReversePInvoke, useHash: false)) - Log.LogMessage(MessageImportance.Low, $"Generating pinvoke reverse table to '{outputPathReversePInvoke}'."); - else - Log.LogMessage(MessageImportance.Low, $"PInvoke reverse table in {outputPathReversePInvoke} is unchanged."); - - return signatures; - } - - private void EmitPInvokeTable(StreamWriter w, SortedDictionary modules, HashSet ignoredModules, List pinvokes) - { - foreach (var pinvoke in pinvokes) - { - if (modules.ContainsKey(pinvoke.Module)) - continue; - if (ignoredModules.Contains(pinvoke.Module)) - continue; - // Handle special modules, and add them to the list of modules - // otherwise, skip them and throw an exception at runtime if they - // are called. - if (pinvoke.WasmLinkage) - { - // WasmLinkage means we need to import the module - modules.Add(pinvoke.Module, pinvoke.Module); - Log.LogMessage(MessageImportance.Low, $"Adding module {pinvoke.Module} for WasmImportLinkage"); - } - else if (pinvoke.Module == "*") - { - // Special case for * module to indicate static linking without specifying the module - modules.Add(pinvoke.Module, pinvoke.Module); - Log.LogMessage(MessageImportance.Low, $"Adding module {pinvoke.Module} for static linking"); - } - else if (pinvoke.Module != "QCall") - { - // Unresolved module: not statically linked, ignored, [WasmImportLinkage], "*" or QCall. - // By design we skip it and throw at runtime if it is ever called. For hand-authored - // apps this is likely a bug, so warn; consumers scanning untrimmed closures full of - // cross-platform interop (library-test bundles) disable the warning to avoid failing - // the build under warn-as-error for P/Invokes that are never called on wasm. - if (_warnOnUnresolvedModules) - Log.Warning("WASM0066", $"PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.DeclaringType}::{pinvoke.Method.Name}' is not in the list of allowed modules. It is also not a specially treated module."); - else if (ignoredModules.Add(pinvoke.Module)) - Log.LogMessage(MessageImportance.Low, $"Skipping unresolved PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.DeclaringType}::{pinvoke.Method.Name}' (not statically linked on wasm; will throw if called)." ); - } - } - - w.WriteLine( - $$""" - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. - // - - // - // GENERATED FILE, DON'T EDIT - // Generated by coreclr callhelpers generator - // - - #include - #include - - extern "C" { - """); - - var pinvokesGroupedByEntryPoint = pinvokes - .Where(l => modules.ContainsKey(l.Module)) - .OrderBy(l => l.EntryPoint, StringComparer.Ordinal) - .GroupBy(CEntryPoint, StringComparer.Ordinal); - var comparer = new PInvokeComparer(); - foreach (IGrouping group in pinvokesGroupedByEntryPoint) - { - var candidates = group.Distinct(comparer).ToArray(); - PInvoke first = candidates[0]; - if (ShouldTreatAsVariadic(candidates)) - { - string imports = string.Join(Environment.NewLine, - candidates.Select( - p => $" {p.Method} (in [{p.Method.DeclaringType?.Assembly.GetName().Name}] {p.Method.DeclaringType})")); - Log.Warning("WASM0001", $"Found a native function ({first.EntryPoint}) with varargs in {first.Module}." + - " Calling such functions is not supported, and will fail at runtime." + - $" Managed DllImports: {Environment.NewLine}{imports}"); - - foreach (var c in candidates) - c.Skip = true; - - continue; - } - - var decls = new HashSet(); - foreach (var candidate in candidates) - { - var decl = GenPInvokeDecl(candidate); - if (decl is null || decls.Contains(decl)) - continue; - - w.WriteLine(decl); - decls.Add(decl); - } - } - - w.Write( - $$""" - } // extern "C" - - """); - - var moduleImports = new Dictionary>(); - foreach (var module in modules.Keys) - { - // the order here is not important, because we use hash tables, we want it to be stable though - var imports = pinvokes - .Where(l => l.Module == module && !l.Skip) - .OrderBy(l => l.EntryPoint, StringComparer.Ordinal) - .GroupBy(d => d.EntryPoint, StringComparer.Ordinal) - .Select(l => - { - PInvoke p = l.First(); - // Runtime resolver looks up by managed EntryPoint. - // [WasmImportLinkage] mangles the C symbol per module, - // so emit the entry-point string explicitly rather than - // stringifying the mangled name via DllImportEntry. - if (p.WasmLinkage) - return $" {{ \"{EscapeLiteral(p.EntryPoint)}\", (void*)&{CEntryPoint(p)} }}, // {ListRefs(l)}{w.NewLine}"; - return $" DllImportEntry({CEntryPoint(p)}) // {ListRefs(l)}{w.NewLine}"; - }) - .ToList(); - - moduleImports[module] = imports; - w.Write( - $$""" - - static const Entry s_{{_fixupSymbolName(module)}} [] = { - {{string.Join("", imports)}}}; - - """); - } - - w.Write( - $$""" - - typedef struct PInvokeTable { - const char* LibraryName; - const Entry* Entries; - size_t EntryCount; - } PInvokeTable; - - static PInvokeTable s_PInvokeTables[] = { - {{modules.Keys.Join($",{w.NewLine} ", m => $"{{\"{EscapeLiteral(m)}\", s_{_fixupSymbolName(m)}, {moduleImports[m].Count}}}")}} - }; - const size_t s_PInvokeTablesCount = sizeof(s_PInvokeTables) / sizeof(s_PInvokeTables[0]); - - const void* callhelpers_pinvoke_override(const char* library_name, const char* entry_point_name) - { - for (size_t i = 0; i < s_PInvokeTablesCount; i++) - { - if (strcmp(library_name, s_PInvokeTables[i].LibraryName) == 0) - { - LOG((LF_INTEROP, LL_INFO1000, "Wasm callhelpers PInvoke override for: lib: %s, entry: %s \n", library_name, entry_point_name)); - return minipal_resolve_dllimport(s_PInvokeTables[i].Entries, s_PInvokeTables[i].EntryCount, entry_point_name); - } - } - - return nullptr; - } - - """); - - static bool ShouldTreatAsVariadic(PInvoke[] candidates) - { - if (candidates.Length < 2) - return false; - - PInvoke first = candidates[0]; - if (!TryIsMethodGetParametersSupported(first.Method, out _)) - return false; - - int firstNumArgs = first.Method.GetParameters().Length; - return candidates - .Skip(1) - // detect possible vararg entrypoint usage - // where the same entrypoint is used with different - // number of arguments - .Any(c => TryIsMethodGetParametersSupported(c.Method, out _) && - c.Method.GetParameters().Length != firstNumArgs); - } - - static string ListRefs(IGrouping l) => - string.Join(", ", l.Select(c => c.Method.DeclaringType!.Module!.Assembly!.GetName()!.Name!).Distinct().OrderBy(n => n)); - } - - private string CEntryPoint(PInvoke pinvoke) - { - if (pinvoke.WasmLinkage) - { - // We mangle the name to avoid collisions with symbols in other modules - string namespaceName = pinvoke.Method.DeclaringType?.Namespace ?? string.Empty; - return _fixupSymbolName($"{namespaceName}#{pinvoke.Module}#{pinvoke.EntryPoint}"); - } - return _fixupSymbolName(pinvoke.EntryPoint); - } - - private static string MapType(Type t) => t.Name switch - { - "Void" => "void", - nameof(Double) => "double", - nameof(Single) => "float", - nameof(Int64) => "int64_t", - nameof(UInt64) => "uint64_t", - nameof(Int32) => "int32_t", - nameof(UInt32) => "uint32_t", - nameof(Int16) => "int32_t", - nameof(UInt16) => "uint32_t", - nameof(Char) => "int32_t", - nameof(Boolean) => "int32_t", - nameof(SByte) => "int32_t", - nameof(Byte) => "uint32_t", - nameof(IntPtr) => "void *", - nameof(UIntPtr) => "void *", - _ => PickCTypeNameForUnknownType(t) - }; - - private static string PickCTypeNameForUnknownType(Type t) - { - // Pass objects by-reference (their address by-value) - if (!t.IsValueType) - return "void *"; - // Pass pointers and function pointers by-value - else if (t.IsPointer || IsFunctionPointer(t)) - return "void *"; - else if (t.IsPrimitive) - throw new NotImplementedException("No native type mapping for type " + t); - - // https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md#function-signatures - // Any struct or union that recursively (including through nested structs, unions, and arrays) - // contains just a single scalar value and is not specified to have greater than natural alignment. - // FIXME: Handle the scenario where there are fields of struct types that contain no members - var fields = t.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - if (fields.Length == 1) - return MapType(fields[0].FieldType); - else - return "void *"; - } - - // FIXME: System.Reflection.MetadataLoadContext can't decode function pointer types - // https://github.com/dotnet/runtime/issues/43791 - private static bool TryIsMethodGetParametersSupported(MethodInfo method, [NotNullWhen(false)] out string? reason) - { - try - { - method.GetParameters(); - } - catch (NotSupportedException nse) - { - reason = nse.Message; - return false; - } - catch - { - // not concerned with other exceptions - } - - reason = null; - return true; - } - - private string? GenPInvokeDecl(PInvoke pinvoke) - { - var method = pinvoke.Method; - - if (!TryIsMethodGetParametersSupported(pinvoke.Method, out string? reason)) - { - // Don't use method.ToString() or any of it's parameters, or return type - // because at least one of those are unsupported, and will throw - Log.Warning("WASM0001", $"Skipping pinvoke '{pinvoke.Method.DeclaringType!.FullName}::{pinvoke.Method.Name}' because '{reason}'."); - - pinvoke.Skip = true; - return null; - } - - var realReturnType = method.ReturnType; - var realParameterTypes = method.GetParameters().Select(p => MapType(p.ParameterType)).ToList(); - - _signatureMapper.TypeToChar(realReturnType, out bool resultIsByRef); - if (resultIsByRef) { - realReturnType = typeof(void); - realParameterTypes.Insert(0, "void *"); - } - - var importAttributes = pinvoke.WasmLinkage - ? $"__attribute__((import_module(\"{EscapeLiteral(pinvoke.Module)}\"),import_name(\"{EscapeLiteral(pinvoke.EntryPoint)}\"))) " - : ""; - var externKeyword = pinvoke.WasmLinkage ? "extern " : ""; - - return $" {importAttributes}{externKeyword}{MapType(realReturnType)} {CEntryPoint(pinvoke)} ({string.Join(", ", realParameterTypes)});"; - } - - private static string EscapeLiteral(string? input) - { - if (input == null) - return string.Empty; - - StringBuilder sb = new StringBuilder(); - - for (int i = 0; i < input.Length; i++) - { - char c = input[i]; - - sb.Append(c switch - { - '\\' => "\\\\", - '\"' => "\\\"", - '\n' => "\\n", - '\r' => "\\r", - '\t' => "\\t", - // take special care with surrogate pairs to avoid - // potential decoding issues in generated C literals - _ when char.IsHighSurrogate(c) && i + 1 < input.Length && char.IsLowSurrogate(input[i + 1]) - => $"\\U{char.ConvertToUtf32(c, input[++i]):X8}", - _ when char.IsControl(c) || c > 127 - => $"\\u{(int)c:X4}", - _ => c.ToString() - }); - } - - return sb.ToString(); - } - - // this is eqivalent to `ULONG HashString(LPCWSTR szStr)` in CoreCLR runtime, src/coreclr/inc/utilcode.h - private static uint HashString(string str) - { - uint hash = 5381; - foreach (char c in str) - { - hash = ((hash << 5) + hash) ^ (uint)c; - } - - return hash; - } - - private void EmitNativeToInterp(StreamWriter w, List callbacks) - { - // Generate native->interp entry functions - // These are called by native code, so they need to obtain - // the interp entry function/arg from a global array - // They also need to have a signature matching what the - // native code expects, which is the native signature - // of the delegate invoke in the [MonoPInvokeCallback] - // or [UnmanagedCallersOnly] attribute. - // Only blittable parameter/return types are supposed. - w.Write( - $$""" - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. - // - - // - // GENERATED FILE, DON'T EDIT - // Generated by coreclr callhelpers generator - // - - #include - - // 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); - - """); - - var callbackNames = new HashSet(); - var keys = new HashSet(); - int cb_index = 0; - callbacks = callbacks.OrderBy(c => c, new PInvokeCallbackComparer()).ToList(); - foreach (var cb in callbacks) - { - cb.EntrySymbol = FixedSymbolName(cb); - - if (callbackNames.Contains(cb.EntrySymbol)) - { - Error($"Two callbacks with the same symbol '{cb.EntrySymbol}' are not supported."); - } - callbackNames.Add(cb.EntrySymbol); - if (keys.Contains(cb.Key)) - { - Error($"Two callbacks with the same Name and number of arguments '{cb.Key}' are not supported."); - } - keys.Add(cb.Key); - - // The signature of the interp entry function - // This is a gsharedvt_in signature - var entryArgs = new List(); - if (!cb.IsVoid) - { - entryArgs.Add("(int*)&result"); - } - entryArgs.AddRange(cb.Parameters.Select((_, i) => $"(int*)&arg{i}")); - entryArgs.Add($"(int*)wasm_native_to_interp_ftndescs [{cb_index}].arg"); - - var argsArgs = cb.Parameters.Length > 0 ? "(int8_t*)args, sizeof(args)" : "nullptr, 0"; - var argsDeclaration = cb.Parameters.Length > 0 - ? $"\n int64_t args[{cb.Parameters.Length}] = {{ {cb.Parameters.Join(", ", (info, i) => $"(int64_t)arg{i}")} }};\n" - : string.Empty; - var parametersDeclaration = cb.Parameters.Join(", ", (info, i) => $"{MapType(info.ParameterType)} arg{i}"); - var exportFunction = cb.IsExport ? - $$""" - - - extern "C" {{MapType(cb.ReturnType)}} {{cb.EntryPoint}}({{parametersDeclaration}}) - { - {{(cb.IsVoid ? "" : "return ")}}Call_{{cb.EntrySymbol}}({{cb.Parameters.Join(", ", (info, i) => $"arg{i}")}}); - } - """ : string.Empty; - w.Write( - $$""" - - static MethodDesc* MD_{{cb.EntrySymbol}} = nullptr; - 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;" : "")}} - ExecuteInterpretedMethodFromUnmanaged(MD_{{cb.EntrySymbol}}, {{argsArgs}}, {{(cb.IsVoid ? "nullptr" : "(int8_t*)&result")}}, (PCODE)&Call_{{cb.EntrySymbol}});{{ - (!cb.IsVoid ? $"{w.NewLine} return result;" : "")}} - }{{exportFunction}} - - """); - cb_index++; - } - - w.Write( - $$""" - - const ReverseThunkMapEntry g_ReverseThunks[] = - { - {{callbacks.Join($",{w.NewLine}", ThunkMapEntryLine)}} - }; - - const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks) / sizeof(g_ReverseThunks[0]); - - """); - } - - private string FixedSymbolName(PInvokeCallback cb) - { - var paramTypes = cb.Parameters.Length > 0 ? cb.Parameters.Join("_", (info, i) => _signatureMapper.TypeToNameType(info.ParameterType)).ToString() : "Void"; - var sig = $"{paramTypes}_Ret{_signatureMapper.TypeToNameType(cb.ReturnType)}"; - - return _fixupSymbolName($"{cb.EntryName}_{sig}"); - } - - - private string ThunkMapEntryLine(PInvokeCallback cb) - { - var fsName = FixedSymbolName(cb); - - return $" {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{fsName}, (void*)&Call_{cb.EntrySymbol} }} }}"; - } - - private static readonly Dictionary _blittableCache = new(); - - public static bool IsFunctionPointer(Type type) - { - object? bIsFunctionPointer = type.GetType().GetProperty("IsFunctionPointer")?.GetValue(type); - return (bIsFunctionPointer is bool b) && b; - } - - public static bool IsBlittable(Type type, LogAdapter log) - { - // We maintain a cache of results in order to only produce log messages the first time - // we analyze a given type. Otherwise, each (successful) use of a user-defined type - // in a callback or pinvoke would generate duplicate messages. - lock (_blittableCache) - if (_blittableCache.TryGetValue(type, out bool blittable)) - return blittable; - - bool result = IsBlittableUncached(type, log); - lock (_blittableCache) - _blittableCache[type] = result; - return result; - - static bool IsBlittableUncached(Type type, LogAdapter log) - { - if (type.IsPrimitive || type.IsByRef || type.IsPointer || type.IsEnum) - return true; - - if (IsFunctionPointer(type)) - return true; - - // HACK: SkiaSharp has pinvokes that rely on this - if (HasAttribute(type, "System.Runtime.InteropServices.UnmanagedFunctionPointerAttribute")) - return true; - - if (type.Name == "__NonBlittableTypeForAutomatedTests__") - return false; - - if (!type.IsValueType) - { - log.InfoHigh("WASM0060", "Type {0} is not blittable: Not a ValueType", type); - return false; - } - - var fields = type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - - if (!type.IsLayoutSequential && (fields.Length > 1)) - { - log.InfoHigh("WASM0061", "Type {0} is not blittable: LayoutKind is not Sequential", type); - return false; - } - - foreach (var ft in fields) - { - if (!IsBlittable(ft.FieldType, log)) - { - log.InfoHigh("WASM0062", "Type {0} is not blittable: Field {1} is not blittable", type, ft.Name); - return false; - } - // HACK: Skip literals since they're complicated - // Ideally we would block initonly fields too since the callee could mutate them, but - // we rely on being able to pass types like System.Guid which are readonly - if (ft.IsLiteral) - { - log.InfoHigh("WASM0063", "Type {0} is not blittable: Field {1} is literal", type, ft.Name); - return false; - } - } - - return true; - } - } - - public static bool HasAttribute(MemberInfo element, params string[] attributeNames) - { - foreach (CustomAttributeData cattr in CustomAttributeData.GetCustomAttributes(element)) - { - try - { - for (int i = 0; i < attributeNames.Length; ++i) - { - if (cattr.AttributeType.FullName == attributeNames[i] || - cattr.AttributeType.Name == attributeNames[i]) - { - return true; - } - } - } - catch - { - // Assembly not found, ignore - } - } - return false; - } - - private static void Error(string msg) => throw new LogAsErrorException(msg); -} diff --git a/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs b/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs deleted file mode 100644 index 72efc6d0d4b307..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs +++ /dev/null @@ -1,268 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -// Computes Wasm signature strings from reflection metadata. -// The signature string format is documented in docs/design/coreclr/botr/readytorun-format.md -// (section "Wasm Signature String Encoding"). -internal sealed class SignatureMapper -{ - private readonly LogAdapter _log; - private readonly IWasmAbiTypeResolver _resolver; - - public SignatureMapper(LogAdapter log, IWasmAbiTypeResolver resolver) - { - _log = log; - _resolver = resolver; - } - - internal char? TypeToChar( - Type t, - out bool isByRefStruct, - int depth = 0) - { - isByRefStruct = false; - - if (depth > 5) { - _log.Warning("WASM0064", $"Unbounded recursion detected through parameter type '{t.Name}'"); - return null; - } - - // See https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md - char? c = null; - if (t.Namespace == "System") - { - c = t.Name switch - { - nameof(String) => 'i', - nameof(Boolean) => 'i', - nameof(Char) => 'i', - nameof(SByte) => 'i', - nameof(Byte) => 'i', - nameof(Int16) => 'i', - nameof(UInt16) => 'i', - nameof(Int32) => 'i', - nameof(UInt32) => 'i', - nameof(Int64) => 'l', - nameof(UInt64) => 'l', - nameof(Single) => 'f', - nameof(Double) => 'd', - // FIXME: These will need to be L for wasm64 - nameof(IntPtr) => 'i', - nameof(UIntPtr) => 'i', - "Void" => 'v', - _ => null - }; - } - - if (c != null) - return c; - - // FIXME: Most of these need to be L for wasm64 - if (t.IsByRef) - c = 'i'; - else if (t.IsClass) - c = 'i'; - else if (t.IsInterface) - c = 'i'; - else if (t.IsEnum) - { - Type underlyingType = t.GetEnumUnderlyingType(); - c = TypeToChar(underlyingType, out _, ++depth); - } - else if (t.IsPointer) - c = 'i'; - else if (PInvokeTableGenerator.IsFunctionPointer(t)) - c = 'i'; - else if (t.IsValueType) - { - // Reflection has no field layout engine, so the ABI encoding of a struct - its size and - // alignment, and whether it collapses to a single primitive or spreads across several - // wasm parameters - comes from the compiler's own type system. - string token = _resolver.GetAbiToken(t); - if (IsMultiSlotToken(token)) - { - // A type the wasm ABI splits across several by-value slots is rejected in interop - // rather than encoded. Supporting it means teaching the thunk generator that one - // signature token can map to several native parameters, which is a larger design - // question; today no InternalCall or PInvoke signature uses one. - _log.Error("WASM0068", - $"SignatureMapper: '{t.FullName ?? t.Name}' is passed across multiple wasm slots, which interop signatures do not support"); - return null; - } - - if (token[0] is 'S' or 'A') - { - isByRefStruct = true; - } - - c = token[0]; - } - else - _log.Warning("WASM0065", $"Unsupported parameter type '{t.Name}'"); - - return c; - } - - /// - /// Returns the wasm signature string for a method. - /// - /// - /// Delegates to the compiler's own lowering rather than building the string from - /// . That resolves each parameter from the method's signature blob, so - /// generic instantiations work, and it keeps one implementation of the encoding instead of a - /// second one here that has to be kept in agreement with compiled code. - /// - public string? MethodToSignature(MethodInfo method, bool includeThis = false) - { - // A managed signature is what picks up the 'T' for an instance method and the trailing 'p'; - // everything else describes a native function. - WasmLoweringFlags flags = includeThis ? WasmLoweringFlags.None : WasmLoweringFlags.IsUnmanagedCallersOnly; - - return _resolver.GetMethodSignature(method, flags); - } - - /// - /// Parses a signature string into individual tokens. - /// Single-char types produce one-char tokens; struct encodings produce multi-char tokens like - /// "S8" or "A32", and a multi-slot parameter produces a two-char token like "l2" or "V4". - /// The 'a' and 'p' suffixes are included as their own tokens. - /// - public static List ParseSignatureTokens(string signature) - { - var tokens = new List(); - int i = 0; - while (i < signature.Length) - { - if (signature[i] is 'S' or 'A') - { - int start = i; - i++; // skip 'S'/'A' - while (i < signature.Length && char.IsDigit(signature[i])) - i++; - tokens.Add(signature.Substring(start, i - start)); - } - else if (signature[i] is 'l' or 'V' && i + 1 < signature.Length && char.IsDigit(signature[i + 1])) - { - tokens.Add(signature.Substring(i, 2)); - i += 2; - } - else - { - tokens.Add(signature[i].ToString()); - i++; - } - } - - return tokens; - } - - /// - /// True for a token describing a type passed by value across several wasm parameters - /// ("l2", "V2", "V4"). Interop signatures do not use these today. - /// - private static bool IsMultiSlotToken(string token) - => token.Length == 2 && token[0] is 'l' or 'V' && char.IsDigit(token[1]); - - private static void RejectMultiSlotToken(string token) - { - if (IsMultiSlotToken(token)) - throw new NotSupportedException($"Multi-slot signature token '{token}' is not supported in interop thunks"); - } - - public static string TokenToNativeType(string token) - { - RejectMultiSlotToken(token); - return token[0] switch - { - 'v' => "void", - 'i' => "int32_t", - 'l' => "int64_t", - 'f' => "float", - 'd' => "double", - 'S' or 'A' => "int32_t", - 'T' => "int32_t", - 'p' => "PCODE", - _ => throw new InvalidSignatureCharException(token[0]) - }; - } - - public static string TokenToNameType(string token) - { - RejectMultiSlotToken(token); - return token[0] switch - { - 'v' => "Void", - 'i' => "I32", - 'l' => "I64", - 'f' => "F32", - 'd' => "F64", - 'S' or 'A' => token, - 'T' => "This", - 'p' => "PE", - _ => throw new InvalidSignatureCharException(token[0]) - }; - } - - public static string TokenToArgType(string token) - { - RejectMultiSlotToken(token); - return token[0] switch - { - 'i' => "ARG_I32", - 'l' => "ARG_I64", - 'f' => "ARG_F32", - 'd' => "ARG_F64", - 'S' or 'A' => "ARG_IND", - 'T' => "ARG_I32", - _ => throw new InvalidSignatureCharException(token[0]) - }; - } - - /// - /// Returns the number of INTERP_STACK_SLOT_SIZE slots consumed by a token. - /// Struct tokens consume max((size + 7) / 8, 1) slots; all others consume 1. - /// - public static int TokenToSlotCount(string token) - { - if (token[0] is not ('S' or 'A') || token.Length < 2) - return 1; - - int size = GetStructSize(token); - return Math.Max((size + 7) / 8, 1); - } - - internal static int GetStructSize(string token) - { - return int.Parse(token.Substring(1)); - } - - // Legacy single-char overloads — still used by consumers that don't encounter S tokens. - public static string CharToNativeType(char c) => TokenToNativeType(c.ToString()); - public static string CharToNameType(char c) => TokenToNameType(c.ToString()); - public static string CharToArgType(char c) => TokenToArgType(c.ToString()); - - public string TypeToNameType(Type t) - { - char? c = TypeToChar(t, out _); - if (c is null) - throw new InvalidSignatureCharException('?'); - - return CharToNameType(c.Value); - } - - public static bool IsVoidSignature(string signature) => signature[0] == 'v'; -} - -internal sealed class InvalidSignatureCharException : Exception -{ - public char Char { get; private set; } - - public InvalidSignatureCharException(char c) : base($"Can't handle signature '{c}'") => Char = c; -} diff --git a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs b/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs deleted file mode 100644 index f5953660c79bf2..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs +++ /dev/null @@ -1,263 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Reflection; -using System.Text; -using Microsoft.Build.Framework; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -/// -/// Resolves wasm ABI encodings by asking crossgen2, running in its --wasm-abi-query mode. -/// -/// -/// The generated helpers have to agree with compiled code exactly - a struct whose size is off by one -/// produces a call that reads the wrong stack slots at runtime - so the sizes come from the compiler's -/// own type system rather than from reflection, which has no field layout engine. -/// -/// crossgen2 answers rather than a purpose-built tool so that there is exactly one implementation of -/// the wasm lowering rules and one type system configuration. Query mode never loads the JIT, so the -/// crossgen2 used here does not have to be the wasm-targeting one; the target is selected by the -/// --targetos and --targetarch arguments. -/// -/// It runs out of process because this task also runs under .NET Framework MSBuild, which cannot load -/// a netcoreapp type system assembly. Loading the assembly closure is the expensive part, so the -/// process is started once and reused for every query. -/// -internal sealed class WasmAbiTypeResolver : IWasmAbiTypeResolver, IDisposable -{ - private readonly string _dotnetHostPath; - private readonly string _crossgen2Path; - private readonly string _targetOS; - private readonly IReadOnlyList _assemblies; - private readonly LogAdapter _log; - private readonly Dictionary<(string Assembly, int Token), string> _typeCache = new(); - private readonly Dictionary<(string Assembly, int Token, WasmLoweringFlags Flags), string> _methodCache = new(); - - private Process? _process; - private string? _responseFilePath; - private readonly StringBuilder _stderr = new(); - - public WasmAbiTypeResolver(string dotnetHostPath, string crossgen2Path, string targetOS, IReadOnlyList assemblies, LogAdapter log) - { - _dotnetHostPath = dotnetHostPath; - _crossgen2Path = crossgen2Path; - _targetOS = targetOS; - _assemblies = assemblies; - _log = log; - } - - public string GetAbiToken(Type type) - { - if (type.IsConstructedGenericType || type.IsGenericParameter || type.ContainsGenericParameters) - { - throw new LogAsErrorException( - $"Cannot compute the wasm ABI encoding of generic type '{type.FullName ?? type.Name}'. " + - "Generic types are not addressable by metadata token, so the size of an instantiation cannot be resolved."); - } - - string assemblyName = type.Module.Assembly.GetName().Name - ?? throw new LogAsErrorException($"Type '{type.FullName ?? type.Name}' comes from an assembly with no simple name."); - int metadataToken = type.MetadataToken; - - var key = (assemblyName, metadataToken); - if (_typeCache.TryGetValue(key, out string? cached)) - return cached; - - string reply = Query($"t {assemblyName} 0x{metadataToken:x8}"); - if (reply[0] == '!') - { - throw new LogAsErrorException( - $"Could not compute the wasm ABI encoding of '{type.FullName ?? type.Name}': {reply.Substring(1)}"); - } - - _typeCache[key] = reply; - return reply; - } - - public string GetMethodSignature(MethodInfo method, WasmLoweringFlags flags) - { - string assemblyName = method.Module.Assembly.GetName().Name - ?? throw new LogAsErrorException($"Method '{method.Name}' comes from an assembly with no simple name."); - int metadataToken = method.MetadataToken; - - var key = (assemblyName, metadataToken, flags); - if (_methodCache.TryGetValue(key, out string? cached)) - return cached; - - string reply = Query($"m {assemblyName} 0x{metadataToken:x8} {(int)flags}"); - if (reply[0] == '!') - { - throw new LogAsErrorException( - $"Could not compute the wasm signature of '{method.DeclaringType?.FullName}::{method.Name}': {reply.Substring(1)}"); - } - - _methodCache[key] = reply; - return reply; - } - - private string Query(string request) - { - Process process = EnsureStarted(); - process.StandardInput.WriteLine(request); - process.StandardInput.Flush(); - - string? reply = process.StandardOutput.ReadLine(); - if (reply is null) - { - throw new LogAsErrorException( - $"crossgen2 ('{_crossgen2Path}') exited unexpectedly while resolving '{request}'. {ReadStandardError(process)}"); - } - - return reply; - } - - private Process EnsureStarted() - { - if (_process is not null) - return _process; - - if (!File.Exists(_crossgen2Path)) - { - throw new LogAsErrorException( - $"crossgen2 was not found at '{_crossgen2Path}'. It computes the wasm ABI struct sizes the generated " + - "helpers need. Set the Crossgen2Path task parameter to its location."); - } - - // A response file keeps the command line under the platform limit; the framework alone is - // ~170 assemblies and an app can add many more. - _responseFilePath = Path.GetTempFileName(); - File.WriteAllLines(_responseFilePath, _assemblies, Encoding.UTF8); - - // The assemblies are passed as crossgen2's positional inputs rather than as references so - // that its "no input files" check is satisfied; query mode writes no image, so nothing is - // compiled for them. - string arguments = $"--wasm-abi-query --targetos {_targetOS} --targetarch wasm {Quote("@" + _responseFilePath)}"; - - // crossgen2 normally ships as an apphost, but an IL-only build is run through the muxer. - string executable = _crossgen2Path; - if (_crossgen2Path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - { - executable = _dotnetHostPath; - arguments = $"exec {Quote(_crossgen2Path)} {arguments}"; - } - - var startInfo = new ProcessStartInfo(executable) - { - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - // ProcessStartInfo.ArgumentList is not available on .NET Framework, which this task also - // targets, so the command line is quoted by hand. - Arguments = arguments, - }; - - _log.LogMessage(MessageImportance.Low, $"Starting wasm ABI query: {executable} {arguments}"); - - Process process; - try - { - process = Process.Start(startInfo) - ?? throw new LogAsErrorException($"Failed to start crossgen2 '{_crossgen2Path}'."); - } - catch (Exception ex) when (ex is not LogAsErrorException) - { - throw new LogAsErrorException($"Failed to start crossgen2 '{_crossgen2Path}': {ex.Message}"); - } - - // Take ownership before the handshake so a failure below still goes through Dispose. An - // orphaned tool would hold open file handles on every assembly in the closure, which on - // Windows blocks a subsequent build from overwriting them. - _process = process; - - // stderr has to be drained continuously: the tool would otherwise block once the pipe - // buffer filled, while this side blocks reading stdout. - process.ErrorDataReceived += (_, e) => - { - if (e.Data is not null) - { - lock (_stderr) - { - _stderr.AppendLine(e.Data); - } - } - }; - process.BeginErrorReadLine(); - - string? ready = process.StandardOutput.ReadLine(); - if (ready != "ready") - { - throw new LogAsErrorException( - $"crossgen2 '{_crossgen2Path}' failed to load the assembly closure. {ReadStandardError(process)}"); - } - - return process; - } - - private static readonly char[] s_charsNeedingQuotes = new[] { ' ', '"', '\t' }; - - private static string Quote(string argument) - { - if (argument.Length > 0 && argument.IndexOfAny(s_charsNeedingQuotes) < 0) - return argument; - - return "\"" + argument.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; - } - - private string ReadStandardError(Process process) - { - // Give the asynchronous reader a moment to flush what the tool wrote before it died, - // but never block the build waiting on a process that is still alive. - try - { - process.WaitForExit(2000); - } - catch (Exception) - { - } - - lock (_stderr) - { - return _stderr.ToString().Trim(); - } - } - - public void Dispose() - { - if (_process is not null) - { - try - { - // Closing stdin ends the tool's read loop, letting it exit on its own. - _process.StandardInput.Close(); - if (!_process.WaitForExit(5000)) - _process.Kill(); - } - catch (Exception ex) - { - _log.LogMessage(MessageImportance.Low, $"Failed to shut down the wasm ABI query process: {ex.Message}"); - } - - _process.Dispose(); - _process = null; - } - - if (_responseFilePath is not null) - { - try - { - File.Delete(_responseFilePath); - } - catch (IOException) - { - } - - _responseFilePath = null; - } - } -} diff --git a/src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs b/src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs deleted file mode 100644 index c2c9fd8ed0be75..00000000000000 --- a/src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; - -namespace Microsoft.WebAssembly.Build.Tasks.CoreClr; - -/// -/// Mirrors Internal.JitInterface.WasmLowering.LoweringFlags, which this task cannot reference -/// because the type system it lives in does not load on .NET Framework MSBuild. The values are passed -/// through to the signature resolver unchanged, so they must stay in sync. -/// -[Flags] -internal enum WasmLoweringFlags -{ - /// - /// A managed call. The signature gains a 'T' for an instance method and a trailing 'p' for the - /// portable entry point parameter. - /// - None = 0x0, - - HasGenericContextArg = 0x1, - - IsAsyncCall = 0x2, - - /// - /// A native signature: the lowered parameters and return value with no managed calling convention - /// additions. Used for P/Invoke targets and reverse P/Invoke entry points. - /// - IsUnmanagedCallersOnly = 0x4, -} diff --git a/src/tasks/WasmAppBuilder/mono/ManagedToNativeGenerator.cs b/src/tasks/WasmAppBuilder/mono/ManagedToNativeGenerator.cs index 6169c9dccf5e13..89d58d22ebd573 100644 --- a/src/tasks/WasmAppBuilder/mono/ManagedToNativeGenerator.cs +++ b/src/tasks/WasmAppBuilder/mono/ManagedToNativeGenerator.cs @@ -88,7 +88,7 @@ private void ExecuteInternal(LogAdapter log) if (ShouldRun(managedAssemblies)) { var pinvoke = new PInvokeTableGenerator(FixupSymbolName, log, IsLibraryMode, TargetOS); - var icall = new IcallTableGenerator(RuntimeIcallTableFile, FixupSymbolName, log, isCoreClr: false); + var icall = new IcallTableGenerator(RuntimeIcallTableFile, FixupSymbolName, log); var resolver = new PathAssemblyResolver(managedAssemblies); using var mlc = new MetadataLoadContext(resolver, "System.Private.CoreLib"); From 1d6a100a1831cd172600b94d234a0e8d124c2e7d Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 10:43:20 +0200 Subject: [PATCH 08/72] [wasm] Skip String constructors when collecting InternalCall signatures Unlike Type.GetMethods(BindingFlags), which structurally never returns constructors, MetadataType.GetMethods() does, so the crossgen2 generator started emitting interp-to-managed thunks for the nine InternalCall String constructors (five distinct signatures). Those entries are unreachable. String constructors are compiled as static factories, "String Ctor(args)" rather than "void .ctor(this, args)", via WasmLowering.GetStringCtorActualSignature, and the runtime special-cases them in both directions with hardcoded keys before it ever consults the generated table: GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk in src/coreclr/vm/wasm/helpers.cpp. Nothing looks up the declared instance shape. Skip them, which also restores the generated output to byte-for-byte parity with the previous generator. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmInternalCallSignatureCollector.cs | 9 +++++ .../browser/callhelpers-interp-to-managed.cpp | 40 ------------------- .../wasi/callhelpers-interp-to-managed.cpp | 40 ------------------- 3 files changed, 9 insertions(+), 80 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs index 5b9c50bc2c7ed6..dde80f1776d07a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs @@ -60,6 +60,15 @@ public void ScanType(EcmaType type) if (!method.IsInternalCall) continue; + // String constructors never reach a signature-derived thunk. They are compiled as static + // factories ("String Ctor(args)", see WasmLowering.GetStringCtorActualSignature), and the + // runtime special-cases them in both directions with hardcoded keys before it consults + // this table: GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk in + // src/coreclr/vm/wasm/helpers.cpp. Emitting the declared "void .ctor(this, args)" shape + // here would only add entries nothing can look up. + if (method.IsConstructor && method.OwningType.IsWellKnownType(WellKnownType.String)) + continue; + // An uninstantiated generic has no single signature to generate a thunk from, because // its parameters stand for whatever the instantiation supplies. if (method.HasInstantiation || method.OwningType.HasInstantiation) diff --git a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp index 6bfccf16e8a064..dc7af3ffa4ea3a 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp @@ -510,41 +510,6 @@ namespace (*fptr)(ARG_IND(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), ARG_I32(5), ARG_I32(6), ARG_I32(7), ARG_I32(8), ARG_I32(9), ARG_I32(10), ARG_I32(11)); } - NOINLINE static void CallFunc_This_S8_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_IND(1), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), pPortableEntryPoint); - } - NOINLINE static void CallFunc_This_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) { alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; @@ -776,11 +741,6 @@ const StringToWasmSigThunk g_wasmThunks[] = { { "MvS8iiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiiiiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_RetVoid }, - { "MvTS8p", (void*)&CallFunc_This_S8_RetVoid_PE }, - { "MvTiiiip", (void*)&CallFunc_This_I32_I32_I32_I32_RetVoid_PE }, - { "MvTiiip", (void*)&CallFunc_This_I32_I32_I32_RetVoid_PE }, - { "MvTiip", (void*)&CallFunc_This_I32_I32_RetVoid_PE }, - { "MvTip", (void*)&CallFunc_This_I32_RetVoid_PE }, { "MvTp", (void*)&CallFunc_This_RetVoid_PE }, { "Mvdddddddddii", (void*)&CallFunc_F64_F64_F64_F64_F64_F64_F64_F64_F64_I32_I32_RetVoid }, { "Mvdi", (void*)&CallFunc_F64_I32_RetVoid }, diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp index e13326d784da4a..0033e59ea2469e 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp @@ -510,41 +510,6 @@ namespace (*fptr)(ARG_IND(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), ARG_I32(5), ARG_I32(6), ARG_I32(7), ARG_I32(8), ARG_I32(9), ARG_I32(10), ARG_I32(11)); } - NOINLINE static void CallFunc_This_S8_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_IND(1), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), ARG_I32(4), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), ARG_I32(3), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), ARG_I32(2), pPortableEntryPoint); - } - - NOINLINE static void CallFunc_This_I32_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) - { - alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; - void (*fptr)(int*, int32_t, int32_t, PCODE) = *(void (**)(int*, int32_t, int32_t, PCODE))(pPortableEntryPoint); - (*fptr)(&framePointer, ARG_I32(0), ARG_I32(1), pPortableEntryPoint); - } - NOINLINE static void CallFunc_This_RetVoid_PE(PCODE pPortableEntryPoint, int8_t* pArgs, int8_t* pRet) { alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK; @@ -752,11 +717,6 @@ const StringToWasmSigThunk g_wasmThunks[] = { { "MvS8iiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_RetVoid }, { "MvS8iiiiiiiiiii", (void*)&CallFunc_S8_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_I32_RetVoid }, - { "MvTS8p", (void*)&CallFunc_This_S8_RetVoid_PE }, - { "MvTiiiip", (void*)&CallFunc_This_I32_I32_I32_I32_RetVoid_PE }, - { "MvTiiip", (void*)&CallFunc_This_I32_I32_I32_RetVoid_PE }, - { "MvTiip", (void*)&CallFunc_This_I32_I32_RetVoid_PE }, - { "MvTip", (void*)&CallFunc_This_I32_RetVoid_PE }, { "MvTp", (void*)&CallFunc_This_RetVoid_PE }, { "Mvdiip", (void*)&CallFunc_F64_I32_I32_RetVoid_PE }, { "Mvfiip", (void*)&CallFunc_F32_I32_I32_RetVoid_PE }, From 8337799a7f617310bf446f3cb2b235986516d047 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 10:59:43 +0200 Subject: [PATCH 09/72] [wasm] Use ordinal sort for assembly attribution, refresh stale comments Generated build output must be byte-reproducible regardless of the host's current culture. Every other sort in WasmPInvokeTableGenerator already passes StringComparer.Ordinal; the assembly-attribution comment builder was the one exception and used the default culture-sensitive string ordering. No baseline drift on en-US, so this is latent-bug hardening rather than a fix for observed breakage. Also refresh four comments that still described the abandoned "generator shells out to crossgen2 and asks it ABI questions" design. crossgen2 now generates the call helpers itself, so the query-mode framing was misleading. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- eng/pipelines/common/wasm-post-build-steps.yml | 2 +- src/coreclr/tools/Common/JitInterface/WasmLowering.cs | 4 ++-- .../Wasm/WasmPInvokeTableGenerator.cs | 2 +- src/libraries/sendtohelix-browser.targets | 4 ++-- .../WorkloadManifest.targets.in | 8 ++++---- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/eng/pipelines/common/wasm-post-build-steps.yml b/eng/pipelines/common/wasm-post-build-steps.yml index d198252b0e3c73..4feacb79840348 100644 --- a/eng/pipelines/common/wasm-post-build-steps.yml +++ b/eng/pipelines/common/wasm-post-build-steps.yml @@ -8,7 +8,7 @@ parameters: steps: - # The wasm CoreCLR generator shells out to crossgen2 for P/Invoke struct sizes while building test + # The wasm CoreCLR build runs crossgen2 to generate the P/Invoke call helpers while building test # apps, so the Wasm.Build.Tests leg ships it to Helix as a correlation payload. That leg builds only # the test project and takes everything else from here, so crossgen2 has to travel in these # artifacts. The glob matches nothing for Mono, which has no crossgen2 to send. diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index 2e66332a868548..c89d5bda32f0a0 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -389,8 +389,8 @@ public static WasmValueType LowerType(TypeDesc type) /// /// Maps a WasmValueType to its single-character signature encoding. /// - // internal rather than private so the wasm ABI query mode can answer single-type questions - // with the same encoding table the signature builder below uses. + // internal rather than private so the call-helper generator can encode a single type with the + // same table the signature builder below uses (see ILCompiler.Wasm.WasmInteropSignature). internal static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch { WasmValueType.I32 => 'i', diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs index 0372cfb76147d0..f77b090dc2f6cf 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs @@ -197,7 +197,7 @@ static bool ShouldTreatAsVariadic(WasmPInvoke[] candidates) } static string ListRefs(IGrouping l) - => string.Join(", ", l.Select(c => ((EcmaAssembly)c.Method.Module).GetName().Name).Distinct().OrderBy(n => n)); + => string.Join(", ", l.Select(c => ((EcmaAssembly)c.Method.Module).GetName().Name).Distinct().OrderBy(n => n, StringComparer.Ordinal)); } public void EmitNativeToInterp(TextWriter w, List callbacks) diff --git a/src/libraries/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index 88a0edd0a71310..ef8ee588b30113 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -295,8 +295,8 @@ - + diff --git a/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in b/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in index 06a3199fa7d0b6..7420fab69354c2 100644 --- a/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in +++ b/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in @@ -216,10 +216,10 @@ No MonoTargets.Sdk, no MonoAOTCompiler, no AOT.Cross pack — the SDK resolves the CoreCLR runtime pack via its own KnownRuntimePack. - crossgen2 is imported for its type system, not to compile ReadyToRun images: the wasm P/Invoke - generator asks it for the ABI signatures of the P/Invokes it finds, which encode struct sizes - that cannot be derived from metadata alone. The SDK only resolves its own KnownCrossgen2Pack - when PublishReadyToRun is set, so the pack is acquired through the workload instead. --> + crossgen2 is imported for its type system, not to compile ReadyToRun images: it generates the + wasm P/Invoke call helpers, which encode struct sizes that cannot be derived from metadata + alone. The SDK only resolves its own KnownCrossgen2Pack when PublishReadyToRun is set, so the + pack is acquired through the workload instead. --> From 75881d4dbfc03a9f37a05e816098691439e4aea6 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 11:11:34 +0200 Subject: [PATCH 10/72] [wasm] Declare by-reference struct parameters as void * in generated C GenPInvokeDecl already asked the real ABI whether a struct return travels through a hidden buffer, but the parameter path still unwrapped any single-field struct to that field's C type without checking that the field fills the struct. WasmLowering.LowerToAbiType refuses to unwrap a padded single-field struct, so the signature the runtime encodes says by-reference while the declaration said otherwise. The two disagreed inside one generated file: for [StructLayout(LayoutKind.Sequential, Size = 16)] struct PaddedLong { public long Value; } the return position emitted `void RetPaddedLong (void *)` while the parameter position emitted `void UsePaddedLong (int64_t)` for the same type. The caller passes an i32 pointer, so this is a wasm value type mismatch rather than a C-level spelling difference. Reverse thunks share MapType and had the same gap. Route both positions through one IsPassedByReference helper. The old generator unwrapped in both places, so it was at least self-consistent; only the cookie side was corrected when this moved into crossgen2. No baseline drift: no P/Invoke in CoreLib or the libraries takes a padded single-field struct today, which is why this went unnoticed. It matters for the arbitrary user structs this change exists to support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Wasm/WasmPInvokeTableGenerator.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs index f77b090dc2f6cf..a6e881d346ddf3 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs @@ -312,7 +312,7 @@ private string GenPInvokeDecl(WasmPInvoke pinvoke) TypeDesc returnType = signature.ReturnType; List parameterTypes = ParameterTypes(signature).Select(MapType).ToList(); - if (IsReturnedByReference(returnType)) + if (IsPassedByReference(returnType)) { returnType = pinvoke.Method.Context.GetWellKnownType(WellKnownType.Void); parameterTypes.Insert(0, "void *"); @@ -339,10 +339,11 @@ private string ThunkMapEntryLine(WasmPInvokeCallback cb) => $" {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{FixedSymbolName(cb)}, (void*)&Call_{cb.EntrySymbol} }} }}"; /// - /// Whether a struct return is turned into a hidden by-reference first argument, which the C - /// declaration has to spell out because the generated code calls the import directly. + /// Whether the wasm ABI moves a struct by reference instead of as a bare value. Padding, + /// several fields, or a type too wide for one slot all force the hidden-pointer form, so the + /// C declaration has to say void * rather than unwrap to the field's type. /// - private static bool IsReturnedByReference(TypeDesc type) + private static bool IsPassedByReference(TypeDesc type) { if (!type.IsValueType || type.IsPrimitive || type.IsEnum || type is FunctionPointerType) return false; @@ -384,6 +385,14 @@ private static string PickCTypeNameForUnknownType(TypeDesc type) if (type.IsEnum) return MapType(type.UnderlyingType); + // The wasm C ABI hands a struct over as a bare scalar only when it recursively contains a + // single scalar that fills it. Padding or extra fields make it travel by reference, so ask + // the same lowering the runtime encodes into the signature instead of unwrapping blindly: + // otherwise a `[StructLayout(Size = 16)] struct { long V; }` parameter is declared int64_t + // while the caller passes a pointer. + if (IsPassedByReference(type)) + return "void *"; + // https://github.com/WebAssembly/tool-conventions/blob/main/BasicCABI.md#function-signatures // Any struct or union that recursively (including through nested structs, unions, and arrays) // contains just a single scalar value and is not specified to have greater than natural alignment. From 544dedc298abf56f109630c594ec06b38d89e3d1 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 11:24:40 +0200 Subject: [PATCH 11/72] [wasm] Don't emit a valueless --wasm-ignored-pinvoke-module Item batching on an empty collection still evaluates the element once, with an empty %(Identity), so `Include="--wasm-ignored-pinvoke-module;%(...)"` wrote a bare option into the response file. crossgen2 reads one token per line and binds the next one as the option's value, so the first managed assembly was silently swallowed as the argument instead of being scanned. _WasmIgnoredPInvokeModules is only populated when InvariantGlobalization is true, so the broken shape was the default configuration. The targets add System.Private.CoreLib explicitly, and it sorts first, so the assembly most likely to be dropped is the one every InternalCall comes from. With a single input crossgen2 instead fails outright: Required argument 'input-file-path' missing for command: 'crossgen2'. Guard both module options on a non-empty identity. The in-repo regeneration script builds its own argument list and never had this problem, which is why the checked-in helpers still reproduce byte for byte. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/mono/browser/build/BrowserWasmApp.CoreCLR.targets | 4 ++-- src/mono/wasi/build/WasiApp.CoreCLR.targets | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 5c221b84a95ef8..edde5aef2697a7 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -659,8 +659,8 @@ <_WasmInteropGeneratorArg Include="wasm" /> <_WasmInteropGeneratorArg Include="--wasm-generate-callhelpers" /> <_WasmInteropGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> - <_WasmInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasmPInvokeModules.Identity)" /> - <_WasmInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasmIgnoredPInvokeModules.Identity)" /> + <_WasmInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> + <_WasmInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasmIgnoredPInvokeModules.Identity)" Condition="'%(_WasmIgnoredPInvokeModules.Identity)' != ''" /> <_WasmInteropGeneratorArg Include="--wasm-no-warn-unresolved-pinvoke-modules" Condition="'$(WasmWarnOnUnresolvedPInvokeModules)' == 'false'" /> <_WasmInteropGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index f481fd84eae8b3..80cb97dcca4e53 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -182,8 +182,8 @@ <_WasiInteropGeneratorArg Include="--wasm-generate-callhelpers" /> <_WasiInteropGeneratorArg Include="$(_WasiRelinkObjDir)" /> <_WasiInteropGeneratorArg Include="--wasm-no-warn-unresolved-pinvoke-modules" /> - <_WasiInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasiPInvokeModules.Identity)" /> - <_WasiInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasiIgnoredPInvokeModules.Identity)" /> + <_WasiInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> + <_WasiInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasiIgnoredPInvokeModules.Identity)" Condition="'%(_WasiIgnoredPInvokeModules.Identity)' != ''" /> <_WasiInteropGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> From 4bffa450e3938814526f5ebf7c15ff48a504750d Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 12:15:52 +0200 Subject: [PATCH 12/72] [wasm] Address review feedback on the generator Make WasmInteropGenerator and its options internal. Every other type under Wasm/ already is, and the generator is a moving target - keeping it out of the assembly's public surface leaves it free to change. crossgen2 is a separate assembly, so it gets an InternalsVisibleTo. Throw LogAsErrorException rather than NotSupportedException for multi-slot tokens. The call sites in WasmInterpToNativeGenerator sit outside the catches that downgrade to WASM0001, so the exception reached the top uncaught and a stack trace replaced the clean diagnostic the old generator produced. Pattern-match instead of casting to MetadataType in the blittability check. No TypeDesc that reaches that line is anything else - generic parameters report IsValueType false one branch earlier - but an InvalidCastException there would have been downgraded to a warning and silently skipped the callback. Regeneration is byte identical with no WASM warnings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj | 1 + .../aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs | 4 ++-- .../aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs | 2 +- .../aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs | 7 ++++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index 25e4641894c16f..fa190faf1de8de 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -24,6 +24,7 @@ + diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs index 9418d33b4d40a5..9fcd4b0d483273 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs @@ -15,7 +15,7 @@ namespace ILCompiler.Wasm /// /// Options for , mirroring the command line. /// - public sealed class WasmInteropGeneratorOptions + internal sealed class WasmInteropGeneratorOptions { public string OutputDirectory { get; init; } public IReadOnlyList PInvokeModules { get; init; } = []; @@ -39,7 +39,7 @@ public sealed class WasmInteropGeneratorOptions /// crossgen2 --wasm-generate-callhelpers <dir> --targetos <browser|wasi> --targetarch wasm \ /// --wasm-pinvoke-module <name>... <assembly>... /// - public static class WasmInteropGenerator + internal static class WasmInteropGenerator { public const string PInvokeFileName = "callhelpers-pinvoke.cpp"; public const string ReversePInvokeFileName = "callhelpers-reverse.cpp"; diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs index 2d406cbaa69d4d..b1e236884ee59b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs @@ -119,7 +119,7 @@ public static bool IsMultiSlotToken(string token) private static void RejectMultiSlotToken(string token) { if (IsMultiSlotToken(token)) - throw new NotSupportedException($"Multi-slot signature token '{token}' is not supported in interop thunks"); + throw new LogAsErrorException($"Multi-slot signature token '{token}' is not supported in interop thunks"); } public static string TokenToNativeType(string token) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs index 104a81b9b123a7..57ae082ff9bf89 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs @@ -355,7 +355,12 @@ private bool IsBlittableUncached(TypeDesc type) return false; } - var metadataType = (MetadataType)type; + if (type is not MetadataType metadataType) + { + log.InfoHigh("WASM0060", $"Type {type} is not blittable: No metadata"); + return false; + } + List fields = []; foreach (FieldDesc field in metadataType.GetFields()) { From 08491ee8a82485b71075409bfb5e5b46e0024e05 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 14:42:08 +0200 Subject: [PATCH 13/72] [wasm] Encode multi-segment types the way the compiler lowers them GetAbiToken treated every type LowerToAbiType leaves alone as a struct passed by reference, but WasmLowering.GetSignature splits that case in two: multi-segment types travel by value across several wasm parameters and get a token instead. The two encoders disagreeing meant Int128, UInt128, Decimal128, Vector256 and Vector512 came back as 'A16' at the interop boundary while a method signature spelled them 'l2'. That both hid them from the multi-slot rejection, which is what is supposed to turn them into a clean diagnostic, and declared them 'void *' in C when the ABI passes them by value. Mirror the branch GetSignature takes, so these types now reach RejectMultiSlotToken and fail the build with a message instead of generating a thunk that disagrees with the runtime. No P/Invoke or callback in the framework passes one of these by value today, so the generated helpers are unchanged; the new test pins the two encoders together for each shape the ABI treats differently rather than hardcoding tokens, since that invariant is what both this and the earlier padded-struct defect broke. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 29 +++++++++++++++++++ .../Wasm/WasmInteropSignature.cs | 9 ++++++ 2 files changed, 38 insertions(+) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 7bcdd7dd54b5b4..2f1239d1757ac1 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -607,6 +607,35 @@ public void WasmInteropGeneratorEncodesVoid() Assert.Equal("v", WasmInteropSignature.GetAbiToken(context.GetWellKnownType(WellKnownType.Void))); } + /// + /// A type has to get the same token at the interop boundary as it does inside a lowered method + /// signature, because the runtime looks a thunk up by the signature the compiler produced. The + /// two encoders are separate code, so this pins them together for each shape the ABI treats + /// differently: multi-segment types passed by value across several slots, structs passed by + /// reference, single-field wrappers, and primitives. + /// + [Theory] + [InlineData("Int128")] + [InlineData("UInt128")] + [InlineData("Guid")] + [InlineData("DateTime")] + [InlineData("Int32")] + [InlineData("Double")] + public void WasmInteropGeneratorEncodesTypesTheSameWayInAndOutOfASignature(string typeName) + { + ReadyToRunCompilerContext context = CreateWasmContext(); + TypeDesc type = GetSystemType(context, typeName); + + string signature = WasmLowering.GetSignature( + MakeStaticVoidSignature(context, type), + WasmLowering.LoweringFlags.None).SignatureString; + _output.WriteLine($"{typeName} lowers to '{signature}' in a signature"); + + // 'v' return, then the single parameter, then the 'p' entrypoint suffix. + List tokens = WasmInteropSignature.ParseSignatureTokens(signature); + Assert.Equal(tokens[1], WasmInteropSignature.GetAbiToken(type)); + } + /// /// Real builds hand the generator the whole app closure, not one assembly. The compilation group /// it configures has to accept that: a multi-assembly set is only legal in composite mode, and a diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs index b1e236884ee59b..819e2d2464a91b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Globalization; +using ILCompiler.DependencyAnalysis.Wasm; using Internal.JitInterface; using Internal.TypeSystem; @@ -63,6 +64,14 @@ public static string GetAbiToken(TypeDesc type) TypeDesc loweredType = WasmLowering.LowerToAbiType(type); if (loweredType is null) { + // WasmLowering.GetSignature splits this case in two, and both have to be mirrored + // here or a type gets one token in a method signature and a different one at the + // interop boundary. Multi-segment types come first: they travel by value across + // several wasm parameters, so calling them by-reference structs would both hide + // them from the multi-slot rejection and mis-declare them in C. + if (WasmLowering.TryGetMultiSegmentLayout(type, out WasmValueType slotType, out int slotCount)) + return string.Create(CultureInfo.InvariantCulture, $"{WasmLowering.WasmValueTypeToSigChar(slotType)}{slotCount}"); + // Passed by reference; the size is what the callee needs to know. 'A' marks a struct // whose alignment exceeds a stack slot, matching what WasmLowering.GetSignature emits // so a type gets the same token here as it does inside a method signature. From 7689e35bc6ebebf1f8cf199060fc7795061804ac Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 15:28:05 +0200 Subject: [PATCH 14/72] [wasm] Don't reject the generator's inputs over duplicate simple names The call-helper generator is handed the app's whole bundle, which comes from ResolvedFileToPublish and so carries native content alongside the managed assemblies. A package that ships per-architecture payloads puts several files with the same simple name in that set - TraceEvent's KernelTraceControl.dll, for one - and crossgen2's input parser rejects that outright, which took out the browser-wasm LibraryTestsCoreCLR leg building System.Diagnostics.Tracing.Tests. A compilation has to reject the ambiguity because it cannot pick which of the two to compile. A scan does not: crossgen2 already skips inputs it cannot load as managed assemblies, and the deleted MSBuild task filtered unmanaged binaries out for exactly this reason. So parse the generator's inputs the tolerant way and let the existing load-time skip handle them. Verified against the two real KernelTraceControl.dll files: generating with them appended to the framework scan produces output byte-identical to generating without them, and a normal compilation still fails on duplicate simple names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index fea071723f569f..09008b1541851b 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -168,6 +168,15 @@ internal class Crossgen2RootCommand : RootCommand public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) { + // A compilation cannot resolve two inputs with the same simple name, so it rejects them. + // The call-helper generator only scans, and it is handed the app's whole bundle, which + // routinely carries several native files sharing a name (per-architecture payloads out + // of a NuGet package, say). Those get skipped as unloadable further on, so take the + // first of each name here rather than failing the build over an ambiguity that only a + // compilation has to settle. + InputFilePaths.CustomParser = result => + Helpers.BuildPathDictionary(result.Tokens, strict: result.GetResult(WasmGenerateCallHelpers) is null); + Arguments.Add(InputFilePaths); Options.Add(UnrootedInputFilePaths); Options.Add(ReferenceFilePaths); From 9b2bdcbf80338388633d6066c056682e61ec1b5d Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 18 Aug 2026 17:04:25 +0200 Subject: [PATCH 15/72] [wasm] Prefer the managed candidate when input file names collide Argument parsing keys input files by simple name and keeps the first one. An app bundle can legitimately carry several files sharing a name - the per-architecture native payloads that packages ship as content are the common case - so the first one can be a native file that shadows a managed assembly and silently drops its P/Invokes. The type system holds a single module per simple name either way, so the generator now walks every input path and lets the first file that actually loads claim the name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/coreclr/tools/aot/crossgen2/Program.cs | 30 +++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index 90a13fbaecc216..044a7332f62732 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -184,8 +184,35 @@ public int Run() // typeSystemContext.InputFilePaths = inFilePaths; // - foreach (var inputFile in inputFilePathsArg) + // Argument parsing collapses input files that share a simple name and keeps the + // first one, which is what a compilation wants. An app bundle can legitimately + // carry several files with the same name though - per-architecture native + // payloads shipped as content are the common case - and keeping the first one + // there can shadow a managed assembly with a native file. The type system holds + // a single module per simple name either way, so the generator walks every path + // and lets the first one that actually loads claim the name. + IEnumerable> inputFilesToLoad = inputFilePathsArg; + if (_wasmGenerateCallHelpers is not null + && _command.Result.GetResult(_command.InputFilePaths) is { } inputFilePathsResult) { + List> everyInputFile = new(); + foreach (string path in Helpers.BuildPathList(inputFilePathsResult.Tokens)) + { + everyInputFile.Add(new KeyValuePair(Path.GetFileNameWithoutExtension(path), path)); + } + + inputFilesToLoad = everyInputFile; + } + + HashSet claimedSimpleNames = new(StringComparer.OrdinalIgnoreCase); + + foreach (var inputFile in inputFilesToLoad) + { + if (claimedSimpleNames.Contains(inputFile.Key)) + { + continue; + } + try { var module = _typeSystemContext.GetModuleFromPath(inputFile.Value); @@ -195,6 +222,7 @@ public int Run() Console.WriteLine(SR.IgnoringCompositeImage, inputFile.Value); continue; } + claimedSimpleNames.Add(inputFile.Key); _allInputFilePaths.Add(inputFile.Key, inputFile.Value); inputFilePaths.Add(inputFile.Key, inputFile.Value); _referenceableModules.Add(module); From 912a9b2a3896bcba5f12d788df7ba916745d5151 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 19 Aug 2026 16:35:14 +0200 Subject: [PATCH 16/72] [wasm] Fail the call-helper scan instead of warning past a bad signature Every non-generic InternalCall has a signature the wasm lowering can describe, so a failure to produce one is a bug in the generator rather than something the scanned assembly did. Warning and moving on drops the thunk silently and defers the failure to the point where the interpreter tries to call the method, which is far from the cause. Throw instead. The catch-all was inherited verbatim from the deleted MSBuild task, so this is a behavior change only in the case that was already broken. A full framework scan of both browser and wasi regenerates the checked-in baselines byte-identically with no diagnostics, so the new path does not fire on anything we ship. Also drop the pregenerated-signature array: it has always been empty and nothing reads it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Wasm/WasmInternalCallSignatureCollector.cs | 6 +++++- .../ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs | 8 +------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs index dde80f1776d07a..ef86ee57ec4a5e 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs @@ -87,7 +87,11 @@ public void ScanType(EcmaType type) } catch (Exception ex) when (ex is not LogAsErrorException) { - log.Warning("WASM0001", $"Could not get signature for InternalCall method '{type}::{method.Name.ToString()}' because '{ex.Message}'"); + // Every non-generic InternalCall has a signature the lowering can describe, so a + // failure here is a bug in the generator rather than something the assembly did. + // Skipping it would silently drop a thunk and only fail once the interpreter tries + // to call the method. + throw new LogAsErrorException($"Could not get the signature for InternalCall method '{type}::{method.Name.ToString()}': {ex.Message}"); } } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs index 9fcd4b0d483273..89907656fa908b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs @@ -103,13 +103,7 @@ private static void Generate(ReadyToRunCompilerContext context, WasmInteropGener WriteIfDifferent(Path.Combine(options.OutputDirectory, ReversePInvokeFileName), log, w => generator.EmitNativeToInterp(w, callbacks)); - // Pregenerated signatures for commonly used shapes used by R2R code to reduce duplication - // in generated R2R binaries. Currently none, but can be added here as needed in the future. - string[] pregeneratedInterpreterToNativeSignatures = []; - - IEnumerable cookies = signatures - .Concat(internalCallCollector.Signatures) - .Concat(pregeneratedInterpreterToNativeSignatures); + IEnumerable cookies = signatures.Concat(internalCallCollector.Signatures); WriteIfDifferent(Path.Combine(options.OutputDirectory, InterpToNativeFileName), log, w => WasmInterpToNativeGenerator.Emit(w, cookies)); From 31fb782d1ceade44ee22b65c99d64c96e23cf961 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 19 Aug 2026 16:35:32 +0200 Subject: [PATCH 17/72] [wasm] Name the call-helper options after their NativeAOT counterparts The options were wasm-prefixed even though nothing about them is wasm-specific: they name P/Invoke modules to resolve directly and a directory to write helpers into. NativeAOT already spells the same concept --directpinvoke, so adopt that spelling and drop the prefix. --wasm-generate-callhelpers -> --generate-portable-callhelpers --wasm-pinvoke-module -> --directpinvoke --wasm-ignored-pinvoke-module -> --ignored-directpinvoke --wasm-no-warn-unresolved-pinvoke-modules -> --no-warn-unresolved-directpinvoke Note the shared name is narrower here than in NativeAOT: that one takes Module!Entrypoint and applies module-name variations, while this one matches whole module names with an ordinal comparison. The accepted syntax is a strict subset, so nothing valid here means something else there. The options are new in this branch and have never shipped, so there is no compatibility surface to keep. Renames the resource strings to match and updates the two targets files and the generate scripts that pass them. Also corrects a comment describing the input parser that had outlived the code it described. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Wasm/WasmInteropGenerator.cs | 4 +-- .../aot/crossgen2/Crossgen2RootCommand.cs | 32 +++++++++---------- src/coreclr/tools/aot/crossgen2/Program.cs | 22 ++++++------- .../aot/crossgen2/Properties/Resources.resx | 14 ++++---- .../vm/wasm/generate-coreclr-helpers.cmd | 12 +++---- .../vm/wasm/generate-coreclr-helpers.md | 2 +- .../vm/wasm/generate-coreclr-helpers.sh | 6 ++-- .../build/BrowserWasmApp.CoreCLR.targets | 8 ++--- src/mono/wasi/build/WasiApp.CoreCLR.targets | 8 ++--- 9 files changed, 54 insertions(+), 54 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs index 89907656fa908b..874c330c006e1a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs @@ -36,8 +36,8 @@ internal sealed class WasmInteropGeneratorOptions /// drift from it. /// /// Usage: - /// crossgen2 --wasm-generate-callhelpers <dir> --targetos <browser|wasi> --targetarch wasm \ - /// --wasm-pinvoke-module <name>... <assembly>... + /// crossgen2 --generate-portable-callhelpers <dir> --targetos <browser|wasi> --targetarch wasm \ + /// --directpinvoke <name>... <assembly>... /// internal static class WasmInteropGenerator { diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 09008b1541851b..08b8c99ca1dea0 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -98,14 +98,14 @@ internal class Crossgen2RootCommand : RootCommand new("--jitpath") { Description = SR.JitPathOption }; public Option PrintReproInstructions { get; } = new("--print-repro-instructions") { Description = SR.PrintReproInstructionsOption }; - public Option WasmGenerateCallHelpers { get; } = - new("--wasm-generate-callhelpers") { Description = SR.WasmGenerateCallHelpersOption }; - public Option WasmPInvokeModule { get; } = - new("--wasm-pinvoke-module") { Description = SR.WasmPInvokeModuleOption }; - public Option WasmIgnoredPInvokeModule { get; } = - new("--wasm-ignored-pinvoke-module") { Description = SR.WasmIgnoredPInvokeModuleOption }; - public Option WasmNoWarnUnresolvedPInvokeModules { get; } = - new("--wasm-no-warn-unresolved-pinvoke-modules") { Description = SR.WasmNoWarnUnresolvedPInvokeModulesOption }; + public Option GeneratePortableCallHelpers { get; } = + new("--generate-portable-callhelpers") { Description = SR.GeneratePortableCallHelpersOption }; + public Option DirectPInvoke { get; } = + new("--directpinvoke") { Description = SR.DirectPInvokeOption }; + public Option IgnoredDirectPInvoke { get; } = + new("--ignored-directpinvoke") { Description = SR.IgnoredDirectPInvokeOption }; + public Option NoWarnUnresolvedDirectPInvoke { get; } = + new("--no-warn-unresolved-directpinvoke") { Description = SR.NoWarnUnresolvedDirectPInvokeOption }; public Option SingleMethodTypeName { get; } = new("--singlemethodtypename") { Description = SR.SingleMethodTypeName }; public Option SingleMethodName { get; } = @@ -171,11 +171,11 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) // A compilation cannot resolve two inputs with the same simple name, so it rejects them. // The call-helper generator only scans, and it is handed the app's whole bundle, which // routinely carries several native files sharing a name (per-architecture payloads out - // of a NuGet package, say). Those get skipped as unloadable further on, so take the - // first of each name here rather than failing the build over an ambiguity that only a - // compilation has to settle. + // of a NuGet package, say). Parsing must not fail over an ambiguity only a compilation + // has to settle: the generator re-expands the tokens itself and lets the first path that + // actually loads claim each simple name, so a native file cannot shadow a managed one. InputFilePaths.CustomParser = result => - Helpers.BuildPathDictionary(result.Tokens, strict: result.GetResult(WasmGenerateCallHelpers) is null); + Helpers.BuildPathDictionary(result.Tokens, strict: result.GetResult(GeneratePortableCallHelpers) is null); Arguments.Add(InputFilePaths); Options.Add(UnrootedInputFilePaths); @@ -218,10 +218,10 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(TargetOS); Options.Add(JitPath); Options.Add(PrintReproInstructions); - Options.Add(WasmGenerateCallHelpers); - Options.Add(WasmPInvokeModule); - Options.Add(WasmIgnoredPInvokeModule); - Options.Add(WasmNoWarnUnresolvedPInvokeModules); + Options.Add(GeneratePortableCallHelpers); + Options.Add(DirectPInvoke); + Options.Add(IgnoredDirectPInvoke); + Options.Add(NoWarnUnresolvedDirectPInvoke); Options.Add(SingleMethodTypeName); Options.Add(SingleMethodName); Options.Add(SingleMethodIndex); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index 044a7332f62732..6b5f2e52cb2dec 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -39,7 +39,7 @@ internal sealed class Program private readonly bool _singleFileCompilation; private readonly bool _outNearInput; private readonly string _outputFilePath; - private readonly string _wasmGenerateCallHelpers; + private readonly string _generatePortableCallHelpers; public Program(Crossgen2RootCommand command) { @@ -48,7 +48,7 @@ public Program(Crossgen2RootCommand command) _singleFileCompilation = Get(command.SingleFileCompilation); _outNearInput = Get(command.OutNearInput); _outputFilePath = Get(command.OutputFilePath); - _wasmGenerateCallHelpers = Get(command.WasmGenerateCallHelpers); + _generatePortableCallHelpers = Get(command.GeneratePortableCallHelpers); if (Get(command.WaitForDebugger)) { @@ -72,7 +72,7 @@ public int Run() { // Interop generation mode reads the input assemblies and writes source files, so the // output arguments the compilation path requires do not apply. - if (_outputFilePath == null && !_outNearInput && _wasmGenerateCallHelpers is null) + if (_outputFilePath == null && !_outNearInput && _generatePortableCallHelpers is null) throw new CommandLineException(SR.MissingOutputFile); if (_singleFileCompilation && !_outNearInput) @@ -86,10 +86,10 @@ public int Run() // The interop generator answers ABI questions (struct sizes, argument lowering) through the // same type system the compiler uses, so an unspecified target would silently produce host // layouts. Reject anything but a wasm target instead of emitting subtly wrong helpers. - if (_wasmGenerateCallHelpers is not null + if (_generatePortableCallHelpers is not null && (targetArchitecture != TargetArchitecture.Wasm32 || targetOS is not (TargetOS.Browser or TargetOS.Wasi))) { - throw new CommandLineException(SR.WasmGenerateCallHelpersRequiresWasmTarget); + throw new CommandLineException(SR.GeneratePortableCallHelpersRequiresWasmTarget); } bool targetAllowsRuntimeCodeGeneration = GetTargetAllowsRuntimeCodeGeneration(targetOS, targetArchitecture); @@ -192,7 +192,7 @@ public int Run() // a single module per simple name either way, so the generator walks every path // and lets the first one that actually loads claim the name. IEnumerable> inputFilesToLoad = inputFilePathsArg; - if (_wasmGenerateCallHelpers is not null + if (_generatePortableCallHelpers is not null && _command.Result.GetResult(_command.InputFilePaths) is { } inputFilePathsResult) { List> everyInputFile = new(); @@ -316,17 +316,17 @@ public int Run() _typeSystemContext.SetSystemModule((EcmaModule)_typeSystemContext.GetModuleForSimpleName(systemModuleName)); ReadyToRunCompilerContext typeSystemContext = _typeSystemContext; - if (_wasmGenerateCallHelpers is not null) + if (_generatePortableCallHelpers is not null) { return Wasm.WasmInteropGenerator.Run(typeSystemContext, new Wasm.WasmInteropGeneratorOptions { - OutputDirectory = _wasmGenerateCallHelpers, - PInvokeModules = Get(_command.WasmPInvokeModule), - IgnoredPInvokeModules = Get(_command.WasmIgnoredPInvokeModule), + OutputDirectory = _generatePortableCallHelpers, + PInvokeModules = Get(_command.DirectPInvoke), + IgnoredPInvokeModules = Get(_command.IgnoredDirectPInvoke), // The normalized name, so that platform attributes match regardless of how // --targetos was spelled on the command line. TargetOS = targetOS.ToString().ToLowerInvariant(), - WarnOnUnresolvedPInvokeModules = !Get(_command.WasmNoWarnUnresolvedPInvokeModules), + WarnOnUnresolvedPInvokeModules = !Get(_command.NoWarnUnresolvedDirectPInvoke), }, logger); } diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index 378d3a184efdfe..de2a45e23c7fda 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -285,20 +285,20 @@ Target OS for cross compilation - - Generate the wasm call helper sources into the given directory instead of compiling + + Generate the portable entry point call helper sources into the given directory instead of compiling - + Name of a statically linked native module P/Invokes may resolve against - + Name of a native module to leave out of the generated P/Invoke table - + Do not warn about P/Invokes to modules that are not statically linked - - --wasm-generate-callhelpers requires --targetarch wasm together with --targetos browser or --targetos wasi + + --generate-portable-callhelpers requires --targetarch wasm together with --targetos browser or --targetos wasi Target OS is not supported diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd index 09de81adad5ee9..8a96bc1264c313 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd @@ -81,11 +81,11 @@ cd /d "%repo_root%" set crossgen2=%repo_root%\artifacts\bin\coreclr\windows.x64.%configuration%\crossgen2\crossgen2.dll :: Modules the runtime links statically; a P/Invoke into any of them resolves to a direct call. -set pinvoke_module_args=--wasm-pinvoke-module libSystem.Native ^ - --wasm-pinvoke-module libSystem.Native.Browser ^ - --wasm-pinvoke-module libSystem.IO.Compression.Native ^ - --wasm-pinvoke-module libSystem.Globalization.Native ^ - --wasm-pinvoke-module libSystem.Runtime.InteropServices.JavaScript.Native +set pinvoke_module_args=--directpinvoke libSystem.Native ^ + --directpinvoke libSystem.Native.Browser ^ + --directpinvoke libSystem.IO.Compression.Native ^ + --directpinvoke libSystem.Globalization.Native ^ + --directpinvoke libSystem.Runtime.InteropServices.JavaScript.Native :: Resolve scan paths (allow overrides). if not "%browser_scan_path_override%"=="" ( @@ -130,7 +130,7 @@ if not exist "%crossgen2%" ( echo [%target_os%] Scan path: %scan_path% echo [%target_os%] Output path: %output_dir% echo Running generator for %target_os%... -call .\dotnet.cmd "%crossgen2%" --targetos %target_os% --targetarch wasm --wasm-generate-callhelpers "%output_dir%" --wasm-no-warn-unresolved-pinvoke-modules %pinvoke_module_args% "%scan_path%*.dll" +call .\dotnet.cmd "%crossgen2%" --targetos %target_os% --targetarch wasm --generate-portable-callhelpers "%output_dir%" --no-warn-unresolved-directpinvoke %pinvoke_module_args% "%scan_path%*.dll" if errorlevel 1 ( echo Generator failed for %target_os%! diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.md b/src/coreclr/vm/wasm/generate-coreclr-helpers.md index 32a6b484d98682..870b9692cdad02 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.md +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.md @@ -2,7 +2,7 @@ The `generate-coreclr-helpers.cmd` (Windows) and `generate-coreclr-helpers.sh` (Linux/macOS) scripts in this directory regenerate the checked-in CoreCLR call-helper source files used by the -WebAssembly runtime. They run crossgen2 in `--wasm-generate-callhelpers` mode, which scans the managed +WebAssembly runtime. They run crossgen2 in `--generate-portable-callhelpers` mode, which scans the managed framework assemblies and emits the native P/Invoke, reverse-P/Invoke, and interpreter-to-managed call helpers. The generator lives in [`ILCompiler.ReadyToRun/Wasm`](../../tools/aot/ILCompiler.ReadyToRun/Wasm) so it can use crossgen2's diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.sh b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh index 8fcde25f21ac5f..77a7bcc9d27211 100755 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.sh +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh @@ -88,12 +88,12 @@ run_generator() { local args=( --targetos "$target_os" --targetarch wasm - --wasm-generate-callhelpers "$output_dir" - --wasm-no-warn-unresolved-pinvoke-modules + --generate-portable-callhelpers "$output_dir" + --no-warn-unresolved-directpinvoke ) local module for module in "${pinvoke_modules[@]}"; do - args+=(--wasm-pinvoke-module "$module") + args+=(--directpinvoke "$module") done ./dotnet.sh "$crossgen2" "${args[@]}" "$scan_path"*.dll diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index edde5aef2697a7..a33098e5d504af 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -657,11 +657,11 @@ <_WasmInteropGeneratorArg Include="browser" /> <_WasmInteropGeneratorArg Include="--targetarch" /> <_WasmInteropGeneratorArg Include="wasm" /> - <_WasmInteropGeneratorArg Include="--wasm-generate-callhelpers" /> + <_WasmInteropGeneratorArg Include="--generate-portable-callhelpers" /> <_WasmInteropGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> - <_WasmInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> - <_WasmInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasmIgnoredPInvokeModules.Identity)" Condition="'%(_WasmIgnoredPInvokeModules.Identity)' != ''" /> - <_WasmInteropGeneratorArg Include="--wasm-no-warn-unresolved-pinvoke-modules" Condition="'$(WasmWarnOnUnresolvedPInvokeModules)' == 'false'" /> + <_WasmInteropGeneratorArg Include="--directpinvoke;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> + <_WasmInteropGeneratorArg Include="--ignored-directpinvoke;%(_WasmIgnoredPInvokeModules.Identity)" Condition="'%(_WasmIgnoredPInvokeModules.Identity)' != ''" /> + <_WasmInteropGeneratorArg Include="--no-warn-unresolved-directpinvoke" Condition="'$(WasmWarnOnUnresolvedPInvokeModules)' == 'false'" /> <_WasmInteropGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 80cb97dcca4e53..1a17e2c4b51184 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -179,11 +179,11 @@ <_WasiInteropGeneratorArg Include="wasi" /> <_WasiInteropGeneratorArg Include="--targetarch" /> <_WasiInteropGeneratorArg Include="wasm" /> - <_WasiInteropGeneratorArg Include="--wasm-generate-callhelpers" /> + <_WasiInteropGeneratorArg Include="--generate-portable-callhelpers" /> <_WasiInteropGeneratorArg Include="$(_WasiRelinkObjDir)" /> - <_WasiInteropGeneratorArg Include="--wasm-no-warn-unresolved-pinvoke-modules" /> - <_WasiInteropGeneratorArg Include="--wasm-pinvoke-module;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> - <_WasiInteropGeneratorArg Include="--wasm-ignored-pinvoke-module;%(_WasiIgnoredPInvokeModules.Identity)" Condition="'%(_WasiIgnoredPInvokeModules.Identity)' != ''" /> + <_WasiInteropGeneratorArg Include="--no-warn-unresolved-directpinvoke" /> + <_WasiInteropGeneratorArg Include="--directpinvoke;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> + <_WasiInteropGeneratorArg Include="--ignored-directpinvoke;%(_WasiIgnoredPInvokeModules.Identity)" Condition="'%(_WasiIgnoredPInvokeModules.Identity)' != ''" /> <_WasiInteropGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> From cebef96064af5d74bb2a8e25125101a3b1e84f8b Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 19 Aug 2026 18:42:54 +0200 Subject: [PATCH 18/72] [wasm] Match versioned OS platform attributes in the call-helper scan Port of #132332, which fixed this in the MSBuild task this branch deletes. A platform name may carry a version, as in [SupportedOSPlatform("browser1.0")], and comparing it to the target OS for exact equality does not match. The P/Invoke is then treated as unsupported and silently dropped from the generated table, so the call fails at runtime rather than at build time. Accept "" as naming the target, matching the task's fix, and compare ordinal-ignore-case as it does. Nothing generated here varies by platform version, so the version needs no further inspection beyond confirming it parses. Verified against the asset added by #132332: the browser1.0 P/Invoke is absent from callhelpers-pinvoke.cpp before this change and present after, while the windows1.0 one stays absent throughout. Regenerating the checked-in baselines produces no diff, so no framework P/Invoke was affected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Wasm/WasmPInvokeCollector.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs index 57ae082ff9bf89..800e6ca6a7c72c 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs @@ -318,8 +318,22 @@ private PlatformSupport EvaluatePlatformAttributes( return hasSupportedOSPlatform ? PlatformSupport.Unsupported : PlatformSupport.Unknown; bool MatchesTargetOS(CustomAttributeValue attribute) - => attribute.FixedArguments.Length > 0 - && attribute.FixedArguments[0].Value?.ToString() == targetOS; + { + if (attribute.FixedArguments.Length == 0) + return false; + + string platformName = attribute.FixedArguments[0].Value?.ToString(); + if (string.Equals(platformName, targetOS, StringComparison.OrdinalIgnoreCase)) + return true; + + // A platform name may carry a version, as in [SupportedOSPlatform("browser1.0")]. + // Nothing generated here varies by platform version, so a versioned name still + // names the target as long as a version is all that follows it. + if (platformName?.StartsWith(targetOS, StringComparison.OrdinalIgnoreCase) != true) + return false; + + return Version.TryParse(platformName.AsSpan(targetOS.Length), out _); + } } /// From 8fc74ee2e3505be42953c6b4ab2517e7f053bd9c Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Fri, 21 Aug 2026 11:28:54 +0200 Subject: [PATCH 19/72] Drop the WBT crossgen2 artifact copy made dead by #132478 The copy staged the in-build crossgen2 binaries so sendtohelix-browser.targets could ship them to Helix as a correlation payload. #132478 moved CoreCLR Wasm.Build.Tests onto the real workload and deleted that payload along with the rest of the repo-path bridging, so nothing reads these binaries any more: the generated test apps import the workload SDK, which has no $(Crossgen2InBuildDir) and resolves crossgen2 from $(Crossgen2ToolPath) instead. The pack behind that property is already staged for the legs that need it. browser-wasm-build-tests.yml copies Microsoft.NETCore.App.Crossgen2.* into the local feed under includeCoreClrRuntimePack, which runtime.yml and runtime-extra-platforms-wasm.yml both set for their CoreCLR legs. Removing the entry also drops the comment above it, which described the Helix payload that no longer exists, and leaves this file identical to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- eng/pipelines/common/wasm-post-build-steps.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/eng/pipelines/common/wasm-post-build-steps.yml b/eng/pipelines/common/wasm-post-build-steps.yml index 4feacb79840348..8637dd280c6c08 100644 --- a/eng/pipelines/common/wasm-post-build-steps.yml +++ b/eng/pipelines/common/wasm-post-build-steps.yml @@ -8,10 +8,6 @@ parameters: steps: - # The wasm CoreCLR build runs crossgen2 to generate the P/Invoke call helpers while building test - # apps, so the Wasm.Build.Tests leg ships it to Helix as a correlation payload. That leg builds only - # the test project and takes everything else from here, so crossgen2 has to travel in these - # artifacts. The glob matches nothing for Mono, which has no crossgen2 to send. - task: CopyFiles@2 displayName: Copy artifacts needed for running WBT condition: and(succeeded(), ${{ parameters.publishArtifactsForWorkload }}) @@ -25,7 +21,6 @@ steps: bin/WorkloadBuildTasks/** bin/installer.tasks/** bin/Crossgen2Tasks/** - bin/coreclr/${{ parameters.osGroup }}.wasm.*/*/crossgen2/** TargetFolder: '$(Build.StagingDirectory)/IntermediateArtifacts' CleanTargetFolder: true From be10766cbfee314dafdebd383abd47088ce69b2b Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Fri, 21 Aug 2026 16:32:47 +0200 Subject: [PATCH 20/72] Drop the WASM0066 unresolved-P/Invoke warning Assemblies routinely carry P/Invokes for platforms they do not run on, so warning at build time about modules that are not statically linked on wasm mostly produces false positives. SkiaSharp on Blazor+CoreCLR emits ten of them (ole32.dll, Kernel32.dll, libEGL.dll, libc) for entry points that are never reached on wasm, see #131874. Mono has never warned here: it skips the unresolved module and throws if it is ever called. Match that. Generator behaviour is otherwise unchanged - the module is still left out of the table and still throws at runtime - and the skip is now logged once per module instead of once per method. This also removes --no-warn-unresolved-directpinvoke, which existed only to turn the warning off, along with the two suppressions it had accumulated: the unconditional opt-out on the wasi leg and the NoWarn in System.Diagnostics.Tracing.Tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 1 - .../Wasm/WasmInteropGenerator.cs | 3 +-- .../Wasm/WasmPInvokeTableGenerator.cs | 19 +++++++------------ .../aot/crossgen2/Crossgen2RootCommand.cs | 3 --- src/coreclr/tools/aot/crossgen2/Program.cs | 1 - .../aot/crossgen2/Properties/Resources.resx | 3 --- .../vm/wasm/generate-coreclr-helpers.cmd | 2 +- .../vm/wasm/generate-coreclr-helpers.sh | 1 - .../System.Diagnostics.Tracing.Tests.csproj | 1 - .../build/BrowserWasmApp.CoreCLR.targets | 1 - src/mono/wasi/build/WasiApp.CoreCLR.targets | 9 +-------- 11 files changed, 10 insertions(+), 34 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 2f1239d1757ac1..c3049000b489be 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -659,7 +659,6 @@ public void WasmInteropGeneratorAcceptsMoreThanOneInputAssembly() OutputDirectory = outputDirectory, TargetOS = "browser", PInvokeModules = new[] { "libSystem.Native" }, - WarnOnUnresolvedPInvokeModules = false, }; Assert.Equal(0, WasmInteropGenerator.Run(context, options, new Logger(TextWriter.Null, isVerbose: false))); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs index 874c330c006e1a..9c8177a5801240 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs @@ -21,7 +21,6 @@ internal sealed class WasmInteropGeneratorOptions public IReadOnlyList PInvokeModules { get; init; } = []; public IReadOnlyList IgnoredPInvokeModules { get; init; } = []; public string TargetOS { get; init; } - public bool WarnOnUnresolvedPInvokeModules { get; init; } = true; } /// @@ -95,7 +94,7 @@ private static void Generate(ReadyToRunCompilerContext context, WasmInteropGener } } - var generator = new WasmPInvokeTableGenerator(log, options.WarnOnUnresolvedPInvokeModules); + var generator = new WasmPInvokeTableGenerator(log); WriteIfDifferent(Path.Combine(options.OutputDirectory, PInvokeFileName), log, w => generator.EmitPInvokeTable(w, options.PInvokeModules, options.IgnoredPInvokeModules, pinvokes)); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs index a6e881d346ddf3..edb59de5889709 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs @@ -16,7 +16,7 @@ namespace ILCompiler.Wasm /// /// Emits the static P/Invoke resolution table and the native-to-interpreter reverse thunks. /// - internal sealed class WasmPInvokeTableGenerator(WasmInteropLogger log, bool warnOnUnresolvedModules) + internal sealed class WasmPInvokeTableGenerator(WasmInteropLogger log) { public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, IEnumerable ignoredPInvokeModules, List pinvokes) { @@ -53,18 +53,13 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, I else if (pinvoke.Module != "QCall") { // Unresolved module: not statically linked, ignored, [WasmImportLinkage], "*" or QCall. - // By design we skip it and throw at runtime if it is ever called. For hand-authored - // apps this is likely a bug, so warn; consumers scanning untrimmed closures full of - // cross-platform interop (library-test bundles) disable the warning to avoid failing - // the build under warn-as-error for P/Invokes that are never called on wasm. - if (warnOnUnresolvedModules) - { - log.Warning("WASM0066", $"PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' is not in the list of allowed modules. It is also not a specially treated module."); - } - else if (ignoredModules.Add(pinvoke.Module)) - { + // Skip it and throw at runtime if it is ever called, which is what Mono does too. + // Deliberately not a warning: assemblies routinely carry P/Invokes for platforms they + // are not running on - a NuGet package with Windows and Linux entry points, say - and + // those are never reached on wasm. P/Invoke resolution failure is a runtime condition, + // so reporting it at build time produces false positives that have to be suppressed. + if (ignoredModules.Add(pinvoke.Module)) log.Verbose($"Skipping unresolved PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' (not statically linked on wasm; will throw if called)."); - } } } diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 08b8c99ca1dea0..79dd4c7debf08f 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -104,8 +104,6 @@ internal class Crossgen2RootCommand : RootCommand new("--directpinvoke") { Description = SR.DirectPInvokeOption }; public Option IgnoredDirectPInvoke { get; } = new("--ignored-directpinvoke") { Description = SR.IgnoredDirectPInvokeOption }; - public Option NoWarnUnresolvedDirectPInvoke { get; } = - new("--no-warn-unresolved-directpinvoke") { Description = SR.NoWarnUnresolvedDirectPInvokeOption }; public Option SingleMethodTypeName { get; } = new("--singlemethodtypename") { Description = SR.SingleMethodTypeName }; public Option SingleMethodName { get; } = @@ -221,7 +219,6 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(GeneratePortableCallHelpers); Options.Add(DirectPInvoke); Options.Add(IgnoredDirectPInvoke); - Options.Add(NoWarnUnresolvedDirectPInvoke); Options.Add(SingleMethodTypeName); Options.Add(SingleMethodName); Options.Add(SingleMethodIndex); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index 6b5f2e52cb2dec..b20a63c10f396b 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -326,7 +326,6 @@ public int Run() // The normalized name, so that platform attributes match regardless of how // --targetos was spelled on the command line. TargetOS = targetOS.ToString().ToLowerInvariant(), - WarnOnUnresolvedPInvokeModules = !Get(_command.NoWarnUnresolvedDirectPInvoke), }, logger); } diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index de2a45e23c7fda..fae5bc5c1e0b1b 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -294,9 +294,6 @@ Name of a native module to leave out of the generated P/Invoke table - - Do not warn about P/Invokes to modules that are not statically linked - --generate-portable-callhelpers requires --targetarch wasm together with --targetos browser or --targetos wasi diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd index 8a96bc1264c313..a88b2104d4bc31 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd @@ -130,7 +130,7 @@ if not exist "%crossgen2%" ( echo [%target_os%] Scan path: %scan_path% echo [%target_os%] Output path: %output_dir% echo Running generator for %target_os%... -call .\dotnet.cmd "%crossgen2%" --targetos %target_os% --targetarch wasm --generate-portable-callhelpers "%output_dir%" --no-warn-unresolved-directpinvoke %pinvoke_module_args% "%scan_path%*.dll" +call .\dotnet.cmd "%crossgen2%" --targetos %target_os% --targetarch wasm --generate-portable-callhelpers "%output_dir%" %pinvoke_module_args% "%scan_path%*.dll" if errorlevel 1 ( echo Generator failed for %target_os%! diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.sh b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh index 77a7bcc9d27211..ea19d9c2d41ca7 100755 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.sh +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh @@ -89,7 +89,6 @@ run_generator() { --targetos "$target_os" --targetarch wasm --generate-portable-callhelpers "$output_dir" - --no-warn-unresolved-directpinvoke ) local module for module in "${pinvoke_modules[@]}"; do diff --git a/src/libraries/System.Diagnostics.Tracing/tests/System.Diagnostics.Tracing.Tests.csproj b/src/libraries/System.Diagnostics.Tracing/tests/System.Diagnostics.Tracing.Tests.csproj index 1789b2304a360f..85df35f6731827 100644 --- a/src/libraries/System.Diagnostics.Tracing/tests/System.Diagnostics.Tracing.Tests.csproj +++ b/src/libraries/System.Diagnostics.Tracing/tests/System.Diagnostics.Tracing.Tests.csproj @@ -8,7 +8,6 @@ false $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) - $(NoWarn);WASM0066 diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index b0103339fd1936..532f77a67baeb6 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -656,7 +656,6 @@ <_WasmInteropGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> <_WasmInteropGeneratorArg Include="--directpinvoke;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> <_WasmInteropGeneratorArg Include="--ignored-directpinvoke;%(_WasmIgnoredPInvokeModules.Identity)" Condition="'%(_WasmIgnoredPInvokeModules.Identity)' != ''" /> - <_WasmInteropGeneratorArg Include="--no-warn-unresolved-directpinvoke" Condition="'$(WasmWarnOnUnresolvedPInvokeModules)' == 'false'" /> <_WasmInteropGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 1a17e2c4b51184..d39e78a4535e1a 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -135,13 +135,7 @@ - + <_WasiManagedAssemblies Include="$(WasmAppDir)managed\*.dll" /> @@ -181,7 +175,6 @@ <_WasiInteropGeneratorArg Include="wasm" /> <_WasiInteropGeneratorArg Include="--generate-portable-callhelpers" /> <_WasiInteropGeneratorArg Include="$(_WasiRelinkObjDir)" /> - <_WasiInteropGeneratorArg Include="--no-warn-unresolved-directpinvoke" /> <_WasiInteropGeneratorArg Include="--directpinvoke;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> <_WasiInteropGeneratorArg Include="--ignored-directpinvoke;%(_WasiIgnoredPInvokeModules.Identity)" Condition="'%(_WasiIgnoredPInvokeModules.Identity)' != ''" /> <_WasiInteropGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> From d800611347987ac69a8215a6165e39696556b4d0 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 24 Aug 2026 21:57:38 +0200 Subject: [PATCH 21/72] Drop the --ignored-directpinvoke switch made dead by the WASM0066 removal The option was introduced in #129336 purely to dodge WASM0066: at that commit the unresolved-module branch did nothing but emit the warning, so listing a module as ignored just routed it to `continue`. With the warning gone, an ignored module and an unresolved one take the same path and produce the same table. The one code path that could still have mattered - subtracting an ignored module from --directpinvoke - was unreachable, since the targets populate the two item groups under mutually exclusive InvariantGlobalization conditions. Verified byte-identical output: generating the callhelpers with the old binary plus --ignored-directpinvoke libSystem.Globalization.Native and with the new binary without it yields matching SHA-1s for all three generated .cpp files. The only difference was the wording of one verbose log line, and MSBuild never passes --verbose. Mono has no equivalent option; it silently skips unresolved modules, which is what CoreCLR now does too. Addresses review feedback from @jkotas. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Wasm/WasmInteropGenerator.cs | 3 +-- .../Wasm/WasmPInvokeTableGenerator.cs | 19 +++++++------------ .../aot/crossgen2/Crossgen2RootCommand.cs | 3 --- src/coreclr/tools/aot/crossgen2/Program.cs | 1 - .../aot/crossgen2/Properties/Resources.resx | 3 --- .../build/BrowserWasmApp.CoreCLR.targets | 2 -- src/mono/wasi/build/WasiApp.CoreCLR.targets | 2 -- 7 files changed, 8 insertions(+), 25 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs index 9c8177a5801240..5d555a93f9c33b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs @@ -19,7 +19,6 @@ internal sealed class WasmInteropGeneratorOptions { public string OutputDirectory { get; init; } public IReadOnlyList PInvokeModules { get; init; } = []; - public IReadOnlyList IgnoredPInvokeModules { get; init; } = []; public string TargetOS { get; init; } } @@ -97,7 +96,7 @@ private static void Generate(ReadyToRunCompilerContext context, WasmInteropGener var generator = new WasmPInvokeTableGenerator(log); WriteIfDifferent(Path.Combine(options.OutputDirectory, PInvokeFileName), log, - w => generator.EmitPInvokeTable(w, options.PInvokeModules, options.IgnoredPInvokeModules, pinvokes)); + w => generator.EmitPInvokeTable(w, options.PInvokeModules, pinvokes)); WriteIfDifferent(Path.Combine(options.OutputDirectory, ReversePInvokeFileName), log, w => generator.EmitNativeToInterp(w, callbacks)); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs index edb59de5889709..ac70481aa73be4 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs @@ -18,22 +18,17 @@ namespace ILCompiler.Wasm /// internal sealed class WasmPInvokeTableGenerator(WasmInteropLogger log) { - public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, IEnumerable ignoredPInvokeModules, List pinvokes) + public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, List pinvokes) { - var ignoredModules = new HashSet(ignoredPInvokeModules, StringComparer.Ordinal); + // Modules an unresolved P/Invoke has already been reported for, so each is logged once. + var skippedModules = new HashSet(StringComparer.Ordinal); var modules = new SortedDictionary(StringComparer.Ordinal); foreach (string module in pinvokeModules) - { - if (!ignoredModules.Contains(module)) - modules[module] = module; - } - - foreach (string module in ignoredModules.OrderBy(module => module, StringComparer.Ordinal)) - log.Verbose($"Ignoring PInvoke module {module}"); + modules[module] = module; foreach (WasmPInvoke pinvoke in pinvokes) { - if (modules.ContainsKey(pinvoke.Module) || ignoredModules.Contains(pinvoke.Module)) + if (modules.ContainsKey(pinvoke.Module) || skippedModules.Contains(pinvoke.Module)) continue; // Handle special modules, and add them to the list of modules otherwise, skip them @@ -52,13 +47,13 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, I } else if (pinvoke.Module != "QCall") { - // Unresolved module: not statically linked, ignored, [WasmImportLinkage], "*" or QCall. + // Unresolved module: not statically linked, [WasmImportLinkage], "*" or QCall. // Skip it and throw at runtime if it is ever called, which is what Mono does too. // Deliberately not a warning: assemblies routinely carry P/Invokes for platforms they // are not running on - a NuGet package with Windows and Linux entry points, say - and // those are never reached on wasm. P/Invoke resolution failure is a runtime condition, // so reporting it at build time produces false positives that have to be suppressed. - if (ignoredModules.Add(pinvoke.Module)) + if (skippedModules.Add(pinvoke.Module)) log.Verbose($"Skipping unresolved PInvoke module '{pinvoke.Module}' for method '{pinvoke.Method.OwningType}::{pinvoke.Method.Name.ToString()}' (not statically linked on wasm; will throw if called)."); } } diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 79dd4c7debf08f..f1b0cc48abf9a8 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -102,8 +102,6 @@ internal class Crossgen2RootCommand : RootCommand new("--generate-portable-callhelpers") { Description = SR.GeneratePortableCallHelpersOption }; public Option DirectPInvoke { get; } = new("--directpinvoke") { Description = SR.DirectPInvokeOption }; - public Option IgnoredDirectPInvoke { get; } = - new("--ignored-directpinvoke") { Description = SR.IgnoredDirectPInvokeOption }; public Option SingleMethodTypeName { get; } = new("--singlemethodtypename") { Description = SR.SingleMethodTypeName }; public Option SingleMethodName { get; } = @@ -218,7 +216,6 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(PrintReproInstructions); Options.Add(GeneratePortableCallHelpers); Options.Add(DirectPInvoke); - Options.Add(IgnoredDirectPInvoke); Options.Add(SingleMethodTypeName); Options.Add(SingleMethodName); Options.Add(SingleMethodIndex); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index b20a63c10f396b..4bc0cae80d753d 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -322,7 +322,6 @@ public int Run() { OutputDirectory = _generatePortableCallHelpers, PInvokeModules = Get(_command.DirectPInvoke), - IgnoredPInvokeModules = Get(_command.IgnoredDirectPInvoke), // The normalized name, so that platform attributes match regardless of how // --targetos was spelled on the command line. TargetOS = targetOS.ToString().ToLowerInvariant(), diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index fae5bc5c1e0b1b..eb18aac1eee08c 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -291,9 +291,6 @@ Name of a statically linked native module P/Invokes may resolve against - - Name of a native module to leave out of the generated P/Invoke table - --generate-portable-callhelpers requires --targetarch wasm together with --targetos browser or --targetos wasi diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 532f77a67baeb6..5290565513f0b8 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -601,7 +601,6 @@ <_WasmPInvokeModules Include="libSystem.Native" /> <_WasmPInvokeModules Include="libSystem.IO.Compression.Native" /> <_WasmPInvokeModules Include="libSystem.Globalization.Native" Condition="'$(InvariantGlobalization)' != 'true'" /> - <_WasmIgnoredPInvokeModules Include="libSystem.Globalization.Native" Condition="'$(InvariantGlobalization)' == 'true'" /> <_WasmPInvokeModules Include="libSystem.Native.Browser" /> <_WasmPInvokeModules Include="libSystem.Runtime.InteropServices.JavaScript.Native" /> @@ -655,7 +654,6 @@ <_WasmInteropGeneratorArg Include="--generate-portable-callhelpers" /> <_WasmInteropGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> <_WasmInteropGeneratorArg Include="--directpinvoke;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> - <_WasmInteropGeneratorArg Include="--ignored-directpinvoke;%(_WasmIgnoredPInvokeModules.Identity)" Condition="'%(_WasmIgnoredPInvokeModules.Identity)' != ''" /> <_WasmInteropGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index d39e78a4535e1a..c3c90c5b541b8f 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -142,7 +142,6 @@ <_WasiPInvokeModules Include="libSystem.Native" /> <_WasiPInvokeModules Include="libSystem.IO.Compression.Native" /> <_WasiPInvokeModules Include="libSystem.Globalization.Native" Condition="'$(InvariantGlobalization)' != 'true'" /> - <_WasiIgnoredPInvokeModules Include="libSystem.Globalization.Native" Condition="'$(InvariantGlobalization)' == 'true'" /> + - <_WasmManagedAssemblies Include="@(WasmAssembliesToBundle->Distinct())" /> + <_WasmBundledFile Include="@(WasmAssembliesToBundle->Distinct())" /> + + + + <_HasCoreLib Condition="'%(_WasmManagedAssemblies.FileName)%(_WasmManagedAssemblies.Extension)' == 'System.Private.CoreLib.dll'">true diff --git a/src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs b/src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs new file mode 100644 index 00000000000000..8fe6f2c8069cd2 --- /dev/null +++ b/src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.WebAssembly.Build.Tasks; + +/// +/// Narrows a bundle's files down to the managed assemblies. +/// An app bundle legitimately carries native payloads named .dll - per-architecture content +/// shipped by a NuGet package is the usual case - and the tools that read managed metadata cannot +/// be handed those. +/// +public class FilterManagedAssemblies : Task +{ + [Required, NotNull] + public ITaskItem[]? Assemblies { get; set; } + + [Output] + public ITaskItem[] ManagedAssemblies { get; private set; } = Array.Empty(); + + public override bool Execute() + { + List managedAssemblies = new(Assemblies.Length); + + foreach (ITaskItem assembly in Assemblies) + { + string path = assembly.GetMetadata("FullPath"); + + if (!File.Exists(path)) + { + Log.LogError($"Cannot find assembly '{path}'."); + continue; + } + + bool isManaged; + try + { + isManaged = Utils.IsManagedAssembly(path); + } + catch (Exception ex) + { + Log.LogError($"Failed to read assembly '{path}': {ex.Message}"); + continue; + } + + if (!isManaged) + { + Log.LogMessage(MessageImportance.Low, $"Skipping unmanaged {path}."); + continue; + } + + managedAssemblies.Add(assembly); + } + + ManagedAssemblies = managedAssemblies.ToArray(); + + return !Log.HasLoggedErrors; + } +} From 55da13d6dae87a4003acef359cac8e8a95234e60 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 25 Aug 2026 14:29:49 +0200 Subject: [PATCH 24/72] Dedupe simple names in the filter, and apply it to wasi too Satellite assemblies for different cultures share a simple name, and they are managed, so filtering only unmanaged files still left crossgen2's strict parser throwing "Multiple input files matching same simple name" for any localized app. The type system holds a single module per simple name anyway, so keep the first and skip the rest - after the unmanaged files are gone, so a native payload still cannot claim a name ahead of the managed assembly sharing it. The wasi target feeds the same generator from a flat managed directory, where duplicate simple names cannot arise, so it was not broken by the parser going back to strict. It was still handing crossgen2 whatever the bundle carried and relying on the compiler to shrug off native payloads, and a .dll that is not a PE at all takes it down rather than being shrugged off. Filter there as well so both consumers of --generate-portable-callhelpers get a clean list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/mono/wasi/build/WasiApp.CoreCLR.targets | 12 ++++++++++-- .../WasmAppBuilder/FilterManagedAssemblies.cs | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index c3c90c5b541b8f..250a3fb878871f 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -21,6 +21,8 @@ WasmAppRuntimeFlavor=Mono default. --> + + CoreCLR false @@ -135,15 +137,21 @@ - + - <_WasiManagedAssemblies Include="$(WasmAppDir)managed\*.dll" /> + <_WasiBundledFile Include="$(WasmAppDir)managed\*.dll" /> <_WasiPInvokeModules Include="libSystem.Native" /> <_WasiPInvokeModules Include="libSystem.IO.Compression.Native" /> <_WasiPInvokeModules Include="libSystem.Globalization.Native" Condition="'$(InvariantGlobalization)' != 'true'" /> + + + + + + + + $(NetCoreAppCurrent) + + + + + + + + <_Crossgen2Path>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', '$(HostOS).$(BuildArchitecture).$(Configuration)', 'crossgen2', 'crossgen2.dll')) + <_TesthostDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'testhost')) + <_FrameworkVersion>$(MajorVersion).$(MinorVersion).0 + + + + <_WasmCallHelperTarget Include="browser"> + $([MSBuild]::EnsureTrailingSlash('$(BrowserScanPath)')) + $(_TesthostDir)$(NetCoreAppCurrent)-browser-$(Configuration)-wasm/shared/Microsoft.NETCore.App/$(_FrameworkVersion)/ + + <_WasmCallHelperTarget Include="wasi"> + $([MSBuild]::EnsureTrailingSlash('$(WasiScanPath)')) + $(_TesthostDir)$(NetCoreAppCurrent)-wasi-$(Configuration)-wasm/shared/Microsoft.NETCore.App/$(_FrameworkVersion)/ + + + + + + + <_TargetOS>%(_WasmCallHelperTarget.Identity) + <_ScanPath>%(_WasmCallHelperTarget.ScanPath) + <_OutputDir>$(MSBuildThisFileDirectory)$(_TargetOS)/ + + + + + + + + + + + + + + + + diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.sh b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh index 44033ea77d4d12..c45bf9f729eda6 100755 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.sh +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.sh @@ -62,84 +62,22 @@ echo "Repo root: $repo_root" cd "$repo_root" -# Run the generator for a given target OS. -# Arguments: -run_generator() { - local target_os="$1" - local scan_path="$2" - local output_dir="$3" +# The scan paths, the crossgen2 lookup and the P/Invoke module list all live in the project next +# to this script, so they are not restated here and in the .cmd. +args=( + build "$script_dir/generate-coreclr-helpers.proj" + -t:GenerateCallHelpers + "-p:Configuration=$configuration" +) - if [[ ! -d "$scan_path" ]]; then - echo "Error: Scan path for $target_os does not exist: $scan_path" - echo "Please build the runtime first using: ./build.sh clr+libs -os $target_os -c $configuration" - exit 1 - fi - - if [[ ! -f "$crossgen2" ]]; then - echo "Error: crossgen2 was not found at: $crossgen2" - echo "Please build the clr subset first using: ./build.sh clr -c $configuration" - exit 1 - fi - - echo "[$target_os] Scan path: $scan_path" - echo "[$target_os] Output path: $output_dir" - echo "Running generator for $target_os..." - - local args=( - --targetos "$target_os" - --targetarch wasm - --generate-portable-callhelpers "$output_dir" - ) - local module - for module in "${pinvoke_modules[@]}"; do - args+=(--directpinvoke "$module") - done - - ./dotnet.sh "$crossgen2" "${args[@]}" "$scan_path"*.dll -} - -# Modules the runtime links statically; a P/Invoke into any of them resolves to a direct call. -# Read from the list the runtime tests' corerun relink imports, so the two cannot be edited apart. -pinvoke_modules_file="$repo_root/eng/wasm/WasmPInvokeModules.props" -if [[ ! -f "$pinvoke_modules_file" ]]; then - echo "Error: P/Invoke module list not found at: $pinvoke_modules_file" >&2 - exit 1 -fi - -pinvoke_modules=() -while IFS= read -r module; do - pinvoke_modules+=("$module") -done < <(sed -n 's/.*&2 - exit 1 -fi - -# The generator lives in crossgen2 and uses its type system to compute the wasm ABI. Generation -# does not load the JIT, so the host-targeting crossgen2 answers wasm questions correctly. Its -# configuration has to match the one the scanned assemblies came from. -crossgen2="$repo_root/artifacts/bin/coreclr/$(uname -s | tr '[:upper:]' '[:lower:]').$(uname -m).$configuration/crossgen2/crossgen2.dll" -case "$(uname -s)" in - Darwin) crossgen2="${crossgen2/darwin./osx.}" ;; -esac -crossgen2="${crossgen2/aarch64./arm64.}" -crossgen2="${crossgen2/x86_64./x64.}" - -# Resolve scan paths (allow overrides). if [[ -n "$browser_scan_path_override" ]]; then - browser_scan_path="$browser_scan_path_override" -else - browser_scan_path="$repo_root/artifacts/bin/testhost/net11.0-browser-$configuration-wasm/shared/Microsoft.NETCore.App/11.0.0/" + args+=("-p:BrowserScanPath=$browser_scan_path_override") fi if [[ -n "$wasi_scan_path_override" ]]; then - wasi_scan_path="$wasi_scan_path_override" -else - wasi_scan_path="$repo_root/artifacts/bin/testhost/net11.0-wasi-$configuration-wasm/shared/Microsoft.NETCore.App/11.0.0/" + args+=("-p:WasiScanPath=$wasi_scan_path_override") fi -run_generator "browser" "$browser_scan_path" "$repo_root/src/coreclr/vm/wasm/browser/" -run_generator "wasi" "$wasi_scan_path" "$repo_root/src/coreclr/vm/wasm/wasi/" +./dotnet.sh "${args[@]}" echo "Done!" From 602cb97faa69bbe7ae30a092ea9202d8055d8cff Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 25 Aug 2026 19:14:02 +0200 Subject: [PATCH 27/72] Pass the generator arguments through a response file The scan glob was going to crossgen2 as a single quoted argument for it to expand, to keep a framework-sized closure off a command line cmd.exe caps at 8191 characters. A response file removes the constraint that forced that, and is what the browser and wasi relink targets already do. Expanding the closure in MSBuild and writing one token per line also makes the run reproducible from the file left in artifacts/obj/wasm-callhelpers, and turns an empty scan directory into an error rather than a degenerate table generated from no assemblies at all. Regenerating both targets still reproduces the checked-in tables byte for byte. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../vm/wasm/generate-coreclr-helpers.md | 4 +++ .../vm/wasm/generate-coreclr-helpers.proj | 33 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.md b/src/coreclr/vm/wasm/generate-coreclr-helpers.md index 500a0574856484..ace339b061f4d1 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.md +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.md @@ -12,6 +12,10 @@ type system to compute the wasm ABI layout of the structs that cross the boundar Keeping the scan paths, the crossgen2 lookup and the module list in the project rather than in the scripts means they are stated once instead of once per shell language. +Each run leaves the exact generator invocation in a response file under +`artifacts/obj/wasm-callhelpers//generate-coreclr-helpers.rsp`, which is the first thing +to look at when a regenerated table is not what was expected. + The relink targets for browser and wasi apps run the same crossgen2 mode over the app's own assembly closure, so these checked-in files and a relinked app are produced by one code path. diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index 3caa69fe12ee44..dc5536f1ffeb3b 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -46,6 +46,8 @@ <_TargetOS>%(_WasmCallHelperTarget.Identity) <_ScanPath>%(_WasmCallHelperTarget.ScanPath) <_OutputDir>$(MSBuildThisFileDirectory)$(_TargetOS)/ + <_ResponseFileDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsObjDir)', 'wasm-callhelpers', '$(_TargetOS)')) + <_ResponseFile>$(_ResponseFileDir)generate-coreclr-helpers.rsp - + - - + <_ScanAssembly Remove="@(_ScanAssembly)" /> + <_ScanAssembly Include="$(_ScanPath)*.dll" /> + + + + + + + <_GeneratorArg Remove="@(_GeneratorArg)" /> + <_GeneratorArg Include="--targetos" /> + <_GeneratorArg Include="$(_TargetOS)" /> + <_GeneratorArg Include="--targetarch" /> + <_GeneratorArg Include="wasm" /> + <_GeneratorArg Include="--generate-portable-callhelpers" /> + <_GeneratorArg Include="$(_OutputDir)" /> + <_GeneratorArg Include="--directpinvoke;%(WasmCoreClrFrameworkPInvokeModule.Identity)" /> + <_GeneratorArg Include="@(_ScanAssembly->'%(FullPath)')" /> + + + + + From 0b293a134092e42c2a56486e7ab90da71ab14d30 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 25 Aug 2026 19:38:56 +0200 Subject: [PATCH 28/72] Fix review findings: skipped-module short circuit and Windows regen script An unresolved P/Invoke used to poison its module for the rest of the scan: the module went into skippedModules and the loop then skipped every later P/Invoke naming it, so a module that a subsequent [WasmImportLinkage] import did resolve never reached the table and its import went missing at run time. The set exists to log each unresolved module once, so only the logging is suppressed now. Verified with a probe declaring both an unresolved and a [WasmImportLinkage] import of one module: absent before, present after, and a linkage-only control unaffected either way. The Windows regen script took the project path from %~dp0 after the argument loop, but SHIFT moves %0 as well, so it resolved to the wrong directory as soon as any option was passed - which is every documented invocation. The script already captured repo_root before the loop for that reason; capture the script directory there too. Scan path overrides also went in unquoted and split on spaces, so each is one quoted argument now, with a trailing backslash dropped so it cannot escape the closing quote. Regenerating both targets still reproduces the checked-in tables byte for byte. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../Wasm/WasmPInvokeTableGenerator.cs | 4 +++- .../vm/wasm/generate-coreclr-helpers.cmd | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs index 3bbbf82592b124..bd92a37f4f6cdf 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs @@ -21,6 +21,8 @@ internal sealed class WasmPInvokeTableGenerator(WasmInteropLogger log) public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, List pinvokes) { // Modules an unresolved P/Invoke has already been reported for, so each is logged once. + // Only the logging is suppressed: a module a later P/Invoke does resolve - through + // [WasmImportLinkage], say - still has to make it into the table. var skippedModules = new HashSet(StringComparer.Ordinal); var modules = new SortedDictionary(StringComparer.Ordinal); foreach (string module in pinvokeModules) @@ -34,7 +36,7 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L foreach (WasmPInvoke pinvoke in pinvokes) { - if (modules.ContainsKey(pinvoke.Module) || skippedModules.Contains(pinvoke.Module)) + if (modules.ContainsKey(pinvoke.Module)) continue; // A static archive is named libFoo.a, so the module list - built from the file names diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd index 0d7b7f26ab05c0..020edfca8f131c 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.cmd @@ -8,6 +8,7 @@ set "wasi_scan_path_override=" :: Get the repo root (script is in src/coreclr/vm/wasm). :: This must be computed before argument parsing, because SHIFT also shifts %0. +set "script_dir=%~dp0" for %%I in ("%~dp0..\..\..\..") do set "repo_root=%%~fI" set "usage=Usage: %~nx0 [options]" @@ -77,18 +78,24 @@ cd /d "%repo_root%" :: The scan paths, the crossgen2 lookup and the P/Invoke module list all live in the project next :: to this script, so they are not restated here and in the .sh. -set "generator_proj=%~dp0generate-coreclr-helpers.proj" -set "build_args=-t:GenerateCallHelpers -p:Configuration=%configuration%" +set "generator_proj=%script_dir%generate-coreclr-helpers.proj" +:: Each override goes in as one quoted argument so that a path containing spaces survives. A +:: trailing backslash would escape the closing quote, so it is dropped here; the project puts the +:: separator back. +set "browser_prop=" if not "%browser_scan_path_override%"=="" ( - set "build_args=!build_args! -p:BrowserScanPath=%browser_scan_path_override%" + if "%browser_scan_path_override:~-1%."=="\." set "browser_scan_path_override=%browser_scan_path_override:~0,-1%" + set browser_prop="-p:BrowserScanPath=!browser_scan_path_override!" ) +set "wasi_prop=" if not "%wasi_scan_path_override%"=="" ( - set "build_args=!build_args! -p:WasiScanPath=%wasi_scan_path_override%" + if "%wasi_scan_path_override:~-1%."=="\." set "wasi_scan_path_override=%wasi_scan_path_override:~0,-1%" + set wasi_prop="-p:WasiScanPath=!wasi_scan_path_override!" ) -call .\dotnet.cmd build "%generator_proj%" !build_args! +call .\dotnet.cmd build "%generator_proj%" -t:GenerateCallHelpers "-p:Configuration=%configuration%" %browser_prop% %wasi_prop% if errorlevel 1 exit /b 1 echo Done! From 45d4cc25796659ff0bbfd14b61749f54511e0700 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 26 Aug 2026 10:53:30 +0200 Subject: [PATCH 29/72] Generate the test corerun call helpers with crossgen2 CLRTest.WasmCorerun.targets arrived from main in the last merge and drives the CoreCLR ManagedToNativeGenerator task, which this branch deletes. Nothing reconciled the two, because main only added the file and git had no conflict to report, so the browser-wasm Pri0 test leg failed to load the task at all: error MSB4062: The "Microsoft.WebAssembly.Build.Tasks.CoreClr.ManagedToNativeGenerator" task could not be loaded from WasmAppBuilder.dll Point it at crossgen2 the way the browser and wasi app targets already do: one response file, the same three output names in the same directory, and the module list still coming from the shared props. The assemblies now go through FilterManagedAssemblies first - the input is a glob of the test's output directory, which is exactly where a native payload named .dll turns up - and the task's own cache file is gone because the target already tracks its inputs and outputs. Verified by running the target against a real test project, with the link kit and a test static library stubbed: 181 assemblies and 6 direct-P/Invoke modules in the response file, and all three call-helper sources generated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/tests/Common/CLRTest.WasmCorerun.targets | 60 ++++++++++++++------ 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/src/tests/Common/CLRTest.WasmCorerun.targets b/src/tests/Common/CLRTest.WasmCorerun.targets index 78790e4a1698bd..233b4126c21fa3 100644 --- a/src/tests/Common/CLRTest.WasmCorerun.targets +++ b/src/tests/Common/CLRTest.WasmCorerun.targets @@ -10,8 +10,8 @@ time the way a .so/.dylib/.dll is elsewhere. It has to be linked into the host u with a generated table mapping each [DllImport] to the address of its target. That is what this file does, once per test that has a CMakeProjectReference: - 1. ManagedToNativeGenerator (src/tasks/WasmAppBuilder) scans the test's managed assemblies plus - the whole framework closure and emits callhelpers-{pinvoke,reverse,interp-to-managed}.cpp. + 1. crossgen2, in its portable call-helpers generation mode, scans the test's managed assemblies + plus the whole framework closure and emits callhelpers-{pinvoke,reverse,interp-to-managed}.cpp. 2. Those three sources are compiled with em++ using the response file exported by the CoreCLR build (see src/coreclr/hosts/corerun/wasm/testkit.cmake). 3. They are linked, together with the test's own static libraries, against the same archives @@ -60,7 +60,7 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s <_WasmCorerunScriptExt Condition="$([MSBuild]::IsOSPlatform('windows'))">.bat - @@ -189,6 +189,7 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s and corerun.wasm below belong in the output directory. --> <_WasmCorerunDir>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', 'wasm-corerun')) <_WasmCorerunInputManifest>$(_WasmCorerunDir)inputs.txt + <_WasmCorerunGeneratorRsp>$(_WasmCorerunDir)callhelpers-generator.rsp @@ -312,22 +313,45 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s - - - + + + + + + <_WasmCorerunGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + <_WasmCorerunGeneratorPath Condition="'$(_WasmCorerunGeneratorPath)' == '' and '$(Crossgen2InBuildDir)' != ''">$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmCorerunGeneratorExeSuffix)')) + + <_WasmCorerunGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(_WasmCorerunGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(_WasmCorerunGeneratorPath)" + <_WasmCorerunGeneratorCommand Condition="'$(_WasmCorerunGeneratorCommand)' == ''">"$(_WasmCorerunGeneratorPath)" + + + + + + + <_WasmCorerunGeneratorArg Include="--targetos" /> + <_WasmCorerunGeneratorArg Include="browser" /> + <_WasmCorerunGeneratorArg Include="--targetarch" /> + <_WasmCorerunGeneratorArg Include="wasm" /> + <_WasmCorerunGeneratorArg Include="--generate-portable-callhelpers" /> + <_WasmCorerunGeneratorArg Include="$(_WasmCorerunDir)" /> + <_WasmCorerunGeneratorArg Include="--directpinvoke;%(_WasmCorerunPInvokeModule.Identity)" Condition="'%(_WasmCorerunPInvokeModule.Identity)' != ''" /> + <_WasmCorerunGeneratorArg Include="@(_WasmCorerunManagedAssembly->'%(FullPath)')" /> + + + + + + + + + + From 6951c9e422a97a014a78182b3733415a835e2c4b Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 26 Aug 2026 11:12:03 +0200 Subject: [PATCH 30/72] Take wasm out of the portable call-helper generator's names Portable entry points exist for any platform that cannot generate code at run time. wasm is the only one today, but game consoles are the same shape, so naming the generator after wasm describes the current caller rather than the functionality. Move it to ILCompiler.PortableCallHelpers, in a directory of that name, and drop the Wasm prefix from the types: the entry point becomes PortableCallHelpersGenerator, the rest keep the names they already had without it. The MSBuild side follows - $(PortableCallHelpersGeneratorPath) overrides the tool, and the response file the browser and wasi targets write is now callhelpers-generator.rsp, matching the one the test corerun already emits. What keeps wasm in its name is what is genuinely about wasm: the ABI itself in WasmLowering, the emitted g_wasmThunks and wasm_ret_S* symbols, which the runtime looks up by name in src/coreclr/vm/wasm/helpers.cpp, and the comments describing the ABI the generator currently encodes. Regenerating both targets reproduces the checked-in tables byte for byte, and the 65 argument-layout tests still pass. The 33 R2R suite failures in this tree are unrelated: they reproduce identically with the rename stashed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../tools/Common/JitInterface/WasmLowering.cs | 2 +- .../WasmArgumentLayoutTests.cs | 38 ++++++++-------- .../ILCompiler.ReadyToRun.csproj | 14 +++--- .../InternalCallSignatureCollector.cs} | 8 ++-- .../InteropSignature.cs} | 4 +- .../InterpToNativeGenerator.cs} | 28 ++++++------ .../PInvokeCollector.cs} | 32 +++++++------- .../PInvokeTableGenerator.cs} | 44 +++++++++---------- .../PortableCallHelpersGenerator.cs} | 28 ++++++------ .../TypeNames.cs} | 4 +- src/coreclr/tools/aot/crossgen2/Program.cs | 2 +- .../build/BrowserWasmApp.CoreCLR.targets | 38 ++++++++-------- src/mono/wasi/build/WasiApp.CoreCLR.targets | 40 ++++++++--------- 13 files changed, 141 insertions(+), 141 deletions(-) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmInternalCallSignatureCollector.cs => PortableCallHelpers/InternalCallSignatureCollector.cs} (93%) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmInteropSignature.cs => PortableCallHelpers/InteropSignature.cs} (98%) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmInterpToNativeGenerator.cs => PortableCallHelpers/InterpToNativeGenerator.cs} (85%) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmPInvokeCollector.cs => PortableCallHelpers/PInvokeCollector.cs} (94%) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmPInvokeTableGenerator.cs => PortableCallHelpers/PInvokeTableGenerator.cs} (93%) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmInteropGenerator.cs => PortableCallHelpers/PortableCallHelpersGenerator.cs} (87%) rename src/coreclr/tools/aot/ILCompiler.ReadyToRun/{Wasm/WasmTypeNames.cs => PortableCallHelpers/TypeNames.cs} (95%) diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index deb5370fc7bca5..ee2df3235520d7 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -390,7 +390,7 @@ public static WasmValueType LowerType(TypeDesc type) /// Maps a WasmValueType to its single-character signature encoding. /// // internal rather than private so the call-helper generator can encode a single type with the - // same table the signature builder below uses (see ILCompiler.Wasm.WasmInteropSignature). + // same table the signature builder below uses (see ILCompiler.PortableCallHelpers.InteropSignature). internal static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch { WasmValueType.I32 => 'i', diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 2306feb800dec8..6f1d3a025e4184 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -11,7 +11,7 @@ using crossgen2::ILCompiler; using crossgen2::ILCompiler.DependencyAnalysis.ReadyToRun; using crossgen2::ILCompiler.DependencyAnalysis.Wasm; -using crossgen2::ILCompiler.Wasm; +using crossgen2::ILCompiler.PortableCallHelpers; using crossgen2::Internal.CallingConvention; using crossgen2::Internal.JitInterface; @@ -557,11 +557,11 @@ private static MethodSignature MakeProbeSignature(ReadyToRunCompilerContext cont [InlineData("Guid", "S16")] [InlineData("DateTime", "l")] [InlineData("Int32", "i")] - public void WasmInteropGeneratorEncodesTypesTheWayTheCompilerLowersThem(string typeName, string expected) + public void PortableCallHelpersGeneratorEncodesTypesTheWayTheCompilerLowersThem(string typeName, string expected) { ReadyToRunCompilerContext context = CreateWasmContext(); - Assert.Equal(expected, WasmInteropSignature.GetAbiToken(GetSystemType(context, typeName))); + Assert.Equal(expected, InteropSignature.GetAbiToken(GetSystemType(context, typeName))); } /// @@ -570,7 +570,7 @@ public void WasmInteropGeneratorEncodesTypesTheWayTheCompilerLowersThem(string t /// it has to configure a group itself for that question to have an answer at all. /// [Fact] - public void WasmInteropGeneratorComputesLayoutOfStructsHoldingReferences() + public void PortableCallHelpersGeneratorComputesLayoutOfStructsHoldingReferences() { ReadyToRunCompilerContext context = CreateWasmContext(); var type = GetSystemType(context, "RuntimeTypeHandle"); @@ -579,7 +579,7 @@ public void WasmInteropGeneratorComputesLayoutOfStructsHoldingReferences() Assert.True(type.ContainsGCPointers, $"{type} was chosen because it holds a reference"); // One field the size of the whole struct: lowered to that field, a reference, passed as i32. - Assert.Equal("i", WasmInteropSignature.GetAbiToken(type)); + Assert.Equal("i", InteropSignature.GetAbiToken(type)); } /// @@ -588,7 +588,7 @@ public void WasmInteropGeneratorComputesLayoutOfStructsHoldingReferences() /// thunk built for a different shape. /// [Fact] - public void WasmInteropGeneratorEncodesMethodsLikeTheCompiler() + public void PortableCallHelpersGeneratorEncodesMethodsLikeTheCompiler() { ReadyToRunCompilerContext context = CreateWasmContext(); var method = (EcmaMethod)GetSystemType(context, "DateTime").GetMethod("AddTicks"u8, null); @@ -596,7 +596,7 @@ public void WasmInteropGeneratorEncodesMethodsLikeTheCompiler() string expected = WasmLowering.GetSignature(method.Signature, WasmLowering.LoweringFlags.None).SignatureString; _output.WriteLine($"{method} lowers to '{expected}'"); - Assert.Equal(expected, WasmInteropSignature.GetMethodSignature(method, includeThis: true)); + Assert.Equal(expected, InteropSignature.GetMethodSignature(method, includeThis: true)); } /// @@ -604,11 +604,11 @@ public void WasmInteropGeneratorEncodesMethodsLikeTheCompiler() /// but it is still what a thunk returns, so the generator has to encode it. /// [Fact] - public void WasmInteropGeneratorEncodesVoid() + public void PortableCallHelpersGeneratorEncodesVoid() { ReadyToRunCompilerContext context = CreateWasmContext(); - Assert.Equal("v", WasmInteropSignature.GetAbiToken(context.GetWellKnownType(WellKnownType.Void))); + Assert.Equal("v", InteropSignature.GetAbiToken(context.GetWellKnownType(WellKnownType.Void))); } /// @@ -625,7 +625,7 @@ public void WasmInteropGeneratorEncodesVoid() [InlineData("DateTime")] [InlineData("Int32")] [InlineData("Double")] - public void WasmInteropGeneratorEncodesTypesTheSameWayInAndOutOfASignature(string typeName) + public void PortableCallHelpersGeneratorEncodesTypesTheSameWayInAndOutOfASignature(string typeName) { ReadyToRunCompilerContext context = CreateWasmContext(); TypeDesc type = GetSystemType(context, typeName); @@ -636,8 +636,8 @@ public void WasmInteropGeneratorEncodesTypesTheSameWayInAndOutOfASignature(strin _output.WriteLine($"{typeName} lowers to '{signature}' in a signature"); // 'v' return, then the single parameter, then the 'p' entrypoint suffix. - List tokens = WasmInteropSignature.ParseSignatureTokens(signature); - Assert.Equal(tokens[1], WasmInteropSignature.GetAbiToken(type)); + List tokens = InteropSignature.ParseSignatureTokens(signature); + Assert.Equal(tokens[1], InteropSignature.GetAbiToken(type)); } /// @@ -646,7 +646,7 @@ public void WasmInteropGeneratorEncodesTypesTheSameWayInAndOutOfASignature(strin /// group built without it asserts in checked builds and lays out nothing in any build. /// [Fact] - public void WasmInteropGeneratorAcceptsMoreThanOneInputAssembly() + public void PortableCallHelpersGeneratorAcceptsMoreThanOneInputAssembly() { // Any second real assembly will do. This one is guaranteed to exist because it is the // assembly currently executing. @@ -658,20 +658,20 @@ public void WasmInteropGeneratorAcceptsMoreThanOneInputAssembly() try { - var options = new WasmInteropGeneratorOptions + var options = new PortableCallHelpersGeneratorOptions { OutputDirectory = outputDirectory, TargetOS = "browser", PInvokeModules = new[] { "libSystem.Native" }, }; - Assert.Equal(0, WasmInteropGenerator.Run(context, options, new Logger(TextWriter.Null, isVerbose: false))); + Assert.Equal(0, PortableCallHelpersGenerator.Run(context, options, new Logger(TextWriter.Null, isVerbose: false))); foreach (string fileName in new[] { - WasmInteropGenerator.PInvokeFileName, - WasmInteropGenerator.ReversePInvokeFileName, - WasmInteropGenerator.InterpToNativeFileName, + PortableCallHelpersGenerator.PInvokeFileName, + PortableCallHelpersGenerator.ReversePInvokeFileName, + PortableCallHelpersGenerator.InterpToNativeFileName, }) { string path = Path.Combine(outputDirectory, fileName); @@ -681,7 +681,7 @@ public void WasmInteropGeneratorAcceptsMoreThanOneInputAssembly() // The statically linked module has to resolve to direct calls, which is the whole point // of naming it on the command line. - Assert.Contains("SystemNative_", File.ReadAllText(Path.Combine(outputDirectory, WasmInteropGenerator.PInvokeFileName))); + Assert.Contains("SystemNative_", File.ReadAllText(Path.Combine(outputDirectory, PortableCallHelpersGenerator.PInvokeFileName))); } finally { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index 8b7a0861bc2f42..b5c78d984ba297 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -377,13 +377,13 @@ - - - - - - - + + + + + + + diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs similarity index 93% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs index ef86ee57ec4a5e..5ae8c426f8792b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInternalCallSignatureCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs @@ -7,7 +7,7 @@ using Internal.TypeSystem; using Internal.TypeSystem.Ecma; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// /// Reports a condition that should fail the build, with a message that is complete on its own. @@ -18,7 +18,7 @@ internal sealed class LogAsErrorException(string message) : Exception(message); /// Emits generator diagnostics in the canonical MSBuild format, so that a build driving /// crossgen2 through Exec still reports them with their codes. /// - internal sealed class WasmInteropLogger(Logger logger) + internal sealed class InteropLogger(Logger logger) { private readonly HashSet _reportedInfo = []; @@ -47,7 +47,7 @@ public void Verbose(string message) /// collects the portable entry point signatures the interpreter-to-native thunks are generated /// from. /// - internal sealed class WasmInternalCallSignatureCollector(WasmInteropLogger log) + internal sealed class InternalCallSignatureCollector(InteropLogger log) { private readonly HashSet _signatures = []; @@ -81,7 +81,7 @@ public void ScanType(EcmaType type) { // A managed signature: the lowering adds the 'T' for an instance method and the // trailing 'p' for the portable entry point parameter. - string signature = WasmInteropSignature.GetMethodSignature(method, includeThis: true); + string signature = InteropSignature.GetMethodSignature(method, includeThis: true); if (_signatures.Add(signature)) log.Verbose($"Adding InternalCall signature {signature} for method '{type}.{method.Name.ToString()}'"); } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs similarity index 98% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs index bc25c35ba14b88..492575586e8e9b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs @@ -10,7 +10,7 @@ using Internal.JitInterface; using Internal.TypeSystem; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// /// Thrown when a signature token has no representation in the generated C. @@ -31,7 +31,7 @@ internal sealed class InvalidSignatureCharException(char c) /// implementation of the encoding; everything here either produces a string through it or /// consumes one it produced. /// - internal static class WasmInteropSignature + internal static class InteropSignature { /// /// Returns the wasm signature string for a method. diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs similarity index 85% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs index ee9567e24ea082..62154647e233ca 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInterpToNativeGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs @@ -6,7 +6,7 @@ using System.IO; using System.Linq; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// /// Generates the g_wasmThunks array and CallFunc_* functions used by the CoreCLR @@ -16,7 +16,7 @@ namespace ILCompiler.Wasm /// The generated code has to stay in sync with the CoreCLR runtime code that consumes these /// thunks and call functions. /// - internal static class WasmInterpToNativeGenerator + internal static class InterpToNativeGenerator { public static void Emit(TextWriter w, IEnumerable cookies) { @@ -27,9 +27,9 @@ public static void Emit(TextWriter w, IEnumerable cookies) var structReturnSizes = new SortedSet(); foreach (string signature in signatures) { - string returnToken = WasmInteropSignature.ParseSignatureTokens(signature)[0]; + string returnToken = InteropSignature.ParseSignatureTokens(signature)[0]; if (returnToken[0] == 'S' && returnToken.Length > 1) - structReturnSizes.Add(WasmInteropSignature.GetStructSize(returnToken)); + structReturnSizes.Add(InteropSignature.GetStructSize(returnToken)); } w.Write( @@ -71,7 +71,7 @@ public static void Emit(TextWriter w, IEnumerable cookies) { try { - List tokens = WasmInteropSignature.ParseSignatureTokens(signature); + List tokens = InteropSignature.ParseSignatureTokens(signature); string returnToken = tokens[0]; (bool isVoid, string nativeType) result = Result(returnToken); bool isPortableEntryPointCall = IsPortableEntryPointCall(tokens); @@ -85,7 +85,7 @@ public static void Emit(TextWriter w, IEnumerable cookies) RemoveAsyncCallMarker(tokens); List args = Args(tokens); - string argTypes = string.Join(", ", args.Select(WasmInteropSignature.TokenToNativeType)); + string argTypes = string.Join(", ", args.Select(InteropSignature.TokenToNativeType)); string portableEntryPointComma = args.Count > 0 ? ", " : ""; string portableEntrypointDeclaration = isPortableEntryPointCall ? portableEntryPointComma + "PCODE" : ""; @@ -96,7 +96,7 @@ public static void Emit(TextWriter w, IEnumerable cookies) w.Write( $$""" - {{(isPortableEntryPointCall ? "NOINLINE " : "")}}static void {{CallFuncName(args, WasmInteropSignature.TokenToNameType(returnToken), isPortableEntryPointCall)}}(PCODE {{(isPortableEntryPointCall ? "pPortableEntryPoint" : "pcode")}}, int8_t* pArgs, int8_t* pRet) + {{(isPortableEntryPointCall ? "NOINLINE " : "")}}static void {{CallFuncName(args, InteropSignature.TokenToNameType(returnToken), isPortableEntryPointCall)}}(PCODE {{(isPortableEntryPointCall ? "pPortableEntryPoint" : "pcode")}}, int8_t* pArgs, int8_t* pRet) {{{(isPortableEntryPointCall ? "\n alignas(16) int framePointer = TERMINATE_R2R_STACK_WALK;" : "")}} {{result.nativeType}} (*fptr)({{portableEntrypointStackDeclaration}}{{argTypes}}{{portableEntrypointDeclaration}}) = {{portableEntrypointPointerRD}}({{result.nativeType}} ({{portableEntrypointPointerRD}}*)({{portableEntrypointStackDeclaration}}{{argTypes}}{{portableEntrypointDeclaration}})){{(isPortableEntryPointCall ? "(pPortableEntryPoint)" : "pcode")}}; {{(result.isVoid ? "" : $"*(({result.nativeType}*)pRet) = ")}}(*fptr)({{portableEntrypointStackParam}}{{string.Join(", ", ArgsWithSlotOffsets(args))}}{{portableEntrypointParam}}); @@ -124,13 +124,13 @@ public static void Emit(TextWriter w, IEnumerable cookies) static string ThunkEntry(string signature) { - List tokens = WasmInteropSignature.ParseSignatureTokens(signature); + List tokens = InteropSignature.ParseSignatureTokens(signature); bool isPortableEntryPointCall = IsPortableEntryPointCall(tokens); if (isPortableEntryPointCall) tokens.RemoveAt(tokens.Count - 1); RemoveAsyncCallMarker(tokens); - string name = CallFuncName(Args(tokens), WasmInteropSignature.TokenToNameType(tokens[0]), isPortableEntryPointCall); + string name = CallFuncName(Args(tokens), InteropSignature.TokenToNameType(tokens[0]), isPortableEntryPointCall); return $" {{ \"M{signature}\", (void*)&{name} }}"; } @@ -146,8 +146,8 @@ static List ArgsWithSlotOffsets(List args) if (token[0] == 'A') slot = (slot + 1) & ~1; - result.Add($"{WasmInteropSignature.TokenToArgType(token)}({slot})"); - slot += WasmInteropSignature.TokenToSlotCount(token); + result.Add($"{InteropSignature.TokenToArgType(token)}({slot})"); + slot += InteropSignature.TokenToSlotCount(token); } return result; @@ -157,9 +157,9 @@ static List ArgsWithSlotOffsets(List args) { // For struct returns, use the typedef so emcc generates the correct sret ABI if (returnToken[0] == 'S' && returnToken.Length > 1) - return (false, $"wasm_ret_S{WasmInteropSignature.GetStructSize(returnToken)}"); + return (false, $"wasm_ret_S{InteropSignature.GetStructSize(returnToken)}"); - return (returnToken == "v", WasmInteropSignature.TokenToNativeType(returnToken)); + return (returnToken == "v", InteropSignature.TokenToNativeType(returnToken)); } static bool IsPortableEntryPointCall(List tokens) @@ -176,7 +176,7 @@ static void RemoveAsyncCallMarker(List tokens) private static string CallFuncName(List args, string result, bool isPortableEntryPointCall) { string paramTypes = args.Count > 0 - ? string.Join("_", args.Select(WasmInteropSignature.TokenToNameType)) + ? string.Join("_", args.Select(InteropSignature.TokenToNameType)) : "Void"; return $"CallFunc_{paramTypes}_Ret{result}{(isPortableEntryPointCall ? "_PE" : "")}"; diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs similarity index 94% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 800e6ca6a7c72c..336db005ec6b83 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -9,13 +9,13 @@ using Internal.TypeSystem; using Internal.TypeSystem.Ecma; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// /// A P/Invoke discovered while scanning the input assemblies. /// - internal sealed class WasmPInvoke(string entryPoint, string module, EcmaMethod method, bool wasmLinkage) - : IEquatable + internal sealed class PInvokeInfo(string entryPoint, string module, EcmaMethod method, bool wasmLinkage) + : IEquatable { public string EntryPoint { get; } = entryPoint; public string Module { get; } = module; @@ -26,10 +26,10 @@ internal sealed class WasmPInvoke(string entryPoint, string module, EcmaMethod m /// A stable identity for de-duplicating declarations of the same import. private string Identity => $"{EntryPoint}!{Module}!{Method.OwningType}::{Method.Name.ToString()}{Method.Signature}"; - public bool Equals(WasmPInvoke other) + public bool Equals(PInvokeInfo other) => other is not null && string.Equals(Identity, other.Identity, StringComparison.Ordinal); - public override bool Equals(object obj) => Equals(obj as WasmPInvoke); + public override bool Equals(object obj) => Equals(obj as PInvokeInfo); public override int GetHashCode() => Identity.GetHashCode(StringComparison.Ordinal); @@ -39,22 +39,22 @@ public bool Equals(WasmPInvoke other) /// /// A managed method callable from native code, discovered while scanning the input assemblies. /// - internal sealed class WasmPInvokeCallback + internal sealed class PInvokeCallback { - public WasmPInvokeCallback(EcmaMethod method) + public PInvokeCallback(EcmaMethod method) { Method = method; var type = (EcmaType)method.OwningType; TypeName = type.Name.ToString(); - TypeFullName = WasmTypeNames.GetFullName(type); + TypeFullName = TypeNames.GetFullName(type); AssemblyName = ((EcmaAssembly)type.Module).GetName().Name; // Nested types: the runtime reverse-thunk key (vm/wasm/helpers.cpp GetHashCode -> // GetFullyQualifiedNameInfo) reports an empty namespace for nested types, so match that // here or the emitted g_ReverseThunks key won't be found at lookup time (#130129). // This key drops the enclosing-type chain, so nested types with the same simple name in - // different namespaces collide; the duplicate-key check in WasmPInvokeTableGenerator + // different namespaces collide; the duplicate-key check in PInvokeTableGenerator // (EmitNativeToInterp) turns that into a build error. // Tracked by https://github.com/dotnet/runtime/issues/130739. Namespace = type.ContainingType is not null ? string.Empty : type.Namespace.ToString(); @@ -110,9 +110,9 @@ public WasmPInvokeCallback(EcmaMethod method) public string Key { get; } } - internal sealed class WasmPInvokeCallbackComparer : IComparer + internal sealed class PInvokeCallbackComparer : IComparer { - public int Compare(WasmPInvokeCallback x, WasmPInvokeCallback y) + public int Compare(PInvokeCallback x, PInvokeCallback y) { int compare = string.Compare(x.Key, y.Key, StringComparison.Ordinal); return compare != 0 ? compare : (int)(x.Token - y.Token); @@ -123,14 +123,14 @@ public int Compare(WasmPInvokeCallback x, WasmPInvokeCallback y) /// Scans assemblies for the interop surface the wasm interpreter needs thunks for: P/Invokes, /// methods callable from native code, native function pointer signatures, and InternalCalls. /// - internal sealed class WasmPInvokeCollector(WasmInteropLogger log, string targetOS) + internal sealed class PInvokeCollector(InteropLogger log, string targetOS) { private readonly Dictionary _assemblyDisableRuntimeMarshalling = []; private readonly Dictionary _typeUnsupportedOnPlatform = []; private readonly Dictionary _assemblyUnsupportedOnPlatform = []; private readonly Dictionary _blittable = []; - public void CollectPInvokes(List pinvokes, List callbacks, HashSet signatures, EcmaType type) + public void CollectPInvokes(List pinvokes, List callbacks, HashSet signatures, EcmaType type) { foreach (MethodDesc methodDesc in type.GetMethods()) { @@ -139,7 +139,7 @@ public void CollectPInvokes(List pinvokes, List signatures, MethodDesc method, bool includeThis, string kind) { - string signature = WasmInteropSignature.GetMethodSignature(method, includeThis); + string signature = InteropSignature.GetMethodSignature(method, includeThis); if (signatures.Add(signature)) log.Verbose($"Adding {kind} signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs similarity index 93% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index bd92a37f4f6cdf..07c5a2eadb6220 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmPInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -11,14 +11,14 @@ using Internal.TypeSystem; using Internal.TypeSystem.Ecma; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// /// Emits the static P/Invoke resolution table and the native-to-interpreter reverse thunks. /// - internal sealed class WasmPInvokeTableGenerator(WasmInteropLogger log) + internal sealed class PInvokeTableGenerator(InteropLogger log) { - public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, List pinvokes) + public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, List pinvokes) { // Modules an unresolved P/Invoke has already been reported for, so each is logged once. // Only the logging is suppressed: a module a later P/Invoke does resolve - through @@ -34,7 +34,7 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L // [WasmImportLinkage] and has no archive behind it at all. var linkedModules = new HashSet(modules.Keys, StringComparer.Ordinal); - foreach (WasmPInvoke pinvoke in pinvokes) + foreach (PInvokeInfo pinvoke in pinvokes) { if (modules.ContainsKey(pinvoke.Module)) continue; @@ -99,10 +99,10 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L .OrderBy(l => l.EntryPoint, StringComparer.Ordinal) .GroupBy(CEntryPoint, StringComparer.Ordinal); - foreach (IGrouping group in pinvokesGroupedByEntryPoint) + foreach (IGrouping group in pinvokesGroupedByEntryPoint) { - WasmPInvoke[] candidates = group.Distinct().ToArray(); - WasmPInvoke first = candidates[0]; + PInvokeInfo[] candidates = group.Distinct().ToArray(); + PInvokeInfo first = candidates[0]; if (ShouldTreatAsVariadic(candidates)) { string imports = string.Join(Environment.NewLine, @@ -112,14 +112,14 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L " Calling such functions is not supported, and will fail at runtime." + $" Managed DllImports: {Environment.NewLine}{imports}"); - foreach (WasmPInvoke candidate in candidates) + foreach (PInvokeInfo candidate in candidates) candidate.Skip = true; continue; } var decls = new HashSet(); - foreach (WasmPInvoke candidate in candidates) + foreach (PInvokeInfo candidate in candidates) { string decl = GenPInvokeDecl(candidate); if (decls.Add(decl)) @@ -143,7 +143,7 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L .GroupBy(d => d.EntryPoint, StringComparer.Ordinal) .Select(l => { - WasmPInvoke p = l.First(); + PInvokeInfo p = l.First(); // Runtime resolver looks up by managed EntryPoint. // [WasmImportLinkage] mangles the C symbol per module, // so emit the entry-point string explicitly rather than @@ -194,7 +194,7 @@ typedef struct PInvokeTable { """); - static bool ShouldTreatAsVariadic(WasmPInvoke[] candidates) + static bool ShouldTreatAsVariadic(PInvokeInfo[] candidates) { if (candidates.Length < 2) return false; @@ -205,11 +205,11 @@ static bool ShouldTreatAsVariadic(WasmPInvoke[] candidates) return candidates.Skip(1).Any(c => c.Method.Signature.Length != firstNumArgs); } - static string ListRefs(IGrouping l) + static string ListRefs(IGrouping l) => string.Join(", ", l.Select(c => ((EcmaAssembly)c.Method.Module).GetName().Name).Distinct().OrderBy(n => n, StringComparer.Ordinal)); } - public void EmitNativeToInterp(TextWriter w, List callbacks) + public void EmitNativeToInterp(TextWriter w, List callbacks) { // Generate native->interp entry functions // These are called by native code, so they need to obtain @@ -242,8 +242,8 @@ public void EmitNativeToInterp(TextWriter w, List callbacks var callbackNames = new HashSet(); var keys = new HashSet(); int callbackIndex = 0; - callbacks = callbacks.Order(new WasmPInvokeCallbackComparer()).ToList(); - foreach (WasmPInvokeCallback cb in callbacks) + callbacks = callbacks.Order(new PInvokeCallbackComparer()).ToList(); + foreach (PInvokeCallback cb in callbacks) { cb.EntrySymbol = FixedSymbolName(cb); @@ -303,19 +303,19 @@ public void EmitNativeToInterp(TextWriter w, List callbacks """); } - private string CEntryPoint(WasmPInvoke pinvoke) + private string CEntryPoint(PInvokeInfo pinvoke) { if (pinvoke.WasmLinkage) { // We mangle the name to avoid collisions with symbols in other modules - string namespaceName = WasmTypeNames.GetNamespace(pinvoke.Method.OwningType); + string namespaceName = TypeNames.GetNamespace(pinvoke.Method.OwningType); return FixupSymbolName($"{namespaceName}#{pinvoke.Module}#{pinvoke.EntryPoint}"); } return FixupSymbolName(pinvoke.EntryPoint); } - private string GenPInvokeDecl(WasmPInvoke pinvoke) + private string GenPInvokeDecl(PInvokeInfo pinvoke) { MethodSignature signature = pinvoke.Method.Signature; TypeDesc returnType = signature.ReturnType; @@ -335,7 +335,7 @@ private string GenPInvokeDecl(WasmPInvoke pinvoke) return $" {importAttributes}{externKeyword}{MapType(returnType)} {CEntryPoint(pinvoke)} ({string.Join(", ", parameterTypes)});"; } - private string FixedSymbolName(WasmPInvokeCallback cb) + private string FixedSymbolName(PInvokeCallback cb) { string paramTypes = cb.Parameters.Length > 0 ? string.Join("_", ParameterTypes(cb.Parameters).Select(TypeToNameType)) @@ -344,7 +344,7 @@ private string FixedSymbolName(WasmPInvokeCallback cb) return FixupSymbolName($"{cb.EntryName}_{paramTypes}_Ret{TypeToNameType(cb.ReturnType)}"); } - private string ThunkMapEntryLine(WasmPInvokeCallback cb) + private string ThunkMapEntryLine(PInvokeCallback cb) => $" {{ {HashString(cb.Key)}, \"{EscapeLiteral(cb.Key)}\", {{ &MD_{FixedSymbolName(cb)}, (void*)&Call_{cb.EntrySymbol} }} }}"; /// @@ -357,7 +357,7 @@ private static bool IsPassedByReference(TypeDesc type) if (!type.IsValueType || type.IsPrimitive || type.IsEnum || type is FunctionPointerType) return false; - return WasmInteropSignature.GetAbiToken(type)[0] is 'S' or 'A'; + return InteropSignature.GetAbiToken(type)[0] is 'S' or 'A'; } private static string TypeToNameType(TypeDesc type) @@ -368,7 +368,7 @@ private static string TypeToNameType(TypeDesc type) if (type.IsEnum) return TypeToNameType(type.UnderlyingType); - return WasmInteropSignature.TokenToNameType(WasmInteropSignature.GetAbiToken(type)); + return InteropSignature.TokenToNameType(InteropSignature.GetAbiToken(type)); } private static string MapType(TypeDesc type) => type.Category switch diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs similarity index 87% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 5d555a93f9c33b..f7171557974912 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmInteropGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -10,12 +10,12 @@ using Internal.TypeSystem; using Internal.TypeSystem.Ecma; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// - /// Options for , mirroring the command line. + /// Options for , mirroring the command line. /// - internal sealed class WasmInteropGeneratorOptions + internal sealed class PortableCallHelpersGeneratorOptions { public string OutputDirectory { get; init; } public IReadOnlyList PInvokeModules { get; init; } = []; @@ -37,15 +37,15 @@ internal sealed class WasmInteropGeneratorOptions /// crossgen2 --generate-portable-callhelpers <dir> --targetos <browser|wasi> --targetarch wasm \ /// --directpinvoke <name>... <assembly>... /// - internal static class WasmInteropGenerator + internal static class PortableCallHelpersGenerator { public const string PInvokeFileName = "callhelpers-pinvoke.cpp"; public const string ReversePInvokeFileName = "callhelpers-reverse.cpp"; public const string InterpToNativeFileName = "callhelpers-interp-to-managed.cpp"; - public static int Run(ReadyToRunCompilerContext context, WasmInteropGeneratorOptions options, Logger logger) + public static int Run(ReadyToRunCompilerContext context, PortableCallHelpersGeneratorOptions options, Logger logger) { - var log = new WasmInteropLogger(logger); + var log = new InteropLogger(logger); try { @@ -59,15 +59,15 @@ public static int Run(ReadyToRunCompilerContext context, WasmInteropGeneratorOpt } } - private static void Generate(ReadyToRunCompilerContext context, WasmInteropGeneratorOptions options, WasmInteropLogger log) + private static void Generate(ReadyToRunCompilerContext context, PortableCallHelpersGeneratorOptions options, InteropLogger log) { ConfigureCompilationGroup(context); - var collector = new WasmPInvokeCollector(log, options.TargetOS); - var internalCallCollector = new WasmInternalCallSignatureCollector(log); + var collector = new PInvokeCollector(log, options.TargetOS); + var internalCallCollector = new InternalCallSignatureCollector(log); - List pinvokes = []; - List callbacks = []; + List pinvokes = []; + List callbacks = []; HashSet signatures = []; foreach (string simpleName in context.InputFilePaths.Keys) @@ -93,7 +93,7 @@ private static void Generate(ReadyToRunCompilerContext context, WasmInteropGener } } - var generator = new WasmPInvokeTableGenerator(log); + var generator = new PInvokeTableGenerator(log); WriteIfDifferent(Path.Combine(options.OutputDirectory, PInvokeFileName), log, w => generator.EmitPInvokeTable(w, options.PInvokeModules, pinvokes)); @@ -104,7 +104,7 @@ private static void Generate(ReadyToRunCompilerContext context, WasmInteropGener IEnumerable cookies = signatures.Concat(internalCallCollector.Signatures); WriteIfDifferent(Path.Combine(options.OutputDirectory, InterpToNativeFileName), log, - w => WasmInterpToNativeGenerator.Emit(w, cookies)); + w => InterpToNativeGenerator.Emit(w, cookies)); } /// @@ -112,7 +112,7 @@ private static void Generate(ReadyToRunCompilerContext context, WasmInteropGener /// already there, so that an unchanged file keeps its timestamp and does not retrigger the /// native build that consumes it. /// - private static void WriteIfDifferent(string path, WasmInteropLogger log, Action emit) + private static void WriteIfDifferent(string path, InteropLogger log, Action emit) { var buffer = new StringWriter { NewLine = Environment.NewLine }; emit(buffer); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/TypeNames.cs similarity index 95% rename from src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs rename to src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/TypeNames.cs index a776eacace27fa..5430ce447e46bd 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Wasm/WasmTypeNames.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/TypeNames.cs @@ -3,14 +3,14 @@ using Internal.TypeSystem; -namespace ILCompiler.Wasm +namespace ILCompiler.PortableCallHelpers { /// /// Formats type names the way reports them. The runtime looks /// callbacks up by these names and the emitted symbols embed them, so they have to match /// reflection rather than the type system, which spells nested types differently. /// - internal static class WasmTypeNames + internal static class TypeNames { /// /// The name would report, with nested types joined by '+'. diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index 68a8a47195094a..6472e509c4a6af 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -290,7 +290,7 @@ public int Run() if (_generatePortableCallHelpers is not null) { - return Wasm.WasmInteropGenerator.Run(typeSystemContext, new Wasm.WasmInteropGeneratorOptions + return PortableCallHelpers.PortableCallHelpersGenerator.Run(typeSystemContext, new PortableCallHelpers.PortableCallHelpersGeneratorOptions { OutputDirectory = _generatePortableCallHelpers, PInvokeModules = Get(_command.DirectPInvoke), diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 3ba817270dad2b..cdc6895574b0bd 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -651,39 +651,39 @@ correctly. In the repo it comes from the build output; outside it, from the crossgen2 pack the wasm-tools workload acquires, which sets $(Crossgen2ToolPath) from its Sdk.props. --> - <_WasmInteropGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe - $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmInteropGeneratorExeSuffix)')) - $(Crossgen2ToolPath) + <_PortableCallHelpersGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_PortableCallHelpersGeneratorExeSuffix)')) + $(Crossgen2ToolPath) - + - <_WasmInteropGeneratorRsp>$(_WasmIntermediateOutputPath)wasm-interop-generator.rsp + <_PortableCallHelpersGeneratorRsp>$(_WasmIntermediateOutputPath)callhelpers-generator.rsp - <_WasmInteropGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(WasmInteropGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(WasmInteropGeneratorPath)" - <_WasmInteropGeneratorCommand Condition="'$(_WasmInteropGeneratorCommand)' == ''">"$(WasmInteropGeneratorPath)" + <_PortableCallHelpersGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(PortableCallHelpersGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(PortableCallHelpersGeneratorPath)" + <_PortableCallHelpersGeneratorCommand Condition="'$(_PortableCallHelpersGeneratorCommand)' == ''">"$(PortableCallHelpersGeneratorPath)" - <_WasmInteropGeneratorArg Include="--targetos" /> - <_WasmInteropGeneratorArg Include="browser" /> - <_WasmInteropGeneratorArg Include="--targetarch" /> - <_WasmInteropGeneratorArg Include="wasm" /> - <_WasmInteropGeneratorArg Include="--generate-portable-callhelpers" /> - <_WasmInteropGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> - <_WasmInteropGeneratorArg Include="--directpinvoke;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> - <_WasmInteropGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> + <_PortableCallHelpersGeneratorArg Include="--targetos" /> + <_PortableCallHelpersGeneratorArg Include="browser" /> + <_PortableCallHelpersGeneratorArg Include="--targetarch" /> + <_PortableCallHelpersGeneratorArg Include="wasm" /> + <_PortableCallHelpersGeneratorArg Include="--generate-portable-callhelpers" /> + <_PortableCallHelpersGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> + <_PortableCallHelpersGeneratorArg Include="--directpinvoke;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> + <_PortableCallHelpersGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> - + - + - + diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 250a3fb878871f..bce2b4340f876d 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -156,42 +156,42 @@ Generation does not load the JIT, so a host-targeting crossgen2 answers wasm questions correctly. In the repo it comes from the build output; outside it, from a crossgen2 pack that sets $(Crossgen2ToolPath). The wasi-experimental workload does not acquire that pack - today, so out-of-repo wasi relink needs $(WasmInteropGeneratorPath) or + today, so out-of-repo wasi relink needs $(PortableCallHelpersGeneratorPath) or $(Crossgen2ToolPath) to be set explicitly. --> - <_WasmInteropGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe - $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmInteropGeneratorExeSuffix)')) - $(Crossgen2ToolPath) + <_PortableCallHelpersGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_PortableCallHelpersGeneratorExeSuffix)')) + $(Crossgen2ToolPath) - + - <_WasiInteropGeneratorRsp>$(_WasiRelinkObjDir)wasm-interop-generator.rsp + <_PortableCallHelpersGeneratorRsp>$(_WasiRelinkObjDir)callhelpers-generator.rsp - <_WasiInteropGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(WasmInteropGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(WasmInteropGeneratorPath)" - <_WasiInteropGeneratorCommand Condition="'$(_WasiInteropGeneratorCommand)' == ''">"$(WasmInteropGeneratorPath)" + <_PortableCallHelpersGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(PortableCallHelpersGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(PortableCallHelpersGeneratorPath)" + <_PortableCallHelpersGeneratorCommand Condition="'$(_PortableCallHelpersGeneratorCommand)' == ''">"$(PortableCallHelpersGeneratorPath)" - <_WasiInteropGeneratorArg Include="--targetos" /> - <_WasiInteropGeneratorArg Include="wasi" /> - <_WasiInteropGeneratorArg Include="--targetarch" /> - <_WasiInteropGeneratorArg Include="wasm" /> - <_WasiInteropGeneratorArg Include="--generate-portable-callhelpers" /> - <_WasiInteropGeneratorArg Include="$(_WasiRelinkObjDir)" /> - <_WasiInteropGeneratorArg Include="--directpinvoke;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> - <_WasiInteropGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> + <_PortableCallHelpersGeneratorArg Include="--targetos" /> + <_PortableCallHelpersGeneratorArg Include="wasi" /> + <_PortableCallHelpersGeneratorArg Include="--targetarch" /> + <_PortableCallHelpersGeneratorArg Include="wasm" /> + <_PortableCallHelpersGeneratorArg Include="--generate-portable-callhelpers" /> + <_PortableCallHelpersGeneratorArg Include="$(_WasiRelinkObjDir)" /> + <_PortableCallHelpersGeneratorArg Include="--directpinvoke;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> + <_PortableCallHelpersGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> - + - + - + <_WasmCorerunGeneratorArg Include="--targetos" /> - <_WasmCorerunGeneratorArg Include="browser" /> + + <_WasmCorerunGeneratorArg Include="$(TargetOS)" /> <_WasmCorerunGeneratorArg Include="--targetarch" /> <_WasmCorerunGeneratorArg Include="wasm" /> <_WasmCorerunGeneratorArg Include="--generate-portable-callhelpers" /> From 03596f783b85d865c0bc7942d30efcea3b17f3e6 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 26 Aug 2026 13:56:41 +0200 Subject: [PATCH 33/72] Replace LINQ with loops where the generator reads better for it core-runtime.instructions.md asks CG2/ILC code to use direct loops rather than LINQ. Five places were a plain projection or scan dressed up as a query: * the module list handed to the compilation group, once per invocation over the whole input closure * the callback sort, which was rebuilding the list to sort it and can sort in place instead * the vararg-entrypoint scan, which allocated an iterator and a delegate to compare argument counts * the parameter-type projection in GenPInvokeDecl * the signature dedupe, which now fills the set the sort consumes directly What stays is the relational work: the Where/OrderBy/GroupBy pipelines that build the P/Invoke tables, and the string.Join(..., Select(...)) that formats emitted C. Hand-rolling those would cost more in readability and risk than the allocations are worth. Sorting in place is the one change that could have moved output, since Order() is stable and List.Sort is not. The comparer orders by key and breaks ties on token, so it only ties for duplicates, which the emitter already rejects. Confirmed by regenerating: the reverse and interp-to-managed tables come out byte for byte identical, and the 65 argument-layout tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/InterpToNativeGenerator.cs | 4 +++- .../PortableCallHelpers/PInvokeTableGenerator.cs | 14 +++++++++++--- .../PortableCallHelpersGenerator.cs | 6 +++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs index 1d4d9c199ecde2..cd64b07e22ff54 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs @@ -20,7 +20,9 @@ internal static class InterpToNativeGenerator { public static void Emit(TextWriter w, IEnumerable cookies) { - string[] signatures = cookies.Distinct().ToArray(); + var unique = new HashSet(cookies, StringComparer.Ordinal); + string[] signatures = new string[unique.Count]; + unique.CopyTo(signatures); Array.Sort(signatures, StringComparer.Ordinal); // Collect unique struct return sizes so we can emit typedefs diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 07c5a2eadb6220..a2c0f3ec32750d 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -202,7 +202,13 @@ static bool ShouldTreatAsVariadic(PInvokeInfo[] candidates) // Detect possible vararg entrypoint usage, where the same entrypoint is used with // different numbers of arguments. int firstNumArgs = candidates[0].Method.Signature.Length; - return candidates.Skip(1).Any(c => c.Method.Signature.Length != firstNumArgs); + for (int i = 1; i < candidates.Length; i++) + { + if (candidates[i].Method.Signature.Length != firstNumArgs) + return true; + } + + return false; } static string ListRefs(IGrouping l) @@ -242,7 +248,7 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) var callbackNames = new HashSet(); var keys = new HashSet(); int callbackIndex = 0; - callbacks = callbacks.Order(new PInvokeCallbackComparer()).ToList(); + callbacks.Sort(new PInvokeCallbackComparer()); foreach (PInvokeCallback cb in callbacks) { cb.EntrySymbol = FixedSymbolName(cb); @@ -319,7 +325,9 @@ private string GenPInvokeDecl(PInvokeInfo pinvoke) { MethodSignature signature = pinvoke.Method.Signature; TypeDesc returnType = signature.ReturnType; - List parameterTypes = ParameterTypes(signature).Select(MapType).ToList(); + List parameterTypes = []; + foreach (TypeDesc parameter in ParameterTypes(signature)) + parameterTypes.Add(MapType(parameter)); if (IsPassedByReference(returnType)) { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index f7171557974912..8673b824b7350b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -142,9 +142,9 @@ private static void WriteIfDifferent(string path, InteropLogger log, Action private static void ConfigureCompilationGroup(ReadyToRunCompilerContext context) { - List modules = context.InputFilePaths.Keys - .Select(simpleName => context.GetModuleForSimpleName(simpleName)) - .ToList(); + List modules = new(context.InputFilePaths.Count); + foreach (string simpleName in context.InputFilePaths.Keys) + modules.Add(context.GetModuleForSimpleName(simpleName)); context.SetCompilationGroup(new ReadyToRunSingleAssemblyCompilationModuleGroup(new ReadyToRunCompilationModuleGroupConfig { From 7a237617891f3bf043fbbff50c0d8c79eb575669 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 26 Aug 2026 15:27:11 +0200 Subject: [PATCH 34/72] Name the right build script in the regen errors, default --directpinvoke Centralising the regeneration logic into the project moved two error messages out of the shell scripts, and with them the guidance the .cmd used to give: both now said ./build.sh regardless of host. Pick the script that matches the platform instead. Also give --directpinvoke the same Array.Empty default ILCompilerRootCommand gives its own, so the empty case is stated rather than left to the parser. It was raised as a null-reference risk, which it is not - generating with no --directpinvoke works today and produces byte-identical output before and after this change - but the sibling command spells it out and this one may as well. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs | 2 +- src/coreclr/vm/wasm/generate-coreclr-helpers.proj | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 8a8be3b6e6863a..0ba6785d6bee94 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -101,7 +101,7 @@ internal class Crossgen2RootCommand : RootCommand public Option GeneratePortableCallHelpers { get; } = new("--generate-portable-callhelpers") { Description = SR.GeneratePortableCallHelpersOption }; public Option DirectPInvoke { get; } = - new("--directpinvoke") { Description = SR.DirectPInvokeOption }; + new("--directpinvoke") { DefaultValueFactory = _ => Array.Empty(), Description = SR.DirectPInvokeOption }; public Option SingleMethodTypeName { get; } = new("--singlemethodtypename") { Description = SR.SingleMethodTypeName }; public Option SingleMethodName { get; } = diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index dc5536f1ffeb3b..fd0b84228280a2 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -27,6 +27,11 @@ <_Crossgen2Path>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', '$(HostOS).$(BuildArchitecture).$(Configuration)', 'crossgen2', 'crossgen2.dll')) <_TesthostDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'testhost')) <_FrameworkVersion>$(MajorVersion).$(MinorVersion).0 + + + <_BuildScript Condition="$([MSBuild]::IsOSPlatform('Windows'))">.\build.cmd + <_BuildScript Condition="'$(_BuildScript)' == ''">./build.sh @@ -51,9 +56,9 @@ + Text="crossgen2 was not found at $(_Crossgen2Path). Build the clr subset first: $(_BuildScript) clr -c $(Configuration)" /> + Text="Scan path for $(_TargetOS) does not exist: $(_ScanPath). Build the runtime first: $(_BuildScript) clr+libs -os $(_TargetOS) -c $(Configuration)" /> From 24a92cdb45e687249e3163585882eb1dd4d65b24 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 26 Aug 2026 17:27:21 +0200 Subject: [PATCH 35/72] Finish the "wasm interop generator" rename in prose and diagnostics The type/namespace/property rename to PortableCallHelpers left the surrounding comments and error messages still calling it the "wasm interop generator". Per review feedback the portable entry points are not wasm-specific, so drop wasm from the prose too and name it consistently after the generator. Also correct coreclr_compat.h, which named mono's pinvoke-table.cpp and wasm_m2n_invoke.g.cpp while attributing them to crossgen2. The generator emits callhelpers-*.cpp; those two files belong to the mono build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- eng/wasm/WasmPInvokeModules.props | 4 ++-- src/coreclr/tools/aot/crossgen2/Program.cs | 7 ++++--- src/mono/browser/build/BrowserWasmApp.CoreCLR.targets | 2 +- src/mono/browser/build/coreclr_compat.h | 6 +++--- src/mono/wasi/build/WasiApp.CoreCLR.targets | 6 +++--- src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs | 3 ++- src/tests/Common/CLRTest.WasmCorerun.targets | 2 +- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/eng/wasm/WasmPInvokeModules.props b/eng/wasm/WasmPInvokeModules.props index cc74336b80c8cb..843ce0127f8d20 100644 --- a/eng/wasm/WasmPInvokeModules.props +++ b/eng/wasm/WasmPInvokeModules.props @@ -1,8 +1,8 @@ From 0720ac35d3dfddc8fc5c0bd8cce0e45ad72ecd57 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 11:27:28 +0200 Subject: [PATCH 36/72] Pass crossgen2 options as --name:value and drop the IL-only fallback Review feedback: write each generator option as a single --name:value token instead of relying on the two-item ';' trick, matching how NativeAOT builds its ILC command line, and take the item transform idiom from there as well. That also removes the need for the non-empty-identity guards: a transform over an empty item list produces nothing, whereas the batched Include evaluated once with an empty %(Identity) and wrote a valueless option. Every path the app and test targets resolve is a self-contained crossgen2 - crossgen2_inbuild.csproj is SelfContained+PublishSingleFile, and the pack's crossgen2_publish.csproj is SelfContained - so there is no IL-only build to launch through the dotnet host, and the .dll branch is dropped. The regeneration project keeps that form: it runs the crossgen2.csproj output, which is a framework-dependent apphost that will not launch without a matching shared runtime installed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../vm/wasm/generate-coreclr-helpers.proj | 23 ++++++++++--------- .../build/BrowserWasmApp.CoreCLR.targets | 19 ++++++--------- src/mono/wasi/build/WasiApp.CoreCLR.targets | 19 ++++++--------- src/tests/Common/CLRTest.WasmCorerun.targets | 19 ++++++--------- 4 files changed, 33 insertions(+), 47 deletions(-) diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index fd0b84228280a2..4a2b3142fbe7bb 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -23,7 +23,10 @@ + JIT, so the crossgen2 built for the host answers wasm questions correctly. This one is a + framework-dependent apphost, which only launches if a matching shared runtime happens to + be installed, so run its IL through the repo's own host. The self-contained crossgen2 the + app and test targets resolve has no such constraint and is invoked directly. --> <_Crossgen2Path>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', '$(HostOS).$(BuildArchitecture).$(Configuration)', 'crossgen2', 'crossgen2.dll')) <_TesthostDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'testhost')) <_FrameworkVersion>$(MajorVersion).$(MinorVersion).0 @@ -75,18 +78,16 @@ - + <_GeneratorArg Remove="@(_GeneratorArg)" /> - <_GeneratorArg Include="--targetos" /> - <_GeneratorArg Include="$(_TargetOS)" /> - <_GeneratorArg Include="--targetarch" /> - <_GeneratorArg Include="wasm" /> - <_GeneratorArg Include="--generate-portable-callhelpers" /> - <_GeneratorArg Include="$(_OutputDir)" /> - <_GeneratorArg Include="--directpinvoke;%(WasmCoreClrFrameworkPInvokeModule.Identity)" /> + <_GeneratorArg Include="--targetos:$(_TargetOS)" /> + <_GeneratorArg Include="--targetarch:wasm" /> + <_GeneratorArg Include="--generate-portable-callhelpers:$(_OutputDir)" /> + <_GeneratorArg Include="@(WasmCoreClrFrameworkPInvokeModule->'--directpinvoke:%(Identity)')" /> <_GeneratorArg Include="@(_ScanAssembly->'%(FullPath)')" /> diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 1f81a709fa76d0..408340125e504c 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -705,26 +705,21 @@ <_PortableCallHelpersGeneratorRsp>$(_WasmIntermediateOutputPath)callhelpers-generator.rsp - - <_PortableCallHelpersGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(PortableCallHelpersGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(PortableCallHelpersGeneratorPath)" - <_PortableCallHelpersGeneratorCommand Condition="'$(_PortableCallHelpersGeneratorCommand)' == ''">"$(PortableCallHelpersGeneratorPath)" - + - <_PortableCallHelpersGeneratorArg Include="--targetos" /> - <_PortableCallHelpersGeneratorArg Include="browser" /> - <_PortableCallHelpersGeneratorArg Include="--targetarch" /> - <_PortableCallHelpersGeneratorArg Include="wasm" /> - <_PortableCallHelpersGeneratorArg Include="--generate-portable-callhelpers" /> - <_PortableCallHelpersGeneratorArg Include="$(_WasmIntermediateOutputPath)" /> - <_PortableCallHelpersGeneratorArg Include="--directpinvoke;%(_WasmPInvokeModules.Identity)" Condition="'%(_WasmPInvokeModules.Identity)' != ''" /> + <_PortableCallHelpersGeneratorArg Include="--targetos:browser" /> + <_PortableCallHelpersGeneratorArg Include="--targetarch:wasm" /> + <_PortableCallHelpersGeneratorArg Include="--generate-portable-callhelpers:$(_WasmIntermediateOutputPath)" /> + <_PortableCallHelpersGeneratorArg Include="@(_WasmPInvokeModules->'--directpinvoke:%(Identity)')" /> <_PortableCallHelpersGeneratorArg Include="@(_WasmManagedAssemblies->'%(FullPath)')" /> - + diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 136e5084f05d58..d65096ef0b25b3 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -169,26 +169,21 @@ <_PortableCallHelpersGeneratorRsp>$(_WasiRelinkObjDir)callhelpers-generator.rsp - - <_PortableCallHelpersGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(PortableCallHelpersGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(PortableCallHelpersGeneratorPath)" - <_PortableCallHelpersGeneratorCommand Condition="'$(_PortableCallHelpersGeneratorCommand)' == ''">"$(PortableCallHelpersGeneratorPath)" - + - <_PortableCallHelpersGeneratorArg Include="--targetos" /> - <_PortableCallHelpersGeneratorArg Include="wasi" /> - <_PortableCallHelpersGeneratorArg Include="--targetarch" /> - <_PortableCallHelpersGeneratorArg Include="wasm" /> - <_PortableCallHelpersGeneratorArg Include="--generate-portable-callhelpers" /> - <_PortableCallHelpersGeneratorArg Include="$(_WasiRelinkObjDir)" /> - <_PortableCallHelpersGeneratorArg Include="--directpinvoke;%(_WasiPInvokeModules.Identity)" Condition="'%(_WasiPInvokeModules.Identity)' != ''" /> + <_PortableCallHelpersGeneratorArg Include="--targetos:wasi" /> + <_PortableCallHelpersGeneratorArg Include="--targetarch:wasm" /> + <_PortableCallHelpersGeneratorArg Include="--generate-portable-callhelpers:$(_WasiRelinkObjDir)" /> + <_PortableCallHelpersGeneratorArg Include="@(_WasiPInvokeModules->'--directpinvoke:%(Identity)')" /> <_PortableCallHelpersGeneratorArg Include="@(_WasiManagedAssemblies->'%(FullPath)')" /> - + diff --git a/src/tests/Common/CLRTest.WasmCorerun.targets b/src/tests/Common/CLRTest.WasmCorerun.targets index c1f5f5a3b1689f..fc4c6c53b4d47e 100644 --- a/src/tests/Common/CLRTest.WasmCorerun.targets +++ b/src/tests/Common/CLRTest.WasmCorerun.targets @@ -324,31 +324,26 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s <_WasmCorerunGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe <_WasmCorerunGeneratorPath Condition="'$(_WasmCorerunGeneratorPath)' == '' and '$(Crossgen2InBuildDir)' != ''">$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmCorerunGeneratorExeSuffix)')) - - <_WasmCorerunGeneratorCommand Condition="'$([System.IO.Path]::GetExtension(`$(_WasmCorerunGeneratorPath)`))' == '.dll'">"$(DOTNET_HOST_PATH)" "$(_WasmCorerunGeneratorPath)" - <_WasmCorerunGeneratorCommand Condition="'$(_WasmCorerunGeneratorCommand)' == ''">"$(_WasmCorerunGeneratorPath)" - + - <_WasmCorerunGeneratorArg Include="--targetos" /> - <_WasmCorerunGeneratorArg Include="$(TargetOS)" /> - <_WasmCorerunGeneratorArg Include="--targetarch" /> - <_WasmCorerunGeneratorArg Include="wasm" /> - <_WasmCorerunGeneratorArg Include="--generate-portable-callhelpers" /> - <_WasmCorerunGeneratorArg Include="$(_WasmCorerunDir)" /> - <_WasmCorerunGeneratorArg Include="--directpinvoke;%(_WasmCorerunPInvokeModule.Identity)" Condition="'%(_WasmCorerunPInvokeModule.Identity)' != ''" /> + <_WasmCorerunGeneratorArg Include="--targetos:$(TargetOS)" /> + <_WasmCorerunGeneratorArg Include="--targetarch:wasm" /> + <_WasmCorerunGeneratorArg Include="--generate-portable-callhelpers:$(_WasmCorerunDir)" /> + <_WasmCorerunGeneratorArg Include="@(_WasmCorerunPInvokeModule->'--directpinvoke:%(Identity)')" /> <_WasmCorerunGeneratorArg Include="@(_WasmCorerunManagedAssembly->'%(FullPath)')" /> - + From bde58bf6b665c92d2f35e35c1cf4db8cb6f79678 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 11:47:38 +0200 Subject: [PATCH 37/72] Diagnose a crossgen2.dll override, and correct the response-file comment Review follow-up on the previous commit. $(PortableCallHelpersGeneratorPath) is a documented override, and dropping the host launch means pointing it at an IL assembly now reaches Exec and fails with "Permission denied" rather than anything a reader can act on. Reject a .dll up front instead. This is a diagnostic, not a reinstated fallback: the supported paths all resolve a self-contained executable. The comment reworded in the previous commit conflated two things. One token per line is the response-file rule and it covers the bare assembly paths in the same item group; joining an option to its value with a colon is why an option no longer spills onto the next line. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/coreclr/vm/wasm/generate-coreclr-helpers.proj | 5 +++-- src/mono/browser/build/BrowserWasmApp.CoreCLR.targets | 7 +++++-- src/mono/wasi/build/WasiApp.CoreCLR.targets | 7 +++++-- src/tests/Common/CLRTest.WasmCorerun.targets | 5 +++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index 4a2b3142fbe7bb..327b8880571935 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -78,8 +78,9 @@ - diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 408340125e504c..3f7bd43b796294 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -702,13 +702,16 @@ + <_PortableCallHelpersGeneratorRsp>$(_WasmIntermediateOutputPath)callhelpers-generator.rsp - + <_PortableCallHelpersGeneratorArg Include="--targetos:browser" /> <_PortableCallHelpersGeneratorArg Include="--targetarch:wasm" /> diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index d65096ef0b25b3..b516bec5a785de 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -166,13 +166,16 @@ + <_PortableCallHelpersGeneratorRsp>$(_WasiRelinkObjDir)callhelpers-generator.rsp - + <_PortableCallHelpersGeneratorArg Include="--targetos:wasi" /> <_PortableCallHelpersGeneratorArg Include="--targetarch:wasm" /> diff --git a/src/tests/Common/CLRTest.WasmCorerun.targets b/src/tests/Common/CLRTest.WasmCorerun.targets index fc4c6c53b4d47e..4706cd7fd9a702 100644 --- a/src/tests/Common/CLRTest.WasmCorerun.targets +++ b/src/tests/Common/CLRTest.WasmCorerun.targets @@ -329,8 +329,9 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s - + From 90c5720445a48990b24caf9b91339adf0ecdc8e9 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 12:00:45 +0200 Subject: [PATCH 38/72] Describe the CoreCLR reverse-thunk mechanism, not Mono's Both comments were carried over from the Mono emitter and describe a mechanism this generator does not use. Neither get_native_to_interp nor wasm_native_to_interp_ftndescs exists here, and the generated wrapper does not take an interpreter function and argument out of a global descriptor array. What it actually does: the key has to match the one GetHashCode builds from the MethodDesc in vm/wasm/helpers.cpp, and each wrapper caches its MethodDesc in a static, resolves it lazily by name, and dispatches through ExecuteInterpretedMethodFromUnmanaged; g_ReverseThunks maps the runtime's key to the wrapper for the other direction. The [MonoPInvokeCallback] mention stays, because MethodHasCallbackAttributes really does accept that attribute alongside [UnmanagedCallersOnly] - it is the "delegate invoke" wording around it that was Mono's. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeCollector.cs | 9 ++++----- .../PInvokeTableGenerator.cs | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 336db005ec6b83..36d707c5f4fe47 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -63,11 +63,10 @@ public PInvokeCallback(EcmaMethod method) IsVoid = ReturnType.IsVoid; Token = (uint)MetadataTokens.GetToken(method.Handle); - // FIXME: this is a hack, we need to encode this better and allow reflection in the interp case - // but either way it needs to match the key generated in get_native_to_interp since the key is - // used to look up the interp entry function. It must be unique for each callback runtime errors - // can occur since it is used to look up the index in the wasm_native_to_interp_ftndescs and - // the signature of the interp entry function must match the native signature + // FIXME: This name-based key is a temporary workaround. It must match the key generated by + // GetHashCode in vm/wasm/helpers.cpp because LookupThunk uses it to find the corresponding + // entry in g_ReverseThunks. The key must uniquely identify each callback; otherwise a + // collision could select a thunk whose native signature does not match the method. // // the key also needs to survive being encoded in C literals, if in doubt // add something like "\U0001F412" to the key on both the managed and unmanaged side diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index a2c0f3ec32750d..1fb627d4e48de1 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -217,14 +217,17 @@ static string ListRefs(IGrouping l) public void EmitNativeToInterp(TextWriter w, List callbacks) { - // Generate native->interp entry functions - // These are called by native code, so they need to obtain - // the interp entry function/arg from a global array - // They also need to have a signature matching what the - // native code expects, which is the native signature - // of the delegate invoke in the [MonoPInvokeCallback] - // or [UnmanagedCallersOnly] attribute. - // Only blittable parameter/return types are supposed. + // Generate the native->interpreter entry functions. Native code calls these directly, so + // each one carries the native signature its caller expects, taken from the managed method + // it wraps - one marked [UnmanagedCallersOnly], or the [MonoPInvokeCallback] that + // MethodHasCallbackAttributes also accepts. Only blittable parameter and return types are + // supported. + // + // Each wrapper caches the MethodDesc it dispatches to in a static and hands the arguments to + // ExecuteInterpretedMethodFromUnmanaged. The g_ReverseThunks table emitted at the end maps + // 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. w.Write( """ // Licensed to the .NET Foundation under one or more agreements. From 17c2eed3b018f769388a97537207b5c22d5d62e5 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 12:38:05 +0200 Subject: [PATCH 39/72] Drop the unused callbackIndex from the reverse-thunk emitter Another Mono leftover, where it indexed wasm_native_to_interp_ftndescs. The CoreCLR emitter keys off cb.EntrySymbol and never reads it, so it was only being declared and incremented. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeTableGenerator.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 1fb627d4e48de1..22bff29b472451 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -250,7 +250,6 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) var callbackNames = new HashSet(); var keys = new HashSet(); - int callbackIndex = 0; callbacks.Sort(new PInvokeCallbackComparer()); foreach (PInvokeCallback cb in callbacks) { @@ -296,7 +295,6 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) }{{exportFunction}} """); - callbackIndex++; } w.Write( From 77e1d0a2731d2d45ee50cb4bd7a14dd78ba3fa7a Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 12:45:56 +0200 Subject: [PATCH 40/72] Clean up three more constructs inherited from the Mono generator None of these change a byte of generated output. PInvokeCallbackComparer compared tokens by unsigned subtraction cast to int. That happens to order correctly only because metadata tokens stay well under 2^31; CompareTo says what is meant and does not depend on that. The module list was a SortedDictionary that only ever mapped each key to itself, carried over from src/tasks/WasmAppBuilder/mono. A SortedSet is the same thing without the redundant value, and enumerates in the same order. The driver pulled in System.Linq for a single Concat feeding a set the callee rebuilds anyway. UnionWith folds the InternalCall signatures into the set the collector already filled, which drops the dependency the CG2/ILC guidance asks us to avoid. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeCollector.cs | 2 +- .../PInvokeTableGenerator.cs | 20 +++++++++---------- .../PortableCallHelpersGenerator.cs | 5 ++--- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 36d707c5f4fe47..bef140af3a7177 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -114,7 +114,7 @@ internal sealed class PInvokeCallbackComparer : IComparer public int Compare(PInvokeCallback x, PInvokeCallback y) { int compare = string.Compare(x.Key, y.Key, StringComparison.Ordinal); - return compare != 0 ? compare : (int)(x.Token - y.Token); + return compare != 0 ? compare : x.Token.CompareTo(y.Token); } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 22bff29b472451..8877b88ffa9338 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -24,19 +24,19 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L // Only the logging is suppressed: a module a later P/Invoke does resolve - through // [WasmImportLinkage], say - still has to make it into the table. var skippedModules = new HashSet(StringComparer.Ordinal); - var modules = new SortedDictionary(StringComparer.Ordinal); + var modules = new SortedSet(StringComparer.Ordinal); foreach (string module in pinvokeModules) - modules[module] = module; + modules.Add(module); // What actually gets linked in, captured before the scan below starts adding to modules. // The lib-prefix fallback has to resolve against this rather than against modules, or an // alias could be derived from another alias, or from a module that is only imported for // [WasmImportLinkage] and has no archive behind it at all. - var linkedModules = new HashSet(modules.Keys, StringComparer.Ordinal); + var linkedModules = new HashSet(modules, StringComparer.Ordinal); foreach (PInvokeInfo pinvoke in pinvokes) { - if (modules.ContainsKey(pinvoke.Module)) + if (modules.Contains(pinvoke.Module)) continue; // A static archive is named libFoo.a, so the module list - built from the file names @@ -45,7 +45,7 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L // runtime resolver looks up, so accept it as naming the same module. if (linkedModules.Contains($"lib{pinvoke.Module}")) { - modules.Add(pinvoke.Module, pinvoke.Module); + modules.Add(pinvoke.Module); log.Verbose($"Adding module {pinvoke.Module} for statically linked lib{pinvoke.Module}"); continue; } @@ -55,13 +55,13 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L if (pinvoke.WasmLinkage) { // WasmLinkage means we need to import the module - modules.Add(pinvoke.Module, pinvoke.Module); + modules.Add(pinvoke.Module); log.Verbose($"Adding module {pinvoke.Module} for WasmImportLinkage"); } else if (pinvoke.Module == "*") { // Special case for * module to indicate static linking without specifying the module - modules.Add(pinvoke.Module, pinvoke.Module); + modules.Add(pinvoke.Module); log.Verbose($"Adding module {pinvoke.Module} for static linking"); } else if (pinvoke.Module != "QCall") @@ -95,7 +95,7 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L """); var pinvokesGroupedByEntryPoint = pinvokes - .Where(l => modules.ContainsKey(l.Module)) + .Where(l => modules.Contains(l.Module)) .OrderBy(l => l.EntryPoint, StringComparer.Ordinal) .GroupBy(CEntryPoint, StringComparer.Ordinal); @@ -134,7 +134,7 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L """); var moduleImports = new Dictionary>(); - foreach (string module in modules.Keys) + foreach (string module in modules) { // the order here is not important, because we use hash tables, we want it to be stable though List imports = pinvokes @@ -174,7 +174,7 @@ typedef struct PInvokeTable { } PInvokeTable; static PInvokeTable s_PInvokeTables[] = { - {{string.Join($",{w.NewLine} ", modules.Keys.Select(m => $"{{\"{EscapeLiteral(m)}\", s_{FixupSymbolName(m)}, {moduleImports[m].Count}}}"))}} + {{string.Join($",{w.NewLine} ", modules.Select(m => $"{{\"{EscapeLiteral(m)}\", s_{FixupSymbolName(m)}, {moduleImports[m].Count}}}"))}} }; const size_t s_PInvokeTablesCount = sizeof(s_PInvokeTables) / sizeof(s_PInvokeTables[0]); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 8673b824b7350b..5ab9fd7affc7d8 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Text; using Internal.TypeSystem; @@ -101,10 +100,10 @@ private static void Generate(ReadyToRunCompilerContext context, PortableCallHelp WriteIfDifferent(Path.Combine(options.OutputDirectory, ReversePInvokeFileName), log, w => generator.EmitNativeToInterp(w, callbacks)); - IEnumerable cookies = signatures.Concat(internalCallCollector.Signatures); + signatures.UnionWith(internalCallCollector.Signatures); WriteIfDifferent(Path.Combine(options.OutputDirectory, InterpToNativeFileName), log, - w => InterpToNativeGenerator.Emit(w, cookies)); + w => InterpToNativeGenerator.Emit(w, signatures)); } /// From 2e8d211ac90b4da87d3fb928ed82b09d7c66e5a5 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 13:36:25 +0200 Subject: [PATCH 41/72] Reject an exported callback whose name is ambiguous at run time An export wrapper resolves its MethodDesc with LookupUnmanagedCallersOnlyMethodByName, which walks the declaring type and takes the first [UnmanagedCallersOnly] method whose name matches, comparing no signature. Two exported overloads therefore resolve to the same method, whichever the walk reaches first, and one of the wrappers calls it with the wrong arguments. The existing checks miss this because everything the generator controls does carry the arity - the thunk keys are "Handle#1:..." and "Handle#2:...", and the symbols differ by parameter type. Only the lookup drops it. App code reaches this with two exported overloads in one class, which builds clean today; no framework callback has a twin, so nothing that builds now starts failing. Callbacks the runtime resolves through g_ReverseThunks are unaffected, because GetUnmanagedCallersOnlyThunk finds them by the arity-aware key and back-fills the static, so only exports are rejected. The scan asks the declaring type rather than the collected callbacks, since a method this generator skipped is still a candidate for the runtime's walk. This only makes the case a build error. It should be removed if the runtime ever resolves these unambiguously. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PInvokeTableGenerator.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 8877b88ffa9338..7c7eee49a0f6d2 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -261,6 +261,18 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) if (!keys.Add(cb.Key)) throw new LogAsErrorException($"Two callbacks with the same Name and number of arguments '{cb.Key}' are not supported."); + // That check only catches overloads of the same arity, which collide outright. Different + // arities produce distinct keys and distinct symbols, yet the export wrapper resolves its + // MethodDesc through LookupUnmanagedCallersOnlyMethodByName, which matches on the + // declaring type and the method name alone. Overloads are indistinguishable to it, so an + // export sharing its name with another callback would be handed whichever MethodDesc the + // walk reached first and would then call it with its own arguments. + // + // This is a stopgap for a lookup that cannot express what it means to ask. If the runtime + // ever resolves these unambiguously, this rejection should go away with it. + if (cb.IsExport) + RejectAmbiguousExport(cb); + int parameterCount = cb.Parameters.Length; string argsArgs = parameterCount > 0 ? "(int8_t*)args, sizeof(args)" : "nullptr, 0"; string argsDeclaration = parameterCount > 0 @@ -308,6 +320,30 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) const size_t g_ReverseThunksCount = sizeof(g_ReverseThunks) / sizeof(g_ReverseThunks[0]); """); + + // The runtime walks every [UnmanagedCallersOnly] method the type declares, so match that. + static void RejectAmbiguousExport(PInvokeCallback cb) + { + List ambiguous = []; + foreach (MethodDesc candidate in cb.Method.OwningType.GetMethods()) + { + if (candidate != cb.Method + && candidate.Name.StringEquals(cb.MethodName) + && candidate.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute")) + { + ambiguous.Add(candidate.ToString()); + } + } + + if (ambiguous.Count == 0) + return; + + ambiguous.Add(cb.Method.ToString()); + ambiguous.Sort(StringComparer.Ordinal); + + 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)}"); + } } private string CEntryPoint(PInvokeInfo pinvoke) From 9e89c56a58c1076365802ad3a1516c2a2ceaa97c Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 14:39:43 +0200 Subject: [PATCH 42/72] Test that generation rejects an export it could not resolve by name Review feedback: the rejection had no automated coverage, and neither do the duplicate-symbol and duplicate-key errors beside it. The generator already runs end to end in this file, so the test compiles a small input assembly and asserts the exit code and the message. Five cases pin the contract: two exported overloads and an exported one beside a non-exported twin are rejected, while distinct names, a pair that is not exported at all, and a [MonoPInvokeCallback] twin that the runtime walk does not match all generate. Disabling the check fails exactly the two rejection cases and leaves the other three passing, so they are testing the check rather than agreeing with it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 6f1d3a025e4184..607475d9093def 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -8,6 +8,10 @@ using System.IO; using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + using crossgen2::ILCompiler; using crossgen2::ILCompiler.DependencyAnalysis.ReadyToRun; using crossgen2::ILCompiler.DependencyAnalysis.Wasm; @@ -692,6 +696,111 @@ public void PortableCallHelpersGeneratorAcceptsMoreThanOneInputAssembly() private const string CoreLibSimpleName = "System.Private.CoreLib"; + /// + /// An exported callback resolves its MethodDesc at run time through + /// LookupUnmanagedCallersOnlyMethodByName, which matches on the declaring type and the method name + /// alone. Overloads are indistinguishable to it, so generation has to reject a name it could not + /// resolve rather than emit a wrapper that calls whichever one the walk reaches first. + /// + [Theory] + // Two exported overloads: the lookup cannot tell them apart. + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle", + "[UnmanagedCallersOnly(EntryPoint = \"cb_two\")]", "Handle", true)] + // The twin does not have to be exported to be returned by the walk, which only tests the attribute. + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle", + "[UnmanagedCallersOnly]", "Handle", true)] + // Distinct names resolve unambiguously. + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "HandleOne", + "[UnmanagedCallersOnly(EntryPoint = \"cb_two\")]", "HandleTwo", false)] + // Nothing is exported, so neither wrapper reaches the name lookup: the runtime hands both their + // MethodDesc through the arity-aware g_ReverseThunks key instead. + [InlineData("[UnmanagedCallersOnly]", "Handle", "[UnmanagedCallersOnly]", "Handle", false)] + // [MonoPInvokeCallback] is collected as a callback but carries no UnmanagedCallersOnly attribute, + // so the runtime walk skips it and it cannot be confused with the export. + [InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle", + "[MonoPInvokeCallback]", "Handle", false)] + public void PortableCallHelpersGeneratorRejectsAnExportItCouldNotResolveByName( + string firstAttribute, string firstName, string secondAttribute, string secondName, bool expectRejected) + { + string source = $$""" + using System.Runtime.InteropServices; + + public sealed class MonoPInvokeCallbackAttribute : System.Attribute { } + + public static class Exports + { + {{firstAttribute}} + public static int {{firstName}}(int a) => a; + + {{secondAttribute}} + public static int {{secondName}}(int a, int b) => a + b; + } + """; + + 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)); + + if (expectRejected) + { + Assert.Equal(1, exitCode); + Assert.Contains($"declares more than one [UnmanagedCallersOnly] method named '{firstName}'", log.ToString()); + } + else + { + Assert.Equal(0, exitCode); + Assert.DoesNotContain("declares more than one", 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) + { + } + } + } + + /// + /// 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) + { + CSharpCompilation compilation = CSharpCompilation.Create( + Path.GetFileNameWithoutExtension(outputPath), + new[] { CSharpSyntaxTree.ParseText(source) }, + new[] { MetadataReference.CreateFromFile(TestPaths.SystemPrivateCoreLibPath) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + EmitResult result = compilation.Emit(outputPath); + Assert.True(result.Success, + string.Join(Environment.NewLine, result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + + return outputPath; + } + private static EcmaType GetSystemType(ReadyToRunCompilerContext context, string typeName) { return (EcmaType)context.SystemModule.GetType("System"u8, System.Text.Encoding.UTF8.GetBytes(typeName)); From 6f02d7781c2f8adbb45451a68b23ef5de1f537d0 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 15:42:57 +0200 Subject: [PATCH 43/72] Regenerate through the self-contained crossgen2, and drop the rsp comments Review feedback. The regeneration project ran crossgen2.csproj's framework-dependent output through DOTNET_HOST_PATH. crossgen2_inbuild.csproj publishes a self-contained one, is built by the clr subset the script already tells you to build, and is what the app and test targets resolve, so use $(Crossgen2InBuildDir) and invoke it directly, the same way tests.readytorun.targets and the CoreCLR sfxproj do. It resolves inside the target because eng/liveBuilds.targets defines that property after this file is imported. The comments describing how crossgen2 reads a response file are removed. That is how response files work in every .NET tool that supports them, and no other place that uses one explains it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../vm/wasm/generate-coreclr-helpers.proj | 16 ++++------------ .../browser/build/BrowserWasmApp.CoreCLR.targets | 3 --- src/mono/wasi/build/WasiApp.CoreCLR.targets | 3 --- src/tests/Common/CLRTest.WasmCorerun.targets | 3 --- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index 327b8880571935..8f5fe63310a402 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -22,12 +22,6 @@ - - <_Crossgen2Path>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', '$(HostOS).$(BuildArchitecture).$(Configuration)', 'crossgen2', 'crossgen2.dll')) <_TesthostDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'testhost')) <_FrameworkVersion>$(MajorVersion).$(MinorVersion).0 @@ -51,6 +45,9 @@ + + <_Crossgen2Path>$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(ExeSuffix)')) <_TargetOS>%(_WasmCallHelperTarget.Identity) <_ScanPath>%(_WasmCallHelperTarget.ScanPath) <_OutputDir>$(MSBuildThisFileDirectory)$(_TargetOS)/ @@ -78,11 +75,6 @@ - <_GeneratorArg Remove="@(_GeneratorArg)" /> <_GeneratorArg Include="--targetos:$(_TargetOS)" /> @@ -94,7 +86,7 @@ - diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index 3f7bd43b796294..92dd4005c3ac03 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -709,9 +709,6 @@ <_PortableCallHelpersGeneratorRsp>$(_WasmIntermediateOutputPath)callhelpers-generator.rsp - <_PortableCallHelpersGeneratorArg Include="--targetos:browser" /> <_PortableCallHelpersGeneratorArg Include="--targetarch:wasm" /> diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index b516bec5a785de..b296693eb0d9e5 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -173,9 +173,6 @@ <_PortableCallHelpersGeneratorRsp>$(_WasiRelinkObjDir)callhelpers-generator.rsp - <_PortableCallHelpersGeneratorArg Include="--targetos:wasi" /> <_PortableCallHelpersGeneratorArg Include="--targetarch:wasm" /> diff --git a/src/tests/Common/CLRTest.WasmCorerun.targets b/src/tests/Common/CLRTest.WasmCorerun.targets index 4706cd7fd9a702..9b08eb063625fe 100644 --- a/src/tests/Common/CLRTest.WasmCorerun.targets +++ b/src/tests/Common/CLRTest.WasmCorerun.targets @@ -329,9 +329,6 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s - From 5a505d800f0cbe068ad4640ddd02962fd835e814 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 27 Aug 2026 16:11:46 +0200 Subject: [PATCH 44/72] Take crossgen2 from the same build that produced the scan path Review feedback: regeneration should not need two build commands. It resolved crossgen2 from the host artifacts, so a plain "clr+libs -os wasi" did not put one where this looked, and a separate host "clr" build was needed on top. Each flavor's build publishes a self-contained crossgen2 of its own, so take that one and let "clr+libs -os " supply everything for that flavor. Both errors now name the same command. Checked by moving the host crossgen2 aside: regeneration still succeeds and the tables come out byte-identical. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/coreclr/vm/wasm/generate-coreclr-helpers.md | 8 +++----- src/coreclr/vm/wasm/generate-coreclr-helpers.proj | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.md b/src/coreclr/vm/wasm/generate-coreclr-helpers.md index e81f46904d0aa4..d0d15105b859e5 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.md +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.md @@ -51,9 +51,9 @@ time rather than as a build failure. ## What needs to be built first The generator scans the **managed framework assemblies** in the `testhost` folder produced by a -`clr+libs` build, and runs the crossgen2 built by the `clr` subset for your **host** platform. -Because the scripts generate both the `browser` and `wasi` variations, you must build **both** -WebAssembly flavors before running them. The first build of either flavor also downloads and +`clr+libs` build, and runs the self-contained crossgen2 that the same build produces. Because the +scripts generate both the `browser` and `wasi` variations, you must build **both** WebAssembly +flavors before running them. The first build of either flavor also downloads and provisions the Emscripten SDK (emsdk) automatically. From the repository root: @@ -74,8 +74,6 @@ Notes: - Use a matching `-c ` for the configuration you intend to pass to the generator script (the script derives the scan path from the configuration name). -- Generation does not load the JIT, so the host-targeting crossgen2 from a plain `clr` build - answers wasm questions correctly; no wasm-targeting crossgen2 is needed. - If a required `testhost` scan path or crossgen2 is missing, the script stops and prints the exact `build` command needed to produce it. diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index 8f5fe63310a402..2fe6ffaaa103b3 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -45,18 +45,18 @@ - - <_Crossgen2Path>$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(ExeSuffix)')) <_TargetOS>%(_WasmCallHelperTarget.Identity) <_ScanPath>%(_WasmCallHelperTarget.ScanPath) + + <_Crossgen2Path>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', '$(_TargetOS).wasm.$(Configuration)', '$(BuildArchitecture)', 'crossgen2', 'crossgen2$(ExeSuffix)')) <_OutputDir>$(MSBuildThisFileDirectory)$(_TargetOS)/ <_ResponseFileDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsObjDir)', 'wasm-callhelpers', '$(_TargetOS)')) <_ResponseFile>$(_ResponseFileDir)generate-coreclr-helpers.rsp + Text="crossgen2 was not found at $(_Crossgen2Path). Build the runtime first: $(_BuildScript) clr+libs -os $(_TargetOS) -c $(Configuration)" /> Date: Mon, 31 Aug 2026 11:33:32 +0200 Subject: [PATCH 45/72] Apply batched suggestions from code review Co-authored-by: Jan Kotas --- src/coreclr/tools/aot/crossgen2/Program.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index d360be363ffe70..7905720e3a1c48 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -70,8 +70,6 @@ private void ConfigureImageBase(TargetDetails targetDetails) public int Run() { - // Interop generation mode reads the input assemblies and writes source files, so the - // output arguments the compilation path requires do not apply. if (_outputFilePath == null && !_outNearInput && _generatePortableCallHelpers is null) throw new CommandLineException(SR.MissingOutputFile); @@ -83,10 +81,7 @@ public int Run() (TargetArchitecture targetArchitecture, TargetOS targetOS, TargetAbi targetAbi) = Helpers.GetTargetSpec(Get(_command.TargetArchitecture), Get(_command.TargetOS)); - // The portable call-helpers generator answers ABI questions (struct sizes, argument - // lowering) through the same type system the compiler uses, so an unspecified target - // would silently produce host layouts. Reject anything but a wasm target instead of - // emitting subtly wrong helpers. + // The portable call-helpers generator is currently supported only for Wasm. if (_generatePortableCallHelpers is not null && (targetArchitecture != TargetArchitecture.Wasm32 || targetOS is not (TargetOS.Browser or TargetOS.Wasi))) { From 5ad42e338409bed645b4ee18eb9c321395d270b5 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 31 Aug 2026 11:26:49 +0200 Subject: [PATCH 46/72] Drop the crossgen2 friends access from ILCompiler.ReadyToRun Review feedback: why is this needed, and can we avoid it? It was needed because an earlier round of this PR made the generator entry points internal, on the premise that they were new public API surface subject to the API approval gate. That premise does not hold for this assembly: it has no ref assembly, no ApiCompat or package validation, no XML documentation, and is not packaged - it ships bundled inside the self-contained crossgen2 executable, so nothing outside the repo can reference it. 210 of its types are already public. Make the two entry points public instead and drop the InternalsVisibleTo. All of their members were public already, so only the type declarations change; the rest of PortableCallHelpers stays internal. The ILCompiler.ReadyToRun.Tests entry predates this PR and stays - the layout tests use internals of InteropSignature and WasmLowering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj | 1 - .../PortableCallHelpers/PortableCallHelpersGenerator.cs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index b5c78d984ba297..5d549817ee7817 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -24,7 +24,6 @@ - diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 5ab9fd7affc7d8..2930b4ac63aea6 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -14,7 +14,7 @@ namespace ILCompiler.PortableCallHelpers /// /// Options for , mirroring the command line. /// - internal sealed class PortableCallHelpersGeneratorOptions + public sealed class PortableCallHelpersGeneratorOptions { public string OutputDirectory { get; init; } public IReadOnlyList PInvokeModules { get; init; } = []; @@ -36,7 +36,7 @@ internal sealed class PortableCallHelpersGeneratorOptions /// crossgen2 --generate-portable-callhelpers <dir> --targetos <browser|wasi> --targetarch wasm \ /// --directpinvoke <name>... <assembly>... /// - internal static class PortableCallHelpersGenerator + public static class PortableCallHelpersGenerator { public const string PInvokeFileName = "callhelpers-pinvoke.cpp"; public const string ReversePInvokeFileName = "callhelpers-reverse.cpp"; From fd2a683dda3ef908a6cc6f67496a1769fbcb639a Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 31 Aug 2026 12:33:56 +0200 Subject: [PATCH 47/72] Pass the lowering flags through instead of a bool Review feedback: includeThis named the leading 'T' rather than the choice being made, and was the inverse of the LoweringFlags value it mapped to. Taking the flags removes both the translation and the inversion; managed callers now pass nothing. Tables regenerate byte-identical. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 2 +- .../InternalCallSignatureCollector.cs | 2 +- .../PortableCallHelpers/InteropSignature.cs | 18 ++++++------------ .../PortableCallHelpers/PInvokeCollector.cs | 9 +++++---- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 607475d9093def..e5b8c4ffe80313 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -600,7 +600,7 @@ public void PortableCallHelpersGeneratorEncodesMethodsLikeTheCompiler() string expected = WasmLowering.GetSignature(method.Signature, WasmLowering.LoweringFlags.None).SignatureString; _output.WriteLine($"{method} lowers to '{expected}'"); - Assert.Equal(expected, InteropSignature.GetMethodSignature(method, includeThis: true)); + Assert.Equal(expected, InteropSignature.GetMethodSignature(method)); } /// diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs index 5ae8c426f8792b..7e631f2cfe4798 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs @@ -81,7 +81,7 @@ public void ScanType(EcmaType type) { // A managed signature: the lowering adds the 'T' for an instance method and the // trailing 'p' for the portable entry point parameter. - string signature = InteropSignature.GetMethodSignature(method, includeThis: true); + string signature = InteropSignature.GetMethodSignature(method); if (_signatures.Add(signature)) log.Verbose($"Adding InternalCall signature {signature} for method '{type}.{method.Name.ToString()}'"); } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs index 492575586e8e9b..251e51037cc0b3 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs @@ -37,19 +37,13 @@ internal static class InteropSignature /// Returns the wasm signature string for a method. /// /// The method to encode. - /// - /// for a managed signature, which picks up the leading 'T' for an - /// instance method and the trailing 'p' for the portable entry point argument. - /// describes a native function. + /// + /// The default lowers as a managed method, taking the leading 'T' for an instance method + /// and the trailing 'p' for the portable entry point argument; + /// lowers as a native function. /// - public static string GetMethodSignature(MethodDesc method, bool includeThis) - { - WasmLowering.LoweringFlags flags = includeThis - ? WasmLowering.LoweringFlags.None - : WasmLowering.LoweringFlags.IsUnmanagedCallersOnly; - - return WasmLowering.GetSignature(method.Signature, flags).SignatureString; - } + public static string GetMethodSignature(MethodDesc method, WasmLowering.LoweringFlags flags = WasmLowering.LoweringFlags.None) + => WasmLowering.GetSignature(method.Signature, flags).SignatureString; /// /// Gets the signature encoding for a type in parameter position: a primitive character diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index bef140af3a7177..d9458195e6aa82 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.JitInterface; using Internal.TypeSystem; using Internal.TypeSystem.Ecma; @@ -159,7 +160,7 @@ public void CollectPInvokes(List pinvokes, List ca MethodDesc invokeMethod = type.GetMethod("Invoke"u8, null); if (invokeMethod is not null) - AddSignature(signatures, invokeMethod, includeThis: false, "pinvoke"); + AddSignature(signatures, invokeMethod, WasmLowering.LoweringFlags.IsUnmanagedCallersOnly, "pinvoke"); } void CollectPInvokesForMethod(EcmaMethod method) @@ -175,13 +176,13 @@ void CollectPInvokesForMethod(EcmaMethod method) pinvokes.Add(new PInvokeInfo(metadata.Name, metadata.Module, method, wasmLinkage)); - AddSignature(signatures, method, includeThis: false, "pinvoke"); + AddSignature(signatures, method, WasmLowering.LoweringFlags.IsUnmanagedCallersOnly, "pinvoke"); } } - private void AddSignature(HashSet signatures, MethodDesc method, bool includeThis, string kind) + private void AddSignature(HashSet signatures, MethodDesc method, WasmLowering.LoweringFlags flags, string kind) { - string signature = InteropSignature.GetMethodSignature(method, includeThis); + string signature = InteropSignature.GetMethodSignature(method, flags); if (signatures.Add(signature)) log.Verbose($"Adding {kind} signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); } From 8d3ecbe448f3f5e702d6936dcb4a6d4a3c055f5b Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 31 Aug 2026 14:13:21 +0200 Subject: [PATCH 48/72] Stop stamping generated interop files with the MIT header Review feedback: over a user application the generated file carries names and signatures from that application and its packages, which are not the .NET Foundation's to license. Mono's generators, which only ever run that way, emit the GENERATED FILE banner alone; the header came in with the port. All three emit sites drop it and the checked-in tables are regenerated to match. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/InterpToNativeGenerator.cs | 4 ---- .../PortableCallHelpers/PInvokeTableGenerator.cs | 8 -------- .../vm/wasm/browser/callhelpers-interp-to-managed.cpp | 4 ---- src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp | 4 ---- src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp | 4 ---- .../vm/wasm/wasi/callhelpers-interp-to-managed.cpp | 4 ---- src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp | 4 ---- src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp | 4 ---- 8 files changed, 36 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs index cd64b07e22ff54..7a2a611de414cf 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs @@ -36,10 +36,6 @@ public static void Emit(TextWriter w, IEnumerable cookies) w.Write( """ - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. - // - // // GENERATED FILE, DON'T EDIT // Generated by coreclr InterpToNativeGenerator diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 7c7eee49a0f6d2..8ad388adb238b0 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -79,10 +79,6 @@ public void EmitPInvokeTable(TextWriter w, IEnumerable pinvokeModules, L w.WriteLine( """ - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. - // - // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator @@ -230,10 +226,6 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) // itself, by name, on the first call. w.Write( """ - // Licensed to the .NET Foundation under one or more agreements. - // The .NET Foundation licenses this file to you under the MIT license. - // - // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp index 273ac75f498b17..cc2419fac34ed9 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp @@ -1,7 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// - // // GENERATED FILE, DON'T EDIT // Generated by coreclr InterpToNativeGenerator diff --git a/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp b/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp index 9dbd7f579c68e6..977e20cf0733f6 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp @@ -1,7 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// - // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp b/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp index 571bfb64e85cb7..1e814102d97c26 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp @@ -1,7 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// - // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp index bfb0a657354890..5da0938e70c4e1 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp @@ -1,7 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// - // // GENERATED FILE, DON'T EDIT // Generated by coreclr InterpToNativeGenerator diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp index dc57848a7d6cbb..a2e86798ddfa11 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp @@ -1,7 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// - // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp index dede69947952fb..41a568e9713a0e 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp @@ -1,7 +1,3 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// - // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator From c2cb84cb2e971ba904b40b8f1e6d915460f0c814 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Mon, 31 Aug 2026 14:13:21 +0200 Subject: [PATCH 49/72] Name the method behind a signature the thunks cannot emit Review feedback on "Element 'V' of signature 'ilV4ip' can't be handled by managed2native generator": the name was Mono's, and the message left the user to work out which method produced that encoding. Signatures are de-duplicated into a set on the way here, so the method was gone rather than merely unprinted. Carry the MethodDesc alongside each signature and report it: Cannot generate an interop thunk for 'Native.PassesTheValue(Vector128`1)': its signature 'vV' contains a 128-bit vector, which interop thunks cannot pass. Take it by reference, or wrap it in a blittable struct. 'V' is the reachable case; the wording lives in WasmLowering beside the table that emits the characters. It is a separate table rather than RaiseSigChar, which resolves 'V' to one representative v128 type. The multi-slot rejection was equally anonymous and is wrapped with the same context. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../tools/Common/JitInterface/WasmLowering.cs | 16 ++++++++++++++++ .../InternalCallSignatureCollector.cs | 6 +++--- .../PortableCallHelpers/InteropSignature.cs | 4 ++++ .../InterpToNativeGenerator.cs | 17 ++++++++++++----- .../PortableCallHelpers/PInvokeCollector.cs | 6 +++--- .../PortableCallHelpersGenerator.cs | 5 +++-- 6 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index ee2df3235520d7..dc41b7b2c70eae 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -411,6 +411,22 @@ public static WasmValueType LowerType(TypeDesc type) _ => throw new InvalidOperationException($"Unknown signature char: {c}") }; + internal static string DescribeSigChar(char c) => c switch + { + 'v' => "a void result", + 'i' => "a 32-bit integer", + 'l' => "a 64-bit integer", + 'f' => "a 32-bit float", + 'd' => "a 64-bit float", + 'V' => "a 128-bit vector", + 'S' or 'A' => "a struct passed by reference", + 'T' => "the 'this' argument", + 'p' => "the portable entry point argument", + 'a' => "the async continuation argument", + 'e' => "an empty struct", + _ => null + }; + private static int ParseStructSize(string sig, ref int pos) { Debug.Assert(sig[pos] is 'S' or 'A'); diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs index 7e631f2cfe4798..17da51f16fe851 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs @@ -49,9 +49,9 @@ public void Verbose(string message) /// internal sealed class InternalCallSignatureCollector(InteropLogger log) { - private readonly HashSet _signatures = []; + private readonly Dictionary _signatures = []; - public IEnumerable Signatures => _signatures; + public IReadOnlyDictionary Signatures => _signatures; public void ScanType(EcmaType type) { @@ -82,7 +82,7 @@ public void ScanType(EcmaType type) // A managed signature: the lowering adds the 'T' for an instance method and the // trailing 'p' for the portable entry point parameter. string signature = InteropSignature.GetMethodSignature(method); - if (_signatures.Add(signature)) + if (_signatures.TryAdd(signature, method)) log.Verbose($"Adding InternalCall signature {signature} for method '{type}.{method.Name.ToString()}'"); } catch (Exception ex) when (ex is not LogAsErrorException) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs index 251e51037cc0b3..5d544d6c87886a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs @@ -19,6 +19,10 @@ internal sealed class InvalidSignatureCharException(char c) : Exception($"Can't handle signature '{c}'") { public char Char { get; } = c; + + /// The element in the caller's terms, worded by the lowering that spells it. + public string Description + => WasmLowering.DescribeSigChar(Char) ?? $"the unrecognized element '{Char}'"; } /// diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs index 7a2a611de414cf..6eb89f9e85b7d8 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs @@ -6,6 +6,8 @@ using System.IO; using System.Linq; +using Internal.TypeSystem; + namespace ILCompiler.PortableCallHelpers { /// @@ -18,11 +20,9 @@ namespace ILCompiler.PortableCallHelpers /// internal static class InterpToNativeGenerator { - public static void Emit(TextWriter w, IEnumerable cookies) + public static void Emit(TextWriter w, IReadOnlyDictionary cookies) { - var unique = new HashSet(cookies, StringComparer.Ordinal); - string[] signatures = new string[unique.Count]; - unique.CopyTo(signatures); + string[] signatures = cookies.Keys.ToArray(); Array.Sort(signatures, StringComparer.Ordinal); // Collect unique struct return sizes so we can emit typedefs @@ -104,7 +104,14 @@ public static void Emit(TextWriter w, IEnumerable cookies) } catch (InvalidSignatureCharException e) { - throw new LogAsErrorException($"Element '{e.Char}' of signature '{signature}' can't be handled by managed2native generator"); + throw new LogAsErrorException( + $"Cannot generate an interop thunk for '{cookies[signature]}': its signature '{signature}' contains {e.Description}, " + + "which interop thunks cannot pass. Take it by reference, or wrap it in a blittable struct."); + } + catch (LogAsErrorException e) + { + // The only place that still knows which method the signature came from. + throw new LogAsErrorException($"Cannot generate an interop thunk for '{cookies[signature]}': {e.Message}"); } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index d9458195e6aa82..c91abbf67d44c0 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -130,7 +130,7 @@ internal sealed class PInvokeCollector(InteropLogger log, string targetOS) private readonly Dictionary _assemblyUnsupportedOnPlatform = []; private readonly Dictionary _blittable = []; - public void CollectPInvokes(List pinvokes, List callbacks, HashSet signatures, EcmaType type) + public void CollectPInvokes(List pinvokes, List callbacks, Dictionary signatures, EcmaType type) { foreach (MethodDesc methodDesc in type.GetMethods()) { @@ -180,10 +180,10 @@ void CollectPInvokesForMethod(EcmaMethod method) } } - private void AddSignature(HashSet signatures, MethodDesc method, WasmLowering.LoweringFlags flags, string kind) + private void AddSignature(Dictionary signatures, MethodDesc method, WasmLowering.LoweringFlags flags, string kind) { string signature = InteropSignature.GetMethodSignature(method, flags); - if (signatures.Add(signature)) + if (signatures.TryAdd(signature, method)) log.Verbose($"Adding {kind} signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 2930b4ac63aea6..0e63cd62f253e7 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -67,7 +67,7 @@ private static void Generate(ReadyToRunCompilerContext context, PortableCallHelp List pinvokes = []; List callbacks = []; - HashSet signatures = []; + Dictionary signatures = []; foreach (string simpleName in context.InputFilePaths.Keys) { @@ -100,7 +100,8 @@ private static void Generate(ReadyToRunCompilerContext context, PortableCallHelp WriteIfDifferent(Path.Combine(options.OutputDirectory, ReversePInvokeFileName), log, w => generator.EmitNativeToInterp(w, callbacks)); - signatures.UnionWith(internalCallCollector.Signatures); + foreach (KeyValuePair internalCall in internalCallCollector.Signatures) + signatures.TryAdd(internalCall.Key, internalCall.Value); WriteIfDifferent(Path.Combine(options.OutputDirectory, InterpToNativeFileName), log, w => InterpToNativeGenerator.Emit(w, signatures)); From 1be0694b84032c3662ecd49f98f57bde882dbe61 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 12:21:11 +0200 Subject: [PATCH 50/72] Reject a generic InternalCall instead of skipping it Review feedback: a generic has no single signature to generate a thunk from, so nothing can look one up. Skipping it deferred the failure to the call, where the interpreter finds no thunk and PORTABILITY_ASSERT is _ASSERTE - compiled out of a release build, which then takes the null cookie. Nothing is lost by rejecting it: the wasm CoreLib declares 214 InternalCall methods and none of them is generic, on either target. Only CoreLib is scanned for InternalCalls, so this cannot fail a build over some other assembly. The generic UnmanagedFunctionPointer delegate nearby stays a warning: that scan covers every module, and merely declaring such a type must not fail a build. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../InternalCallSignatureCollector.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs index 17da51f16fe851..1da76145e87976 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InternalCallSignatureCollector.cs @@ -69,12 +69,13 @@ public void ScanType(EcmaType type) if (method.IsConstructor && method.OwningType.IsWellKnownType(WellKnownType.String)) continue; - // An uninstantiated generic has no single signature to generate a thunk from, because - // its parameters stand for whatever the instantiation supplies. + // A generic has no single signature to generate a thunk from, so the interpreter + // would find none at call time - and a release build does not even assert on the + // miss, it takes the null cookie. CoreLib declares no such method today. if (method.HasInstantiation || method.OwningType.HasInstantiation) { - log.Warning("WASM0001", $"Skipping generic InternalCall method '{type}::{method.Name.ToString()}', which has no single signature"); - continue; + throw new LogAsErrorException( + $"Generic InternalCall method '{type}::{method.Name.ToString()}' has no single signature to generate a thunk from."); } try From d772061e3bb98b8cea734f23bbad91c486c5426f Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 13:18:11 +0200 Subject: [PATCH 51/72] Warn on [MonoPInvokeCallback] instead of generating for it The attribute is a Mono convention that no assembly declares, which is why both generators match it by simple name. Mono honours it on wasm whether interpreted or AOT - WasmAppBuilder's _GenerateManagedToNative is not conditioned on AOT at all - but it means nothing here: a reverse thunk is only ever looked up for a method carrying UnmanagedCallersOnly, gated in PortableEntryPoint::EnsureCodeForUnmanagedCallersOnly and asserted again in GetUnmanagedCallersOnlyThunk. A thunk emitted for a [MonoPInvokeCallback] method could never be found. Collect only UnmanagedCallersOnly, and warn on the ones now left out so they fail visibly rather than by silently doing nothing at run time. Nothing in CoreLib or the libraries carries the attribute - dropping it regenerates the reverse tables byte-identical, and the scan emits no WASM0065. The ambiguity theory keeps its [MonoPInvokeCallback] row, which now holds because the method is not collected at all rather than because the runtime walk skips it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 3 +-- .../PortableCallHelpers/PInvokeCollector.cs | 18 +++++++++++++----- .../PInvokeTableGenerator.cs | 5 ++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index e5b8c4ffe80313..f7ba75c299c97f 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -715,8 +715,7 @@ public void PortableCallHelpersGeneratorAcceptsMoreThanOneInputAssembly() // Nothing is exported, so neither wrapper reaches the name lookup: the runtime hands both their // MethodDesc through the arity-aware g_ReverseThunks key instead. [InlineData("[UnmanagedCallersOnly]", "Handle", "[UnmanagedCallersOnly]", "Handle", false)] - // [MonoPInvokeCallback] is collected as a callback but carries no UnmanagedCallersOnly attribute, - // so the runtime walk skips it and it cannot be confused with the export. + // [MonoPInvokeCallback] is not collected at all, so it cannot collide with the export. [InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle", "[MonoPInvokeCallback]", "Handle", false)] public void PortableCallHelpersGeneratorRejectsAnExportItCouldNotResolveByName( diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index c91abbf67d44c0..2134f44499f198 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -189,8 +189,20 @@ private void AddSignature(Dictionary signatures, MethodDesc private bool DoesMethodHaveCallbacks(EcmaMethod method) { - if (!MethodHasCallbackAttributes(method)) + if (!method.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute")) + { + // Mono matches [MonoPInvokeCallback] by simple name - no assembly declares it - and + // wraps such a method on wasm whether interpreted or AOT. Nothing here can dispatch + // one: a thunk is only ever looked up for a method carrying UnmanagedCallersOnly, so + // an entry emitted for it is unreachable. + if (HasAttributeByName(method, "MonoPInvokeCallbackAttribute")) + { + log.Warning("WASM0065", + $"Ignoring [MonoPInvokeCallback] on '{method}', which does not make it callable from native code. Use [UnmanagedCallersOnly] instead."); + } + return false; + } if (IsUnsupportedOnPlatform(method)) return false; @@ -212,10 +224,6 @@ private bool DoesMethodHaveCallbacks(EcmaMethod method) return true; } - private static bool MethodHasCallbackAttributes(EcmaMethod method) - => method.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute") - || HasAttributeByName(method, "MonoPInvokeCallbackAttribute"); - /// /// Matches an attribute by its simple name in any namespace, for attributes that are /// declared by user code rather than by the framework. diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 8ad388adb238b0..1862da1ff0e17c 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -214,9 +214,8 @@ static string ListRefs(IGrouping l) public void EmitNativeToInterp(TextWriter w, List callbacks) { // Generate the native->interpreter entry functions. Native code calls these directly, so - // each one carries the native signature its caller expects, taken from the managed method - // it wraps - one marked [UnmanagedCallersOnly], or the [MonoPInvokeCallback] that - // MethodHasCallbackAttributes also accepts. Only blittable parameter and return types are + // each one carries the native signature its caller expects, taken from the + // [UnmanagedCallersOnly] method it wraps. Only blittable parameter and return types are // supported. // // Each wrapper caches the MethodDesc it dispatches to in a static and hands the arguments to From 8c4c80475c60360e2c40d6571ebb303a0e3ddaed Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 16:34:05 +0200 Subject: [PATCH 52/72] Cover the cross-assembly blittability check without the marker The different-assembly theory made its struct non-blittable with a type the generator recognises by name, which only Mono's generator still does. Mark it category=mono so it keeps covering that path there, and add the same 2x2 matrix with the struct made non-blittable by LayoutKind.Auto instead - the way the three same-assembly tests in this file already do it. What is under test either way is that the DisableRuntimeMarshalling honoured is the one on the assembly declaring the callback, not the struct. The same-assembly tests cannot cover that, having only one assembly to choose from. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PInvokeTableGeneratorTests.cs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs index 404fcfccbea1cb..664acb2ffee4bb 100644 --- a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs @@ -90,19 +90,49 @@ private ProjectInfo PrepreProjectForBlittableTests(Configuration config, bool ao new object[] { /*libraryHasAttribute*/ true, /*appHasAttribute*/ true, /*expectSuccess*/ true } ).UnwrapItemsAsArrays(); + // The library's struct is made non-blittable by a type the generator recognises by name, which + // only Mono's generator still does. Kept here so that path stays covered; the CoreCLR + // equivalent is the LayoutKind.Auto theory below. + [Theory, TestCategory("mono")] + [MemberData(nameof(SeparateAssemblyWithDisableMarshallingAttributeTestData), parameters: Configuration.Debug)] + [MemberData(nameof(SeparateAssemblyWithDisableMarshallingAttributeTestData), parameters: Configuration.Release)] + public Task UnmanagedStructsAreConsideredBlittableFromDifferentAssembly + (Configuration config, bool aot, bool libraryHasAttribute, bool appHasAttribute, bool expectSuccess) + => BlittableFromDifferentAssembly( + config, aot, libraryHasAttribute, appHasAttribute, expectSuccess, useAutoLayout: false); + + // Same scenario, with the struct made non-blittable by its layout rather than by a name only a + // test can produce. What is under test either way is that the DisableRuntimeMarshalling the + // generator honours is the one on the assembly declaring the callback, not the struct. [Theory] [MemberData(nameof(SeparateAssemblyWithDisableMarshallingAttributeTestData), parameters: Configuration.Debug)] [MemberData(nameof(SeparateAssemblyWithDisableMarshallingAttributeTestData), parameters: Configuration.Release)] - public async Task UnmanagedStructsAreConsideredBlittableFromDifferentAssembly + public Task UnmanagedStructsAreConsideredBlittableFromDifferentAssembly_WithAutoLayout (Configuration config, bool aot, bool libraryHasAttribute, bool appHasAttribute, bool expectSuccess) + => BlittableFromDifferentAssembly( + config, aot, libraryHasAttribute, appHasAttribute, expectSuccess, useAutoLayout: true); + + private async Task BlittableFromDifferentAssembly + (Configuration config, bool aot, bool libraryHasAttribute, bool appHasAttribute, bool expectSuccess, bool useAutoLayout) { string extraProperties = aot ? string.Empty : "true"; string extraItems = @$""; string libRelativePath = Path.Combine("..", "Library", "Library.cs"); string programRelativePath = Path.Combine("Common", "Program.cs"); - ProjectInfo info = CopyTestAsset(config, aot, TestAsset.WasmBasicTestApp, "blittable_different_library", extraProperties: extraProperties, extraItems: extraItems); + string prefix = useAutoLayout ? "blittable_different_library_auto" : "blittable_different_library"; + ProjectInfo info = CopyTestAsset(config, aot, TestAsset.WasmBasicTestApp, prefix, extraProperties: extraProperties, extraItems: extraItems); ReplaceFile(libRelativePath, Path.Combine(BuildEnvironment.TestAssetsPath, "EntryPoints", "PInvoke", "BittableDifferentAssembly_Lib.cs")); ReplaceFile(programRelativePath, Path.Combine(BuildEnvironment.TestAssetsPath, "EntryPoints", "PInvoke", "BittableDifferentAssembly.cs")); + if (useAutoLayout) + { + // Drop the marker type and let the layout make S non-blittable instead. + UpdateFile(libRelativePath, new Dictionary + { + { "public struct __NonBlittableTypeForAutomatedTests__ { }", "" }, + { "public struct S {", "[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Auto)]\n public struct S {" }, + { "public __NonBlittableTypeForAutomatedTests__ NonBlittable;", "public float Value2;" }, + }); + } if (!libraryHasAttribute) { UpdateFile(libRelativePath, new Dictionary { { "[assembly: System.Runtime.CompilerServices.DisableRuntimeMarshalling]", "" } }); From ea1416d0907878b887ac085b58845a1fa9a7d837 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 17:07:56 +0200 Subject: [PATCH 53/72] Stamp the regenerated tables from the regeneration project Review feedback: the checked-in tables should keep the license header even though the generator no longer emits one, since the copies under this directory are ours while the generator's output over a user application is not. crossgen2 now writes into obj and the project stamps the files on the way to this directory. Both scripts already delegate everything to the project rather than restate it in shell and batch, so this covers .sh and .cmd alike. Staging is what keeps the timestamps: writing the header in place would leave the file differing from what the generator produces by exactly the header, so its WriteIfDifferent could never match and every run would touch every file. Comparing the stamped content against the destination instead means an unchanged table is not rewritten and the native build is not retriggered. Verified both ways: a run that changes nothing leaves all six files' mtimes alone, a run that changes the pinvoke tables reports and touches only those two, and invoking crossgen2 the way the app build does still produces files with no header at all. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../browser/callhelpers-interp-to-managed.cpp | 4 ++ .../vm/wasm/browser/callhelpers-pinvoke.cpp | 4 ++ .../vm/wasm/browser/callhelpers-reverse.cpp | 4 ++ .../vm/wasm/generate-coreclr-helpers.proj | 55 ++++++++++++++++++- .../wasi/callhelpers-interp-to-managed.cpp | 4 ++ .../vm/wasm/wasi/callhelpers-pinvoke.cpp | 4 ++ .../vm/wasm/wasi/callhelpers-reverse.cpp | 4 ++ 7 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp index cc2419fac34ed9..273ac75f498b17 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-interp-to-managed.cpp @@ -1,3 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// + // // GENERATED FILE, DON'T EDIT // Generated by coreclr InterpToNativeGenerator diff --git a/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp b/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp index 977e20cf0733f6..9dbd7f579c68e6 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-pinvoke.cpp @@ -1,3 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// + // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp b/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp index 1e814102d97c26..571bfb64e85cb7 100644 --- a/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp +++ b/src/coreclr/vm/wasm/browser/callhelpers-reverse.cpp @@ -1,3 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// + // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index 2fe6ffaaa103b3..5dfdf508c26461 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -42,6 +42,46 @@ + + + + + + + + + + + + + + + @@ -53,6 +93,7 @@ <_OutputDir>$(MSBuildThisFileDirectory)$(_TargetOS)/ <_ResponseFileDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsObjDir)', 'wasm-callhelpers', '$(_TargetOS)')) <_ResponseFile>$(_ResponseFileDir)generate-coreclr-helpers.rsp + <_StagingDir>$(_ResponseFileDir)generated/ - + + <_ScanAssembly Remove="@(_ScanAssembly)" /> @@ -79,7 +121,7 @@ <_GeneratorArg Remove="@(_GeneratorArg)" /> <_GeneratorArg Include="--targetos:$(_TargetOS)" /> <_GeneratorArg Include="--targetarch:wasm" /> - <_GeneratorArg Include="--generate-portable-callhelpers:$(_OutputDir)" /> + <_GeneratorArg Include="--generate-portable-callhelpers:$(_StagingDir)" /> <_GeneratorArg Include="@(WasmCoreClrFrameworkPInvokeModule->'--directpinvoke:%(Identity)')" /> <_GeneratorArg Include="@(_ScanAssembly->'%(FullPath)')" /> @@ -88,6 +130,15 @@ + + + <_StagedCallHelper Remove="@(_StagedCallHelper)" /> + <_StagedCallHelper Include="$(_StagingDir)*.cpp" /> + + + + + diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp index 5da0938e70c4e1..bfb0a657354890 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-interp-to-managed.cpp @@ -1,3 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// + // // GENERATED FILE, DON'T EDIT // Generated by coreclr InterpToNativeGenerator diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp index a2e86798ddfa11..dc57848a7d6cbb 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-pinvoke.cpp @@ -1,3 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// + // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator diff --git a/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp b/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp index 41a568e9713a0e..dede69947952fb 100644 --- a/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp +++ b/src/coreclr/vm/wasm/wasi/callhelpers-reverse.cpp @@ -1,3 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// + // // GENERATED FILE, DON'T EDIT // Generated by coreclr callhelpers generator From 38b7cdaf4bc5cb4e86c035d2887070aac5842203 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 17:17:16 +0200 Subject: [PATCH 54/72] Drop the test-only non-blittable marker The generator called a type non-blittable by name so that a test could produce one, since an empty struct is blittable under every real rule. Only the different-assembly WBT theory used it, and that is now mono-only, with the CoreCLR side covered by a LayoutKind.Auto struct instead - a shape the rule rejects on its own merits. Mono's generator keeps its copy, which is what that theory still exercises. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeCollector.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 2134f44499f198..7366f12fd523cb 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -368,9 +368,6 @@ private bool IsBlittableUncached(TypeDesc type) && delegateType.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedFunctionPointerAttribute")) return true; - if (type is MetadataType nonBlittableMarker && nonBlittableMarker.Name.StringEquals("__NonBlittableTypeForAutomatedTests__")) - return false; - if (!type.IsValueType) { log.InfoHigh("WASM0060", $"Type {type} is not blittable: Not a ValueType"); From 9357b0a5e69b5d18d8455b9977e94c693ca338c3 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 17:30:29 +0200 Subject: [PATCH 55/72] Give DescribeSigChar a description for every character Review feedback: the table was declared to return a string but returned null for a character it does not spell, which the one caller then patched up with ??. Annotating the return as nullable is not open here - the projects that compile this file build with nullable disabled and warnings as errors, so the annotation itself would be an error - so describe the unknown character instead. That leaves nothing for the exception to add, so its Description goes and the generator asks the table directly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- src/coreclr/tools/Common/JitInterface/WasmLowering.cs | 2 +- .../PortableCallHelpers/InteropSignature.cs | 4 ---- .../PortableCallHelpers/InterpToNativeGenerator.cs | 3 ++- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs index dc41b7b2c70eae..e04c0e77001a00 100644 --- a/src/coreclr/tools/Common/JitInterface/WasmLowering.cs +++ b/src/coreclr/tools/Common/JitInterface/WasmLowering.cs @@ -424,7 +424,7 @@ public static WasmValueType LowerType(TypeDesc type) 'p' => "the portable entry point argument", 'a' => "the async continuation argument", 'e' => "an empty struct", - _ => null + _ => $"an unrecognized element '{c}'" }; private static int ParseStructSize(string sig, ref int pos) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs index 5d544d6c87886a..251e51037cc0b3 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InteropSignature.cs @@ -19,10 +19,6 @@ internal sealed class InvalidSignatureCharException(char c) : Exception($"Can't handle signature '{c}'") { public char Char { get; } = c; - - /// The element in the caller's terms, worded by the lowering that spells it. - public string Description - => WasmLowering.DescribeSigChar(Char) ?? $"the unrecognized element '{Char}'"; } /// diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs index 6eb89f9e85b7d8..cde95412ec9376 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/InterpToNativeGenerator.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; +using Internal.JitInterface; using Internal.TypeSystem; namespace ILCompiler.PortableCallHelpers @@ -105,7 +106,7 @@ public static void Emit(TextWriter w, IReadOnlyDictionary co catch (InvalidSignatureCharException e) { throw new LogAsErrorException( - $"Cannot generate an interop thunk for '{cookies[signature]}': its signature '{signature}' contains {e.Description}, " + + $"Cannot generate an interop thunk for '{cookies[signature]}': its signature '{signature}' contains {WasmLowering.DescribeSigChar(e.Char)}, " + "which interop thunks cannot pass. Take it by reference, or wrap it in a blittable struct."); } catch (LogAsErrorException e) From 1ae2fc7d16ccab2857e8aa15616e10e8755ec550 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 17:51:40 +0200 Subject: [PATCH 56/72] Reject an output directory and a generator path that cannot work Review feedback, two of a kind. --generate-portable-callhelpers with an empty directory wrote the three files into whatever the current directory happened to be, silently: verified before the change by finding them in the repo root. It now fails with an error line instead. The test corerun relink lets $(_WasmCorerunGeneratorPath) be overridden but, unlike the browser and wasi app targets, did not reject an IL-only crossgen2. Pointing it at crossgen2.dll got as far as Exec and failed there. It now uses the same guard and the same wording as those two. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PortableCallHelpersGenerator.cs | 5 +++++ src/tests/Common/CLRTest.WasmCorerun.targets | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 0e63cd62f253e7..44594f04a5923c 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -48,6 +48,11 @@ public static int Run(ReadyToRunCompilerContext context, PortableCallHelpersGene try { + // An empty directory would quietly write the files next to whatever the current + // directory happens to be, so name it as the error it is. + if (string.IsNullOrEmpty(options.OutputDirectory)) + throw new LogAsErrorException("--generate-portable-callhelpers needs a directory to write to."); + Generate(context, options, log); return 0; } diff --git a/src/tests/Common/CLRTest.WasmCorerun.targets b/src/tests/Common/CLRTest.WasmCorerun.targets index 9b08eb063625fe..d8f8f3d3d051dd 100644 --- a/src/tests/Common/CLRTest.WasmCorerun.targets +++ b/src/tests/Common/CLRTest.WasmCorerun.targets @@ -328,6 +328,8 @@ against CORE_ROOT, and no SDK is involved. Only the generator task assembly is s + <_PortableCallHelpersGeneratorExeSuffix Condition="'$(OS)' == 'Windows_NT'">.exe From 03bd9d1f6930e9631d684771783b635cb6d08d5f Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Tue, 1 Sep 2026 22:09:47 +0200 Subject: [PATCH 62/72] Say what blittable means, and what this check cannot see Review feedback: the summary described what the answer is used for rather than what a blittable type is, and got even that wrong by crediting the interpreter - an UnmanagedCallersOnly method with R2R code is called by native code directly, with the reverse thunk only a fallback. State the definition and link it. Record what the check cannot answer while the code is here to read: it is given a type, and a type alone does not determine what it marshals into. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeCollector.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index ff3110940f9dbd..1c177706602fb8 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -212,6 +212,12 @@ private bool DoesMethodHaveCallbacks(EcmaMethod method) return true; // No DisableRuntimeMarshalling attribute, so check if the params/ret-type are blittable + // + // WASM-TODO: asking about the type alone is not enough to know what it marshals into, + // because [MarshalAs] and the other interop attributes on a parameter can change that. + // Marshaller.IsMarshallingRequired, or a more lenient variant of it, is the question to + // ask instead. Requiring DisableRuntimeMarshalling on wasm would retire the check + // altogether, and let the runtime drop its built-in marshalling code with it. MethodSignature signature = method.Signature; if (!signature.ReturnType.IsVoid && !IsBlittable(signature.ReturnType)) throw new LogAsErrorException($"The return type '{signature.ReturnType}' of pinvoke callback method '{method}' needs to be blittable."); @@ -346,8 +352,9 @@ bool MatchesTargetOS(CustomAttributeValue attribute) } /// - /// Whether a type can be handed to native code as-is. Results are cached so that a type used - /// by many P/Invokes only produces one diagnostic. + /// Whether a type has the same representation in managed and unmanaged memory. See + /// https://learn.microsoft.com/dotnet/standard/native-interop/blittable-and-non-blittable-types. + /// Results are cached so that a type used by many callbacks only produces one diagnostic. /// public bool IsBlittable(TypeDesc type) { From 7e9b317116a2276999ac4c8b32cbe68b2c713292 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 2 Sep 2026 16:35:15 +0200 Subject: [PATCH 63/72] Apply batched suggestions from code review Co-authored-by: Jan Kotas --- .../PortableCallHelpers/PInvokeCollector.cs | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 1c177706602fb8..3b870818d0dcc1 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -188,9 +188,9 @@ private void AddSignature(Dictionary signatures, MethodDesc log.Verbose($"Adding {kind} signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); } - private bool DoesMethodHaveCallbacks(EcmaMethod method) + private bool IsMethodCallback(EcmaMethod method) { - if (!method.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute")) + if (!method.IsUnmanagedCallersOnly) { // Mono matches [MonoPInvokeCallback] by simple name - no assembly declares it - and // wraps such a method on wasm whether interpreted or AOT. Nothing here can dispatch @@ -208,27 +208,7 @@ private bool DoesMethodHaveCallbacks(EcmaMethod method) if (IsUnsupportedOnPlatform(method)) return false; - if (HasAssemblyDisableRuntimeMarshallingAttribute((EcmaAssembly)method.Module)) - return true; - - // No DisableRuntimeMarshalling attribute, so check if the params/ret-type are blittable - // - // WASM-TODO: asking about the type alone is not enough to know what it marshals into, - // because [MarshalAs] and the other interop attributes on a parameter can change that. - // Marshaller.IsMarshallingRequired, or a more lenient variant of it, is the question to - // ask instead. Requiring DisableRuntimeMarshalling on wasm would retire the check - // altogether, and let the runtime drop its built-in marshalling code with it. - MethodSignature signature = method.Signature; - if (!signature.ReturnType.IsVoid && !IsBlittable(signature.ReturnType)) - throw new LogAsErrorException($"The return type '{signature.ReturnType}' of pinvoke callback method '{method}' needs to be blittable."); - - foreach (TypeDesc parameterType in signature) - { - if (!IsBlittable(parameterType)) - throw new LogAsErrorException($"Parameter types of pinvoke callback method '{method}' needs to be blittable."); - } - - return true; + return false; } /// From 6dc630d503170659942093911e2377e63d5ac9d0 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 2 Sep 2026 16:44:12 +0200 Subject: [PATCH 64/72] Finish the callback-collection cleanup The batched suggestions renamed the method but not its caller, so the build was broken, and the final return was left as false, which made IsMethodCallback answer false for every method. Regenerating with that dropped all six JavaScriptExports thunks: its assembly has no DisableRuntimeMarshalling, so it never took the early return the check used to have. Dropping the blittability check also orphaned IsBlittable, IsBlittableUncached, HasAssemblyDisableRuntimeMarshallingAttribute and the two caches behind them. Two WBT theories assert the build error that check produced, so they are now mono-only, matching the marker theory beside them. The two that expect a successful build still run on both. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeCollector.cs | 53 +------------------ .../PInvokeTableGeneratorTests.cs | 6 ++- 2 files changed, 6 insertions(+), 53 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 3b870818d0dcc1..56e4df84040201 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -9,7 +9,6 @@ using Internal.JitInterface; using Internal.TypeSystem; using Internal.TypeSystem.Ecma; -using Internal.TypeSystem.Interop; namespace ILCompiler.PortableCallHelpers { @@ -126,10 +125,8 @@ public int Compare(PInvokeCallback x, PInvokeCallback y) /// internal sealed class PInvokeCollector(InteropLogger log, string targetOS) { - private readonly Dictionary _assemblyDisableRuntimeMarshalling = []; private readonly Dictionary _typeUnsupportedOnPlatform = []; private readonly Dictionary _assemblyUnsupportedOnPlatform = []; - private readonly Dictionary _blittable = []; public void CollectPInvokes(List pinvokes, List callbacks, Dictionary signatures, EcmaType type) { @@ -139,7 +136,7 @@ public void CollectPInvokes(List pinvokes, List ca try { CollectPInvokesForMethod(method); - if (DoesMethodHaveCallbacks(method)) + if (IsMethodCallback(method)) callbacks.Add(new PInvokeCallback(method)); } catch (Exception ex) when (ex is not LogAsErrorException) @@ -208,7 +205,7 @@ private bool IsMethodCallback(EcmaMethod method) if (IsUnsupportedOnPlatform(method)) return false; - return false; + return true; } /// @@ -230,17 +227,6 @@ private static bool HasAttributeByName(EcmaMethod method, string attributeName) return false; } - private bool HasAssemblyDisableRuntimeMarshallingAttribute(EcmaAssembly assembly) - { - if (!_assemblyDisableRuntimeMarshalling.TryGetValue(assembly, out bool value)) - { - _assemblyDisableRuntimeMarshalling[assembly] = value = - assembly.HasAssemblyCustomAttribute("System.Runtime.CompilerServices", "DisableRuntimeMarshallingAttribute"); - } - - return value; - } - private bool IsUnsupportedOnPlatform(EcmaMethod method) => EvaluatePlatformAttributes(method.GetDecodedCustomAttributes) switch { @@ -330,40 +316,5 @@ bool MatchesTargetOS(CustomAttributeValue attribute) return Version.TryParse(platformName.AsSpan(targetOS.Length), out _); } } - - /// - /// Whether a type has the same representation in managed and unmanaged memory. See - /// https://learn.microsoft.com/dotnet/standard/native-interop/blittable-and-non-blittable-types. - /// Results are cached so that a type used by many callbacks only produces one diagnostic. - /// - public bool IsBlittable(TypeDesc type) - { - if (_blittable.TryGetValue(type, out bool blittable)) - return blittable; - - bool result = IsBlittableUncached(type); - _blittable[type] = result; - return result; - } - - private bool IsBlittableUncached(TypeDesc type) - { - // MarshalUtils only considers DefTypes, so neither of these reaches its rules. - if (type.IsPointer || type.IsFunctionPointer) - return true; - - // MarshalUtils accepts an enum as a field but not on its own: System.Enum is a class, - // so the parent check rejects it before the layout is looked at. - if (type.IsEnum) - return IsBlittable(type.UnderlyingType); - - if (!MarshalUtils.IsBlittableType(type)) - { - log.InfoHigh("WASM0060", $"Type {type} is not blittable"); - return false; - } - - return true; - } } } diff --git a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs index 664acb2ffee4bb..ec5511fe58bbdf 100644 --- a/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/PInvokeTableGeneratorTests.cs @@ -21,7 +21,9 @@ public PInvokeTableGeneratorTests(ITestOutputHelper output, SharedBuildPerTestCl { } - [Theory] + // Only Mono's generator rejects a non-blittable callback signature; crossgen2 leaves the + // check to Roslyn and the runtime. + [Theory, TestCategory("mono")] [BuildAndRun()] public void UnmanagedStructAndMethodIn_SameAssembly_WithoutDisableRuntimeMarshallingAttribute_NotConsideredBlittable (Configuration config, bool aot) @@ -104,7 +106,7 @@ public Task UnmanagedStructsAreConsideredBlittableFromDifferentAssembly // Same scenario, with the struct made non-blittable by its layout rather than by a name only a // test can produce. What is under test either way is that the DisableRuntimeMarshalling the // generator honours is the one on the assembly declaring the callback, not the struct. - [Theory] + [Theory, TestCategory("mono")] [MemberData(nameof(SeparateAssemblyWithDisableMarshallingAttributeTestData), parameters: Configuration.Debug)] [MemberData(nameof(SeparateAssemblyWithDisableMarshallingAttributeTestData), parameters: Configuration.Release)] public Task UnmanagedStructsAreConsideredBlittableFromDifferentAssembly_WithAutoLayout From 8df4712eccd073d74829e992903e4c11dd9f1fe9 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 2 Sep 2026 18:26:11 +0200 Subject: [PATCH 65/72] Validate the generator path and target OS, tighten comments Drop the note about generation not loading the JIT from the wasi targets; crossgen2 is a cross-targeting compiler, so the note said nothing useful. The browser copy lost it earlier. Fail with an actionable error when the generator path names a file that does not exist. The empty and .dll guards cannot catch a well-formed path to a crossgen2 that was never built, which is what relinking before building crossgen2 produces, and Exec then reports a generic OS error. Reject an empty target OS as well. It matches no platform attribute, so every method guarded by SupportedOSPlatform would be dropped silently. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PortableCallHelpersGenerator.cs | 5 +++++ src/coreclr/vm/wasm/generate-coreclr-helpers.proj | 9 ++++----- src/mono/browser/build/BrowserWasmApp.CoreCLR.targets | 2 ++ src/mono/wasi/build/WasiApp.CoreCLR.targets | 5 +++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 44594f04a5923c..68fb943003d79f 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -53,6 +53,11 @@ public static int Run(ReadyToRunCompilerContext context, PortableCallHelpersGene if (string.IsNullOrEmpty(options.OutputDirectory)) throw new LogAsErrorException("--generate-portable-callhelpers needs a directory to write to."); + // The scan reads platform attributes against this name, and an empty one matches + // nothing, which would silently drop every method guarded by SupportedOSPlatform. + if (string.IsNullOrEmpty(options.TargetOS)) + throw new LogAsErrorException("--generate-portable-callhelpers needs a target OS to match platform attributes against."); + Generate(context, options, log); return 0; } diff --git a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj index 5dfdf508c26461..673be4e12e67bc 100644 --- a/src/coreclr/vm/wasm/generate-coreclr-helpers.proj +++ b/src/coreclr/vm/wasm/generate-coreclr-helpers.proj @@ -18,15 +18,14 @@ + checked-in tables and the ones the tests generate cannot drift apart. --> <_TesthostDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'testhost')) <_FrameworkVersion>$(MajorVersion).$(MinorVersion).0 - + <_BuildScript Condition="$([MSBuild]::IsOSPlatform('Windows'))">.\build.cmd <_BuildScript Condition="'$(_BuildScript)' == ''">./build.sh @@ -87,8 +86,8 @@ <_TargetOS>%(_WasmCallHelperTarget.Identity) <_ScanPath>%(_WasmCallHelperTarget.ScanPath) - + <_Crossgen2Path>$([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', '$(_TargetOS).wasm.$(Configuration)', '$(BuildArchitecture)', 'crossgen2', 'crossgen2$(ExeSuffix)')) <_OutputDir>$(MSBuildThisFileDirectory)$(_TargetOS)/ <_ResponseFileDir>$([MSBuild]::NormalizeDirectory('$(ArtifactsObjDir)', 'wasm-callhelpers', '$(_TargetOS)')) diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index a8a68226ac26c4..98360e93f59bb4 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -703,6 +703,8 @@ Text="crossgen2 was not found, so the portable call helpers cannot be generated. Install the 'wasm-tools' workload, or set %24(PortableCallHelpersGeneratorPath) to a crossgen2 executable." /> + <_PortableCallHelpersGeneratorRsp>$(_WasmIntermediateOutputPath)callhelpers-generator.rsp diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index b296693eb0d9e5..a1dd6dd842ceeb 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -153,8 +153,7 @@ @@ -168,6 +167,8 @@ Text="crossgen2 was not found, so the portable call helpers cannot be generated. Set %24(PortableCallHelpersGeneratorPath) to a crossgen2 executable." /> + <_PortableCallHelpersGeneratorRsp>$(_WasiRelinkObjDir)callhelpers-generator.rsp From d11533b10bfa5189f9df2f8c20e85e998c61129e Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Wed, 2 Sep 2026 22:04:46 +0200 Subject: [PATCH 66/72] Drop the MonoPInvokeCallback build warning This was a platform compatibility analyzer for a single attribute that no assembly declares, built in parallel to the real one. Marking Marshal.GetDelegateForFunctionPointer unsupported on wasm covers every use rather than only the methods someone happened to annotate, and it is a regular warning that can be suppressed. The collector never treated these methods as callbacks, so only the warning and the name-based attribute lookup go away. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PInvokeCollector.cs | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs index 56e4df84040201..f83b04e7a8b89c 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs @@ -188,19 +188,7 @@ private void AddSignature(Dictionary signatures, MethodDesc private bool IsMethodCallback(EcmaMethod method) { if (!method.IsUnmanagedCallersOnly) - { - // Mono matches [MonoPInvokeCallback] by simple name - no assembly declares it - and - // wraps such a method on wasm whether interpreted or AOT. Nothing here can dispatch - // one: a thunk is only ever looked up for a method carrying UnmanagedCallersOnly, so - // an entry emitted for it is unreachable. - if (HasAttributeByName(method, "MonoPInvokeCallbackAttribute")) - { - log.Warning("WASM0065", - $"Ignoring [MonoPInvokeCallback] on '{method}', which does not make it callable from native code. Use [UnmanagedCallersOnly] instead."); - } - return false; - } if (IsUnsupportedOnPlatform(method)) return false; @@ -208,25 +196,6 @@ private bool IsMethodCallback(EcmaMethod method) return true; } - /// - /// Matches an attribute by its simple name in any namespace, for attributes that are - /// declared by user code rather than by the framework. - /// - private static bool HasAttributeByName(EcmaMethod method, string attributeName) - { - MetadataReader reader = method.MetadataReader; - foreach (CustomAttributeHandle handle in reader.GetMethodDefinition(method.Handle).GetCustomAttributes()) - { - if (reader.GetAttributeNamespaceAndName(handle, out _, out StringHandle name) - && reader.StringComparer.Equals(name, attributeName)) - { - return true; - } - } - - return false; - } - private bool IsUnsupportedOnPlatform(EcmaMethod method) => EvaluatePlatformAttributes(method.GetDecodedCustomAttributes) switch { From b8046d884c4bc31b5725b5e776f7f5e375171e20 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 3 Sep 2026 12:00:21 +0200 Subject: [PATCH 67/72] Report modules that P/Invoke without disabling runtime marshalling The generated helpers describe every P/Invoke with the signature the type system reports. That is what native code sees only when the module opts out of runtime marshalling, so a module that does not opt out can have a signature the runtime marshals differently. Report it per module for now. A message rather than a warning: the framework assemblies that hit it ship prebuilt in the runtime pack, and MSBuild turns an Exec warning into a build failure under TreatWarningsAsErrors, which no user of those assemblies could act on. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpersGenerator.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 68fb943003d79f..78d8cab7d59016 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -8,6 +8,7 @@ using Internal.TypeSystem; using Internal.TypeSystem.Ecma; +using Internal.TypeSystem.Interop; namespace ILCompiler.PortableCallHelpers { @@ -90,6 +91,8 @@ private static void Generate(ReadyToRunCompilerContext context, PortableCallHelp log.Verbose($"Scanning {simpleName} for pinvokes{(scanInternalCalls ? " and InternalCall methods" : "")}"); + int pinvokesFromOtherModules = pinvokes.Count; + foreach (MetadataType type in module.GetAllTypes()) { if (type is not EcmaType ecmaType) @@ -100,6 +103,18 @@ private static void Generate(ReadyToRunCompilerContext context, PortableCallHelp if (scanInternalCalls) internalCallCollector.ScanType(ecmaType); } + + // WASM-TODO: The helpers describe every P/Invoke with the signature the type system + // reports, which is what native sees only when the module disables runtime + // marshalling. Make this check per-P/Invoke and marshalling-aware (see + // Marshaller.IsMarshallingRequired), then raise it to a warning. It stays a message + // while it names whole framework assemblies, which ship prebuilt and would fail + // builds nobody can fix. + if (pinvokes.Count != pinvokesFromOtherModules && MarshalHelpers.IsRuntimeMarshallingEnabled(module)) + { + log.InfoHigh("WASM0065", + $"'{simpleName}' declares P/Invokes without [assembly: DisableRuntimeMarshalling]; the generated helpers assume its signatures cross to native unmarshalled."); + } } var generator = new PInvokeTableGenerator(log); From 23c2f386458f200968b1bd32a8f76f7ca7e50902 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 3 Sep 2026 13:48:58 +0200 Subject: [PATCH 68/72] Give the interpreter the bits of a floating point callback argument A reverse thunk hands each argument to the interpreter in an eight-byte slot, and the interpreter reads the slot as the parameter's bits. Casting to the slot type converts a float numerically instead, so a callback taking a double received a different value than the caller passed. Copy the bits of a float or double argument, and keep the cast for everything else, which reaches the slot unchanged through it. The tables checked in here are unaffected: no framework callback takes a floating point argument today, which is why nothing caught this. The same packing predates moving the generator into crossgen2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 76 +++++++++++++++++++ .../PInvokeTableGenerator.cs | 9 ++- 2 files changed, 83 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 f7ba75c299c97f..356976af68f841 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -696,6 +696,82 @@ public void PortableCallHelpersGeneratorAcceptsMoreThanOneInputAssembly() private const string CoreLibSimpleName = "System.Private.CoreLib"; + /// + /// A reverse thunk hands each argument to the interpreter in an eight-byte slot. Casting a float + /// to the slot type converts it numerically, so the interpreter, which reads the slot as the + /// parameter's bits, would see a different value than the caller passed. + /// + [Theory] + [InlineData("double", "double")] + [InlineData("float", "float")] + [InlineData("int", null)] + [InlineData("nint", null)] + public void PortableCallHelpersGeneratorGivesTheInterpreterTheBitsOfAFloatingPointArgument( + string parameterType, string? expectedCType) + { + string source = $$""" + using System.Runtime.InteropServices; + + public static class Exports + { + [UnmanagedCallersOnly] + public static void Handle({{parameterType}} 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" }, + }; + + Assert.Equal(0, PortableCallHelpersGenerator.Run( + CreateWasmContext(inputAssembly), options, new Logger(TextWriter.Null, isVerbose: false))); + + string generated = File.ReadAllText( + Path.Combine(outputDirectory, PortableCallHelpersGenerator.ReversePInvokeFileName)); + + // The scan also collects the framework's own callbacks, so narrow to this one before + // asserting on how its arguments are packed. + int start = generated.IndexOf("_Exports_Handle", StringComparison.Ordinal); + Assert.True(start >= 0, "no thunk was generated for the callback"); + start = generated.LastIndexOf("static ", start, StringComparison.Ordinal); + int end = generated.IndexOf("\n}", start, StringComparison.Ordinal); + string thunk = generated[start..end]; + + if (expectedCType is not null) + { + Assert.Contains($"{expectedCType} arg0", thunk); + Assert.Contains("memcpy(&args[0], &arg0, sizeof(arg0));", thunk); + Assert.DoesNotContain("(int64_t)arg0", thunk); + } + else + { + Assert.Contains("(int64_t)arg0", thunk); + Assert.DoesNotContain("memcpy", thunk); + } + } + finally + { + try + { + Directory.Delete(workingDirectory, recursive: true); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + } + } + } + /// /// An exported callback resolves its MethodDesc at run time through /// LookupUnmanagedCallersOnlyMethodByName, which matches on the declaring type and the method name diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs index 1862da1ff0e17c..2a4f23dbc21106 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs @@ -266,10 +266,15 @@ public void EmitNativeToInterp(TextWriter w, List callbacks) int parameterCount = cb.Parameters.Length; string argsArgs = parameterCount > 0 ? "(int8_t*)args, sizeof(args)" : "nullptr, 0"; + string[] parameterCTypes = ParameterTypes(cb.Parameters).Select(MapType).ToArray(); + // A cast converts a float numerically, while the slot has to carry its bits, so copy + // 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 => $"(int64_t)arg{i}"))} }};\n" + ? $"\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(", ", ParameterTypes(cb.Parameters).Select((p, i) => $"{MapType(p)} arg{i}")); + string parametersDeclaration = string.Join(", ", parameterCTypes.Select((p, i) => $"{p} arg{i}")); string arguments = string.Join(", ", Enumerable.Range(0, parameterCount).Select(i => $"arg{i}")); string exportFunction = cb.IsExport ? $$""" From 76324b6e9318c7fe26d09929ecabe23df4b98f4c Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 3 Sep 2026 20:06:26 +0200 Subject: [PATCH 69/72] Drop generator tests that only match generated source A test that looks for a substring in the generated C proves nothing runs, and has to be kept in step with the generator to stay green. Remove the three that did that, including the one covering how a floating point callback argument reaches the interpreter, which end-to-end interop tests are the right place to catch. The tests left compare what the interop encoder returns against what the compiler's own lowering returns, so they cannot drift from the generator without one of the two changing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../WasmArgumentLayoutTests.cs | 138 ------------------ 1 file changed, 138 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 356976af68f841..6e01c3a62d1ca6 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -603,18 +603,6 @@ public void PortableCallHelpersGeneratorEncodesMethodsLikeTheCompiler() Assert.Equal(expected, InteropSignature.GetMethodSignature(method)); } - /// - /// Void has no lowering of its own - the compiler never sees it in a position that needs one - - /// but it is still what a thunk returns, so the generator has to encode it. - /// - [Fact] - public void PortableCallHelpersGeneratorEncodesVoid() - { - ReadyToRunCompilerContext context = CreateWasmContext(); - - Assert.Equal("v", InteropSignature.GetAbiToken(context.GetWellKnownType(WellKnownType.Void))); - } - /// /// A type has to get the same token at the interop boundary as it does inside a lowered method /// signature, because the runtime looks a thunk up by the signature the compiler produced. The @@ -644,134 +632,8 @@ public void PortableCallHelpersGeneratorEncodesTypesTheSameWayInAndOutOfASignatu Assert.Equal(tokens[1], InteropSignature.GetAbiToken(type)); } - /// - /// Real builds hand the generator the whole app closure, not one assembly. The compilation group - /// it configures has to accept that: a multi-assembly set is only legal in composite mode, and a - /// group built without it asserts in checked builds and lays out nothing in any build. - /// - [Fact] - public void PortableCallHelpersGeneratorAcceptsMoreThanOneInputAssembly() - { - // Any second real assembly will do. This one is guaranteed to exist because it is the - // assembly currently executing. - string extraInput = typeof(WasmArgumentLayoutTests).Assembly.Location; - Assert.True(File.Exists(extraInput), $"test assembly not found at '{extraInput}'"); - - ReadyToRunCompilerContext context = CreateWasmContext(extraInput); - string outputDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - - try - { - var options = new PortableCallHelpersGeneratorOptions - { - OutputDirectory = outputDirectory, - TargetOS = "browser", - PInvokeModules = new[] { "libSystem.Native" }, - }; - - Assert.Equal(0, PortableCallHelpersGenerator.Run(context, options, new Logger(TextWriter.Null, isVerbose: false))); - - foreach (string fileName in new[] - { - PortableCallHelpersGenerator.PInvokeFileName, - PortableCallHelpersGenerator.ReversePInvokeFileName, - PortableCallHelpersGenerator.InterpToNativeFileName, - }) - { - string path = Path.Combine(outputDirectory, fileName); - Assert.True(File.Exists(path), $"{fileName} was not generated"); - Assert.NotEmpty(File.ReadAllText(path)); - } - - // The statically linked module has to resolve to direct calls, which is the whole point - // of naming it on the command line. - Assert.Contains("SystemNative_", File.ReadAllText(Path.Combine(outputDirectory, PortableCallHelpersGenerator.PInvokeFileName))); - } - finally - { - if (Directory.Exists(outputDirectory)) - Directory.Delete(outputDirectory, recursive: true); - } - } - private const string CoreLibSimpleName = "System.Private.CoreLib"; - /// - /// A reverse thunk hands each argument to the interpreter in an eight-byte slot. Casting a float - /// to the slot type converts it numerically, so the interpreter, which reads the slot as the - /// parameter's bits, would see a different value than the caller passed. - /// - [Theory] - [InlineData("double", "double")] - [InlineData("float", "float")] - [InlineData("int", null)] - [InlineData("nint", null)] - public void PortableCallHelpersGeneratorGivesTheInterpreterTheBitsOfAFloatingPointArgument( - string parameterType, string? expectedCType) - { - string source = $$""" - using System.Runtime.InteropServices; - - public static class Exports - { - [UnmanagedCallersOnly] - public static void Handle({{parameterType}} 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" }, - }; - - Assert.Equal(0, PortableCallHelpersGenerator.Run( - CreateWasmContext(inputAssembly), options, new Logger(TextWriter.Null, isVerbose: false))); - - string generated = File.ReadAllText( - Path.Combine(outputDirectory, PortableCallHelpersGenerator.ReversePInvokeFileName)); - - // The scan also collects the framework's own callbacks, so narrow to this one before - // asserting on how its arguments are packed. - int start = generated.IndexOf("_Exports_Handle", StringComparison.Ordinal); - Assert.True(start >= 0, "no thunk was generated for the callback"); - start = generated.LastIndexOf("static ", start, StringComparison.Ordinal); - int end = generated.IndexOf("\n}", start, StringComparison.Ordinal); - string thunk = generated[start..end]; - - if (expectedCType is not null) - { - Assert.Contains($"{expectedCType} arg0", thunk); - Assert.Contains("memcpy(&args[0], &arg0, sizeof(arg0));", thunk); - Assert.DoesNotContain("(int64_t)arg0", thunk); - } - else - { - Assert.Contains("(int64_t)arg0", thunk); - Assert.DoesNotContain("memcpy", thunk); - } - } - finally - { - try - { - Directory.Delete(workingDirectory, recursive: true); - } - catch (Exception e) when (e is IOException or UnauthorizedAccessException) - { - } - } - } - /// /// An exported callback resolves its MethodDesc at run time through /// LookupUnmanagedCallersOnlyMethodByName, which matches on the declaring type and the method name From 9740d11534f007ef0b7c3e91996c69f661e1ef5e Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 3 Sep 2026 20:28:08 +0200 Subject: [PATCH 70/72] Track the marshalling-aware check with an issue Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47 --- .../PortableCallHelpers/PortableCallHelpersGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs index 78d8cab7d59016..f20fcfbb6a2503 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs @@ -109,7 +109,7 @@ private static void Generate(ReadyToRunCompilerContext context, PortableCallHelp // marshalling. Make this check per-P/Invoke and marshalling-aware (see // Marshaller.IsMarshallingRequired), then raise it to a warning. It stays a message // while it names whole framework assemblies, which ship prebuilt and would fail - // builds nobody can fix. + // builds nobody can fix. Tracked by https://github.com/dotnet/runtime/issues/133190. if (pinvokes.Count != pinvokesFromOtherModules && MarshalHelpers.IsRuntimeMarshallingEnabled(module)) { log.InfoHigh("WASM0065", From 1724b720fedb61fc91c0fa693e8005c808d5883d Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 3 Sep 2026 20:43:48 +0200 Subject: [PATCH 71/72] Update src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs Co-authored-by: Jan Kotas --- .../aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs | 2 -- 1 file changed, 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 6e01c3a62d1ca6..2f3ecf35e15fe6 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -662,8 +662,6 @@ public void PortableCallHelpersGeneratorRejectsAnExportItCouldNotResolveByName( string source = $$""" using System.Runtime.InteropServices; - public sealed class MonoPInvokeCallbackAttribute : System.Attribute { } - public static class Exports { {{firstAttribute}} From 2d7aa4644404836cbf36d7733e659ed672227ee0 Mon Sep 17 00:00:00 2001 From: Radek Doulik Date: Thu, 3 Sep 2026 20:44:29 +0200 Subject: [PATCH 72/72] Update src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs Co-authored-by: Jan Kotas --- .../aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs index 2f3ecf35e15fe6..f510b504affc6a 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs @@ -653,9 +653,6 @@ public void PortableCallHelpersGeneratorEncodesTypesTheSameWayInAndOutOfASignatu // Nothing is exported, so neither wrapper reaches the name lookup: the runtime hands both their // MethodDesc through the arity-aware g_ReverseThunks key instead. [InlineData("[UnmanagedCallersOnly]", "Handle", "[UnmanagedCallersOnly]", "Handle", false)] - // [MonoPInvokeCallback] is not collected at all, so it cannot collide with the export. - [InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle", - "[MonoPInvokeCallback]", "Handle", false)] public void PortableCallHelpersGeneratorRejectsAnExportItCouldNotResolveByName( string firstAttribute, string firstName, string secondAttribute, string secondName, bool expectRejected) {