Description
Description
During the unloading process of an AssemblyLoadContext, coreclr attempts to release a GCHandle but accesses an empty handle value, resulting in an access violation exception. This issue occurs when multiple interdependent AssemblyLoadContexts are being unloaded, particularly when they are processed in a specific order. The root cause is that LoaderAllocator attempts to access an already collected DomainAssembly pointer a second time.
Reproduction Steps
Steps to Reproduce
- Create three ALCs named ALC_A, ALC_B, and ALC_C
- Create three assemblies: AssemblyA, AssemblyB, and AssemblyC, each loaded in their corresponding ALC
- Configure AssemblyB to depend on both AssemblyA and AssemblyC
- Call a method in AssemblyB
- Unload all three ALCs
- Repeat the above steps multiple times - the access violation exception will occur occasionally
Reproduction Code:
1.Test code:
// ALC For AssemblyB
public class AssemblyLoadContextB : AssemblyLoadContext
{
public AssemblyLoadContextB(string name, bool isCollectible) : base(name, isCollectible)
{
}
protected override Assembly Load(AssemblyName assemblyName)
{
if (assemblyName.Name == "AssemblyC")
{
UELog.Log($"AssemblyB is resolving C");
return AssemblyLoadContextTest.AssemblyC;
}
if (assemblyName.Name == "AssemblyA")
{
UELog.Log($"AssemblyB is resolving A");
return AssemblyLoadContextTest.AssemblyA;
}
return null;
}
}
class AssemblyLoadContextTest
{
public static Assembly AssemblyA;
public static Assembly AssemblyC;
public static void Run()
{
for (int i = 0; i < 10; i++)
{
UELog.Log($"Test #{i}");
RunOneTest();
}
}
private static void RunOneTest()
{
// Create three collectible ALCs
var alcB = new AssemblyLoadContextB("ALC_B", isCollectible: true);
var alcC = new AssemblyLoadContext("ALC_C", isCollectible: true);
var alcA = new AssemblyLoadContext("ALC_A", isCollectible: true);
// Track ALCs with weak references
WeakReference alcCRef = new WeakReference(alcC, trackResurrection: true);
WeakReference alcBRef = new WeakReference(alcB, trackResurrection: true);
WeakReference alcARef = new WeakReference(alcA, trackResurrection: true);
// Load assembly A
AssemblyA = PluginInitiator.LoadAssembly(alcA, "AssemblyA");
// Load assembly C
AssemblyC = PluginInitiator.LoadAssembly(alcC, "AssemblyC");
// Load assembly B (depends on assemblies A and C)
Assembly assemblyB = PluginInitiator.LoadAssembly(alcB, "AssemblyB");
// Call method in assembly B
UELog.Log("\nTesting call to method in assembly B:");
Type? typeBClass = assemblyB.GetType("AssemblyB.ClassB");
if (typeBClass != null)
{
object bInstance = Activator.CreateInstance(typeBClass)!;
string resultB = (string)typeBClass.GetMethod("GetMessage").Invoke(bInstance, null);
UELog.Log($"B method returns: {resultB}");
}
AssemblyA = null;
AssemblyC = null;
// Unload the three ALCs
UELog.Log("\nStarting to unload ALCs...");
alcA.Unload();
alcC.Unload();
alcB.Unload();
alcA = null;
alcB = null;
alcC = null;
for (int i = 0; i < 100 &&
(alcARef.IsAlive || alcBRef.IsAlive || alcCRef.IsAlive); i++)
{
Thread.Sleep(1);
UELog.Log("Running GC...");
GC.Collect();
GC.WaitForPendingFinalizers();
}
UELog.Log($"ALC_A status: {(alcARef.IsAlive ? "still in memory" : "unloaded")}");
UELog.Log($"ALC_B status: {(alcBRef.IsAlive ? "still in memory" : "unloaded")}");
UELog.Log($"ALC_C status: {(alcCRef.IsAlive ? "still in memory" : "unloaded")}");
UELog.Log("Test completed");
}
}
2.Code for AssemblyA:
namespace AssemblyA;
public class ClassA
{
}
3.Code for AssemblyB:
using AssemblyC;
namespace AssemblyB;
public class ClassB
{
public string GetMessage()
{
var b = new GenericClass<AssemblyA.ClassA>();
var c = new ClassC();
return $"Hello from Assembly B! -> {c.GetMessage()} -> {b.ToString()}";
}
}
public class GenericClass<T>
{
}
4.Code for AssemblyC:
namespace AssemblyC;
public class ClassC
{
public string GetMessage()
{
return "Hello from Assembly C!";
}
}
Expected behavior
The AssemblyLoadContext should unload correctly without errors, regardless of dependencies and processing order.
Actual behavior
During the unloading process of an AssemblyLoadContext, there may be an access violation exception.
Regression?
No response
Known Workarounds
No response
Configuration
- .NET version: 9.0.5
- Operating System: Windows 11
- Architecture: x64
Other information
Root Cause Analysis
-
When ALCs unload,
LoaderAllocator_Destroy
(runtime/src/coreclr/vm/loaderallocator.cpp
Line 638 in e36e4d1
-
The issue occurs specifically when LA_B is processed first, followed by LA_C, then LA_A
-
When LoaderAllocator_Destroy processes LA_B:
- It iterates through
m_LoaderAllocatorReferences
,decrements m_cReferences
(runtime/src/coreclr/vm/loaderallocator.cpp
Line 186 in e36e4d1
runtime/src/coreclr/vm/loaderallocator.cpp
Line 684 in e36e4d1
- It decrements its own m_cReferences value(
runtime/src/coreclr/vm/loaderallocator.cpp
Line 692 in e36e4d1
- Since its m_cReferences is not zero, LoaderAllocator::GCLoaderAllocators is not called(
runtime/src/coreclr/vm/loaderallocator.cpp
Line 698 in e36e4d1
- It iterates through
-
When LoaderAllocator_Destroy processes LA_C:
- It decrements its own m_cReferences value
- With m_cReferences now at zero, it calls LoaderAllocator::GCLoaderAllocators
- GCLoaderAllocators_RemoveAssemblies returns null and fails to remove the DomainAssembly* for AssemblyC from the AppDomain(
runtime/src/coreclr/vm/loaderallocator.cpp
Line 520 in e36e4d1
- The reason for not collecting the DestroyedLoaderAllocator:
- When iterating to AssemblyA, the Mark operation for live LoaderAllocators also marks LA_C(
runtime/src/coreclr/vm/loaderallocator.cpp
Line 406 in e36e4d1
- When later processing LA_C, it doesn't execute the !IsAlive() related logic(
runtime/src/coreclr/vm/loaderallocator.cpp
Line 430 in e36e4d1
- When iterating to AssemblyA, the Mark operation for live LoaderAllocators also marks LA_C(
- Without collecting DestroyedLoaderAllocator, the corresponding DomainAssembly* is not removed(
runtime/src/coreclr/vm/loaderallocator.cpp
Line 476 in e36e4d1
- The reason for not collecting the DestroyedLoaderAllocator:
- LoaderAllocator::GCLoaderAllocators continues by releasing the LongWeakHandle (ReleaseManagedAssemblyLoadContext) and delete DomainAssembly*
After delete DomainAssembly*, the DomainAssembly* cached in AppDomain becomes a dangling pointer
(runtime/src/coreclr/vm/loaderallocator.cpp
Line 569 in e36e4d1
(runtime/src/coreclr/vm/loaderallocator.cpp
Line 576 in e36e4d1
-
When LoaderAllocator_Destroy processes LA_A:
- It decrements its own m_cReferences value
- With m_cReferences now at zero, it calls LoaderAllocator::GCLoaderAllocators
- In GCLoaderAllocators_RemoveAssemblies, it encounters the dangling pointer from the previous step, possibly retrieving LA_C associated with the already collected DomainAssembly*, and records LA_C in m_pLoaderAllocatorDestroyNext
- When iterating through DestroyedLoaderAllocators in LoaderAllocator::GCLoaderAllocators, it performs a second Release operation on LA_C (ReleaseManagedAssemblyLoadContext), resulting in accessing a null pointer
Metadata
Metadata
Assignees
Type
Projects
Status