Skip to content

Commit d3fa820

Browse files
radekdoulikCopilotjkotas
authored
[wasm] Compute CoreCLR P/Invoke struct sizes with crossgen2's type system (#131877)
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 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 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. ## The change crossgen2 gains `--generate-portable-callhelpers <dir>`, which writes the three C++ call-helper files directly. It sets up its type system 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 deleted rather than adapted: `ManagedToNativeGenerator`, `PInvokeCollector`, `PInvokeTableGenerator`, `SignatureMapper`, `InternalCallSignatureCollector`, `InterpToNativeGenerator`. `_CoreCLRGenerateManagedToNative` keeps its name and position in the target graph; its final step changes from `<UsingTask>` to `<Exec>`. The regeneration scripts move next to their output under `src/coreclr/vm/wasm/` and drive `generate-coreclr-helpers.proj`. Mono's generator is untouched. **−2269 lines under `src/tasks`, +1541 under `ILCompiler.ReadyToRun/PortableCallHelpers`.** 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. 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. Per [review feedback](#131877) nothing in this functionality is named after wasm. Symbols shared by the runtime and the generated tables were renamed on both sides at once: | before | after | |---|---| | `StringToWasmSigThunk` | `StringToPortableSigThunk` | | `g_wasmThunks[Count]` | `g_portableCallHelperThunks[Count]` | | `wasm_ret_S<n>` | `portable_callhelper_ret_S<n>` | 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 Three paths, tried in order: - **Override** — `$(PortableCallHelpersGeneratorPath)`, which must name a crossgen2 executable. - **In repo** — `$(Crossgen2InBuildDir)`; crossgen2 is built unconditionally by the `clr` subset. - **Out of repo** — the `wasm-tools` workload declares the existing `Microsoft.NETCore.App.Crossgen2.<host-rid>` pack, whose `Sdk/Sdk.props` defines `$(Crossgen2ToolPath)`. ~12.5 MB. The SDK resolves this pack only when `PublishReadyToRun` is set, which wasm CoreCLR apps never set — hence the workload. dotnet/sdk#56119 proposes acquiring it directly instead, which would let the workload entry go. If none of the three resolve, the targets error rather than passing an empty path down. 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. The workload-testing legs do not set `$(BuildHostTools)`, so nothing produced a crossgen2 pack for their local package feed. (The perf browser-wasm leg does produce one, but only because it opts in — #133143.) `Microsoft.NETCore.App.Crossgen2.Host.sfxproj` pins the RID to the build host, and is now built by the CoreCLR browser-wasm leg behind `$(BuildCrossgen2HostPackForWorkloadTesting)`, guarded on `$(BuildHostTools)` being unset so the two paths can never emit the same package id twice. The official build is untouched — it already publishes this pack from the host platform legs. ## Behaviour changes **`WASM0066` is removed.** The old task warned for every `DllImport` whose module did not resolve to a linked-in native library — a CoreCLR-only divergence that fires on ordinary cross-platform code never executed on wasm (#131874 reports ten from SkiaSharp alone on a shipped Preview 7 SDK). In-tree it had already accumulated two `NoWarn` suppressions and a `WarnOnUnresolvedPInvokeModules=false`; all three go, along with the `--no-warn-unresolved-directpinvoke` opt-out that existed only to silence it. An unresolved module is not knowably wrong at build time: `callhelpers_pinvoke_override` returns `nullptr` on a miss, so a call that actually happens throws `DllNotFoundException` naming the module, as on every other platform. Dropping a warning is strictly loosening. **`WASM0065` is added, as a message.** Per module, when it declares P/Invokes without `[assembly: DisableRuntimeMarshalling]`, since the generated helpers assume signatures cross unmarshalled. A message rather than a warning: it reports something the app author often cannot fix, and as a warning it would fail `-warnaserror` builds. Four fire across the 181 framework assemblies. **Exported callbacks with an ambiguous name are rejected.** An export wrapper resolves its `MethodDesc` through `LookupUnmanagedCallersOnlyMethodByName`, which takes the first `[UnmanagedCallersOnly]` method of matching name and compares no signature — so two exported overloads resolve to the same method and one wrapper calls it with the wrong arguments. Everything the generator controls carries the arity, so the existing duplicate-key and duplicate-symbol checks both pass. Generation now fails instead, naming both signatures. Only exports: a non-exported callback is found by the arity-aware key and never reaches the name lookup. ## Known limitations - **wasi has no 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. - **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, so nothing exercises this. The old generator rejected such callbacks with `WASM0067`; this one accepts them, so user code would get a bad thunk rather than a diagnostic. - **`'V'` (v128) has no case in the C++ emission helpers.** Pre-existing; fails loudly. - **Multi-slot types (`Int128`, `Vector256`, …) are rejected at the thunk emitter** rather than at the interop boundary, so the diagnostic differs from the old `WASM0068`. Still a clean `crossgen2 : error :` with exit 1. No such P/Invoke exists today. - Does not re-enable the tests disabled in #131811 (#133187), and does not address gaps #3#7 there. ## Verification - **Regeneration reproduces the committed helpers byte for byte**, apart from the rename above, with zero `WASM0001`/`WASM0060`/`WASM0061`/`WASM0062` warnings across a full CoreLib+libraries scan. (The checked-in P/Invoke table is already slightly stale against `main` independently of this PR; that drift is left alone.) - `WasmArgumentLayoutTests` goes from 17 to 22 test methods. The two covering the rejection above were checked against a disabled check, so they test it rather than agree with it. - `clr+libs` builds clean for `browser` and `wasi`; `WasmAppBuilder` still builds for both `net11.0` and `net472`. - Both flavors build end to end from the in-tree samples, with per-architecture native payloads, a non-PE file and duplicate-culture satellites injected into the bundle. - The renamed runtime contract was checked by building: `libcoreclr_static.a` exports `g_portableCallHelperThunks` and no `g_wasmThunks`, and the browser sample links its generated tables against it. Contributes to #131811, closing blocking gap #1 and the struct half of gap #2: a 3-int and a 5-double struct in `[UnmanagedFunctionPointer]` signatures now resolve to `vS12` / `S12i` / `vS40i`, where all three previously threw `NotSupportedException`. > [!NOTE] > This pull request description was drafted with the help of GitHub Copilot. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jan Kotas <jkotas@microsoft.com> Copilot-Session: f6d6e5a4-5b25-4198-b42d-d5b2dc781f47
1 parent e1d442a commit d3fa820

52 files changed

Lines changed: 2436 additions & 2238 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/workflow/wasm-documentation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ For debugging instructions including VS Code and Chrome DevTools setup, see the
5151

5252
### Running coreclr callhelpers generator
5353

54-
After building the runtime, use the `generate-coreclr-helpers` script for your platform (`.cmd` or `.sh`) in `src/tasks/WasmAppBuilder` to [re]generate the call helpers in `src/coreclr/vm/wasm`.
54+
After building the runtime, use the `generate-coreclr-helpers` script for your platform (`.cmd` or `.sh`) in `src/coreclr/vm/wasm` to [re]generate the call helpers in `src/coreclr/vm/wasm`.
5555

5656
## Features and Configuration
5757

eng/Subsets.props

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,17 @@
759759
Non-VMR builds normally use the crossgen2 built for the target host SDK from another build leg, but builds without one can opt in.
760760
-->
761761
<ProjectToBuild Condition="'$(RuntimeFlavor)' != 'Mono' and ('$(TargetsMobile)' != 'true' or '$(TargetOS)' == 'browser') and '$(TargetsLinuxBionic)' != 'true' and '$(BuildHostTools)' == 'true'" Include="$(InstallerProjectRoot)pkg\sfx\Microsoft.NETCore.App\Microsoft.NETCore.App.Crossgen2.Host.sfxproj" Category="packs" />
762+
763+
<!--
764+
The wasm-tools workload manifest declares the host crossgen2 pack, so the workload
765+
testing legs need it in their local package feed. Those legs do not set
766+
$(BuildHostTools), so the project above does not produce one for them. The Host variant
767+
pins the RID to the build host, which is exactly the pack the workload resolves.
768+
769+
Opt-in only. The official build already publishes this pack from the host platform
770+
legs, and building it here as well would produce a second package with the same id.
771+
-->
772+
<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" />
762773
</ItemGroup>
763774
<ItemGroup>
764775
<SharedFrameworkProjectToBuild Condition="'$(_BuildHostPack)' == 'true'" Include="$(InstallerProjectRoot)pkg\archives\dotnet-nethost.proj" />

eng/pipelines/common/templates/browser-wasm-build-tests.yml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,10 @@ jobs:
9292
TargetFolder: '$(Build.SourcesDirectory)/artifacts'
9393
CleanTargetFolder: false
9494

95-
# Download the CoreCLR runtime pack. The wasm-tools workload manifest now includes
96-
# the CoreCLR browser-wasm runtime pack, so installing the workload for testing
97-
# requires the pack to be present in the local package feed.
95+
# Download the CoreCLR runtime pack and the host crossgen2 pack. The wasm-tools workload
96+
# manifest includes both, so installing the workload for testing requires them to be
97+
# present in the local package feed. Only pipelines that build the CoreCLR browser-wasm
98+
# runtime (e.g. runtime.yml) can stage them here.
9899
- ${{ if eq(parameters.includeCoreClrRuntimePack, true) }}:
99100
- task: DownloadPipelineArtifact@2
100101
displayName: Download built nugets for CoreCLR runtime
@@ -104,10 +105,12 @@ jobs:
104105
targetPath: '$(Build.SourcesDirectory)/artifacts/BuildArtifacts_browser_wasm_$(_hostedOs)_Release_CoreCLR'
105106

106107
- task: CopyFiles@2
107-
displayName: Copy CoreCLR runtime pack
108+
displayName: Copy CoreCLR runtime pack and host crossgen2 pack
108109
inputs:
109110
SourceFolder: '$(Build.SourcesDirectory)/artifacts/BuildArtifacts_browser_wasm_$(_hostedOs)_Release_CoreCLR'
110-
Contents: packages/$(_BuildConfig)/Shipping/Microsoft.NETCore.App.Runtime.browser-wasm.*
111+
Contents: |
112+
packages/$(_BuildConfig)/Shipping/Microsoft.NETCore.App.Runtime.browser-wasm.*
113+
packages/$(_BuildConfig)/Shipping/Microsoft.NETCore.App.Crossgen2.*
111114
TargetFolder: '$(Build.SourcesDirectory)/artifacts'
112115
CleanTargetFolder: false
113116

eng/pipelines/extra-platforms/runtime-extra-platforms-wasm.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ jobs:
209209
- browser_wasm_win
210210
jobParameters:
211211
nameSuffix: CoreCLR
212-
buildArgs: -s clr+libs+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:InstallWorkloadForTesting=false
212+
buildArgs: -s clr+libs+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:InstallWorkloadForTesting=false /p:BuildCrossgen2HostPackForWorkloadTesting=true
213213
timeoutInMinutes: 120
214214
postBuildSteps:
215215
- template: /eng/pipelines/common/wasm-post-build-steps.yml

eng/pipelines/runtime.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ extends:
130130
- browser_wasm_win
131131
jobParameters:
132132
nameSuffix: CoreCLR
133-
buildArgs: -s clr+libs+libs.tests+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:TestWasmBuildTests=true /p:ArchiveTests=true /p:InstallWorkloadForTesting=false
133+
buildArgs: -s clr+libs+libs.tests+packs -c Release -rc $(_BuildConfig) /p:TestAssemblies=false /p:TestWasmBuildTests=true /p:ArchiveTests=true /p:InstallWorkloadForTesting=false /p:BuildCrossgen2HostPackForWorkloadTesting=true
134134
timeoutInMinutes: 120
135135
postBuildSteps:
136136
- template: /eng/pipelines/common/wasm-post-build-steps.yml

eng/wasm/WasmPInvokeModules.props

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
<!--
22
The framework P/Invoke modules that CoreCLR resolves without dynamic loading on WebAssembly.
33
4-
ManagedToNativeGenerator emits a direct-call entry for every [DllImport] whose module name is on
5-
this list and nothing for the rest, so two builds have to agree on it:
4+
The portable call-helpers generator emits a direct-call entry for every [DllImport] whose module
5+
name is on this list and nothing for the rest, so two builds have to agree on it:
66
7-
* src/tasks/WasmAppBuilder/WasmAppBuilder.csproj generates the checked-in call tables under
8-
src/coreclr/vm/wasm from it (see generate-coreclr-helpers.md). They are compiled into
9-
coreclr_gen_static, which the product's corerun links.
7+
* src/coreclr/vm/wasm/generate-coreclr-helpers.proj imports this list to regenerate the
8+
checked-in call tables under src/coreclr/vm/wasm (see generate-coreclr-helpers.md). They are
9+
compiled into coreclr_gen_static, which the product's corerun links.
1010
* src/tests/Common/CLRTest.WasmCorerun.targets leaves coreclr_gen_static out when it links a
1111
test-specific corerun, and regenerates equivalent tables in its place. Those have to cover
1212
the same modules: one missing here surfaces as a DllNotFoundException at run time, inside

src/coreclr/tools/Common/JitInterface/WasmLowering.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,9 @@ public static WasmValueType LowerType(TypeDesc type)
389389
/// <summary>
390390
/// Maps a WasmValueType to its single-character signature encoding.
391391
/// </summary>
392-
private static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch
392+
// internal rather than private so the call-helper generator can encode a single type with the
393+
// same table the signature builder below uses (see ILCompiler.PortableCallHelpers.InteropSignature).
394+
internal static char WasmValueTypeToSigChar(WasmValueType vt) => vt switch
393395
{
394396
WasmValueType.I32 => 'i',
395397
WasmValueType.I64 => 'l',
@@ -409,6 +411,22 @@ public static WasmValueType LowerType(TypeDesc type)
409411
_ => throw new InvalidOperationException($"Unknown signature char: {c}")
410412
};
411413

414+
internal static string DescribeSigChar(char c) => c switch
415+
{
416+
'v' => "a void result",
417+
'i' => "a 32-bit integer",
418+
'l' => "a 64-bit integer",
419+
'f' => "a 32-bit float",
420+
'd' => "a 64-bit float",
421+
'V' => "a 128-bit vector",
422+
'S' or 'A' => "a struct passed by reference",
423+
'T' => "the 'this' argument",
424+
'p' => "the portable entry point argument",
425+
'a' => "the async continuation argument",
426+
'e' => "an empty struct",
427+
_ => $"an unrecognized element '{c}'"
428+
};
429+
412430
private static int ParseStructSize(string sig, ref int pos)
413431
{
414432
Debug.Assert(sig[pos] is 'S' or 'A');

src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/WasmArgumentLayoutTests.cs

Lines changed: 203 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
using System.IO;
99
using System.Linq;
1010

11+
using Microsoft.CodeAnalysis;
12+
using Microsoft.CodeAnalysis.CSharp;
13+
using Microsoft.CodeAnalysis.Emit;
14+
1115
using crossgen2::ILCompiler;
1216
using crossgen2::ILCompiler.DependencyAnalysis.ReadyToRun;
1317
using crossgen2::ILCompiler.DependencyAnalysis.Wasm;
18+
using crossgen2::ILCompiler.PortableCallHelpers;
1419
using crossgen2::Internal.CallingConvention;
1520
using crossgen2::Internal.JitInterface;
1621

@@ -546,26 +551,220 @@ private static MethodSignature MakeProbeSignature(ReadyToRunCompilerContext cont
546551
context.GetWellKnownType(WellKnownType.Int32).MakeByRefType());
547552

548553

554+
/// <summary>
555+
/// The generator encodes a type in parameter position with a single token. These are the three
556+
/// shapes that encoding exists to tell apart: a multi-field struct, which goes by reference and
557+
/// carries its size; a single-field wrapper, which is passed as the field it wraps; and a
558+
/// primitive.
559+
/// </summary>
560+
[Theory]
561+
[InlineData("Guid", "S16")]
562+
[InlineData("DateTime", "l")]
563+
[InlineData("Int32", "i")]
564+
public void PortableCallHelpersGeneratorEncodesTypesTheWayTheCompilerLowersThem(string typeName, string expected)
565+
{
566+
ReadyToRunCompilerContext context = CreateWasmContext();
567+
568+
Assert.Equal(expected, InteropSignature.GetAbiToken(GetSystemType(context, typeName)));
569+
}
570+
571+
/// <summary>
572+
/// A struct that holds a reference lays out through the auto-layout path, which asks the
573+
/// compilation group whether the base offset needs aligning. Generation is not a compilation, so
574+
/// it has to configure a group itself for that question to have an answer at all.
575+
/// </summary>
576+
[Fact]
577+
public void PortableCallHelpersGeneratorComputesLayoutOfStructsHoldingReferences()
578+
{
579+
ReadyToRunCompilerContext context = CreateWasmContext();
580+
var type = GetSystemType(context, "RuntimeTypeHandle");
581+
582+
// If this stops holding, the test no longer covers the auto-layout path it was written for.
583+
Assert.True(type.ContainsGCPointers, $"{type} was chosen because it holds a reference");
584+
585+
// One field the size of the whole struct: lowered to that field, a reference, passed as i32.
586+
Assert.Equal("i", InteropSignature.GetAbiToken(type));
587+
}
588+
589+
/// <summary>
590+
/// The thunk a method gets is keyed by its lowered signature, so the generator has to encode a
591+
/// method exactly as the compiler lowers it. Anything else and the interpreter calls through a
592+
/// thunk built for a different shape.
593+
/// </summary>
594+
[Fact]
595+
public void PortableCallHelpersGeneratorEncodesMethodsLikeTheCompiler()
596+
{
597+
ReadyToRunCompilerContext context = CreateWasmContext();
598+
var method = (EcmaMethod)GetSystemType(context, "DateTime").GetMethod("AddTicks"u8, null);
599+
600+
string expected = WasmLowering.GetSignature(method.Signature, WasmLowering.LoweringFlags.None).SignatureString;
601+
_output.WriteLine($"{method} lowers to '{expected}'");
602+
603+
Assert.Equal(expected, InteropSignature.GetMethodSignature(method));
604+
}
605+
606+
/// <summary>
607+
/// A type has to get the same token at the interop boundary as it does inside a lowered method
608+
/// signature, because the runtime looks a thunk up by the signature the compiler produced. The
609+
/// two encoders are separate code, so this pins them together for each shape the ABI treats
610+
/// differently: multi-segment types passed by value across several slots, structs passed by
611+
/// reference, single-field wrappers, and primitives.
612+
/// </summary>
613+
[Theory]
614+
[InlineData("Int128")]
615+
[InlineData("UInt128")]
616+
[InlineData("Guid")]
617+
[InlineData("DateTime")]
618+
[InlineData("Int32")]
619+
[InlineData("Double")]
620+
public void PortableCallHelpersGeneratorEncodesTypesTheSameWayInAndOutOfASignature(string typeName)
621+
{
622+
ReadyToRunCompilerContext context = CreateWasmContext();
623+
TypeDesc type = GetSystemType(context, typeName);
624+
625+
string signature = WasmLowering.GetSignature(
626+
MakeStaticVoidSignature(context, type),
627+
WasmLowering.LoweringFlags.None).SignatureString;
628+
_output.WriteLine($"{typeName} lowers to '{signature}' in a signature");
629+
630+
// 'v' return, then the single parameter, then the 'p' entrypoint suffix.
631+
List<string> tokens = InteropSignature.ParseSignatureTokens(signature);
632+
Assert.Equal(tokens[1], InteropSignature.GetAbiToken(type));
633+
}
634+
635+
private const string CoreLibSimpleName = "System.Private.CoreLib";
636+
637+
/// <summary>
638+
/// An exported callback resolves its MethodDesc at run time through
639+
/// LookupUnmanagedCallersOnlyMethodByName, which matches on the declaring type and the method name
640+
/// alone. Overloads are indistinguishable to it, so generation has to reject a name it could not
641+
/// resolve rather than emit a wrapper that calls whichever one the walk reaches first.
642+
/// </summary>
643+
[Theory]
644+
// Two exported overloads: the lookup cannot tell them apart.
645+
[InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle",
646+
"[UnmanagedCallersOnly(EntryPoint = \"cb_two\")]", "Handle", true)]
647+
// The twin does not have to be exported to be returned by the walk, which only tests the attribute.
648+
[InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "Handle",
649+
"[UnmanagedCallersOnly]", "Handle", true)]
650+
// Distinct names resolve unambiguously.
651+
[InlineData("[UnmanagedCallersOnly(EntryPoint = \"cb_one\")]", "HandleOne",
652+
"[UnmanagedCallersOnly(EntryPoint = \"cb_two\")]", "HandleTwo", false)]
653+
// Nothing is exported, so neither wrapper reaches the name lookup: the runtime hands both their
654+
// MethodDesc through the arity-aware g_ReverseThunks key instead.
655+
[InlineData("[UnmanagedCallersOnly]", "Handle", "[UnmanagedCallersOnly]", "Handle", false)]
656+
public void PortableCallHelpersGeneratorRejectsAnExportItCouldNotResolveByName(
657+
string firstAttribute, string firstName, string secondAttribute, string secondName, bool expectRejected)
658+
{
659+
string source = $$"""
660+
using System.Runtime.InteropServices;
661+
662+
public static class Exports
663+
{
664+
{{firstAttribute}}
665+
public static int {{firstName}}(int a) => a;
666+
667+
{{secondAttribute}}
668+
public static int {{secondName}}(int a, int b) => a + b;
669+
}
670+
""";
671+
672+
string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
673+
Directory.CreateDirectory(workingDirectory);
674+
675+
try
676+
{
677+
string inputAssembly = CompileCallbackAssembly(source, Path.Combine(workingDirectory, "Callbacks.dll"));
678+
string outputDirectory = Path.Combine(workingDirectory, "generated");
679+
680+
var options = new PortableCallHelpersGeneratorOptions
681+
{
682+
OutputDirectory = outputDirectory,
683+
TargetOS = "browser",
684+
PInvokeModules = new[] { "libSystem.Native" },
685+
};
686+
687+
var log = new StringWriter();
688+
int exitCode = PortableCallHelpersGenerator.Run(
689+
CreateWasmContext(inputAssembly), options, new Logger(log, isVerbose: false));
690+
691+
if (expectRejected)
692+
{
693+
Assert.Equal(1, exitCode);
694+
Assert.Contains($"declares more than one [UnmanagedCallersOnly] method named '{firstName}'", log.ToString());
695+
}
696+
else
697+
{
698+
Assert.Equal(0, exitCode);
699+
Assert.DoesNotContain("declares more than one", log.ToString());
700+
}
701+
}
702+
finally
703+
{
704+
// The type system maps an input assembly with FileShare.Read and never releases it - the
705+
// context is not disposable - so on Windows the compiled input cannot be deleted while
706+
// this process lives. Cleaning up is best effort rather than a second way to fail.
707+
try
708+
{
709+
Directory.Delete(workingDirectory, recursive: true);
710+
}
711+
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
712+
{
713+
}
714+
}
715+
}
716+
717+
/// <summary>
718+
/// Builds an input assembly for the generator to scan. It references the same CoreLib the context
719+
/// reads, so the attributes it applies are the ones the type system will resolve.
720+
/// </summary>
721+
private static string CompileCallbackAssembly(string source, string outputPath)
722+
{
723+
CSharpCompilation compilation = CSharpCompilation.Create(
724+
Path.GetFileNameWithoutExtension(outputPath),
725+
new[] { CSharpSyntaxTree.ParseText(source) },
726+
new[] { MetadataReference.CreateFromFile(TestPaths.SystemPrivateCoreLibPath) },
727+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
728+
729+
EmitResult result = compilation.Emit(outputPath);
730+
Assert.True(result.Success,
731+
string.Join(Environment.NewLine, result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)));
732+
733+
return outputPath;
734+
}
735+
736+
private static EcmaType GetSystemType(ReadyToRunCompilerContext context, string typeName)
737+
{
738+
return (EcmaType)context.SystemModule.GetType("System"u8, System.Text.Encoding.UTF8.GetBytes(typeName));
739+
}
740+
549741
/// <summary>
550742
/// Configures a type system context the way crossgen2 does for
551-
/// <c>--targetarch wasm --targetos browser</c>.
743+
/// <c>--targetarch wasm --targetos browser</c>. Extra input assemblies stand in for the rest of an
744+
/// app closure, which a real build always supplies alongside CoreLib.
552745
/// </summary>
553-
private ReadyToRunCompilerContext CreateWasmContext()
746+
private ReadyToRunCompilerContext CreateWasmContext(params string[] extraInputAssemblyPaths)
554747
{
555748
string coreLibPath = TestPaths.SystemPrivateCoreLibPath;
556749
Assert.True(File.Exists(coreLibPath), $"System.Private.CoreLib.dll not found at '{coreLibPath}'");
557750

558751
InstructionSetSupport instructionSetSupport = new(default, default, TargetArchitecture.Wasm32);
559752
TargetDetails target = new(TargetArchitecture.Wasm32, TargetOS.Browser, TargetAbi.NativeAot, instructionSetSupport.GetVectorTSimdVector());
560753

754+
Dictionary<string, string> inputFilePaths = new(StringComparer.OrdinalIgnoreCase) { { CoreLibSimpleName, coreLibPath } };
755+
foreach (string path in extraInputAssemblyPaths)
756+
{
757+
inputFilePaths.Add(Path.GetFileNameWithoutExtension(path), path);
758+
}
759+
561760
// Wasm cannot generate code at runtime, matching what crossgen2's Program computes for this target.
562761
ReadyToRunCompilerContext context = new(target, SharedGenericsMode.CanonicalReferenceTypes, bubbleIncludesCoreModule: true, targetAllowsRuntimeCodeGeneration: false, instructionSetSupport, oldTypeSystemContext: null)
563762
{
564-
InputFilePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "System.Private.CoreLib", coreLibPath } },
763+
InputFilePaths = inputFilePaths,
565764
ReferenceFilePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
566765
};
567766

568-
EcmaModule coreLib = (EcmaModule)context.GetModuleForSimpleName("System.Private.CoreLib");
767+
EcmaModule coreLib = (EcmaModule)context.GetModuleForSimpleName(CoreLibSimpleName);
569768
context.SetSystemModule(coreLib);
570769

571770
// The R2R field layout algorithm reaches into the compilation group to decide whether base

0 commit comments

Comments
 (0)