[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system - #131877

Open
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2
Open

[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system#131877
radekdoulik wants to merge 75 commits into
dotnet:mainfrom
radekdoulik:radekdoulik-wasm-struct-sizes-from-crossgen2

Conversation

@radekdoulik

@radekdoulikradekdoulik commented Aug 5, 2026

Copy link
Copy Markdown
Member

Replaces the hardcoded struct-size table in the CoreCLR wasm P/Invoke generator with crossgen2's real field-layout engine.

The problem

ManagedToNativeGenerator computed wasm ABI signature strings from System.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:

error WASM0067: SignatureMapper: unknown multi-field struct 'X' (fields: N)
- add its size to s_knownStructSizes in SignatureMapper.cs

Size matters here because the CoreCLR interpreter lays struct arguments out inline across 8-byte slots — TokenToSlotCount returns max((size + 7) / 8, 1) for an S<N> token. A wrong N misaligns 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 wasm with --targetos browser|wasi.

The CoreCLR half of the MSBuild task is then deleted outright, not adapted: ManagedToNativeGenerator, PInvokeCollector, PInvokeTableGenerator, SignatureMapper, InternalCallSignatureCollector, InterpToNativeGenerator all go. _CoreCLRGenerateManagedToNative keeps 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 under src/coreclr/vm/wasm/ and now drive generate-coreclr-helpers.proj, which imports the shared eng/wasm/WasmPInvokeModules.props module 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 under src/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.TypeSystem for metadata and WasmLowering for the ABI. Sizes are computed, not enumerated. The only change to WasmLowering is widening WasmValueTypeToSigChar from private to internal.

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.PortableCallHelpers with PortableCallHelpersGenerator as 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:

beforeafter
StringToWasmSigThunkStringToPortableSigThunk
g_wasmThunksg_portableCallHelperThunks
g_wasmThunksCountg_portableCallHelperThunksCount
wasm_ret_S<n>portable_callhelper_ret_S<n>
g_wasmPortableEntryPointThunksg_portableEntryPointThunks

What keeps wasm in its name is what is genuinely about wasm: the ABI in WasmLowering, the --targetos browser|wasi requirement, and the wasm-specific corerun the runtime tests link.

Finding crossgen2 at build time

Three acquisition paths, tried in order:

  • Override$(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 at crossgen2.dll is rejected with that message rather than failing inside Exec.
  • In repo$(Crossgen2InBuildDir). crossgen2 is built unconditionally by the clr subset.
  • Out of repo — the wasm-tools workload now declares the existing Microsoft.NETCore.App.Crossgen2.<host-rid> pack, whose Sdk/Sdk.props defines $(Crossgen2ToolPath).

The SDK already resolves this pack, but only when PublishReadyToRun is 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.proj takes the self-contained one from the same clr+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.sfxproj pins 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 WASM0066 for every DllImport whose 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 two NoWarn suppressions and a WarnOnUnresolvedPInvokeModules=false on the wasi leg; all three are removed here along with the warning and the --no-warn-unresolved-directpinvoke opt-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_override returns nullptr on a miss, so resolution falls through to the normal path and a call that actually happens throws DllNotFoundException naming 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 MethodDesc through 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. Everything the generator controls does carry the arity — the thunk keys are Handle#1:… against Handle#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_ReverseThunks is found by the arity-aware key and has its MethodDesc filled 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

  • Regeneration reproduces the committed helpers. Apart from the symbol rename above, the generated tables are byte for byte what was checked in, and zero WASM0001/WASM0060/WASM0061/WASM0062 warnings 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 against main independently of this PR - regenerating after a fresh clr+libs drops CompressionNative_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 for browser-wasm: 73 passed, 0 failed, 37 skipped (the skips are the classes gated on a non-wasm target). WasmArgumentLayoutTests goes 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.
  • WasmAppBuilder still builds for bothnet11.0 and net472.
  • clr+libs builds clean for both browser and wasi.
  • Both flavors build end to end from the in-tree samples: Wasm.Browser.Sample with a native relink, and Wasi.Console.Sample published 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.
  • Regenerating the checked-in tables through the new project reproduces them byte for byte.
  • The renamed runtime contract was checked by building it, not by reading: the rebuilt libcoreclr_static.a exports g_portableCallHelperThunks and no g_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:

  1. String constructors produced dead thunks.MetadataType.GetMethods() returns constructors where Type.GetMethods(BindingFlags) structurally never did, so the port added 5 interp-to-managed thunks for System.String's 9 InternalCall ctors. The VM never asks for those keys — GetCookieForCalliSig and GetPortableEntryPointToInterpreterThunk both special-case IsCtor() && IsString()before any signature lookup, because crossgen2 compiles String ctors as static factories. Now skipped, restoring a zero-diff baseline.
  2. By-reference struct parameters were declared as scalars.GenPInvokeDecl consulted 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 contained void RetPaddedLong (void *) alongside void 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 one IsPassedByReference helper. 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.
  3. Culture-sensitive sort in generated output. The assembly-attribution comment builder was the only sort in the file without an explicit comparer, making output locale-dependent. Now StringComparer.Ordinal, like its neighbours.
  4. A valueless --ignored-directpinvoke reached the response file. Item batching over an empty collection still evaluates the element once with an empty %(Identity), so Include="--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 — normally System.Private.CoreLib, which the targets add explicitly and which sorts first. _WasmIgnoredPInvokeModules was only populated under InvariantGlobalization, so the broken shape was the default configuration. Both module options were guarded on a non-empty identity; --ignored-directpinvoke has since been dropped outright, made dead by the WASM0066 removal, so only the --directpinvoke guard 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.
  5. Multi-segment types were mistaken for by-reference structs.InteropSignature.GetAbiToken treated every type that LowerToAbiType leaves alone as a by-reference struct, but the compiler's own GetSignature splits that case: a type lowering to several segments gets a <slotChar><slotCount> token instead. Int128 therefore encoded as A16, and IsPassedByReference — which tests the first character for S/A — declared it void * 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. GetAbiToken now consults TryGetMultiSegmentLayout first. The regression test asserts that the two encoders agree rather than pinning literal tokens, since that is the invariant both this and (2) broke.
  6. Duplicate simple names aborted the build. crossgen2's input-file-path parser 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.dll from Microsoft.Diagnostics.Tracing.TraceEvent is 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 .dll that is not a PE at all escapes the TypeSystemException.BadImageFormatException catch as a raw System.BadImageFormatException and takes the build down. The list is narrowed in MSBuild instead, by a FilterManagedAssemblies task built on the same Utils.IsManagedAssembly helper 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 against main, 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.
  7. An unresolved P/Invoke poisoned its module. The set that keeps each unresolved module to a single log line was also short-circuiting the scan loop, so once a module had been recorded every later P/Invoke naming it was skipped — including one that did resolve. A module reached only through [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

  • CI has not yet completed a fully green run, which is why this stays draft. The first run against this design surfaced defect (6) on browser-wasm linux Release LibraryTestsCoreCLR, the only failing leg at the time; runs since then have been against a moving base. The CoreCLR_WasmBuildTests legs 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.
  • The relink path was exercised with a synthetic MSBuild project, not a real Wasm.Build.Tests run. Fix (4) was reproduced and confirmed fixed that way, in both the default and InvariantGlobalization configurations, but has no automated coverage.
  • Fix (2) has no unit test. The wasm test harness synthesizes types from CoreLib 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.
  • The wasi runtime was not rebuilt to link-test the renamed symbols. It shares the header and the generator with browser, which was linked end to end, so this is left to CI.
  • generate-coreclr-helpers.cmd has 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 %~dp0 read after the argument loop, which SHIFT invalidates); the .sh equivalent of each is covered.
  • All local runs were on macOS/arm64. Windows and Linux hosts are covered only by this PR's CI — hence draft.

Cost

The wasm-tools workload gains the Microsoft.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.Tests correlation 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

  • Does not give wasi an out-of-repo acquisition path. wasi-experimental extends microsoft-net-runtime-mono-tooling, not wasm-tools, so it picks up no crossgen2 pack; the targets error explicitly there. Browser is the shipping wasm/CoreCLR target and is covered.
  • Does not re-enable any of the tests disabled in [browser-wasm] CoreCLR runtime tests blocked on interop gaps after test-specific corerun enablement #131811; that is follow-up work.
  • Does not address the generic-callback half of gap Get core-setup building in the consolidated repo. #2, which is rejected by a separate blittability check in 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.
  • Reverse thunks allocate one int64_t slot per managed parameter, while a by-value struct argument occupies ceil(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 with WASM0067; this one accepts them, so the failure mode for user code would be a bad thunk rather than a diagnostic. Wants a follow-up.
  • Reverse thunks also pack their arguments with (int64_t)argN, which converts numerically instead of copying bits, so a float or double callback 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.
  • Does not give the wasi generator the webcil remap the browser target carries. Published R2R images are webcil, which the managed-assembly filter cannot parse; wasi has no R2R publish today so the remap would have nothing to do, but it will need one if that changes.
  • Multi-slot types (Int128, Vector256, …) are rejected at the thunk emitter rather than at the interop boundary, so the diagnostic reads differently than the old WASM0068. Still a clean crossgen2 : 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 to vS12 / S12i / vS40i; neither struct was in the old table, so all three previously threw NotSupportedException: 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.Lowering tool; @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 — WasmLoweringFlags is 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.

radekdoulikand others added 2 commits August 5, 2026 13:38
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
CopilotAI lite review requested due to automatic review settings August 5, 2026 14:42
@azure-pipelines

Copy link
Copy Markdown
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

Copy link
Copy Markdown
MemberAuthor

This should resolve #131874

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.Lowering as an out-of-proc “signature resolver” tool and wire ManagedToNativeGenerator to 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
FileDescription
src/tasks/WasmAppBuilder/WasmAppBuilder.csprojBuilds the resolver tool in RunGenerator and passes its path into ManagedToNativeGenerator.
src/tasks/WasmAppBuilder/IcallTableGenerator.csRequires an injected CoreCLR SignatureMapper when generating CoreCLR icall signatures.
src/tasks/WasmAppBuilder/coreclr/WasmLoweringFlags.csTask-local copy of lowering flags (mirrors compiler enum values).
src/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.csNew resolver client that runs ILCompiler.Wasm.Lowering once and queries over stdin/stdout.
src/tasks/WasmAppBuilder/coreclr/SignatureMapper.csConverts from static helper + hardcoded struct sizes to instance-based mapper backed by resolver.
src/tasks/WasmAppBuilder/coreclr/PInvokeTableGenerator.csRoutes signature/name token decisions through the new SignatureMapper instance.
src/tasks/WasmAppBuilder/coreclr/PInvokeCollector.csUses resolver-backed signature computation; skips open generic callback delegates.
src/tasks/WasmAppBuilder/coreclr/ManagedToNativeGenerator.csAdds SignatureResolverPath/DotNetHostPath and initializes resolver + mapper for generation.
src/tasks/WasmAppBuilder/coreclr/IWasmAbiTypeResolver.csNew abstraction for “type token” and “method signature” ABI queries.
src/tasks/WasmAppBuilder/coreclr/InternalCallSignatureCollector.csUses resolver-based lowering for InternalCall signatures; skips generic InternalCalls.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk/Microsoft.NET.Runtime.WebAssembly.Wasi.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Microsoft.NET.Runtime.WebAssembly.Sdk.pkgprojShips the resolver tool in the SDK pack output layout.
src/coreclr/tools/Common/JitInterface/WasmLowering.MethodDesc.csNew split file for MethodDesc-based lowering + flag computation.
src/coreclr/tools/Common/JitInterface/WasmLowering.csRefactors to use IWasmTypeCacheContext and narrows API surface in this file.
src/coreclr/tools/Common/Compiler/IWasmTypeCacheContext.csNew interface for caching/round-tripping wasm-lowered struct/v128 types.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.Encoding.csSplits encoding/mangling/JIT interface conversions out of WasmTypes.cs.
src/coreclr/tools/Common/Compiler/DependencyAnalysis/Target_Wasm/WasmTypes.csKeeps the wasm type model “type-system only” and makes types partial to split helpers.
src/coreclr/tools/Common/Compiler/CompilerTypeSystemContext.Wasm.csImplements IWasmTypeCacheContext on the compiler context.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmTypeSystemContext.csNew minimal wasm-configured type system context used by the resolver tool.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmMetadataFieldLayoutAlgorithm.csNew wasm field-layout algorithm mirroring crossgen2 instance layout logic.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/WasmAbiTypeResolver.csResolver API implementation: per-type token and per-method signature queries.
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/Program.csImplements the stdin/stdout query server protocol (“ready”, t ..., m ...).
src/coreclr/tools/aot/ILCompiler.Wasm.Lowering/ILCompiler.Wasm.Lowering.csprojNew tool project, links shared lowering/type sources and pins output path.
src/coreclr/tools/aot/ILCompiler.TypeSystem/ILCompiler.TypeSystem.csprojGrants internals visibility to the resolver tool.
src/coreclr/tools/aot/ILCompiler.RyuJit/ILCompiler.RyuJit.csprojIncludes the new WasmLowering.MethodDesc.cs split file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csprojIncludes split wasm encoding + cache interface + MethodDesc lowering file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/VectorOfTFieldLayoutAlgorithm.csExtracted Vector<T> layout algorithm into a standalone file.
src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCompilerContext.csRemoves the now-extracted nested VectorOfTFieldLayoutAlgorithm type.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmLoweringParityTests.csNew parity tests comparing crossgen2 vs resolver lowering across CoreLib.
src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/ILCompiler.ReadyToRun.Tests.csprojAdds aliased reference to the resolver tool for side-by-side parity testing.
src/coreclr/tools/aot/ILCompiler.Compiler/ILCompiler.Compiler.csprojIncludes split wasm encoding + cache interface file.
Directory.Build.propsAdds WasmSignatureResolverDir for pinned resolver output placement.

Comment threadsrc/tasks/WasmAppBuilder/coreclr/SignatureMapper.cs Outdated
Comment threadsrc/tasks/WasmAppBuilder/WasmAppBuilder.csproj Outdated
@jkotas

Copy link
Copy Markdown
Member

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

Would it be better to expose this as crossgen command line option instead of repackaging large part of crossgen in a new tool?

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.

radekdoulikand others added 2 commits August 5, 2026 23:04
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
CopilotAI review requested due to automatic review settings August 6, 2026 08:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
{

Comment threadsrc/tasks/WasmAppBuilder/coreclr/WasmAbiTypeResolver.cs Outdated
radekdoulikand others added 2 commits August 6, 2026 12:36
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
Comment threadsrc/coreclr/tools/aot/crossgen2/Properties/Resources.resx Outdated
CopilotAI review requested due to automatic review settings August 6, 2026 16:10
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
CopilotAI review requested due to automatic review settings September 1, 2026 16:02

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment threadsrc/tests/Common/CLRTest.WasmCorerun.targets
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
CopilotAI review requested due to automatic review settings September 1, 2026 17:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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.Run validates OutputDirectory but not TargetOS. If TargetOS is 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 validating TargetOS up-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 an Exists(...) 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 an Exists(...) check for a clearer failure mode.
  • Files reviewed: 52/52 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

radekdoulikand others added 2 commits September 1, 2026 20:07
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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

  • FilterManagedAssemblies keeps the first file encountered for each simple name, but Assemblies ordering 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:102
  • EntryPoint is a get-only string but it’s only assigned when [UnmanagedCallersOnly] has an EntryPoint named 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

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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I have updated the comment and added WASM-TODO for the rest. I hope we can look into it as follow up?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I 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.

Comment on lines +694 to +695
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could you please fix the remaining places as well?

@radekdoulik

Copy link
Copy Markdown
MemberAuthor

@maraf please review the build related parts

Co-authored-by: Jan Kotas <jkotas@microsoft.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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 PInvokeInfo have nullable-signature mismatches that are likely to warn under nullable annotations (and can become build-breaking if warnings are treated as errors): IEquatable<T>.Equals should accept a nullable argument, and Equals(object) should accept object?. 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

  • PInvokeCallback has non-nullable auto-properties (EntryPoint, EntrySymbol) that are not definitely assigned on all constructor paths (e.g., when [UnmanagedCallersOnly] has no EntryPoint named 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

  • PortableCallHelpersGeneratorOptions declares non-nullable init-only string properties 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 in Run) 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

  • _generatePortableCallHelpers can be null when the option is not specified, but it's stored in a non-nullable string field 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

  • Assemblies is 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 use Assemblies! after validating) before using it.
    src/coreclr/tools/aot/ILCompiler.ReadyToRun/PortableCallHelpers/PInvokeCollector.cs:121
  • IComparer<T>.Compare is 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
Comment on lines +211 to +231
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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I have missed that the IsBlittable check is not done for regular PInvokes. Regular PInvokes is where it would matters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'."

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 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"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if(!method.HasCustomAttribute("System.Runtime.InteropServices","UnmanagedCallersOnlyAttribute"))
if(!method.IsUnmanagedCallersOnly)

Comment on lines +234 to +252
/// <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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// <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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
privateboolDoesMethodHaveCallbacks(EcmaMethodmethod)
privateboolIsMethodCallback(EcmaMethodmethod)

Nit: The method itself is (exactly-one) callback. "DoesMethodHaveCallbacks" does not match what this does.

Comment threadeng/Subsets.props
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" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this duplicate of #133040 ?

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasmWebAssembly architecturearea-ReadyToRun

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

@radekdoulik@jkotas@lewing@pavelsavara@davidwrighton