Uh oh!
There was an error while loading. Please reload this page.
[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877
[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877radekdoulik wants to merge 75 commits into
Conversation
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<N> 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<T> 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<N>; 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
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<int> and Nullable<long> 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
|
Azure Pipelines: Successfully started running 3 pipeline(s). 13 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
radekdoulik
commented
Aug 5, 2026
This should resolve #131874 |
There was a problem hiding this comment.
Pull request overview
This PR updates the CoreCLR WebAssembly P/Invoke helper generation pipeline to compute wasm ABI struct sizing and signature lowering via crossgen2’s type system (hosted in a new out-of-process tool), replacing the previous hardcoded/reflective size logic that couldn’t reliably handle field layout or constructed generics.
Changes:
- Add
ILCompiler.Wasm.Loweringas an out-of-proc “signature resolver” tool and wireManagedToNativeGeneratorto query it for ABI tokens and full method signatures. - Refactor wasm lowering/shared helpers in the compiler toolchain (type-cache interface, moved wasm type encoding, split MethodDesc-facing lowering).
- Add parity tests to ensure the standalone resolver produces the same signatures as crossgen2.
Reviewed changes
Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tasks/WasmAppBuilder/WasmAppBuilder.csproj | Builds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator. |
| src/tasks/WasmAppBuilder/IcallTableGenerator.cs | Requires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures. |
| src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.cs | Task-local copy of lowering flags (mirrors compiler enum values). |
| src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs | New resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout. |
| src/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs | Converts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver. |
| src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.cs | Routes signature/name token decisions through the new SignatureMapper instance. |
| src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.cs | Uses resolver-backed signature computation; skips open generic callback delegates. |
| src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.cs | Adds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation. |
| src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.cs | New abstraction for “type token” and “method signature” ABI queries. |
| src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.cs | Uses resolver-based lowering for InternalCall signatures; skips generic InternalCalls. |
| src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgproj | Ships the resolver tool in the SDK pack output layout. |
| src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgproj | Ships the resolver tool in the SDK pack output layout. |
| src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.cs | New split file for MethodDesc-based lowering + flag computation. |
| src/coreclr/tools/Common/JitInterface/WasmLowering.cs | Refactors to use IWasmTypeCacheContext and narrows API surface in this file. |
| src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.cs | New interface for caching/round-tripping wasm-lowered struct/v128 types. |
| src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.cs | Splits encoding/mangling/JIT interface conversions out of WasmTypes.cs. |
| src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.cs | Keeps the wasm type model “type-system only” and makes types partial to split helpers. |
| src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.cs | Implements IWasmTypeCacheContext on the compiler context. |
| src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.cs | New minimal wasm-configured type system context used by the resolver tool. |
| src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.cs | New wasm field-layout algorithm mirroring crossgen2 instance layout logic. |
| src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.cs | Resolver API implementation: per-type token and per-method signature queries. |
| src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.cs | Implements the stdin/stdout query server protocol (“ready”, t ..., m ...). |
| src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csproj | New tool project, links shared lowering/type sources and pins output path. |
| src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csproj | Grants internals visibility to the resolver tool. |
| src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csproj | Includes the new WasmLowering.MethodDesc.cs split file. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj | Includes split wasm encoding + cache interface + MethodDesc lowering file. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.cs | Extracted Vector<T> layout algorithm into a standalone file. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.cs | Removes the now-extracted nested VectorOfTFieldLayoutAlgorithm type. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.cs | New parity tests comparing crossgen2 vs resolver lowering across CoreLib. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csproj | Adds aliased reference to the resolver tool for side-by-side parity testing. |
| src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csproj | Includes split wasm encoding + cache interface file. |
| Directory.Build.props | Adds WasmSignatureResolverDir for pinned resolver output placement. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
jkotas
commented
Aug 5, 2026
Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool? |
…ct-sizes-from-crossgen2
radekdoulik
commented
Aug 5, 2026
That could work too. We don't include crossgen in the packages yet IIRC, I can add it in this PR though. I will try it. For the record, the new tool is 632K, crossgen pack is 36M (measured on mac). We will ship the crossgen anyway, so it indeed makes sense to add the new option instead of the new tool. |
…ct-sizes-from-crossgen2
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
…ct-sizes-from-crossgen2
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/coreclr/tools/aot/ILCompiler.ReadyToRun/JitInterface/WasmAbiQuery.cs:49
- This type doesn’t appear to need to be public. ILCompiler.ReadyToRun already declares InternalsVisibleTo for ILCompiler.ReadyToRun.Tests, so making this internal would reduce accidental surface area without impacting the crossgen2 entrypoint or tests.
public static class WasmAbiQuery
{
Uh oh!
There was an error while loading. Please reload this page.
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.<host-rid> 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
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
Uh oh!
There was an error while loading. Please reload this page.
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
There was a problem hiding this comment.
🟡 Changes recommended
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (3)
Previously missed (2) — in code that hasn't changed since the last review.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs:56
- PortableCallHelpersGenerator.Run validates OutputDirectory but not TargetOS. If TargetOS is missing/empty (or passed incorrectly via programmatic use), platform-attribute filtering becomes nonsensical and the behavior will be confusing. Fail fast with a clear diagnostic when TargetOS is not provided.
// 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);
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeTableGenerator.cs:97
- This generator code is in the AOT/tooling space and the repo’s core-runtime guidance calls out avoiding LINQ in low-level compiler codebases. This file uses multiple LINQ pipelines (Where/OrderBy/GroupBy/Select) in hot-ish generation loops, which adds allocations and makes debugging harder. Prefer explicit loops and pre-sized collections here to match the rest of the ILCompiler codebase style and reduce overhead.
var pinvokesGroupedByEntryPoint = pinvokes
.Where(l => modules.Contains(l.Module))
.OrderBy(l => l.EntryPoint, StringComparer.Ordinal)
.GroupBy(CEntryPoint, StringComparer.Ordinal);
src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs:34
- FilterManagedAssemblies.Execute dereferences the nullable Assemblies property (Assemblies.Length and foreach). If MSBuild ever invokes the task without setting Assemblies, this will throw a NullReferenceException instead of producing a normal MSBuild error, making the failure harder to diagnose.
- Files reviewed: 52/52 changed files
- Comments generated: 1
- Review effort level: Lite
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Review feedback: the hand-rolled check did not return what its name says, and crossgen2 already compiles MarshalUtils, so the struct rules come from there now. ByRef answers false. MarshalUtils only considers DefTypes, so three cases stay here: - A pointer, blittable when the GC has no stake in what it addresses. Requiring the target to satisfy MarshalUtils instead fails the build on ReleaseJSOwnedObjectByGCHandle(JSMarshalerArgument*), a union with bool and char views over 32 fixed bytes. - A function pointer, blittable when the types in its signature are. - An enum, blittable when its underlying primitive is. MarshalUtils accepts one as a field but not on its own, because System.Enum is a class and the parent check rejects it before the layout is looked at. The UnmanagedFunctionPointer delegate exemption goes too. WASM0061 and WASM0062 go with the field walk that raised them, leaving WASM0060. Regenerating produces the same tables and emits no WASM0060, so nothing in CoreLib or the libraries relies on what is now rejected: bool, char, LayoutKind.Auto structs and those delegates, which the old rule took as primitives, as single-field structs, or by attribute. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs:57
PortableCallHelpersGenerator.RunvalidatesOutputDirectorybut notTargetOS. IfTargetOSis missing/empty (e.g., API usage outside the command-line path), the generator will fail later with less actionable errors when evaluating platform attributes. Consider validatingTargetOSup-front (and restricting it to the supported values) to keep failures deterministic and user-facing diagnostics clear.
// 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);
src/mono/browser/build/BrowserWasmApp.CoreCLR.targets:706
- The targets validate that
$(PortableCallHelpersGeneratorPath)is non-empty and not a.dll, but they don't validate that the resolved executable actually exists. When$(Crossgen2ToolPath)is set incorrectly, the build will fail inside<Exec>with a less actionable error. Add anExists(...)check here (similar to the test corerun targets) to fail early with a clear message.
src/mono/wasi/build/WasiApp.CoreCLR.targets:170 - Like the browser targets, this validates
$(PortableCallHelpersGeneratorPath)is non-empty and not a.dll, but it doesn't validate that the resolved tool exists. If$(Crossgen2ToolPath)is set but points to a non-existent path, the build fails at<Exec>with a less actionable error. Add anExists(...)check for a clearer failure mode.
- Files reviewed: 52/52 changed files
- Comments generated: 0 new
- Review effort level: Lite
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
Review feedback: with $(Crossgen2InBuildDir) unset the path stays empty and the existence check reported "crossgen2 was not found at ''". Guard the empty case first, the way the browser and wasi app targets do, so the message says where crossgen2 comes from. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs:36
FilterManagedAssemblieskeeps the first file encountered for each simple name, butAssembliesordering can be nondeterministic (e.g., wildcards / filesystem enumeration). That makes the retained managed assembly (and therefore generated callhelpers output) potentially nondeterministic when duplicates exist (satellite assemblies, duplicate simple names). Sorting by FullPath before filtering would make this deterministic.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:102EntryPointis a get-onlystringbut it’s only assigned when[UnmanagedCallersOnly]has anEntryPointnamed argument. For non-exported callbacks this leaves the property at its default (null), which is easy to misuse later and may break if nullable analysis is enabled for this project.
public MethodSignature Parameters => Method.Signature;
public string EntryPoint { get; }
public EcmaMethod Method { get; }
public string EntrySymbol { get; set; }
public string AssemblyName { get; }
- Files reviewed: 52/52 changed files
- Comments generated: 0 new
- Review effort level: Lite
Uh oh!
There was an error while loading. Please reload this page.
Taking pointers and function pointers as blittable outright left IsBlittableSignature and IsUnmanaged with no callers. Unused private methods are not a compiler warning, so nothing flagged them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Suppressed comments (6)
Previously missed (5) — in code that hasn't changed since the last review.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs:56
- Run() validates OutputDirectory but does not validate TargetOS. If TargetOS is missing/empty, generation will fail later in less actionable ways (e.g., platform-attribute matching assuming a non-empty target OS). Add an explicit validation before calling Generate.
if (string.IsNullOrEmpty(options.OutputDirectory))
throw new LogAsErrorException("--generate-portable-callhelpers needs a directory to write to.");
Generate(context, options, log);
src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs:35
- The FilterManagedAssemblies task drops duplicate simple names by keeping the first item encountered, but the input item order is not guaranteed to be stable. This can make the chosen “winning” assembly nondeterministic across builds, which risks nondeterministic generated callhelper output.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:36 - PInvokeInfo implements IEquatable, but the Equals signature doesn’t accept null. If nullable annotations are enabled, this typically produces nullability mismatch warnings; even without NRT it’s better to reflect the contract explicitly and avoid the extra as-cast nullability ambiguity.
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 PInvokeInfo);
public override int GetHashCode() => Identity.GetHashCode(StringComparison.Ordinal);
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:102
- PInvokeCallback.EntryPoint is only set for exported callbacks, and EntrySymbol is assigned later during emission. Declaring these as non-nullable makes it easy to accidentally consume them before initialization (and can trigger nullable warnings in projects with NRT enabled).
public MethodSignature Parameters => Method.Signature;
public string EntryPoint { get; }
public EcmaMethod Method { get; }
public string EntrySymbol { get; set; }
public string AssemblyName { get; }
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs:22
- PortableCallHelpersGeneratorOptions exposes required values (OutputDirectory/TargetOS) as non-nullable properties without defaults or a requirement. This makes it easy to construct invalid options that will fail later (or produce nullability warnings if enabled). Consider marking these as required so invalid states are unrepresentable.
public sealed class PortableCallHelpersGeneratorOptions
{
public string OutputDirectory { get; init; }
public IReadOnlyList<string> PInvokeModules { get; init; } = [];
public string TargetOS { get; init; }
}
src/mono/wasi/build/WasiApp.CoreCLR.targets:24
- This UsingTask declaration omits TaskFactory/Condition that are used elsewhere for WasmAppBuilder tasks. Without TaskHostFactory, MSBuild may attempt to load the task in-proc under .NET Framework MSBuild, which is fragile and can break depending on how WasmAppBuilderTasksAssemblyPath is resolved.
- Files reviewed: 52/52 changed files
- Comments generated: 0 new
- Review effort level: Lite
| } | ||
| /// <summary> | ||
| /// Whether a type can be handed to native code as-is. Results are cached so that a type used |
There was a problem hiding this comment.
Whether a type can be handed to native code as-is.
This is not a definition of blittable type. The definition of blittable type is at https://learn.microsoft.com/en-us/dotnet/standard/native-interop/blittable-and-non-blittable-types . It is mostly concerned with whether the payload can be marshalled by pinning.
Also, TypeDesc alone is not sufficient to determine the unmanaged type that the type is going to marshalled into. [MarshalAs] and other interop attributes can alter the type that the type is going to be marshalled into.
To do this correctly, we would have to call Marshaller.IsMarshallingRequired or a more lenient custom method like that.
I am wondering what it would take to require DisableRuntimeMarshalling on wasm so that we do not have to deal with this. It would allow us to strip quite a bit of runtime code that deals with built-in interop marshalling.
There was a problem hiding this comment.
I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?
There was a problem hiding this comment.
I have missed that the IsBlittable check does not cover regular PInvokes - see my other comment.
For this PR, I would drop the IsBlittalble check completel. It is not correct. Instead, instead just print a warning when we encounter PInvoke in a module without DisableRuntimeMarshalling and attached TODO to that.
| 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 the crossgen2 pack |
There was a problem hiding this comment.
| 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 the crossgen2 pack | |
| In the repo it comes from the build output; outside it, from the crossgen2 pack |
Can we drop the note about generation not loading the JIT? It is irrelevant for the build scripts. Also, even if crossgen2 did end up loading the JIT for the generation, it would still work fine. crossgen2 is cross-targeting compiler and loads target specific JIT.
(Fix all places.)
There was a problem hiding this comment.
Could you please fix the remaining places as well?
radekdoulik
commented
Sep 1, 2026
@maraf please review the build related parts |
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Suppressed comments (6)
Previously missed (4) — in code that hasn't changed since the last review.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:36
- Several members in
PInvokeInfohave nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors):IEquatable<T>.Equalsshould accept a nullable argument, andEquals(object)should acceptobject?. Adjust signatures to match the interfaces/overrides and keep the null checks.
This issue also appears on line 114 of the same file.
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 PInvokeInfo);
public override int GetHashCode() => Identity.GetHashCode(StringComparison.Ordinal);
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:112
PInvokeCallbackhas non-nullable auto-properties (EntryPoint,EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when[UnmanagedCallersOnly]has noEntryPointnamed argument). This is both a correctness signal (these values are genuinely optional) and a source of nullable warnings. Make them nullable to reflect semantics and avoid uninitialized non-nullable members.
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; }
}
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PortableCallHelpersGenerator.cs:22
PortableCallHelpersGeneratorOptionsdeclares non-nullable init-onlystringproperties without defaults. With nullable enabled, this is typically a CS8618 warning (non-nullable property must contain a non-null value when exiting constructor). Provide defaults (and rely on the existing validation inRun) so the type is warning-free.
public sealed class PortableCallHelpersGeneratorOptions
{
public string OutputDirectory { get; init; }
public IReadOnlyList<string> PInvokeModules { get; init; } = [];
public string TargetOS { get; init; }
}
src/coreclr/tools/aot/crossgen2/Program.cs:52
_generatePortableCallHelperscan be null when the option is not specified, but it's stored in a non-nullablestringfield and then compared to null. This will trigger nullable warnings under<Nullable>enable</Nullable>and is inconsistent with the subsequent null checks. Make the field nullable (string?).
private readonly string _outputFilePath;
private readonly string _generatePortableCallHelpers;
public Program(Crossgen2RootCommand command)
{
_command = command;
_inputBubble = Get(command.InputBubble);
_singleFileCompilation = Get(command.SingleFileCompilation);
_outNearInput = Get(command.OutNearInput);
_outputFilePath = Get(command.OutputFilePath);
_generatePortableCallHelpers = Get(command.GeneratePortableCallHelpers);
src/tasks/WasmAppBuilder/FilterManagedAssemblies.cs:35
Assembliesis declared nullable but is dereferenced unconditionally (Assemblies.Length,foreach (… in Assemblies)). With<Nullable>enable</Nullable>in this project, this will produce nullable warnings (often treated as errors) and also makes the task less robust if invoked incorrectly. Add an early null check (or useAssemblies!after validating) before using it.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121IComparer<T>.Compareis annotated to accept nullable arguments; using non-nullable parameters here can produce nullability mismatch warnings (CS8767) under nullable enabled builds. Update the signature to accept nullable and handle nulls explicitly.
internal sealed class PInvokeCallbackComparer : IComparer<PInvokeCallback>
{
public int Compare(PInvokeCallback x, PInvokeCallback y)
{
int compare = string.Compare(x.Key, y.Key, StringComparison.Ordinal);
return compare != 0 ? compare : x.Token.CompareTo(y.Token);
}
}
- Files reviewed: 52/52 changed files
- Comments generated: 0 new
- Review effort level: Lite
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
| 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; |
There was a problem hiding this comment.
| if(HasAssemblyDisableRuntimeMarshallingAttribute((EcmaAssembly)method.Module)) | |
| returntrue; | |
| // 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. | |
| MethodSignaturesignature=method.Signature; | |
| if(!signature.ReturnType.IsVoid&&!IsBlittable(signature.ReturnType)) | |
| thrownewLogAsErrorException($"The return type '{signature.ReturnType}' of pinvoke callback method '{method}' needs to be blittable."); | |
| foreach(TypeDescparameterTypeinsignature) | |
| { | |
| if(!IsBlittable(parameterType)) | |
| thrownewLogAsErrorException($"Parameter types of pinvoke callback method '{method}' needs to be blittable."); | |
| } | |
| returntrue; | |
| returnfalse; |
UnmanagedCallersOnly methods do not support any marshalling. They are unconditionally on the "disabled marshalling" plan.
There was a problem hiding this comment.
I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.
There was a problem hiding this comment.
UnmanagedCallersOnly methods do not support any marshalling. They are unconditionally on the "disabled marshalling" plan.
Both Roslyn and runtime produce errors if somebody uses managed types in UnmanagedCallersOnly signatures, so I do not think we need to bother with error handling here. It should be fine to assume that UnmanagedCallersOnly signature is valid.
For example, Roslyn will produce "CS8894: Cannot use 'object' as a parameter type on a method attributed with 'UnmanagedCallersOnly'."
There was a problem hiding this comment.
🔵 Needs a closer look
Review details
Suppressed comments (4)
Previously missed (4) — in code that hasn't changed since the last review.
src/coreclr/vm/wasm/generate-coreclr-helpers.proj:126
- The response file written for crossgen2 uses unquoted paths for the output directory and scan assembly full paths. If the repo/artifacts path contains spaces, the response file parser can split these into multiple tokens and the generator invocation can fail or scan the wrong inputs.
src/mono/browser/build/BrowserWasmApp.CoreCLR.targets:716 - The crossgen2 response file is populated with unquoted paths (output directory and managed assembly FullPath). If the project/intermediate path includes spaces, the response file parser will split these into multiple tokens, breaking --generate-portable-callhelpers and/or the input assembly list.
src/mono/wasi/build/WasiApp.CoreCLR.targets:181 - The response file written for crossgen2 includes unquoted paths (output directory and managed assembly FullPath). If $(WasmAppDir) or the intermediate directory contains spaces, System.CommandLine response-file parsing can split these values and cause crossgen2 argument parsing failures.
src/tests/Common/CLRTest.WasmCorerun.targets:343 - The response file lines written for crossgen2 include unquoted paths (output directory and managed assembly FullPath). If any of these paths contain spaces (common on Windows user profiles or custom checkout locations), System.CommandLine response-file parsing will split them into multiple tokens and crossgen2 will mis-parse the arguments.
- Files reviewed: 52/52 changed files
- Comments generated: 0 new
- Review effort level: Lite
| private bool DoesMethodHaveCallbacks(EcmaMethod method) | ||
| { | ||
| if (!method.HasCustomAttribute("System.Runtime.InteropServices", "UnmanagedCallersOnlyAttribute")) |
There was a problem hiding this comment.
| if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute")) | |
| if(!method.IsUnmanagedCallersOnly) |
| /// <summary> | ||
| /// Matches an attribute by its simple name in any namespace, for attributes that are | ||
| /// declared by user code rather than by the framework. | ||
| /// </summary> | ||
| 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; | ||
| } | ||
There was a problem hiding this comment.
| /// <summary> | |
| /// Matches an attribute by its simple name in any namespace, for attributes that are | |
| /// declared by user code rather than by the framework. | |
| /// </summary> | |
| privatestaticboolHasAttributeByName(EcmaMethodmethod,stringattributeName) | |
| { | |
| MetadataReaderreader=method.MetadataReader; | |
| foreach(CustomAttributeHandlehandleinreader.GetMethodDefinition(method.Handle).GetCustomAttributes()) | |
| { | |
| if(reader.GetAttributeNamespaceAndName(handle,out_,outStringHandlename) | |
| &&reader.StringComparer.Equals(name,attributeName)) | |
| { | |
| returntrue; | |
| } | |
| } | |
| returnfalse; | |
| } |
There is existing HasCustomAttribute method. Can we used that instead?
There was a problem hiding this comment.
Ah ok, this is matching attribute without namespace to check for MonoPInvokeCallbackAttribute . Can we just get rid of all of it? If somebody has a method with MonoPInvokeCallbackAttribute, they are going to find out very quickly when testing their app that it does not work - CoreCLR produces predictable exception in that case.
| log.Verbose($"Adding {kind} signature {signature} for method '{method.OwningType}.{method.Name.ToString()}'"); | ||
| } | ||
| private bool DoesMethodHaveCallbacks(EcmaMethod method) |
There was a problem hiding this comment.
| privateboolDoesMethodHaveCallbacks(EcmaMethodmethod) | |
| privateboolIsMethodCallback(EcmaMethodmethod) |
Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.
| Opt-in only. The official build already publishes this pack from the host platform | ||
| legs, and building it here as well would produce a second package with the same id. | ||
| --> | ||
| <ProjectToBuild Condition="'$(BuildCrossgen2HostPackForWorkloadTesting)' == 'true' and '$(RuntimeFlavor)' != 'Mono' and '$(BuildHostTools)' != 'true'" Include="$(InstallerProjectRoot)pkg\sfx\Microsoft.NETCore.App\Microsoft.NETCore.App.Crossgen2.Host.sfxproj" Category="packs" /> |
Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.
The problem
ManagedToNativeGeneratorcomputed wasm ABI signature strings fromSystem.Reflection.MetadataLoadContext, which has no field-layout engine. Struct sizes therefore came from a 7-entry hardcoded table, and anything outside it was a hard build error:Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots —
TokenToSlotCountreturnsmax((size + 7) / 8, 1)for anS<N>token. A wrongNmisaligns the interpreter frame.(Mono's generator needs none of this: its alphabet has no
S, and it encodes every struct as a pointer, so it never had to know a size.)The change
crossgen2 gains
--generate-portable-callhelpers <dir>, which writes the three C++ call-helper files directly. It sets up its type system exactly as for a real wasm compilation, scans the input assemblies, and emits — no JIT, no R2R image. The option requires--targetarch wasmwith--targetos browser|wasi.The CoreCLR half of the MSBuild task is then deleted outright, not adapted:
ManagedToNativeGenerator,PInvokeCollector,PInvokeTableGenerator,SignatureMapper,InternalCallSignatureCollector,InterpToNativeGeneratorall go._CoreCLRGenerateManagedToNativekeeps its name and position in the target graph; only its final step changes from<UsingTask>to<Exec>. The scripts that regenerate the checked-in tables move next to their output undersrc/coreclr/vm/wasm/and now drivegenerate-coreclr-helpers.proj, which imports the sharedeng/wasm/WasmPInvokeModules.propsmodule list added by #131866 and hands crossgen2 a response file. Mono's generator is untouched.That is the shape of the diff: −2246 lines under
src/tasks, +1575 undersrc/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers. Net ~+36 lines across 40 files. It is a move, not an addition — the second implementation of wasm ABI lowering is gone, and the one that remains is the one the JIT interface itself calls.Because the whole pipeline now runs inside the compiler, it reuses
Internal.TypeSystemfor metadata andWasmLoweringfor the ABI. Sizes are computed, not enumerated. The only change toWasmLoweringis wideningWasmValueTypeToSigCharfromprivatetointernal.Naming
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. Per review feedback nothing in this functionality is named after wasm. The generator lives in
ILCompiler.PortableCallHelperswithPortableCallHelpersGeneratoras its entry point, the MSBuild override is$(PortableCallHelpersGeneratorPath), and the symbols the runtime and the generated tables agree on were renamed on both sides at once:StringToWasmSigThunkStringToPortableSigThunkg_wasmThunksg_portableCallHelperThunksg_wasmThunksCountg_portableCallHelperThunksCountwasm_ret_S<n>portable_callhelper_ret_S<n>g_wasmPortableEntryPointThunksg_portableEntryPointThunksWhat keeps wasm in its name is what is genuinely about wasm: the ABI in
WasmLowering, the--targetos browser|wasirequirement, and the wasm-specific corerun the runtime tests link.Finding crossgen2 at build time
Three acquisition paths, tried in order:
$(PortableCallHelpersGeneratorPath), which has to name a crossgen2 executable. Every path above resolves a self-contained one, so there is no IL-only fallback; pointing the override atcrossgen2.dllis rejected with that message rather than failing insideExec.$(Crossgen2InBuildDir).crossgen2is built unconditionally by theclrsubset.wasm-toolsworkload now declares the existingMicrosoft.NETCore.App.Crossgen2.<host-rid>pack, whoseSdk/Sdk.propsdefines$(Crossgen2ToolPath).The SDK already resolves this pack, but only when
PublishReadyToRunis set, which wasm CoreCLR apps never set — hence declaring it in the workload instead. It costs ~12.5 MB there. If none of the three resolve, the targets error explicitly rather than passing an empty path down.Note the pack is named for the machine that runs crossgen2, not the target: generation never loads the JIT, so a host-targeting crossgen2 answers wasm ABI questions correctly.
Regenerating the checked-in tables resolves crossgen2 separately:
generate-coreclr-helpers.projtakes the self-contained one from the sameclr+libs -os <flavor>build that produced the assemblies it scans, so a single command per flavor supplies both the tool and the scan path.One CI wrinkle: no wasm leg produced a crossgen2 pack, so the workload-testing legs had nothing to install from their local feed.
Microsoft.NETCore.App.Crossgen2.Host.sfxprojpins the RID to the build host and is now built by the CoreCLR browser-wasm leg behind an opt-in property, then staged alongside the CoreCLR runtime pack. The official build is untouched — it already publishes this pack from the host platform legs.Unresolved P/Invoke modules no longer warn
The deleted task warned
WASM0066for everyDllImportwhose module did not resolve to a linked-in native library. That was a CoreCLR-only divergence — Mono's generator silently skips the same imports — and it fires on ordinary cross-platform code that never executes on wasm. #131874 reports ten of them from SkiaSharp alone on a shipped Preview 7 SDK (ole32.dll×2,Kernel32.dll×6,libEGL.dll,libc). In-tree it had already accumulated twoNoWarnsuppressions and aWarnOnUnresolvedPInvokeModules=falseon the wasi leg; all three are removed here along with the warning and the--no-warn-unresolved-directpinvokeopt-out that existed only to silence it.It is deleted rather than re-plumbed because an unresolved module is not knowably wrong at build time.
callhelpers_pinvoke_overridereturnsnullptron a miss, so resolution falls through to the normal path and a call that actually happens throwsDllNotFoundExceptionnaming the module — the same diagnostic every other .NET platform gives. Dropping a warning is strictly loosening, so this is not a breaking change.Exported callbacks with an ambiguous name are rejected
An export wrapper resolves its
MethodDescthroughLookupUnmanagedCallersOnlyMethodByName, 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. Everything the generator controls does carry the arity — the thunk keys areHandle#1:…againstHandle#2:…, and the symbols differ by parameter type — so the existing duplicate-key and duplicate-symbol checks both pass. Generation now fails instead, naming both signatures.Only exports are rejected. A callback the runtime resolves through
g_ReverseThunksis found by the arity-aware key and has itsMethodDescfilled in before the wrapper ever runs, so it never reaches the name lookup; same-named callbacks that are not exported keep working. This makes the case a build error rather than fixing it, and should be removed if the runtime ever resolves these unambiguously.Verification
WASM0001/WASM0060/WASM0061/WASM0062warnings fire across a full CoreLib+libraries scan, so no fallback guard is hit. One caveat worth stating: the checked-in P/Invoke table is already slightly stale againstmainindependently of this PR - regenerating after a freshclr+libsdropsCompressionNative_CompressBound, which nothing P/Invokes any more, and shifts one attribution comment. That drift is left alone here rather than folded into a rename.ILCompiler.ReadyToRun.Tests, built forbrowser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target).WasmArgumentLayoutTestsgoes from 17 to 24 test methods. The five cases covering the rejection above were checked against a disabled check: exactly the two that expect rejection fail, so they test it rather than agree with it.WasmAppBuilderstill builds for bothnet11.0andnet472.clr+libsbuilds clean for bothbrowserandwasi.Wasm.Browser.Samplewith a native relink, andWasi.Console.Samplepublished for wasi. Injecting per-architecture native payloads, a non-PE file and duplicate-culture satellites into the bundle leaves both green, with none of them reaching the generator's response file.libcoreclr_static.aexportsg_portableCallHelperThunksand nog_wasmThunks, and the browser sample compiles and links its own generated tables against it.Seven defects were found and fixed while reviewing this, all with zero baseline drift:
MetadataType.GetMethods()returns constructors whereType.GetMethods(BindingFlags)structurally never did, so the port added 5 interp-to-managed thunks forSystem.String's 9InternalCallctors. The VM never asks for those keys —GetCookieForCalliSigandGetPortableEntryPointToInterpreterThunkboth special-caseIsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.GenPInvokeDeclconsulted the real ABI for returns but the parameter path unwrapped any single-field struct without checking the field fills it. For[StructLayout(Size = 16)] struct PaddedLong { long Value; }one generated file containedvoid RetPaddedLong (void *)alongsidevoid UsePaddedLong (int64_t)— the same type in two positions, disagreeing. The caller passes an i32 pointer, so that is a wasm value-type mismatch, not a spelling difference. Both positions now go through oneIsPassedByReferencehelper. No P/Invoke in CoreLib or the libraries takes this shape today, which is why it went unnoticed; it matters for the arbitrary user structs this change exists to support.StringComparer.Ordinal, like its neighbours.--ignored-directpinvokereached the response file. Item batching over an empty collection still evaluates the element once with an empty%(Identity), soInclude="--ignored-directpinvoke;%(...)"wrote a bare option. crossgen2 reads one token per line and binds the next one as the value, silently swallowing the first managed assembly — normallySystem.Private.CoreLib, which the targets add explicitly and which sorts first._WasmIgnoredPInvokeModuleswas only populated underInvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity;--ignored-directpinvokehas since been dropped outright, made dead by theWASM0066removal, so only the--directpinvokeguard remains — in the browser, wasi and corerun test targets. The in-repo regeneration script builds its own argument list, which is why the byte-identical baseline could not catch this.InteropSignature.GetAbiTokentreated every type thatLowerToAbiTypeleaves alone as a by-reference struct, but the compiler's ownGetSignaturesplits that case: a type lowering to several segments gets a<slotChar><slotCount>token instead.Int128therefore encoded asA16, andIsPassedByReference— which tests the first character forS/A— declared itvoid *while the ABI passes it by value in two slots. Same class of mismatch as (2). It also hid these types from the multi-slot rejection that exists to turn them into a clean diagnostic.GetAbiTokennow consultsTryGetMultiSegmentLayoutfirst. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.input-file-pathparser rejects two inputs sharing a simple name. The deleted task filtered unmanaged binaries out first; the port handed the app bundle straight to the strict parser, so any app carrying per-architecture native payloads died during argument parsing —KernelTraceControl.dllfromMicrosoft.Diagnostics.Tracing.TraceEventis what CI hit. An earlier revision of this PR relaxed the parser for the generator, but that leaned on crossgen2's corert#2785 leniency — the same workaround Remove corert#2785 BadImageFormatException workaround from ILCompiler #127591 had just removed from ILCompiler — and it only ever covered native PE files, since a.dllthat is not a PE at all escapes theTypeSystemException.BadImageFormatExceptioncatch as a rawSystem.BadImageFormatExceptionand takes the build down. The list is narrowed in MSBuild instead, by aFilterManagedAssembliestask built on the sameUtils.IsManagedAssemblyhelper that mono's generator (FilterOutUnmanagedBinaries) and ILLink (ComputeManagedAssemblies) already use on this path. crossgen2's shared argument handling is back to a zero-line diff againstmain, and the browser and wasi targets both hand it a managed-only list. The task also collapses duplicate simple names, which culture satellites produce; unmanaged files are dropped first, so a native payload can never claim a name ahead of the managed assembly sharing it.[WasmImportLinkage]therefore vanished from the table whenever an unresolved import of the same module happened to be scanned first, leaving a missing wasm import to fail at run time rather than at build time. Only the logging is suppressed now. Found in review and confirmed with a probe declaring both an unresolved and a[WasmImportLinkage]import of one module: absent before the fix, present after, with a linkage-only control unaffected either way.Not verified
browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. TheCoreCLR_WasmBuildTestslegs are the ones that matter most now that [browser] Run Wasm.Build.Tests on CoreCLR the same way as Mono #132478 broadened what CoreCLR actually runs there.Wasm.Build.Testsrun. Fix (4) was reproduced and confirmed fixed that way, in both the default andInvariantGlobalizationconfigurations, but has no automated coverage.ValueTuple, which cannot express[StructLayout(Size = …)]padding, so covering it needs a harness extension. It was verified end to end against the real generator instead.generate-coreclr-helpers.cmdhas never been executed — there is no Windows host available here. Two bugs in it were caught by review and by reading (scan-path overrides forwarded unquoted, and%~dp0read after the argument loop, whichSHIFTinvalidates); the.shequivalent of each is covered.Cost
The
wasm-toolsworkload gains theMicrosoft.NETCore.App.Crossgen2.<host-rid>pack, ~12.5 MB on disk for anyone who installs it. Most of that is the single-file apphost.An earlier revision also shipped crossgen2 to Helix as a ~36 MB
Wasm.Build.Testscorrelation payload, because that leg built test apps straight out of the repo. #132478 moved CoreCLR WBT onto the real workload, so the generated apps now resolve crossgen2 from the pack like any other consumer and the payload — along with the artifact copy that fed it — is gone.What this does not do
wasi-experimentalextendsmicrosoft-net-runtime-mono-tooling, notwasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.PInvokeCollector, nor gaps Define a root README.md #3–[master] Update dependencies from dotnet/coreclr #7.'V'(v128) still has no case in the C++ emission helpers. Pre-existing, and still fails loudly rather than silently.int64_tslot per managed parameter, while a by-value struct argument occupiesceil(size/8)interpreter slots. No[UnmanagedCallersOnly]callback in CoreLib or the libraries takes a by-value struct — there are zero struct-typed reverse thunks in either generated baseline — so nothing exercises this. The old generator rejected such callbacks withWASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.(int64_t)argN, which converts numerically instead of copying bits, so afloatordoublecallback parameter would be truncated rather than reinterpreted. Carried over verbatim from the old generator, and equally latent: every reverse thunk in both baselines takes only pointer and integer parameters. Wants the same follow-up.Int128,Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the oldWASM0068. Still a cleancrossgen2 : error :with exit 1. No such P/Invoke exists today.Relationship to #131811
Contributes to #131811, closing blocking gap #1 and the struct half of gap #2. Verified for gap #2: a 3-int struct and a 5-double struct in
[UnmanagedFunctionPointer]delegate signatures now resolve tovS12/S12i/vS40i; neither struct was in the old table, so all three previously threwNotSupportedException: Unsupported parameter type.Review notes
Review the final tree rather than the commits in order — the design went through two discarded revisions. The first packaged this as a standalone
ILCompiler.Wasm.Loweringtool; @jkotas asked why it wasn't simply a crossgen2 option, which was right, since every extraction existed only to give a second host something to link against. The second exposed--wasm-abi-query, a stdin/stdout protocol the task called into. This revision drops the protocol and the task with it: if crossgen2 already has the type system and the lowering, it may as well write the files.That also removes the residual risk called out in the previous revision —
WasmLoweringFlagsis no longer duplicated on the task side, because there is no task side.Note
This pull request description was drafted with the help of GitHub Copilot.