Skip to content

[dotnet-linker] Relocate generic registrar trampolines into the companion assembly for Hot Reload - #26410

Merged
rolfbjarne merged 17 commits into
mainfrom
dev/rolf/relocate-generic-registrar-proxies-2
Aug 6, 2026
Merged

[dotnet-linker] Relocate generic registrar trampolines into the companion assembly for Hot Reload#26410
rolfbjarne merged 17 commits into
mainfrom
dev/rolf/relocate-generic-registrar-proxies-2

Conversation

@rolfbjarne

Copy link
Copy Markdown
Member

The trimmable static registrar can relocate its registrar trampolines into a per-assembly companion assembly (_<Asm>.TypeMap.dll) so that user assemblies stay unmodified, which is a requirement for Hot Reload. Until now generic declaring types were excluded, because the existing implementation adds a proxy interface implementation to the user type - and that modifies the user assembly.

This PR lifts that restriction.

How it works

For a method declared in a generic type we now emit a generic helper method into the companion assembly, mirroring the user type's generic parameters (including their constraints). Its body performs the same type-parameter-aware conversions as the regular trampoline, with the user type's generic parameters remapped to the helper method's own generic parameters.

The static UnmanagedCallersOnly callback - which doesn't know the instance's generic arguments - boxes the native arguments into an object [] and calls the new Runtime.InvokeGenericRegistrarTrampoline, which closes the helper over the instance's actual generic arguments (MakeGenericMethod) and invokes it. This path is only ever reachable in a Hot-Reload-compatible build, which always runs under the JIT, so MakeGenericMethod is safe; it's guarded with Runtime.IsNativeAOT so the linker folds it away (and elides the IL2060/IL3050 warnings) everywhere else.

The closed helper is cached per (open helper method, closed instance type) in a nested type, so the reflection cost is paid once per instantiation, and so the ConcurrentDictionary instantiation is trimmed away together with the trampoline in every other configuration.

Notable details

Detail Why
out/ref parameters Pointer-typed native parameters can't be boxed into an object [], so they're represented as a plain IntPtr in the helper (same size, same native ABI).
exception_gchandle Reflection can't marshal an IntPtr*, so the helper takes an out IntPtr and the callback writes it back through the native pointer.
Value-type returns The helper returns null on failure, so the callback returns default (T) instead of unboxing null (which would throw across the UnmanagedCallersOnly boundary).
[DynamicDependency] The trimmer crashes when comparing a signature's parameter list against a method with a nested type parameter, so signatures now omit the parameter list when unambiguous.

The [DynamicDependency] change works around dotnet/runtime#131892.

Tests

  • New assembly-preparer tests covering the relocated generic trampolines.
  • A new Debug (trimmable static registrar, all optimizations) test variation. This also fixes the existing Release (trimmable static registrar, all optimizations) variation, which was actually running a Debug build.

🤖 Pull request created by Copilot

rolfbjarne and others added 14 commits August 5, 2026 20:15
…nion assembly for Hot Reload

Follow-up to PR #26139 (Hot Reload epic #26069). PR #26139 relocated the
non-generic registrar UnmanagedCallersOnly trampolines (and constructor helpers)
into the per-assembly companion assembly (_<Asm>.TypeMap.dll) so user assemblies
stay byte-for-byte unmodified under $(HotReloadCompatibleBuild) + TrimmableStatic.
This handles the deferred generic-type part.

Previously, generic exported NSObject/INativeObject subclasses modified the user
assembly (a proxy interface, its implementation method, an interface
implementation on the user type, and a static-ctor [DynamicDependency]) because
the static callback doesn't know the generic parameters and reached a T-aware
conversion through the user type's vtable via virtual dispatch.

When relocating (Hot Reload only), the trampoline for a method declared in a
generic type is now dispatched via reflection from the companion instead:

* A generic helper method Impl<T..> is emitted into the companion
  __Registrar_Callbacks__ type, mirroring the user type's generic parameters and
  constraints, with a leading 'self' parameter typed as the user type closed over
  the helper's own generic parameters. Its body is the same proven instance-impl
  IL (reused via EmitCallToExportedMethod, which now remaps the user type's
  generic parameters to the helper's). The exception GCHandle is an 'out IntPtr'
  so reflection can marshal it.
* The UnmanagedCallersOnly callback resolves the instance, boxes the native
  arguments into an object[], and calls Runtime.InvokeGenericRegistrarTrampoline,
  which closes the helper over the instance's actual generic arguments
  (MakeGenericMethod) and invokes it, reporting any exception through the GCHandle
  so nothing escapes the native boundary. The callback writes the returned GCHandle
  into the native IntPtr* exception_gchandle.

The reflection is confined to the generic-type Hot Reload path; the non-generic
managed-static path is unchanged. Trimming keep-alive is emitted on the companion
side only (a [DynamicDependency] plus the ldtoken reference); the user assembly is
never modified. Generic constructors keep throwing MT4133. No build warning is
emitted. Extended RelocateRegistrarTrampolinesTests with a generic exported
NSObject subclass.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…tion

InvokeGenericRegistrarTrampoline resolved the closed generic helper method
(FindClosedTypeInHierarchy + MakeGenericMethod) on every call. Cache the result
in a ConcurrentDictionary keyed on (open helper method handle, closed instance
type) so the reflection cost is only paid once per instantiation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…trar trampolines

The generic registrar trampoline relocation dispatches the companion helper via
reflection, which boxes each native argument into an object[] and calls
MethodInfo.Invoke. A pointer-typed native parameter - which is what out/ref (and
pointer) parameters produce - can't survive that round-trip: a pointer isn't
boxable and Invoke can't marshal it.

Represent such parameters as a plain IntPtr in the generic companion helper (and
its UnmanagedCallersOnly callback) instead. IntPtr is a boxable, pointer-sized
value type, and loading it with 'ldarg' yields a native int that the already
emitted body uses directly as the pointer, for both reads and writes (verified
with a Reflection.Emit experiment). The native ABI is unchanged since a pointer
and an IntPtr are both pointer-sized, and the out/ref write-back still happens
through that same pointer value inside the helper body. This is confined to the
generic Hot-Reload path; the non-generic path is untouched.

Extend RelocateRegistrarTrampolinesTests with a generic exported NSObject
subclass method that has both an 'out T' parameter and a normal by-value
parameter, asserting the user assembly stays unmodified, the companion holds the
trampoline + helper, the helper's out parameter is typed IntPtr, and the
by-value parameter keeps its native value type.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…Trampoline

The repo bans the postfix null-forgiving operator. Replace the four uses in
InvokeGenericRegistrarTrampoline with explicit null checks that throw a
descriptive InvalidOperationException (still caught and marshaled through
exception_gchandle, so nothing escapes the UnmanagedCallersOnly boundary), and
resolve the cached-method nullability without '!'.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…alue-type returns

Runtime.InvokeGenericRegistrarTrampoline returns null on failure (the
exception is reported through exception_gchandle). The generic reflection
dispatch callback previously emitted an unconditional unbox.any on the
result, which for a value-type native return throws a NullReferenceException
when the result is null - and that exception would escape the
UnmanagedCallersOnly boundary.

Emit a null check for value-type returns and return default(<nativeReturnType>)
in that case, while still reporting the exception via exception_gchandle.
Reference-type returns are unaffected (unbox.any acts as castclass, and
castclass of null is null).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…s static constructor

The relocated-generic-trampoline cache was a static field with a field
initializer:

    static readonly ConcurrentDictionary<...> closedGenericRegistrarTrampolines = new ();

A field initializer runs in the declaring type's static constructor, and
Runtime's static constructor is reachable in every build configuration. That
rooted the ConcurrentDictionary<(RuntimeMethodHandle, RuntimeTypeHandle),
MethodInfo> generic instantiation (plus its tuple comparer and reflection
infrastructure) unconditionally - even in Release/NativeAOT builds that never
relocate generic trampolines and where InvokeGenericRegistrarTrampoline itself
is trimmed. This regressed app size for the NativeAOT and
NativeAOT_TrimmableStatic configurations.

Move the cache into a nested holder type so its ConcurrentDictionary is
constructed by the nested type's static constructor, which only runs the first
time InvokeGenericRegistrarTrampoline accesses it. That method is only
reachable under a Hot-Reload-compatible build; in every other configuration it
(and now the nested holder + ConcurrentDictionary instantiation) is trimmed,
restoring the previous app size. This mirrors the existing caches in Runtime,
which are all assigned inside Initialize() rather than via field initializers.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…ar trampolines

When converting a native array parameter to a managed array in a relocated
generic trampoline, the generic argument for `NSArray.ArrayFromHandle<T>` was
looked up on the declaring type instead of using the already-remapped generic
parameter of the companion helper method. That produced invalid IL (a type
generic parameter referenced from a static method), which made the runtime
throw a `BadImageFormatException` when invoking the helper.

Fixes these monotouch-test failures for macOS with the
`trimmable-static-registrar` variation:

* `RegistrarTest.TestConstrainedGenericType`
* `RegistrarTest.TestInstanceMethodOnOpenGenericType`

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tead of suppressing warnings

Replace the [UnconditionalSuppressMessage] IL2060/IL3050 attributes on
InvokeGenericRegistrarTrampoline with an explicit

    if (IsNativeAOT)
        throw CreateNativeAOTNotSupportedException ();

guard at the top of the method. IsNativeAOT is optimizable, so the linker
folds it to a constant and removes the reflection code (MakeGenericMethod +
MethodInfo.Invoke) as unreachable in a NativeAOT build, which elides the
IL2060/IL3050 trimmer/AOT warnings that would otherwise require suppression.
This mirrors the existing NativeAOT guards throughout Runtime.CoreCLR.cs and
is clearer about why the reflection is safe here (the trampoline is only ever
reached under a Hot-Reload-compatible build, which always runs under the JIT).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 26925abb-c851-4e46-b70e-f04ee1589819
…es when possible

The trimmer crashes with a NullReferenceException (surfaced as
'error IL1012: IL Trimmer has encountered an unexpected error') whenever it has
to compare the parameter list of a [DynamicDependency] signature with a method
whose parameters are nested type references:
Mono.Linker.DocumentationSignatureGenerator only handles nested type
*definitions*, and returns null for nested type *references*, which it then
dereferences.

This happens when building monotouch-test for macOS with the trimmable static
registrar + all optimizations in a Hot-Reload-compatible (Debug) build: the
relocated generic registrar trampolines have a 'self' parameter typed as the
(possibly nested) user generic type, and the [DynamicDependency] attribute
keeping the trampoline alive has a parameter list.

The trimmer matches a [DynamicDependency] signature without a parameter list by
name alone, so emit the method name only when it's unambiguous (i.e. when no
other method in the same type has the same name), which is the case for the
generated trampolines. Do the same for [Preserve (AllMembers = true)], where
every overload is preserved anyway.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The generated proxy/trampoline methods no longer have parameter names (see
93f9d03 '[dotnet-linker] Don't emit parameter names for generated proxy
methods.'), so look up the parameters by position instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… all optimizations.

Add a "Debug (trimmable static registrar, all optimizations)" test variation
for iOS, tvOS, Mac Catalyst and macOS, and make the existing "Release"
variation actually use the release configuration (the 'release' component was
missing from its test variation string).

Verified locally by running:

    make run-tests TEST_VARIATION=trimmable-static-registrar-all-optimizations-linkall

in tests/monotouch-test/dotnet/macOS: 3581 tests run, 0 failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…orkaround works around.

Ref: dotnet/runtime#131892

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 853aef08-971d-4361-a962-cef9c1e660d9
The doc comment for InvokeGenericRegistrarTrampoline ended up above the
ClosedGenericRegistrarTrampolines cache type instead of above the method itself.

Also drop the redundant 'cached_impl is not null' check: the cache only ever
stores non-null MethodInfo values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 853aef08-971d-4361-a962-cef9c1e660d9
Copilot AI review requested due to automatic review settings August 5, 2026 19:24
@rolfbjarne
rolfbjarne requested a review from dalexsoto as a code owner August 5, 2026 19:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the trimmable static registrar’s “relocate trampolines into companion assembly” feature to support methods declared on generic NSObject subclasses, enabling Hot-Reload-compatible builds without modifying user assemblies.

Changes:

  • Relocate generic-type trampolines by emitting a generic helper method into the companion assembly and dispatching to it via reflection at runtime.
  • Add Runtime.InvokeGenericRegistrarTrampoline plus caching to close and invoke the helper method based on the instance’s actual generic arguments.
  • Adjust [DynamicDependency] signature generation to optionally omit parameter lists (workaround for a trimmer crash) and add/extend tests & CI variations accordingly.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tools/dotnet-linker/Steps/ManagedRegistrarStep.cs Emits relocated generic trampolines via a companion generic helper + reflection dispatch, with signature/ABI adjustments for ref/out and exception handling.
tools/dotnet-linker/DocumentionComments.cs Adds GetNameSignature helper to build method signatures without parameter lists.
tools/dotnet-linker/AppBundleRewriter.cs Introduces dynamic-dependency signature selection logic and adds a runtime method reference for InvokeGenericRegistrarTrampoline.
tests/xharness/Jenkins/TestVariationsFactory.cs Adds a Debug “trimmable static registrar, all optimizations” variation and fixes the Release variation to actually run Release.
tests/assembly-preparer/RelocateRegistrarTrampolinesTests.cs Adds regression coverage ensuring generic trampolines are relocated without user-assembly modifications.
tests/assembly-preparer/PreserveSmartEnumConversionsTest.cs Updates expectations for DynamicDependency signatures that now omit parameter lists when unambiguous.
tests/assembly-preparer/PreserveBlockCodeHandlerTests.cs Updates expectations for DynamicDependency signatures that now omit parameter lists when unambiguous.
src/ObjCRuntime/Runtime.cs Adds InvokeGenericRegistrarTrampoline and a cache for closed helper methods for relocated generic trampolines.

Comment thread src/ObjCRuntime/Runtime.cs Outdated
Comment thread tools/dotnet-linker/AppBundleRewriter.cs
@vs-mobiletools-engineering-service2

This comment has been minimized.

rolfbjarne and others added 3 commits August 5, 2026 22:17
…overloads.

The trimmer matches a [DynamicDependency] signature without a parameter list on
both the method name and the generic arity, so 'Foo ()' and 'Foo<T> ()' don't
collide and neither of them needs a parameter list.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 853aef08-971d-4361-a962-cef9c1e660d9
…eric trampoline call.

Allocate the argument array with the trailing 'out IntPtr exception_gchandle' slot
in the generated trampoline, so that InvokeGenericRegistrarTrampoline can pass it
straight to MethodInfo.Invoke instead of allocating and copying a larger array on
every call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 853aef08-971d-4361-a962-cef9c1e660d9
@vs-mobiletools-engineering-service2

Copy link
Copy Markdown
Collaborator

✅ API diff for current PR / commit

NET (empty diffs)

✅ API diff vs stable

NET (empty diffs)

ℹ️ Generator diff

Generator Diff: vsdrops (html) vsdrops (raw diff) gist (raw diff) - Please review changes)

Pipeline on Agent
Hash: b81e3acc55f0565800b28f0939f4e75c8cbf7f3e [PR build]

@vs-mobiletools-engineering-service2

Copy link
Copy Markdown
Collaborator

🚀 [CI Build #b81e3ac] Test results 🚀

Test results

✅ All tests passed on VSTS: test results.

🎉 All 205 tests passed 🎉

Tests counts

✅ assembly-processing: All 1 tests passed. Html Report (VSDrops) Download
✅ cecil: All 1 tests passed. Html Report (VSDrops) Download
✅ dotnettests (iOS): All 1 tests passed. Html Report (VSDrops) Download
✅ dotnettests (MacCatalyst): All 1 tests passed. Html Report (VSDrops) Download
✅ dotnettests (macOS): All 1 tests passed. Html Report (VSDrops) Download
✅ dotnettests (Multiple platforms): All 1 tests passed. Html Report (VSDrops) Download
✅ dotnettests (tvOS): All 1 tests passed. Html Report (VSDrops) Download
✅ framework: All 2 tests passed. Html Report (VSDrops) Download
✅ fsharp: All 4 tests passed. Html Report (VSDrops) Download
✅ generator: All 5 tests passed. Html Report (VSDrops) Download
✅ interdependent-binding-projects: All 4 tests passed. Html Report (VSDrops) Download
✅ introspection: All 4 tests passed. Html Report (VSDrops) Download
✅ linker (iOS): All 15 tests passed. Html Report (VSDrops) Download
✅ linker (MacCatalyst): All 15 tests passed. Html Report (VSDrops) Download
✅ linker (macOS): All 21 tests passed. Html Report (VSDrops) Download
✅ linker (tvOS): All 15 tests passed. Html Report (VSDrops) Download
✅ monotouch (iOS): All 19 tests passed. Html Report (VSDrops) Download
✅ monotouch (MacCatalyst): All 18 tests passed. Html Report (VSDrops) Download
✅ monotouch (macOS): All 21 tests passed. Html Report (VSDrops) Download
✅ monotouch (tvOS): All 19 tests passed. Html Report (VSDrops) Download
✅ msbuild: All 2 tests passed. Html Report (VSDrops) Download
✅ sharpie: All 1 tests passed. Html Report (VSDrops) Download
✅ windows: All 3 tests passed. Html Report (VSDrops) Download
✅ xcframework: All 4 tests passed. Html Report (VSDrops) Download
✅ xtro: All 1 tests passed. Html Report (VSDrops) Download

macOS tests

✅ Tests on macOS Monterey (12): All 5 tests passed. Html Report (VSDrops) Download
✅ Tests on macOS Ventura (13): All 5 tests passed. Html Report (VSDrops) Download
✅ Tests on macOS Sonoma (14): All 5 tests passed. Html Report (VSDrops) Download
✅ Tests on macOS Sequoia (15): All 5 tests passed. Html Report (VSDrops) Download
✅ Tests on macOS Tahoe (26): All 5 tests passed. Html Report (VSDrops) Download

Linux Build Verification

Linux build succeeded

Pipeline on Agent
Hash: b81e3acc55f0565800b28f0939f4e75c8cbf7f3e [PR build]

@rolfbjarne rolfbjarne added the ready-to-review This PR is ready to review/merge. label Aug 6, 2026
@rolfbjarne
rolfbjarne merged commit 0d1ec8c into main Aug 6, 2026
56 checks passed
@rolfbjarne
rolfbjarne deleted the dev/rolf/relocate-generic-registrar-proxies-2 branch August 6, 2026 13:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

copilot ready-to-review This PR is ready to review/merge.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants