From ddf51727d2ca332b3b68fe687b7d13af31dc4da6 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Sun, 12 Jul 2026 22:13:38 +0200 Subject: [PATCH 1/2] [TrimmableTypeMap] Emit empty stubs for trimmed per-assembly typemaps The root `_Microsoft.Android.TypeMaps` assembly is generated before trimming with an `[assembly: TypeMapAssemblyTarget("_X.TypeMap")]` entry for every per-assembly typemap. ILLink can trim individual `_X.TypeMap` assemblies when their target Java binding is unused, but those attributes carry an opaque assembly-name string rather than a metadata reference, so ILLink neither keeps the target nor prunes the attribute. At startup CoreCLR's `TypeMapping` enumerates every attribute and calls `Assembly.Load` on the named assembly, throwing `FileNotFoundException` for the trimmed ones, crashing the app. Found while running a real .NET MAUI app on CoreCLR with the trimmable typemap: six unused bindings (GoogleGson, Jsr305Binding, Xamarin.AndroidX.Print, Xamarin.JavaX.Inject, Xamarin.JSpecify, Xamarin.Kotlin.StdLib) were trimmed together with their `_X.TypeMap` assemblies, but the dangling `TypeMapAssemblyTarget` attributes on the root assembly remained. Fix: after ILLink, emit an empty (entry-free) stub assembly for each per-assembly typemap that was trimmed away, so `Assembly.Load` succeeds and contributes no mappings. This leaves the linked root assembly - and the assembly references ILLink reconciled in it - untouched. Regenerating the root instead was rejected because it re-introduces unreconciled System.Runtime.InteropServices facade references that fail to resolve at runtime. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd0c2b70-3ad0-42db-940c-54ae6d5a920b --- .../Generator/TypeMapAssemblyGenerator.cs | 17 ++++ ...roid.Sdk.TypeMap.Trimmable.CoreCLR.targets | 23 +++++ .../Tasks/GenerateMissingTypeMapStubs.cs | 91 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/GenerateMissingTypeMapStubs.cs diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs index 48ca89f45bc..ae1e7cce9fe 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs @@ -34,4 +34,21 @@ public void Generate (IReadOnlyList peers, Stream stream, string a var emitter = new TypeMapAssemblyEmitter (_systemRuntimeVersion); emitter.Emit (model, stream, useSharedTypemapUniverse); } + + /// + /// Emits an empty typemap assembly (containing no type map entries) with the given + /// , writing it to . Used to satisfy + /// [assembly: TypeMapAssemblyTarget<T>("name")] references to per-assembly typemaps + /// that the trimmer removed (their target Java binding was unused): the runtime can still + /// Assembly.Load the stub, which contributes no mappings, instead of throwing + /// . + /// + /// Stream to write the output PE assembly to. + /// Assembly name for the generated stub. + public void GenerateEmpty (Stream stream, string assemblyName) + { + var builder = new PEAssemblyBuilder (_systemRuntimeVersion); + builder.EmitPreamble (assemblyName, assemblyName + ".dll"); + builder.WritePE (stream); + } } diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets index 1beb4ab9505..fbeb48e59f6 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.TypeMap.Trimmable.CoreCLR.targets @@ -1,6 +1,8 @@ + + <_TrimmableRuntimeProviderJavaName Condition=" '$(_TrimmableRuntimeProviderJavaName)' == '' ">mono.MonoRuntimeProvider @@ -114,6 +116,27 @@ <_PostTrimDeletedCopiedJavaFiles Remove="@(_PostTrimDeletedCopiedJavaFiles)" /> + + + + + +/// Emits empty stub assemblies for per-assembly typemaps (_X.TypeMap.dll) that the root +/// _Microsoft.Android.TypeMaps assembly references via +/// [assembly: TypeMapAssemblyTarget<T>("_X.TypeMap")] but that ILLink trimmed away because +/// their target Java binding was unused. +/// +/// Those attributes carry an opaque assembly-name string rather than a metadata reference, so ILLink +/// cannot follow (or prune) them, and at startup CoreCLR's TypeMapping enumerates every +/// attribute and calls Assembly.Load on the named assembly, throwing +/// FileNotFoundException for the trimmed ones. Emitting an empty, entry-free stub for each keeps +/// Assembly.Load succeeding (the stub contributes no type map entries) without editing the +/// linked root assembly, so ILLink's reconciled assembly references are preserved. +/// +public class GenerateMissingTypeMapStubs : AndroidTask +{ + public override string TaskPrefix => "GMTS"; + + /// Directory holding every per-assembly typemap generated before trimming; defines the full referenced set. + [Required] + public string TypeMapDirectory { get; set; } = ""; + + /// Directory holding the surviving (post-trim) assemblies, e.g. the linked/ output. Stubs are written here. + [Required] + public string LinkedAssembliesDirectory { get; set; } = ""; + + /// The root typemap assembly name to skip, e.g. _Microsoft.Android.TypeMaps. + [Required] + public string RootTypeMapAssemblyName { get; set; } = ""; + + /// Used to derive the emitted assembly's System.Runtime reference version. + [Required] + public string TargetFrameworkVersion { get; set; } = ""; + + [Output] + public ITaskItem [] GeneratedStubs { get; set; } = []; + + public override bool RunTask () + { + var stubs = new List (); + if (!Directory.Exists (TypeMapDirectory) || !Directory.Exists (LinkedAssembliesDirectory)) { + Log.LogDebugMessage ($"TypeMap directory '{TypeMapDirectory}' or linked directory '{LinkedAssembliesDirectory}' not found; skipping stub generation."); + GeneratedStubs = stubs.ToArray (); + return true; + } + + var systemRuntimeVersion = ParseTargetFrameworkVersion (TargetFrameworkVersion); + var generator = new TypeMapAssemblyGenerator (systemRuntimeVersion); + + foreach (var file in Directory.EnumerateFiles (TypeMapDirectory, "_*.TypeMap.dll")) { + var name = Path.GetFileNameWithoutExtension (file); + if (string.IsNullOrEmpty (name) || string.Equals (name, RootTypeMapAssemblyName, StringComparison.Ordinal)) + continue; + var linkedPath = Path.Combine (LinkedAssembliesDirectory, name + ".dll"); + if (File.Exists (linkedPath)) + continue; // survived trimming; a real typemap already ships + + using var stream = new MemoryStream (); + generator.GenerateEmpty (stream, name); + Files.CopyIfBytesChanged (stream.ToArray (), linkedPath); + Log.LogDebugMessage ($"Generated empty typemap stub for trimmed assembly '{name}'."); + stubs.Add (new TaskItem (linkedPath)); + } + + if (stubs.Count > 0) + Log.LogDebugMessage ($"Generated {stubs.Count} empty typemap stub(s) for trimmed per-assembly typemaps."); + GeneratedStubs = stubs.ToArray (); + return !Log.HasLoggedErrors; + } + + static Version ParseTargetFrameworkVersion (string tfv) + { + if (tfv.Length > 0 && (tfv [0] == 'v' || tfv [0] == 'V')) { + tfv = tfv.Substring (1); + } + if (Version.TryParse (tfv, out var version)) { + return version; + } + throw new ArgumentException ($"Cannot parse TargetFrameworkVersion '{tfv}' as a Version."); + } +} From ab8b376e658dc695cdc2139f6714dd71c51e9d48 Mon Sep 17 00:00:00 2001 From: Simon Rozsival Date: Mon, 13 Jul 2026 00:01:40 +0200 Subject: [PATCH 2/2] [TrimmableTypeMap] Address review: stream write + unit tests - Write stubs with Files.CopyIfStreamChanged directly on the MemoryStream instead of CopyIfBytesChanged(stream.ToArray()), avoiding an extra byte[] allocation per stub. - Add GenerateMissingTypeMapStubsTests covering (1) a per-assembly typemap present pre-trim but absent from linked/ gets an empty, valid PE stub named after the trimmed assembly while survivors and the root are left untouched, and (2) nothing is emitted when no typemap was trimmed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fd0c2b70-3ad0-42db-940c-54ae6d5a920b --- .../Tasks/GenerateMissingTypeMapStubs.cs | 2 +- .../Tasks/GenerateMissingTypeMapStubsTests.cs | 87 +++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateMissingTypeMapStubs.cs b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateMissingTypeMapStubs.cs index d1b8b8da9f0..3dfbc4fb2c6 100644 --- a/src/Xamarin.Android.Build.Tasks/Tasks/GenerateMissingTypeMapStubs.cs +++ b/src/Xamarin.Android.Build.Tasks/Tasks/GenerateMissingTypeMapStubs.cs @@ -67,7 +67,7 @@ public override bool RunTask () using var stream = new MemoryStream (); generator.GenerateEmpty (stream, name); - Files.CopyIfBytesChanged (stream.ToArray (), linkedPath); + Files.CopyIfStreamChanged (stream, linkedPath); Log.LogDebugMessage ($"Generated empty typemap stub for trimmed assembly '{name}'."); stubs.Add (new TaskItem (linkedPath)); } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs new file mode 100644 index 00000000000..0033d14a502 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/GenerateMissingTypeMapStubsTests.cs @@ -0,0 +1,87 @@ +using System.IO; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.Build.Framework; +using NUnit.Framework; +using Xamarin.Android.Tasks; + +namespace Xamarin.Android.Build.Tests { + [TestFixture] + [Parallelizable (ParallelScope.Children)] + public class GenerateMissingTypeMapStubsTests : BaseTest { + + [Test] + public void Execute_TrimmedTypeMap_GeneratesLoadableStub () + { + var path = Path.Combine (Root, "temp", TestName); + var typeMapDir = Path.Combine (path, "typemap"); + var linkedDir = Path.Combine (path, "linked"); + Directory.CreateDirectory (typeMapDir); + Directory.CreateDirectory (linkedDir); + + // Pre-trim: two per-assembly typemaps plus the root were generated. + File.WriteAllText (Path.Combine (typeMapDir, "_Kept.TypeMap.dll"), "not-a-real-assembly"); + File.WriteAllText (Path.Combine (typeMapDir, "_Trimmed.TypeMap.dll"), "not-a-real-assembly"); + File.WriteAllText (Path.Combine (typeMapDir, "_Microsoft.Android.TypeMaps.dll"), "not-a-real-assembly"); + + // Post-trim (linked/): the root and one typemap survived; _Trimmed was trimmed away. + File.WriteAllText (Path.Combine (linkedDir, "_Kept.TypeMap.dll"), "survivor"); + File.WriteAllText (Path.Combine (linkedDir, "_Microsoft.Android.TypeMaps.dll"), "survivor-root"); + + var task = new GenerateMissingTypeMapStubs { + BuildEngine = new MockBuildEngine (TestContext.Out), + TypeMapDirectory = typeMapDir, + LinkedAssembliesDirectory = linkedDir, + RootTypeMapAssemblyName = "_Microsoft.Android.TypeMaps", + TargetFrameworkVersion = "v11.0", + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + + // Only the trimmed-away typemap should get a stub; the survivor and the root are left alone. + var stubs = task.GeneratedStubs.Select (i => i.ItemSpec).ToArray (); + Assert.AreEqual (1, stubs.Length, "Exactly one stub should be generated."); + var stubPath = Path.Combine (linkedDir, "_Trimmed.TypeMap.dll"); + CollectionAssert.Contains (stubs, stubPath); + FileAssert.Exists (stubPath); + + Assert.AreEqual ("survivor", File.ReadAllText (Path.Combine (linkedDir, "_Kept.TypeMap.dll")), + "A surviving typemap must not be overwritten."); + Assert.AreEqual ("survivor-root", File.ReadAllText (Path.Combine (linkedDir, "_Microsoft.Android.TypeMaps.dll")), + "The root typemap must not be overwritten."); + + // The stub must be a valid managed PE assembly named after the trimmed typemap. + using var stubStream = File.OpenRead (stubPath); + using var peReader = new PEReader (stubStream); + Assert.IsTrue (peReader.HasMetadata, "Stub should be a managed PE assembly."); + var reader = peReader.GetMetadataReader (); + var assemblyName = reader.GetString (reader.GetAssemblyDefinition ().Name); + Assert.AreEqual ("_Trimmed.TypeMap", assemblyName, "Stub assembly name should match the trimmed typemap."); + } + + [Test] + public void Execute_NothingTrimmed_GeneratesNoStubs () + { + var path = Path.Combine (Root, "temp", TestName); + var typeMapDir = Path.Combine (path, "typemap"); + var linkedDir = Path.Combine (path, "linked"); + Directory.CreateDirectory (typeMapDir); + Directory.CreateDirectory (linkedDir); + + File.WriteAllText (Path.Combine (typeMapDir, "_Kept.TypeMap.dll"), "not-a-real-assembly"); + File.WriteAllText (Path.Combine (linkedDir, "_Kept.TypeMap.dll"), "survivor"); + + var task = new GenerateMissingTypeMapStubs { + BuildEngine = new MockBuildEngine (TestContext.Out), + TypeMapDirectory = typeMapDir, + LinkedAssembliesDirectory = linkedDir, + RootTypeMapAssemblyName = "_Microsoft.Android.TypeMaps", + TargetFrameworkVersion = "v11.0", + }; + + Assert.IsTrue (task.Execute (), "Task should succeed."); + Assert.IsEmpty (task.GeneratedStubs, "No stubs should be generated when nothing was trimmed."); + } + } +}