Skip to content

[Hot Reload] ManagedRegistrarStep: emit UnmanagedCallersOnly trampolines into a companion assembly. Fixes #26074. - #26139

Merged
rolfbjarne merged 17 commits into
mainfrom
dev/rolf/issue-26074-hot-reload-managedregistrarstep-emit-un-fd73d5
Aug 3, 2026
Merged

[Hot Reload] ManagedRegistrarStep: emit UnmanagedCallersOnly trampolines into a companion assembly. Fixes #26074.#26139
rolfbjarne merged 17 commits into
mainfrom
dev/rolf/issue-26074-hot-reload-managedregistrarstep-emit-un-fd73d5

Conversation

@rolfbjarne

Copy link
Copy Markdown
Member

Part of the Hot Reload epic (#26069). When $(HotReloadCompatibleBuild) is enabled with the TrimmableStatic registrar, user assemblies must stay byte-for-byte unmodified. Today ManagedRegistrarStep emits the [UnmanagedCallersOnly] registrar trampolines (__Registrar_Callbacks__) and per-type constructor helpers directly into the user assembly, which breaks that requirement.

This PR relocates those trampolines (and the constructor helpers) into the per-assembly companion assembly (_<Asm>.TypeMap.dll) that the trimmable static registrar already produces, so the user assembly is left untouched. Release builds (property disabled) keep the current behavior.

What changed

  • New $(HotReloadCompatibleBuild) plumbing: property flows through _CustomLinkerOptions into Application/LinkerConfiguration; documented in docs/building-apps/build-properties.md. (Minimal self-contained plumbing; the property is fully owned by [Hot Reload] Add $(HotReloadCompatibleBuild) MSBuild property + assembly-preparer safety net #26072.)
  • Shared companion factory RegistrarCompanionAssembly.GetOrCreate — the companion _<Asm>.TypeMap.dll is now created once and shared between ManagedRegistrarStep (which emits the trampolines into it) and TrimmableRegistrarStep (which reuses it for the type map).
  • ManagedRegistrarStep relocation: when relocating, the trampolines are emitted into a top-level __Registrar_Callbacks__ in the companion; the user-side [DynamicDependency] is skipped (the trampoline stays alive via the companion's ldftn reference); leftover user-module references are re-imported into the companion module. The injected cloned constructor is replaced by an inline factory in the trampoline (RuntimeHelpers.GetUninitializedObject + set handle/flags + call the real ctor), so no constructor is added to the user type. Save is now gated on precise modification tracking so a fully-relocated user assembly is never re-serialized.

Scope

Non-generic trampolines (including constructors) are relocated now. Generic-type proxy relocation is deferred to a follow-up.

Testing

Adds a structural assembly-preparer test (RelocateRegistrarTrampolinesTests) asserting the user assembly is not re-saved and has no __Registrar_Callbacks__/cloned ctor/callback [DynamicDependency], while the companion holds the [UnmanagedCallersOnly] trampolines. The full build/runtime path requires Xcode 26.6, which isn't available in the dev environment, so CI verifies the runtime behavior.

Fixes #26074

🤖 Pull request created by Copilot

rolfbjarne and others added 4 commits July 16, 2026 19:01
… companion-assembly factory (#26074)

Groundwork for relocating the trimmable-static registrar's
[UnmanagedCallersOnly] trampolines out of user assemblies (so they stay
byte-unmodified for Hot Reload) and into the per-assembly companion
'_<Asm>.TypeMap.dll'.

- Add the $(HotReloadCompatibleBuild) property plumbing: Application flag,
  LinkerConfiguration load/save, _CustomLinkerOptions wiring and docs.
- Extract the companion-assembly creation from TrimmableRegistrarStep into a
  new shared RegistrarCompanionAssembly.GetOrCreate factory, stored in
  LinkerConfiguration.RegistrarCompanionAssemblies, so ManagedRegistrarStep can
  create/emit into the same companion earlier. TrimmableRegistrarStep now
  reuses it (behavior-preserving).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…o companion assembly (#26074)

When HotReloadCompatibleBuild is enabled with the TrimmableStatic registrar,
emit the [UnmanagedCallersOnly] registrar trampolines (and constructor helpers)
into the per-assembly companion assembly (_<Asm>.TypeMap.dll) instead of the
user assembly, so user assemblies stay byte-unmodified (a Hot Reload requirement).
Release builds (property disabled) keep the current in-user-assembly behavior.

- ManagedRegistrarStep: when relocating, switch the AppBundleRewriter to the
  companion assembly, emit the trampoline into a top-level __Registrar_Callbacks__
  type there, skip the user-side [DynamicDependency], re-import all references into
  the companion module, and track user-assembly modifications precisely so the user
  assembly isn't re-serialized.
- Constructors: instead of cloning a constructor into the user type, emit an inline
  factory (RuntimeHelpers.GetUninitializedObject + set handle/flags + call the real
  constructor) in the companion trampoline, and grant the companion access to the
  platform assembly's NSObject handle/flags setters.
- CollectUnmanagedCallersMethod: in the post-processing pass, resolve the relocated
  trampolines from the companion assembly instead of the user type.
- AppBundleRewriter: add RuntimeHelpers.GetUninitializedObject references.

Only non-generic types are relocated for now; generic-type proxy relocation is a
follow-up.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…cation (#26074)

Adds a structural unit test verifying that when HotReloadCompatibleBuild is
enabled with the TrimmableStatic registrar, the registrar trampolines are
emitted into the companion assembly (_Test.TypeMap.dll) and the user assembly
is left byte-unmodified (not re-saved, no __Registrar_Callbacks__ type, no
cloned ctor, no injected [DynamicDependency]).

The test can't run in this environment (requires Xcode 26.6 to build the
workload); CI verifies it.

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

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 pipeline to support Hot Reload compatibility by relocating [UnmanagedCallersOnly] registrar trampolines (and constructor helper logic) out of user assemblies and into the per-assembly companion _\<Asm>.TypeMap.dll assembly, so user assemblies can remain byte-for-byte unchanged when $(HotReloadCompatibleBuild) is enabled.

Changes:

  • Introduces a shared RegistrarCompanionAssembly.GetOrCreate factory so ManagedRegistrarStep and TrimmableRegistrarStep can share the same companion assembly instance.
  • Relocates non-generic registrar trampolines (and ctor helper behavior) into the companion assembly, with reference re-importing to ensure valid Cecil module ownership.
  • Adds an assembly-preparer test asserting the user assembly is not re-saved and that trampolines live in the companion assembly, plus MSBuild plumbing/docs for HotReloadCompatibleBuild.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tools/dotnet-linker/Steps/TrimmableRegistrarStep.cs Reuses/creates the per-assembly companion TypeMap assembly via RegistrarCompanionAssembly.GetOrCreate.
tools/dotnet-linker/Steps/ManagedRegistrarStep.cs Relocates [UnmanagedCallersOnly] trampolines + ctor helper logic into the companion assembly when Hot Reload compatibility is enabled.
tools/dotnet-linker/RegistrarCompanionAssembly.cs New shared helper to create/cache companion assemblies and track IgnoresAccessChecksTo needs.
tools/dotnet-linker/LinkerConfiguration.cs Adds companion-assembly cache + config plumbing for HotReloadCompatibleBuild.
tools/dotnet-linker/AppBundleRewriter.cs Adds Cecil helpers for RuntimeHelpers.GetUninitializedObject to support relocated ctor logic.
tools/common/Application.cs Adds HotReloadCompatibleBuild flag to the tool configuration surface.
tools/assembly-preparer/assembly-preparer.csproj Includes the new companion-assembly helper in assembly-preparer build.
tests/assembly-preparer/RelocateRegistrarTrampolinesTests.cs New structural test verifying trampolines are relocated and user assembly remains untouched.
tests/assembly-preparer/BaseClass.cs Adds a helper to run assembly-preparer in Hot Reload + trimmable-static mode and return user/companion assemblies.
dotnet/targets/Xamarin.Shared.Sdk.targets Flows $(HotReloadCompatibleBuild) into _CustomLinkerOptions.
docs/building-apps/build-properties.md Documents the new HotReloadCompatibleBuild MSBuild property.

Comment thread tools/dotnet-linker/Steps/ManagedRegistrarStep.cs
Comment thread dotnet/targets/Xamarin.Shared.Sdk.targets
Comment thread tools/dotnet-linker/RegistrarCompanionAssembly.cs Outdated
@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.

rolfbjarne and others added 2 commits July 17, 2026 16:40
…hot-reload-managedregistrarstep-emit-un-fd73d5
- ManagedRegistrarStep: set modifiedCurrentAssembly when a trampoline (or a
  generic-type proxy interface/implementation) is emitted into the user
  assembly on the non-relocated path. Previously the flag was only set on the
  ImplementConstruct paths, so with HotReloadCompatibleBuild enabled a modified
  user assembly (e.g. one with generic exported types) could be skipped when
  saving, dropping the generated trampolines/proxies. (review: error)

- Reconcile with the HotReloadCompatibleBuild property that landed on main via a
  sibling PR: drop the duplicate Application.HotReloadCompatibleBuild field, the
  duplicate config-key handler (a duplicate key in the collection initializer
  would throw at runtime) and the duplicate _CustomLinkerOptions entry in
  Xamarin.Shared.Sdk.targets; use Configuration.HotReloadCompatibleBuild
  everywhere. (review: warning)

- Make RegistrarCompanionAssembly internal + sealed (and the
  RegistrarCompanionAssemblies dictionary internal to match). (review: suggestion)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@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.

@rolfbjarne
rolfbjarne marked this pull request as ready for review July 20, 2026 06:00
@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

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

…hot-reload-managedregistrarstep-emit-un-fd73d5
@rolfbjarne
rolfbjarne marked this pull request as draft July 20, 2026 10:31
@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

@rolfbjarne
rolfbjarne marked this pull request as ready for review July 27, 2026 18:11
@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

rolfbjarne and others added 2 commits July 29, 2026 15:59
…hot-reload-managedregistrarstep-emit-un-fd73d5
…es (#26074)

When relocating registrar trampolines into the companion assembly, the
re-import pass relied on ModuleDefinition.ImportReference to re-scope every
reference into the companion module. That short-circuits when the outermost
element already belongs to the target module, leaving nested types (generic
arguments) pointing at the user module.

This surfaced as `MT2470: Member 'System.Runtime.InteropServices.NFloat' is
declared in another module and needs to be imported` for methods with a
[BindAs] nullable nfloat: GenerateConversionToNative builds a method reference
on Nullable<nfloat> (via CreateMethodReferenceOnGenericType), whose declaring
generic instance keeps the user-module NFloat argument.

Make the import pass recursive: ImportTypeReference now rebuilds type
specifications (generic instances, arrays, pointers, by-references, modifiers)
re-importing each nested type, ImportMethodReference also rebuilds methods
declared on a generic instance type, and a new ImportFieldReference does the
same for fields. Verified against a standalone Cecil reproduction.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4bf8d3a8-cd2f-43cb-9b8b-114ab25fa802
@vs-mobiletools-engineering-service2

This comment has been minimized.

@vs-mobiletools-engineering-service2

This comment has been minimized.

@rolfbjarne rolfbjarne added the ready-to-review This PR is ready to review/merge. label Jul 29, 2026
@rolfbjarne
rolfbjarne enabled auto-merge (squash) July 29, 2026 17:54
dalexsoto
dalexsoto previously approved these changes Jul 29, 2026
@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.

rolfbjarne and others added 2 commits July 30, 2026 17:47
…'flags' property.

The trimmer may remove the metadata for NSObject's private 'flags' property, which
made TestRuntime.GetFlags throw when running trimmed test variations:

    [FAIL] IsDirectBinding : System.InvalidOperationException : Unable to find the property 'flags' in NSObject.

Instead read the flags from the native memory pointed to by NSObject's '__data'
field: fields that are used are always kept by the trimmer, and this doesn't
require preserving anything extra (which would grow every trimmed app).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 55aa047a-5d38-44e8-aade-91cec35a65f3
…hot-reload-managedregistrarstep-emit-un-fd73d5
@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: 48b451be5c7752f59c2df28ddbebbd201315c4ed [PR build]

@vs-mobiletools-engineering-service2

Copy link
Copy Markdown
Collaborator

🚀 [CI Build #48b451b] Test results 🚀

Test results

✅ All tests passed on VSTS: test results.

🎉 All 203 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 19 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: 48b451be5c7752f59c2df28ddbebbd201315c4ed [PR build]

@rolfbjarne

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

.NET for Apple Platforms PR Reviewer completed successfully!

@github-actions github-actions Bot 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.

Review — ⚠️ One issue, one nit

CI is ✅ green.

The approach is solid: the companion assembly factory (RegistrarCompanionAssembly.GetOrCreate) cleanly shares state between ManagedRegistrarStep and TrimmableRegistrarStep; the ImportCallbackReferences re-import pass is thorough; and the modification-tracking change that gates SaveCurrentAssembly on modified == true is exactly right for keeping the user assembly byte-unmodified.

Issues found:

# Severity File Summary
1 ⚠️ tests/common/TestRuntime.cs:1567 Null-forgiving ! (banned) + null check happens after the unboxing cast, so a null field value produces NullReferenceException instead of the intended InvalidOperationException
2 💡 docs/building-apps/build-properties.md:655 Typo: "otherwies" → "otherwise"

Generated by .NET for Apple Platforms PR Reviewer for issue #26139 · 98.3 AIC · ⌖ 5.62 AIC · ⊞ 5.1K
Comment /review to run again

Comment thread docs/building-apps/build-properties.md
Comment thread tests/common/TestRuntime.cs
@rolfbjarne rolfbjarne added the ready-to-review This PR is ready to review/merge. label Aug 3, 2026
@rolfbjarne
rolfbjarne merged commit 7764f83 into main Aug 3, 2026
56 checks passed
@rolfbjarne
rolfbjarne deleted the dev/rolf/issue-26074-hot-reload-managedregistrarstep-emit-un-fd73d5 branch August 3, 2026 17:12
rolfbjarne added a commit that referenced this pull request Aug 5, 2026
…n assembly (#26368)

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

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Rolf Bjarne Kvinge <rokvin@microsoft.com>
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.

[Hot Reload] ManagedRegistrarStep: emit UnmanagedCallersOnly trampolines/proxies into a companion assembly

4 participants