[Hot Reload] Relocate generic registrar trampolines into the companion assembly - #26368
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
There was a problem hiding this comment.
Pull request overview
Relocates generic-type managed registrar trampolines into the per-assembly companion (_<Asm>.TypeMap.dll) for Hot Reload–compatible builds, avoiding any byte-level modifications to user assemblies while still supporting generic exported NSObject/INativeObject subclasses (including out/ref/pointer parameter scenarios) via a reflection-based dispatch helper.
Changes:
- Remove the relocation exclusion for generic declaring types and emit a generic companion helper (
callback__impl<T...>) for exported methods on generic types. - Add a runtime helper (
Runtime.InvokeGenericRegistrarTrampoline) to close and invoke the helper via cachedMakeGenericMethod+MethodInfo.Invoke, reporting failures throughexception_gchandle. - Extend assembly-preparer tests to assert generic trampolines/proxies are fully relocated and that the user assembly remains unmodified.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tools/dotnet-linker/Steps/ManagedRegistrarStep.cs | Emits generic companion helper methods and reflection-dispatch UnmanagedCallersOnly callbacks for relocated generic trampolines. |
| tools/dotnet-linker/AppBundleRewriter.cs | Adds a method reference for the new runtime helper so generated IL can call it. |
| src/ObjCRuntime/Runtime.cs | Implements InvokeGenericRegistrarTrampoline + caching for closing/invoking relocated generic helpers under Hot Reload. |
| tests/assembly-preparer/RelocateRegistrarTrampolinesTests.cs | Adds structural assertions for generic trampoline relocation and “user assembly not modified” guarantees. |
…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
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
✅ 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 |
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
🔥 [CI Build #4b35a8a] Test results 🔥Test results❌ Tests failed on VSTS: test results 1 tests crashed, 8 tests failed, 190 tests passed. Failures❌ dotnettests tests (iOS)1 tests failed, 0 tests passed.Failed tests
Html Report (VSDrops) Download ❌ dotnettests tests (MacCatalyst)1 tests failed, 0 tests passed.Failed tests
Html Report (VSDrops) Download ❌ dotnettests tests (macOS)1 tests failed, 0 tests passed.Failed tests
Html Report (VSDrops) Download ❌ dotnettests tests (tvOS)1 tests failed, 0 tests passed.Failed tests
Html Report (VSDrops) Download ❌ monotouch tests (macOS)3 tests failed, 16 tests passed.Failed tests
Html Report (VSDrops) Download ❌ xtro tests1 tests failed, 0 tests passed.Failed tests
Html Report (VSDrops) Download ❌ Tests on macOS Sonoma (14) tests🔥 Failed catastrophically on VSTS: test results - mac_sonoma (no summary found). Html Report (VSDrops) Download Successes✅ 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 |
|
Follow-up to #26074 / PR #26139, part of the Hot Reload epic #26069.
PR #26139 relocated the non-generic
[UnmanagedCallersOnly]registrar trampolines (and the constructor helpers) into the per-assembly companion assembly (_<Asm>.TypeMap.dll) so that, under$(HotReloadCompatibleBuild)with theTrimmableStaticregistrar, user assemblies are left byte-for-byte unmodified. Generic exported types were left as a deferred case: their trampolines still modified the user assembly (a proxy interface + interface impl + impl method were added to the user type, plus a[DynamicDependency]on its static constructor), so generic exportedNSObject/INativeObjectsubclasses still broke Hot Reload.This PR handles that deferred case. The static callback can't know the instance's generic parameters, and we can't add the virtual-dispatch proxy to the user type without modifying it, so instead we dispatch via reflection entirely from the companion:
ShouldRelocateTrampolinesno longer excludes generic declaring types...._impl<T…>is emitted into the companion__Registrar_Callbacks__type, mirroring the user type's generic parameters and constraints, with a leadingselfparameter typed as the user type closed over the helper's own generic parameters. Its body is the same proven instance-impl IL (reused viaEmitCallToExportedMethod, now remapping the user type's generic parameters to the helper's).[UnmanagedCallersOnly]callback resolves the instance, boxes the native arguments into anobject[], and calls a new runtime helperRuntime.InvokeGenericRegistrarTrampoline, which closes the helper over the instance's actual generic arguments (MakeGenericMethod) and invokes it. Because reflection can't marshal anIntPtr*, the helper'sexception_gchandleis anout IntPtrand the callback writes the returned GCHandle back through the native pointer. Any failure is caught and reported throughexception_gchandle, so nothing escapes the UnmanagedCallersOnly boundary. The closed method is cached per (open helper, closed instance type) to avoid repeated reflection cost.IntPtr(boxable, so they round-trip through theobject[]); the helper body reconstructs the pointer and performs the load/store exactly as the non-generic impl does. The native ABI is unchanged since a pointer and anIntPtrare both pointer-sized.[DynamicDependency]+ theldtokenreference, plus IL2060/ IL3050 suppressions on the JIT-only runtime helper) lives entirely on the companion side; nothing is added to the user assembly.MT4133; their throw-trampolines relocate with no user-assembly change.The reflection-based dispatch is confined to the generic Hot-Reload path — the non-generic managed-static path stays reflection-free — and everything is gated on relocation (i.e.
$(HotReloadCompatibleBuild)), so release builds are unaffected.RelocateRegistrarTrampolinesTestsis extended with a generic exportedNSObjectsubclass (including a method with both anout Tparameter and a normal value parameter), asserting the user assembly has no__Registrar_Callbacks__, no proxy interface (type/impl/interface-impl), no callback/static-ctor[DynamicDependency], and that the companion holds the trampoline + generic helper (with the out parameter typedIntPtrand the by-value parameter keeping its native value type).🤖 Pull request created by Copilot