Description
In a trimmed (non-AOT) CoreCLR app, an assembly that is referenced only by an [assembly: TypeMapAssemblyTarget<TGroup>("SomeAssembly")] attribute can be trimmed away entirely, but the TypeMapAssemblyTarget attribute itself is kept. At startup, TypeMapping.GetOrCreateExternalTypeMapping<TGroup>() walks the surviving attribute and calls Assembly.Load("SomeAssembly"), which throws FileNotFoundException and crashes the app.
The trimmer keeps the pointer (the attribute) but drops the target (the assembly it names), leaving dangling metadata that the runtime's lazy type-map path faithfully — and fatally — follows.
TypeMapAssemblyTargetAttribute references its target by an opaque assembly-name string, not a metadata reference, so ILLink neither roots the target assembly nor prunes the now-dangling attribute. This is specific to the trimmed CoreCLR path; Native AOT is unaffected because the type map is precomputed at build time (no runtime Assembly.Load).
Reproduction Steps
Three projects: a shared library defining the type-map group, an OtherAssembly contributing a single conditional map entry, and an app that is the entry (root) type-map assembly.
The conditional (3-arg) TypeMap entry uses its own target type (Bar) as the trim target, which nothing marks, so ILLink drops the entry → drops Bar → OtherAssembly becomes empty → the whole assembly is trimmed. The app's TypeMapAssemblyTarget("OtherAssembly") attribute survives.
TypeMapShared/MyTypeMapGroup.cs
namespace TypeMapShared;
public sealed class MyTypeMapGroup; // type-map group marker
MyTypeMapGroup lives in its own assembly on purpose: if it were defined in OtherAssembly, the app's TypeMapAssemblyTarget<MyTypeMapGroup> generic argument would be a compile-time metadata reference that keeps OtherAssembly alive and masks the bug. The only link from the app to OtherAssembly must be the attribute's name string.
OtherAssembly/Bar.cs (<AssemblyName>OtherAssembly</AssemblyName>, IsTrimmable=true)
using System.Runtime.InteropServices;
using TypeMapShared;
// 3-arg (conditional) entry: kept only if its trim target survives trimming.
// Here the trim target is Bar itself, and the only thing that would mark Bar
// is this entry -> a cycle nothing breaks. So ILLink drops the entry, then
// Bar, leaving OtherAssembly empty -> the assembly is trimmed away.
[assembly: TypeMap<MyTypeMapGroup>("Bar", typeof(OtherAssembly.Bar), typeof(OtherAssembly.Bar))]
namespace OtherAssembly;
public sealed class Bar;
App/Program.cs (PublishTrimmed=true, TrimMode=full, references both projects)
using System.Runtime.InteropServices;
using TypeMapShared;
// The app is the entry/root type-map assembly. This attribute references
// "OtherAssembly" only by string; there is no compile-time dependency on it.
[assembly: TypeMapAssemblyTarget<MyTypeMapGroup>("OtherAssembly")]
var map = TypeMapping.GetOrCreateExternalTypeMapping<MyTypeMapGroup>();
Console.WriteLine(map.TryGetValue("Bar", out var t) ? $"Bar -> {t}" : "Bar absent");
# Untrimmed: works, prints "Bar -> OtherAssembly.Bar".
dotnet run -c Release --project App
# Trimmed publish: OtherAssembly.dll is trimmed out, app crashes at startup.
dotnet publish -c Release -r <RID> --project App
./App/bin/Release/net11.0/<RID>/publish/App
A complete zipped repro can be attached on request.
Expected behavior
A trimmed app initializes its type map without crashing.
Proposed fixes (in order of preference)
(a) — preferred. Have ILLink strip the [assembly: TypeMapAssemblyTarget<T>("...")] attribute when its target assembly is trimmed away (i.e. the target has no surviving type-map entries). This keeps the output minimal and leaves no dangling metadata for the runtime to follow.
(b). Have ILLink keep the target assembly (even if it ends up empty) whenever it keeps a TypeMapAssemblyTarget attribute that names it, so Assembly.Load succeeds and simply contributes no mappings. This is essentially what the dotnet/android workaround does by emitting empty stub assemblies (dotnet/android#12045), at the cost of extra assemblies in the output.
(c) — defense in depth. Make the runtime tolerant: when TypeMapLazyDictionary / AssemblyTargetProcessor processes a TypeMapAssemblyTarget, catch and ignore FileNotFoundException (treat a non-loadable target as contributing no entries) instead of aborting. Valuable as a safety net even if (a) or (b) is implemented.
Actual behavior
The app aborts at startup (SIGABRT, exit code 134):
Unhandled exception. System.IO.FileNotFoundException: Could not load file or assembly 'OtherAssembly, Culture=neutral, PublicKeyToken=null'. The system cannot find the file specified.
at System.Runtime.InteropServices.TypeMapLazyDictionary.ProcessAttributes(QCallAssembly assembly, QCallTypeHandle groupType, ...)
at System.Runtime.InteropServices.TypeMapLazyDictionary.CreateMaps(RuntimeType groupType, ...)
at System.Runtime.InteropServices.TypeMapLazyDictionary.CreateExternalTypeMap(RuntimeType groupType)
at System.Runtime.InteropServices.TypeMapping.GetOrCreateExternalTypeMapping[TTypeMapGroup]()
at Program.<Main>$(String[] args)
Root cause
In src/tools/illink/src/linker/Linker/TypeMapHandler.cs:
TypeMapResolver.Resolve walks from the entry assembly and, for each TypeMapAssemblyTargetAttribute, resolves the named assembly via context.TryResolve(...) only to discover its TypeMap attributes — this does not root the assembly in the output.
- A 3-arg
TypeMap entry is recorded against its trimTarget type and is marked only if that type is marked (AddExternalTypeMapEntry → _unmarkedExternalTypeMapEntries[trimTarget]).
- When the group is used,
AddAssemblyTarget / ProcessExternalTypeMapGroupSeen mark the TypeMapAssemblyTarget attribute. The only thing that keeps the target assembly alive is MarkTypeMapAttribute calling _markStep.MarkAssembly(entry.Origin, ...) — and that runs only when one of the assembly's TypeMap entries is marked.
- If every entry in the target assembly is gated out by trimming,
MarkAssembly is never called for it → the assembly is swept, while the TypeMapAssemblyTarget attribute remains.
At runtime the app uses the lazy path (TypeMapLazyDictionary, annotated [RequiresUnreferencedCode("Lazy TypeMap isn't supported for Trimmer scenarios")]), whose native AssemblyTargetProcessor calls LoadAssembly for every surviving TypeMapAssemblyTarget — unconditionally, with no tolerance for a missing assembly.
This is the inverse of #120394 / #120477: that fix ensured the attribute is kept when the group is marked; it does not root the target assembly when all of that assembly's entries are conditionally trimmed. Its own summary notes it only keeps assemblies "when a TypeMap attribute is marked" — which never happens here.
Real-world impact and workaround
This reproduces in real .NET MAUI apps on Android using the CoreCLR trimmable type-map. Six unused Java bindings (GoogleGson, Jsr305Binding, Xamarin.AndroidX.Print, Xamarin.JavaX.Inject, Xamarin.JSpecify, Xamarin.Kotlin.StdLib) were trimmed together with their per-assembly _X.TypeMap assemblies, but the dangling TypeMapAssemblyTarget attributes on the root _Microsoft.Android.TypeMaps assembly remained, crashing the app on launch with the same stack.
Workaround in dotnet/android (emits empty stub assemblies for trimmed-away per-assembly typemaps so Assembly.Load succeeds and contributes no mappings): dotnet/android#12045
Related
Configuration
- .NET SDK 11.0.100-preview.5.26302.115 (also reproduces on .NET 10, where the type-map API GA'd)
- Trimmed CoreCLR:
PublishTrimmed=true, TrimMode=full; not Native AOT (AOT does not repro)
- macOS arm64 (
osx-arm64); not platform-specific
- Regression: no — the feature is new in .NET 10
/cc @AaronRobinsonMSFT @jkoritzinsky @jtschuster
Description
In a trimmed (non-AOT) CoreCLR app, an assembly that is referenced only by an
[assembly: TypeMapAssemblyTarget<TGroup>("SomeAssembly")]attribute can be trimmed away entirely, but theTypeMapAssemblyTargetattribute itself is kept. At startup,TypeMapping.GetOrCreateExternalTypeMapping<TGroup>()walks the surviving attribute and callsAssembly.Load("SomeAssembly"), which throwsFileNotFoundExceptionand crashes the app.The trimmer keeps the pointer (the attribute) but drops the target (the assembly it names), leaving dangling metadata that the runtime's lazy type-map path faithfully — and fatally — follows.
TypeMapAssemblyTargetAttributereferences its target by an opaque assembly-name string, not a metadata reference, so ILLink neither roots the target assembly nor prunes the now-dangling attribute. This is specific to the trimmed CoreCLR path; Native AOT is unaffected because the type map is precomputed at build time (no runtimeAssembly.Load).Reproduction Steps
Three projects: a shared library defining the type-map group, an
OtherAssemblycontributing a single conditional map entry, and an app that is the entry (root) type-map assembly.The conditional (3-arg)
TypeMapentry uses its own target type (Bar) as the trim target, which nothing marks, so ILLink drops the entry → dropsBar→OtherAssemblybecomes empty → the whole assembly is trimmed. The app'sTypeMapAssemblyTarget("OtherAssembly")attribute survives.TypeMapShared/MyTypeMapGroup.cs
OtherAssembly/Bar.cs (
<AssemblyName>OtherAssembly</AssemblyName>,IsTrimmable=true)App/Program.cs (
PublishTrimmed=true,TrimMode=full, references both projects)A complete zipped repro can be attached on request.
Expected behavior
A trimmed app initializes its type map without crashing.
Proposed fixes (in order of preference)
(a) — preferred. Have ILLink strip the
[assembly: TypeMapAssemblyTarget<T>("...")]attribute when its target assembly is trimmed away (i.e. the target has no surviving type-map entries). This keeps the output minimal and leaves no dangling metadata for the runtime to follow.(b). Have ILLink keep the target assembly (even if it ends up empty) whenever it keeps a
TypeMapAssemblyTargetattribute that names it, soAssembly.Loadsucceeds and simply contributes no mappings. This is essentially what the dotnet/android workaround does by emitting empty stub assemblies (dotnet/android#12045), at the cost of extra assemblies in the output.(c) — defense in depth. Make the runtime tolerant: when
TypeMapLazyDictionary/AssemblyTargetProcessorprocesses aTypeMapAssemblyTarget, catch and ignoreFileNotFoundException(treat a non-loadable target as contributing no entries) instead of aborting. Valuable as a safety net even if (a) or (b) is implemented.Actual behavior
The app aborts at startup (
SIGABRT, exit code 134):Root cause
In
src/tools/illink/src/linker/Linker/TypeMapHandler.cs:TypeMapResolver.Resolvewalks from the entry assembly and, for eachTypeMapAssemblyTargetAttribute, resolves the named assembly viacontext.TryResolve(...)only to discover itsTypeMapattributes — this does not root the assembly in the output.TypeMapentry is recorded against itstrimTargettype and is marked only if that type is marked (AddExternalTypeMapEntry→_unmarkedExternalTypeMapEntries[trimTarget]).AddAssemblyTarget/ProcessExternalTypeMapGroupSeenmark theTypeMapAssemblyTargetattribute. The only thing that keeps the target assembly alive isMarkTypeMapAttributecalling_markStep.MarkAssembly(entry.Origin, ...)— and that runs only when one of the assembly'sTypeMapentries is marked.MarkAssemblyis never called for it → the assembly is swept, while theTypeMapAssemblyTargetattribute remains.At runtime the app uses the lazy path (
TypeMapLazyDictionary, annotated[RequiresUnreferencedCode("Lazy TypeMap isn't supported for Trimmer scenarios")]), whose nativeAssemblyTargetProcessorcallsLoadAssemblyfor every survivingTypeMapAssemblyTarget— unconditionally, with no tolerance for a missing assembly.This is the inverse of #120394 / #120477: that fix ensured the attribute is kept when the group is marked; it does not root the target assembly when all of that assembly's entries are conditionally trimmed. Its own summary notes it only keeps assemblies "when a TypeMap attribute is marked" — which never happens here.
Real-world impact and workaround
This reproduces in real .NET MAUI apps on Android using the CoreCLR trimmable type-map. Six unused Java bindings (
GoogleGson,Jsr305Binding,Xamarin.AndroidX.Print,Xamarin.JavaX.Inject,Xamarin.JSpecify,Xamarin.Kotlin.StdLib) were trimmed together with their per-assembly_X.TypeMapassemblies, but the danglingTypeMapAssemblyTargetattributes on the root_Microsoft.Android.TypeMapsassembly remained, crashing the app on launch with the same stack.Workaround in dotnet/android (emits empty stub assemblies for trimmed-away per-assembly typemaps so
Assembly.Loadsucceeds and contributes no mappings): dotnet/android#12045Related
TypeMapHandler)TypeMapAssemblyTargetAttributes #120394 / Update TypeMap attribute handling #120477 — Trimmer doesn't keepTypeMapAssemblyTargetAttributes (keeps the attribute; does not root an all-conditional-entries target assembly — this issue)Configuration
PublishTrimmed=true,TrimMode=full; not Native AOT (AOT does not repro)osx-arm64); not platform-specific/cc @AaronRobinsonMSFT @jkoritzinsky @jtschuster