Skip to content

[Trimming] Startup crash when TypeMapAssemblyTarget attribute kept but target assembly trimmed #130587

Description

@simonrozsival

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 BarOtherAssembly 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

Metadata

Metadata

Labels

area-Tools-ILLink.NET linker development as well as trimming analyzers

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions