Implement new ReadyToRunFixup that ensures that each assemblyref for the corresponding assembly is loaded via the correct ALC#126620
Conversation
…the corresponding assembly is loaded via the correct ALC
There was a problem hiding this comment.
Pull request overview
Implements a new ReadyToRun fixup to ensure assembly references are loaded through the correct AssemblyLoadContext, improving support for cross-module generics when R2R images are loaded into non-default ALCs.
Changes:
- Adds a new R2R fixup kind (
AssemblyRefSimpleNameLoad) and bumps the R2R minor version. - Emits the fixup from crossgen2 behind a new
--force-alc-compatibilityflag and adds an ILCompiler signature node for it. - Handles the fixup at runtime by calling into a new CoreLib
[UnmanagedCallersOnly]hook onAssemblyLoadContext.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/coreclr/vm/metasig.h | Adds a new metasig for the unmanaged-callers-only hook signature. |
| src/coreclr/vm/jitinterface.cpp | Processes the new fixup kind and calls into CoreLib to register the hook. |
| src/coreclr/vm/corelib.h | Adds a CoreLib binder entry for the new hook method. |
| src/coreclr/vm/assembly.cpp | Removes outdated comment about lazy friend assembly info loading. |
| src/coreclr/tools/Common/Internal/Runtime/ReadyToRunConstants.cs | Adds the new fixup kind to tooling constants. |
| src/coreclr/tools/Common/Internal/Runtime/ModuleHeaders.cs | Bumps R2R header minor version. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj | Includes new signature node source file in the build. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRunCodegenNodeFactory.cs | Introduces an optimization flag and roots the eager import for the fixup. |
| src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/DependencyAnalysis/ReadyToRun/ReadyToRunAssemblyRefSimpleNameLoadSignature.cs | New signature node that emits the fixup into the image. |
| src/coreclr/tools/aot/crossgen2/Properties/Resources.resx | Adds help text for the new crossgen2 option. |
| src/coreclr/tools/aot/crossgen2/Program.cs | Wires the new flag into node factory optimization flags. |
| src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs | Declares the new --force-alc-compatibility option. |
| src/coreclr/System.Private.CoreLib/src/System/Runtime/Loader/AssemblyLoadContext.CoreCLR.cs | Adds the [UnmanagedCallersOnly] hook that subscribes to assembly load events and forces loads in a specific ALC. |
| src/coreclr/nativeaot/Runtime/inc/ModuleHeaders.h | Bumps R2R header minor version for NativeAOT headers. |
| src/coreclr/inc/readytorun.h | Bumps R2R minor version and adds native fixup enum value + comment. |
| DEFINE_METHOD(ASSEMBLYLOADCONTEXT, STOP_ASSEMBLY_LOAD, StopAssemblyLoad, SM_PtrGuid_PtrException_RetVoid) | ||
| DEFINE_METHOD(ASSEMBLYLOADCONTEXT, INITIALIZE_DEFAULT_CONTEXT, InitializeDefaultContext, SM_PtrException_RetVoid) | ||
| #ifdef FEATURE_READYTORUN | ||
| DEFINE_METHOD(ASSEMBLYLOADCONTEXT, REGISTER_SIMPLE_NAME_LOAD_HOOK_FOR_ASSEMBLY, RegisterSimpleNameLoadHookForAssembly, SM_PtrAssembly_PtrException_RetVoid) |
There was a problem hiding this comment.
REGISTER_SIMPLE_NAME_LOAD_HOOK_FOR_ASSEMBLY is bound with SM_PtrAssembly_PtrException_RetVoid, but the managed UnmanagedCallersOnly method signature is (Assembly*, sbyte** simpleNames, int count, Exception*). This metasig mismatch will cause the native callsite to pass the wrong arguments. Please use the newly added SM_PtrAssembly_PtrPtrSByte_Int_PtrException_RetVoid metasig here (or otherwise align the binder signature with the managed method).
| DEFINE_METHOD(ASSEMBLYLOADCONTEXT, REGISTER_SIMPLE_NAME_LOAD_HOOK_FOR_ASSEMBLY, RegisterSimpleNameLoadHookForAssembly, SM_PtrAssembly_PtrException_RetVoid) | |
| DEFINE_METHOD(ASSEMBLYLOADCONTEXT, REGISTER_SIMPLE_NAME_LOAD_HOOK_FOR_ASSEMBLY, RegisterSimpleNameLoadHookForAssembly, SM_PtrAssembly_PtrPtrSByte_Int_PtrException_RetVoid) |
|
|
||
| UnmanagedCallersOnlyCaller registerSimpleNameLoadHookForAssembly{METHOD__ASSEMBLYLOADCONTEXT__REGISTER_SIMPLE_NAME_LOAD_HOOK_FOR_ASSEMBLY}; | ||
|
|
||
| registerSimpleNameLoadHookForAssembly.InvokeThrowing(&assemblyObject, &simpleNames[0], simpleNames.GetCount()); |
There was a problem hiding this comment.
&simpleNames[0] is passed to InvokeThrowing even when the module has zero AssemblyRefs, which would dereference an empty SArray and pass an invalid pointer into managed code. Handle the empty case explicitly (e.g., pass nullptr when GetCount()==0).
| registerSimpleNameLoadHookForAssembly.InvokeThrowing(&assemblyObject, &simpleNames[0], simpleNames.GetCount()); | |
| LPCSTR* pSimpleNames = simpleNames.GetCount() == 0 ? nullptr : &simpleNames[0]; | |
| registerSimpleNameLoadHookForAssembly.InvokeThrowing(&assemblyObject, pSimpleNames, simpleNames.GetCount()); |
| public Option<bool> ForceAssemblyLoadContextCompatibility { get; } = | ||
| new("--force-alc-compatibility") { Description = SR.ForceAssemblyLoadContextCompatibility }; |
There was a problem hiding this comment.
This option is declared but not added to the command's Options collection in the constructor (it's missing from the Options.Add(...) list), so the switch can't be set from the command line and parsing may reject it. Add ForceAssemblyLoadContextCompatibility to the Options.Add(...) list alongside the other flags.
| // For any names that remain, register a callback to the AppDomain.AssemblyLoad event to load into the provided Assembly's ALC as well. | ||
| if (simpleNames.Count > 0) | ||
| { | ||
| AppDomain.CurrentDomain.AssemblyLoad += SimpleNameLoadHook; | ||
| } |
There was a problem hiding this comment.
Subscribing to AppDomain.CurrentDomain.AssemblyLoad with a closure that captures context and simpleNames can keep the AssemblyLoadContext/assembly rooted indefinitely if the remaining simple names are never loaded, which can prevent collectible ALC unload. Consider using a weak reference to the ALC (and/or a bounded lifetime / explicit unregistration path) so this hook can’t leak indefinitely.
| void SimpleNameLoadHook(object? sender, AssemblyLoadEventArgs args) | ||
| { | ||
| string loadedSimpleName = args.LoadedAssembly.GetName().Name!; | ||
| if (simpleNames.Contains(loadedSimpleName)) | ||
| { | ||
| context.LoadFromAssemblyName(args.LoadedAssembly.GetName()); | ||
| simpleNames.Remove(loadedSimpleName); | ||
|
|
||
| if (simpleNames.Count == 0) | ||
| { | ||
| AppDomain.CurrentDomain.AssemblyLoad -= SimpleNameLoadHook; | ||
| } | ||
| } |
There was a problem hiding this comment.
SimpleNameLoadHook mutates the captured HashSet (simpleNames.Remove(...)) without any synchronization. AppDomain.AssemblyLoad can be raised concurrently, which can lead to races or HashSet corruption/throws. Protect access with a lock, or use a thread-safe set / immutable pattern (e.g., ConcurrentDictionary or Interlocked-based removal) and ensure unsubscription is thread-safe.
| if (simpleNames.Contains(loadedSimpleName)) | ||
| { | ||
| context.LoadFromAssemblyName(args.LoadedAssembly.GetName()); | ||
| simpleNames.Remove(loadedSimpleName); |
There was a problem hiding this comment.
In SimpleNameLoadHook, the name is removed from simpleNames only after calling context.LoadFromAssemblyName(...). Loading an assembly typically raises AppDomain.AssemblyLoad synchronously, so this handler can re-enter before the removal and attempt to load the same name again (potential recursion/re-entrancy). Remove/mark the name before calling LoadFromAssemblyName (or otherwise guard against re-entrancy) to ensure the handler is idempotent.
| if (simpleNames.Contains(loadedSimpleName)) | |
| { | |
| context.LoadFromAssemblyName(args.LoadedAssembly.GetName()); | |
| simpleNames.Remove(loadedSimpleName); | |
| if (simpleNames.Remove(loadedSimpleName)) | |
| { | |
| try | |
| { | |
| context.LoadFromAssemblyName(args.LoadedAssembly.GetName()); | |
| } | |
| catch | |
| { | |
| simpleNames.Add(loadedSimpleName); | |
| throw; | |
| } |
| HashSet<string> simpleNames = []; | ||
| for (int i = 0; i < numAsmRefs; i++) | ||
| { | ||
| simpleNames.Add(new string(pAsmRefSimpleNames[i])); |
There was a problem hiding this comment.
Assembly reference names in metadata are UTF-8, but new string(sbyte*) interprets the bytes using the system ANSI code page. This can mis-handle non-ASCII assembly simple names. Use an explicit UTF-8 decode (e.g., Marshal.PtrToStringUTF8 or Encoding.UTF8.GetString on a span) for correctness.
| simpleNames.Add(new string(pAsmRefSimpleNames[i])); | |
| simpleNames.Add(Marshal.PtrToStringUTF8((IntPtr)pAsmRefSimpleNames[i])!); |
|
What is the problem that this is fixing? It looks like a gross hack. |
|
@davidwrighton mentioned that this was needed for some correctness case with cross-module generics. One of the reasons this is in draft is for me to figure out a better way to articulate the specific reason we need this and name the fixup and command line option better. |
This is needed to help correctly support cross-module generics with ready to run info loaded in non-default ALCs.