'Debugging assembly unloadability in .NET 5

I have an assembly which I need to unload dynamically. I have followed the steps in https://docs.microsoft.com/en-us/dotnet/standard/assembly/unloadability for both the development of my loader as well as the debugging using WinDbg. I have a "memory leak" because my unload command is not working. My question is how do I make my unload work?

Initially, I noticed that memory was not going down after using the unload command, so I created a dictionary of weak references which point at the assemblies. I dynamically load and unload the assembly every time I perform an operation, which is creating multiple copies of the assembly in memory. I can use the Dictionary<int, WeakReference> to see if the assemblies have been unloaded or not as they do in the guide above. They aren't getting released, no matter how long I wait, or how many times the garbage gets collected. This says to me that there's some outstanding reference, but for the life of me I can't find it.

I'm not making either of the mistakes they made in the example code- the only reference I hold onto is the weak reference. I even assign the assembly and the assembly loader to null once I'm done with them, though they go out of scope immediately anyway. I don't have any callbacks or, to the best of my knowledge Types across the border because the only communication I do across the boundary is via a file. The assembly is ending appropriately without issues. So I fired up WinDbg and tried to follow their instructions. Everything goes fine until I try to get the GC roots of the AssemblyLoader:

0:000> !gcroot -all 0x00007ff7b943f9c8
Found 0 roots.

And when I step through the threads in my application, I don't see anything that jumps out to me as suspicious, though honestly I don't have enough intuition in this area that that means much. The output is below.

0:021> ~*e !clrstack
OS Thread Id: 0x3510 (0)
        Child SP               IP Call Site
000000FEA5D7E6E0 00007ff8813111c4 [InlinedCallFrame: 000000fea5d7e6e0] Interop+User32.WaitMessage()
000000FEA5D7E6E0 00007ff7b8f38a15 [InlinedCallFrame: 000000fea5d7e6e0] Interop+User32.WaitMessage()
000000FEA5D7E6B0 00007ff7b8f38a15 ILStubClass.IL_STUB_PInvoke()
000000FEA5D7E770 00007ff813db015e System.Windows.Forms.Application+ComponentManager.Interop.Mso.IMsoComponentManager.FPushMessageLoop(UIntPtr, msoloop, Void*) [/_/src/System.Windows.Forms/src/System/Windows/Forms/Application.ComponentManager.cs @ 398]
000000FEA5D7E860 00007ff813db28f5 System.Windows.Forms.Application+ThreadContext.RunMessageLoopInner(msoloop, System.Windows.Forms.ApplicationContext) [/_/src/System.Windows.Forms/src/System/Windows/Forms/Application.ThreadContext.cs @ 1111]
000000FEA5D7E8F0 00007ff813db2568 System.Windows.Forms.Application+ThreadContext.RunMessageLoop(msoloop, System.Windows.Forms.ApplicationContext) [/_/src/System.Windows.Forms/src/System/Windows/Forms/Application.ThreadContext.cs @ 975]
000000FEA5D7E950 00007ff813a89c86 System.Windows.Forms.Application.Run(System.Windows.Forms.Form) [/_/src/System.Windows.Forms/src/System/Windows/Forms/Application.cs @ 1198]
000000FEA5D7E990 00007ff7b8bd9f4a ConfigTool.Program.Main() [C:\Users\isherman\Documents\Projects\twmp\FormsConfigTool\Program.cs @ 42]
<snipping out unmanaged threads>
OS Thread Id: 0xa2dc (6)
        Child SP               IP Call Site
000000FEA667FC30 00007ff88338d8c4 [DebuggerU2MCatchHandlerFrame: 000000fea667fc30] 
OS Thread Id: 0xc5fc (7)
        Child SP               IP Call Site
<snip>
OS Thread Id: 0x900c (12)
        Child SP               IP Call Site
<snip>
OS Thread Id: 0x9b88 (16)
        Child SP               IP Call Site
<snip>
OS Thread Id: 0xa358 (18)
        Child SP               IP Call Site
OS Thread Id: 0x9a8c (19)
        Child SP               IP Call Site
<snip>

And here's the code that I use to execute and unload the Assembly Load Context:

        private static Dictionary<int, WeakReference> references;
        private static int refCount =0;
        [MethodImpl(MethodImplOptions.NoInlining)]
        private static int ExecuteAndUnload(string assemblyPath, 
                           XmlDocument doc, out WeakReference alcWeakRef)
        {
            references ??= new Dictionary<int, WeakReference>();
            assemblyPath = Path.GetFullPath(assemblyPath);
            var alc = new SequesteredTagBrowser(assemblyPath);
            Assembly a = alc.LoadFromAssemblyPath(assemblyPath);
            Debug.Assert(a.EntryPoint !=null);
            alcWeakRef = new WeakReference(alc, true);
            references.Add(refCount++, alcWeakRef);
            //I pass some xml in as an argument to the Program.Main function
            var args = new object[] {new[] {doc.OuterXml}};
            a.EntryPoint.Invoke(null, args);
            a = null;

            alc.Unload();
            alc = null;
            GC.Collect();

            return 0;
        }

And here's the code for the SequesteredTagBrowser class:

    public class SequesteredTagBrowser:AssemblyLoadContext
    {
        private AssemblyDependencyResolver _resolver;

        public SequesteredTagBrowser(string mainAssemblyToLoadPath) : base(isCollectible: true)
        {
            
            _resolver = new AssemblyDependencyResolver(mainAssemblyToLoadPath);
        }

        protected override Assembly Load(AssemblyName name)
        {
            string assemblyPath = _resolver.ResolveAssemblyToPath(name);
            if (assemblyPath != null)
            {
                return LoadFromAssemblyPath(assemblyPath);
            }

            return null;
        }

    }


Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source