[Hot Reload] ManagedRegistrarStep: emit UnmanagedCallersOnly trampolines into a companion assembly. Fixes #26074. - #26139
Conversation
… 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>
There was a problem hiding this comment.
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.GetOrCreatefactory soManagedRegistrarStepandTrimmableRegistrarStepcan 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. |
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.
…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>
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.
…trarstep-emit-un-fd73d5
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.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…hot-reload-managedregistrarstep-emit-un-fd73d5
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.
…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
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…trarstep-emit-un-fd73d5
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.
…'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
✅ 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 #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 macOS tests✅ Tests on macOS Monterey (12): All 5 tests passed. Html Report (VSDrops) Download Linux Build VerificationPipeline on Agent |
|
/review |
|
✅ .NET for Apple Platforms PR Reviewer completed successfully! |
There was a problem hiding this comment.
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
…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>
Part of the Hot Reload epic (#26069). When
$(HotReloadCompatibleBuild)is enabled with the TrimmableStatic registrar, user assemblies must stay byte-for-byte unmodified. TodayManagedRegistrarStepemits 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
$(HotReloadCompatibleBuild)plumbing: property flows through_CustomLinkerOptionsintoApplication/LinkerConfiguration; documented indocs/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.)RegistrarCompanionAssembly.GetOrCreate— the companion_<Asm>.TypeMap.dllis now created once and shared betweenManagedRegistrarStep(which emits the trampolines into it) andTrimmableRegistrarStep(which reuses it for the type map).ManagedRegistrarSteprelocation: 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'sldftnreference); 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 +callthe 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