Skip to content

Access Violation Exception During AssemblyLoadContext Unloading #116953

Open
@Liangjia0411

Description

@Liangjia0411

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.

Image

Image

Reproduction Steps

Steps to Reproduce

  1. Create three ALCs named ALC_A, ALC_B, and ALC_C
  2. Create three assemblies: AssemblyA, AssemblyB, and AssemblyC, each loaded in their corresponding ALC
  3. Configure AssemblyB to depend on both AssemblyA and AssemblyC
  4. Call a method in AssemblyB
  5. Unload all three ALCs
  6. 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

  1. When ALCs unload, LoaderAllocator_Destroy(

    BOOL LoaderAllocator::Destroy(QCall::LoaderAllocatorHandle pLoaderAllocator)
    ) is called for their corresponding LoaderAllocator (LA)

  2. The issue occurs specifically when LA_B is processed first, followed by LA_C, then LA_A

  3. When LoaderAllocator_Destroy processes LA_B:

    1. It iterates through m_LoaderAllocatorReferences, decrements m_cReferences(
      LONG cNewReferences = InterlockedDecrement((LONG *)&m_cReferences);
      ) for both LA_A and LA_C to 1(
      while (iter != pLoaderAllocator->m_LoaderAllocatorReferences.End())
      )
    2. It decrements its own m_cReferences value(
      BOOL fIsLastReferenceReleased = pLoaderAllocator->Release();
      )
    3. Since its m_cReferences is not zero, LoaderAllocator::GCLoaderAllocators is not called(
      if (fIsLastReferenceReleased)
      )
  4. When LoaderAllocator_Destroy processes LA_C:

    1. It decrements its own m_cReferences value
    2. With m_cReferences now at zero, it calls LoaderAllocator::GCLoaderAllocators
    3. GCLoaderAllocators_RemoveAssemblies returns null and fails to remove the DomainAssembly* for AssemblyC from the AppDomain(
      pFirstDestroyedLoaderAllocator = GCLoaderAllocators_RemoveAssemblies(pAppDomain);
      )
    4. LoaderAllocator::GCLoaderAllocators continues by releasing the LongWeakHandle (ReleaseManagedAssemblyLoadContext) and delete DomainAssembly*
      After delete DomainAssembly*, the DomainAssembly* cached in AppDomain becomes a dangling pointer
      (
      delete (DomainAssembly*)domainAssemblyIt;
      )
      (
      pDomainLoaderAllocatorDestroyIterator->ReleaseManagedAssemblyLoadContext();
      )
  5. When LoaderAllocator_Destroy processes LA_A:

    1. It decrements its own m_cReferences value
    2. With m_cReferences now at zero, it calls LoaderAllocator::GCLoaderAllocators
    3. 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
    4. 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

No one assigned

    Type

    No type

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions