|
8 | 8 | using System.IO; |
9 | 9 | using System.Linq; |
10 | 10 |
|
| 11 | +using Microsoft.CodeAnalysis; |
| 12 | +using Microsoft.CodeAnalysis.CSharp; |
| 13 | +using Microsoft.CodeAnalysis.Emit; |
| 14 | + |
11 | 15 | using crossgen2::ILCompiler; |
12 | 16 | using crossgen2::ILCompiler.DependencyAnalysis.ReadyToRun; |
13 | 17 | using crossgen2::ILCompiler.DependencyAnalysis.Wasm; |
| 18 | +using crossgen2::ILCompiler.PortableCallHelpers; |
14 | 19 | using crossgen2::Internal.CallingConvention; |
15 | 20 | using crossgen2::Internal.JitInterface; |
16 | 21 |
|
@@ -546,26 +551,220 @@ private static MethodSignature MakeProbeSignature(ReadyToRunCompilerContext cont |
546 | 551 | context.GetWellKnownType(WellKnownType.Int32).MakeByRefType()); |
547 | 552 |
|
548 | 553 |
|
| 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 | + |
549 | 741 | /// <summary> |
550 | 742 | /// 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. |
552 | 745 | /// </summary> |
553 | | - private ReadyToRunCompilerContext CreateWasmContext() |
| 746 | + private ReadyToRunCompilerContext CreateWasmContext(params string[] extraInputAssemblyPaths) |
554 | 747 | { |
555 | 748 | string coreLibPath = TestPaths.SystemPrivateCoreLibPath; |
556 | 749 | Assert.True(File.Exists(coreLibPath), $"System.Private.CoreLib.dll not found at '{coreLibPath}'"); |
557 | 750 |
|
558 | 751 | InstructionSetSupport instructionSetSupport = new(default, default, TargetArchitecture.Wasm32); |
559 | 752 | TargetDetails target = new(TargetArchitecture.Wasm32, TargetOS.Browser, TargetAbi.NativeAot, instructionSetSupport.GetVectorTSimdVector()); |
560 | 753 |
|
| 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 | + |
561 | 760 | // Wasm cannot generate code at runtime, matching what crossgen2's Program computes for this target. |
562 | 761 | ReadyToRunCompilerContext context = new(target, SharedGenericsMode.CanonicalReferenceTypes, bubbleIncludesCoreModule: true, targetAllowsRuntimeCodeGeneration: false, instructionSetSupport, oldTypeSystemContext: null) |
563 | 762 | { |
564 | | - InputFilePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { { "System.Private.CoreLib", coreLibPath } }, |
| 763 | + InputFilePaths = inputFilePaths, |
565 | 764 | ReferenceFilePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase), |
566 | 765 | }; |
567 | 766 |
|
568 | | - EcmaModule coreLib = (EcmaModule)context.GetModuleForSimpleName("System.Private.CoreLib"); |
| 767 | + EcmaModule coreLib = (EcmaModule)context.GetModuleForSimpleName(CoreLibSimpleName); |
569 | 768 | context.SetSystemModule(coreLib); |
570 | 769 |
|
571 | 770 | // The R2R field layout algorithm reaches into the compilation group to decide whether base |
|
0 commit comments