Skip to content

UnsafeAccessorType assembly resolution bypasses extension points, breaking profiler-based and startup hook-based tooling #126933

@iskiselev

Description

@iskiselev

Problem

.NET does not provide an API to customize the Trusted Platform Assemblies (TPA) list at runtime. For most applications this is reasonable, but it becomes a critical gap for post-build extensions — tooling injected via the Profiler API or DOTNET_STARTUP_HOOKS. Such extensions may need to load a different (typically newer) version of an assembly than what the application was built against.

This has always been a challenge, and the ecosystem has developed workarounds (see Extended Background below). However, the introduction of UnsafeAccessorTypeAttribute in .NET 10 has made one key scenario impossible to work around from a startup hook, and significantly harder to handle from a profiler.

Concrete scenario: OpenTelemetry .NET Automatic Instrumentation

OpenTelemetry .NET Automatic Instrumentation is an extension that can be deployed via the Profiler API or via DOTNET_STARTUP_HOOKS — without modifying the application's build. It frequently needs a newer version of System.Diagnostics.DiagnosticSource than what the target application ships with (typically the latest version available at instrumentation compile time). In rare cases, Microsoft.Extensions.* assemblies also need upgrading.

Since .NET provides no API to add entries to or modify the TPA list, the instrumentation uses two strategies depending on deployment mode:

Profiler deployment

The native profiler rewrites AssemblyRef metadata tokens at module load time when the instrumentation ships a higher version. If the rewritten version cannot be satisfied by the TPA list, a managed assembly resolver (subscribed to AssemblyLoadContext.Default.Resolving) loads it — into the Default ALC if there is no conflict, or into a custom ALC if the TPA already has a lower version.

Startup hook deployment (no profiler)

Without a profiler there is no IL rewriting. Instead, the startup hook creates a custom AssemblyLoadContext and sets it as the contextual reflection context via AssemblyLoadContext.EnterContextualReflection() for the entire application lifetime. It then loads the application's entry assembly into the custom ALC (the runtime has already loaded it into the Default ALC, so two copies exist — but the Default ALC copy is never executed). The startup hook invokes the application's Main entry point via reflection in the custom ALC and, once it returns, calls Environment.Exit to prevent the runtime from re-executing the Default ALC copy. All assembly loads during application execution go through the custom ALC's Load() override, which picks the highest available version of each dependency. This keeps the instrumentation and the application using the same assembly instance while avoiding TPA conflicts.

What broke in .NET 10

.NET 10 introduced UnsafeAccessorTypeAttribute, a JIT-level reflection mechanism. Unlike managed reflection, it resolves types in the requesting assembly's ALC and ignores CurrentContextualReflectionContext.

The .NET runtime itself uses this in System.Private.CoreLib to access System.Diagnostics.DiagnosticSource types:

// System.Private.CoreLib — EventSourceInitHelper
// https://github.com/dotnet/runtime/blob/v10.0.0/src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/EventSource.cs#L3923-L3926

[UnsafeAccessor(UnsafeAccessorKind.StaticMethod, Name = "GetInstance")]
[return: UnsafeAccessorType("System.Diagnostics.Metrics.MetricsEventSource, System.Diagnostics.DiagnosticSource")]
static extern object GetInstance(
    [UnsafeAccessorType("System.Diagnostics.Metrics.MetricsEventSource, System.Diagnostics.DiagnosticSource")] object? _);

Because System.Private.CoreLib is in the Default ALC, the JIT resolves System.Diagnostics.DiagnosticSource directly from the TPA list into the Default ALC — without firing any Resolving event and without consulting CurrentContextualReflectionContext.

This is not limited to the single EventSourceInitHelper call site. The same problem applies to any Default ALC assembly that uses UnsafeAccessorType to reference types from System.Diagnostics.DiagnosticSource — or from assemblies that transitively depend on it. For example, System.Windows.Forms references System.Diagnostics.DiagnosticSource; if any Default ALC code uses UnsafeAccessorType to access types from System.Windows.Forms (e.g. ComNativeDescriptorProxy), the JIT will also pull DiagnosticSource into the Default ALC as a transitive dependency. As UnsafeAccessorType adoption grows across the .NET ecosystem, the set of code paths that can trigger uncontrolled loads into the Default ALC will only increase.

Impact on startup hook deployment

The custom ALC's Load() override is never invoked for this resolution. DiagnosticSource loads into the Default ALC from the TPA. When application code later requests DiagnosticSource through the custom ALC, a second instance loads there. Result: two copies of DiagnosticSource across ALCs, causing type drift and state drift (Activity.Current, meters, etc. become invisible across the boundary).

This is impossible to work around from managed code. The startup hook has no way to intercept UnsafeAccessorType resolution originating from the Default ALC.

Currently, the instrumentation works around this by allowing DiagnosticSource to leak to the Default ALC on .NET 10 (since the TPA version matches what the instrumentation needs). But once OpenTelemetry upgrades to DiagnosticSource v11, this workaround will stop working on .NET 10 — the TPA will provide v10 while the instrumentation requires v11. At that point, startup hook-only deployment will be broken with no fix available.

Impact on profiler deployment

The profiler can rewrite AssemblyRef tokens in module metadata, but UnsafeAccessorTypeAttribute values are not AssemblyRef tokens — they are string-encoded type references in custom attribute blobs. Rewriting these is possible (and we have a POC) but significantly more complex and fragile than standard AssemblyRef redirection.

Proposed solutions

We see several possible paths forward, listed from most targeted to most general:

1. Use normal reflection instead of UnsafeAccessorType for cross-assembly references in runtime libraries (targeted fix)

The specific EventSourceInitHelper code that loads DiagnosticSource from System.Private.CoreLib could use standard managed reflection (as it did before .NET 10). This would restore compatibility with ContextualReflection and the existing assembly resolution pipeline. The same change should be applied to all other runtime library call sites that use UnsafeAccessorType to reference types in System.Diagnostics.DiagnosticSource — or in assemblies that transitively depend on it (e.g. System.Windows.Forms). This is the narrowest fix but only addresses known call sites in runtime-shipped assemblies that are always loaded into the Default ALC (such as System.Private.CoreLib). Third-party code using UnsafeAccessorType is not a concern here — extensions control where third-party assemblies are loaded and can direct them into a custom ALC. The problem is specifically with assemblies that are inevitably in the Default ALC (CoreLib and its transitive references) where loading cannot be intercepted.

2. Provide an interception point for UnsafeAccessorType assembly resolution

Allow extensions to participate in assembly resolution triggered by UnsafeAccessorType. For example, an event or callback that fires when the JIT needs to resolve a type name from an UnsafeAccessorTypeAttribute, giving the extension a chance to supply the assembly. Alternatively, make UnsafeAccessorType resolution respect CurrentContextualReflectionContext.

3. Provide an API to modify the TPA list for profiler / startup hook scenarios

This was previously discussed in dotnet/runtime#10382 (now closed due to inactivity) as a profiler API (ICorProfilerInfo::AddAssemblyPath) to append to the TPA list, and in dotnet/runtime#66138 as a managed API for startup hook dependency resolving (still open, but received pushback due to concerns about silent dependency upgrades). We believe this should cover both profiler and startup hook scenarios.

A safe design could restrict modifications to:

  • Only assemblies not yet loaded
  • Only callable during early initialization (before Main)

This would be the most general and robust solution, enabling all post-build extension scenarios without fragile workarounds.

References


Extended Background: General Problem

Why post-build extensions need to control assembly versions

The general problem

.NET applications are deployed with a fixed set of dependencies. The TPA list — constructed by the host from *.deps.json and the shared framework — determines which assemblies are available to the Default ALC. Once the runtime starts, this list is immutable.

Post-build extensions (profiler-based or startup hook-based) are injected into applications after the build, without participation in NuGet dependency resolution. They ship their own dependencies, which may overlap with — and need to supersede — the application's dependencies. This is inherently risky, but the responsibility lies with the extension author and the customer who deploys it.

Why the TPA list matters

The TPA list acts as the assembly source for the Default ALC. When a profiler or startup hook needs a newer version of a shared assembly:

  • If the TPA version is sufficient: No conflict. The TPA version is used.
  • If the TPA version is older: The extension must either redirect the reference (profiler) or isolate loads (startup hook) to ensure the newer version is used. Both strategies are complex and fragile.

Existing workarounds and their limits

Approach Mechanism Limitation
Profiler IL rewriting Rewrite AssemblyRef tokens to point to higher version; resolve via Default.Resolving Can rewrite UnsafeAccessorType strings in attribute blobs and System.Private.CoreLib, but it is significantly more complex and risky than standard AssemblyRef redirection
Startup hook ALC isolation Load app into custom ALC; control all assembly loads via Load() override UnsafeAccessorType ignores ContextualReflection, loads into Default ALC directly from TPA
DOTNET_ADDITIONAL_DEPS + DOTNET_SHARED_STORE Add dependencies to the TPA list before runtime starts Only works for framework-dependent apps; does not support self-contained apps; requires additional configuration
Customer dependency alignment Ask customers to reference the extension's version Defeats the purpose of zero-code instrumentation

Impact of UnsafeAccessorType

Before .NET 10, both the profiler and startup hook strategies could ensure a single assembly instance of the correct version. UnsafeAccessorType introduced a new resolution path that:

  1. Ignores ContextualReflection — the JIT resolves in the requesting assembly's ALC, not the current contextual ALC
  2. Does not fire Resolving events if the assembly is found in the TPA
  3. Uses string-encoded type names in attribute blobs rather than AssemblyRef tokens, making profiler-based rewriting much harder

This means any code in the Default ALC (including System.Private.CoreLib) using UnsafeAccessorType can pull assemblies into the Default ALC in a way that no managed code can intercept or redirect. For extensions that rely on controlling assembly versions, this is a fundamental regression in extensibility.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions