[dotnet-linker] Relocate generic registrar trampolines into the companion assembly for Hot Reload - #26410
Conversation
…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
There was a problem hiding this comment.
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.InvokeGenericRegistrarTrampolineplus 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. |
This comment has been minimized.
This comment has been minimized.
…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
…eric-registrar-proxies-2
✅ API diff for current PR / commitNET (empty diffs)✅ API diff vs stableNET (empty diffs)ℹ️ Generator diffGenerator Diff: vsdrops (html) vsdrops (raw diff) gist (raw diff) - Please review changes) Pipeline on Agent |
🚀 [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 macOS tests✅ Tests on macOS Monterey (12): All 5 tests passed. Html Report (VSDrops) Download Linux Build VerificationPipeline on Agent |
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
UnmanagedCallersOnlycallback - which doesn't know the instance's generic arguments - boxes the native arguments into anobject []and calls the newRuntime.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, soMakeGenericMethodis safe; it's guarded withRuntime.IsNativeAOTso 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
ConcurrentDictionaryinstantiation is trimmed away together with the trampoline in every other configuration.Notable details
out/refparametersobject [], so they're represented as a plainIntPtrin the helper (same size, same native ABI).exception_gchandleIntPtr*, so the helper takes anout IntPtrand the callback writes it back through the native pointer.nullon failure, so the callback returnsdefault (T)instead of unboxingnull(which would throw across theUnmanagedCallersOnlyboundary).[DynamicDependency]The
[DynamicDependency]change works around dotnet/runtime#131892.Tests
Debug (trimmable static registrar, all optimizations)test variation. This also fixes the existingRelease (trimmable static registrar, all optimizations)variation, which was actually running a Debug build.🤖 Pull request created by Copilot