Skip to content

[Hot Reload] Relocate generic registrar trampolines into the companion assembly - #26368

Open
rolfbjarne wants to merge 6 commits into
dev/rolf/issue-26074-hot-reload-managedregistrarstep-emit-un-fd73d5from
dev/rolf/relocate-generic-registrar-proxies
Open

[Hot Reload] Relocate generic registrar trampolines into the companion assembly#26368
rolfbjarne wants to merge 6 commits into
dev/rolf/issue-26074-hot-reload-managedregistrarstep-emit-un-fd73d5from
dev/rolf/relocate-generic-registrar-proxies

Conversation

@rolfbjarne

Copy link
Copy Markdown
Member

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 the TrimmableStatic registrar, 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 exported NSObject/INativeObject subclasses 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:

  • ShouldRelocateTrampolines no longer excludes generic declaring types.
  • 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, now remapping the user type's generic parameters to the helper's).
  • The [UnmanagedCallersOnly] callback resolves the instance, boxes the native arguments into an object[], and calls a new runtime helper Runtime.InvokeGenericRegistrarTrampoline, which closes the helper over the instance's actual generic arguments (MakeGenericMethod) and invokes it. Because reflection can't marshal an IntPtr*, the helper's exception_gchandle is an out IntPtr and the callback writes the returned GCHandle back through the native pointer. Any failure is caught and reported through exception_gchandle, so nothing escapes the UnmanagedCallersOnly boundary. The closed method is cached per (open helper, closed instance type) to avoid repeated reflection cost.
  • out/ref/pointer parameters are supported: in the generic path only, such native parameters are represented as a plain IntPtr (boxable, so they round-trip through the object[]); 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 an IntPtr are both pointer-sized.
  • Trimming keep-alive ([DynamicDependency] + the ldtoken reference, plus IL2060/ IL3050 suppressions on the JIT-only runtime helper) lives entirely on the companion side; nothing is added to the user assembly.
  • Generic constructors keep throwing 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.

RelocateRegistrarTrampolinesTests is extended with a generic exported NSObject subclass (including a method with both an out T parameter 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 typed IntPtr and the by-value parameter keeping its native value type).

🤖 Pull request created by Copilot

rolfbjarne and others added 4 commits July 30, 2026 13:18
…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
Copilot AI review requested due to automatic review settings July 30, 2026 11: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

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 cached MakeGenericMethod + MethodInfo.Invoke, reporting failures through exception_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.

Comment thread tools/dotnet-linker/Steps/ManagedRegistrarStep.cs
rolfbjarne and others added 2 commits July 30, 2026 13:49
…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
@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

@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: 4b35a8ae0bcb7578f553a3ee9d743366e1b3b909 [PR build]

@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

Copy link
Copy Markdown
Collaborator

🔥 [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

  • DotNet tests: Failed (Execution failed with exit code 1)
    • Xamarin.Tests.AppSizeTest.MonoVM_Interpreter(iOS,"ios-arm64"): Multiple failures or warnings in test:
  1. The app bundle's file list changed (added: 'System.Collections.Concurrent.dll'). The...
    * Xamarin.Tests.AppSizeTest.MonoVM(iOS,"ios-arm64"): Multiple failures or warnings in test:
  2. The app bundle's file list changed (added: 'System.Collections.Concurrent.aotdata.ar...
    * Xamarin.Tests.AppSizeTest.NativeAOT_TrimmableStatic(iOS,"ios-arm...: App size changed significantly (+16,408 bytes (16.0 KB = 0.0 MB) different > tolerance of +-10,240 bytes (10.0 KB = 0.0 MB)). Ex...
    * ... and 1 more

Html Report (VSDrops) Download

❌ dotnettests tests (MacCatalyst)

1 tests failed, 0 tests passed.

Failed tests

  • DotNet tests: Failed (Execution failed with exit code 1)
    • Xamarin.Tests.AppSizeTest.MonoVM_Interpreter(MacCatalyst,"maccat...: Multiple failures or warnings in test:
  1. The app bundle's file list changed (added: 'Contents/MonoBundle/System.Collections.C...
    * Xamarin.Tests.AppSizeTest.MonoVM(MacCatalyst,"maccatalyst-arm64"...: Multiple failures or warnings in test:
  2. The app bundle's file list changed (added: 'Contents/MonoBundle/System.Collections.C...

Html Report (VSDrops) Download

❌ dotnettests tests (macOS)

1 tests failed, 0 tests passed.

Failed tests

  • DotNet tests: Failed (Execution failed with exit code 1)
    • Xamarin.Tests.AppSizeTest.NativeAOT_TrimmableStatic(MacOSX,"osx-...: App size changed significantly (+32,921 bytes (32.1 KB = 0.0 MB) different > tolerance of +-10,240 bytes (10.0 KB = 0.0 MB)). Ex...
    • Xamarin.Tests.AppSizeTest.NativeAOT(MacOSX,"osx-arm64;osx-x64"): App size changed significantly (+16,537 bytes (16.1 KB = 0.0 MB) different > tolerance of +-10,240 bytes (10.0 KB = 0.0 MB)). Ex...

Html Report (VSDrops) Download

❌ dotnettests tests (tvOS)

1 tests failed, 0 tests passed.

Failed tests

  • DotNet tests: Failed (Execution failed with exit code 1)
    • Xamarin.Tests.AppSizeTest.MonoVM_Interpreter(TVOS,"tvos-arm64"): Multiple failures or warnings in test:
  1. The app bundle's file list changed (added: 'System.Collections.Concurrent.dll'). The...
    * Xamarin.Tests.AppSizeTest.MonoVM(TVOS,"tvos-arm64"): Multiple failures or warnings in test:
  2. The app bundle's file list changed (added: 'System.Collections.Concurrent.aotdata.ar...

Html Report (VSDrops) Download

❌ monotouch tests (macOS)

3 tests failed, 16 tests passed.

Failed tests

  • monotouch-test/macOS/Debug (trimmable static registrar): Failed (Test run failed.
    Tests run: 3736 Passed: 3604 Inconclusive: 13 Failed: 3 Ignored: 129)
  • monotouch-test/macOS/Release (trimmable static registrar): Failed (Test run failed.
    Tests run: 3736 Passed: 3605 Inconclusive: 13 Failed: 3 Ignored: 128)
  • monotouch-test/macOS/Release (trimmable static registrar, all optimizations): BuildFailure

Html Report (VSDrops) Download

❌ xtro tests

1 tests failed, 0 tests passed.

Failed tests

  • Xtro: Failed (Execution failed with exit code 1)
    • Xamarin.Tests.Xtro.RunTest: ExitCode
      Assert.That(rv, Is.EqualTo (0))
      Expected: 0
      But was: 2

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
✅ cecil: All 1 tests passed. Html Report (VSDrops) Download
✅ dotnettests (Multiple platforms): 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 (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

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 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: 4b35a8ae0bcb7578f553a3ee9d743366e1b3b909 [PR build]

@vs-mobiletools-engineering-service2

Copy link
Copy Markdown
Collaborator

⚠️ AppSizeTest expected files changed ⚠️

The AppSizeTest detected changes in the expected app size files.

To update the expected files, add a comment with the following command:

/apply-gist https://gist.github.com/vs-mobiletools-engineering-service2/aa77ab88943de7e810aeaf13dcfc492f
Updated files
  • iOS-MonoVM-interpreter-preservedapis.txt
  • iOS-MonoVM-interpreter-size.txt
  • iOS-MonoVM-preservedapis.txt
  • iOS-MonoVM-size.txt
  • iOS-NativeAOT-size.txt
  • iOS-NativeAOT-TrimmableStatic-size.txt
  • MacCatalyst-MonoVM-interpreter-preservedapis.txt
  • MacCatalyst-MonoVM-interpreter-size.txt
  • MacCatalyst-MonoVM-preservedapis.txt
  • MacCatalyst-MonoVM-size.txt
  • MacOSX-NativeAOT-size.txt
  • MacOSX-NativeAOT-TrimmableStatic-size.txt
  • TVOS-MonoVM-interpreter-preservedapis.txt
  • TVOS-MonoVM-interpreter-size.txt
  • TVOS-MonoVM-preservedapis.txt
  • TVOS-MonoVM-size.txt

Pipeline on Agent
Hash: 4b35a8ae0bcb7578f553a3ee9d743366e1b3b909 [PR build]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants