Debugging Godot Assembly Unloading
If you’ve used Godot C# .NET development before, you might be familiar with an error like this:
ERROR: modules/mono/mono_gd/gd_mono.cpp:749 - .NET: Failed to unload assemblies. Please check https://github.com/godotengine/godot/issues/78513 for more information.
ERROR: modules/mono/csharp_script.cpp:674 - Condition "managed_callable->delegate_handle.value == nullptr" is true. Continuing.
ERROR: modules/mono/csharp_script.cpp:674 - Condition "managed_callable->delegate_handle.value == nullptr" is true. Continuing.
ERROR: modules/mono/csharp_script.cpp:674 - Condition "managed_callable->delegate_handle.value == nullptr" is true. Continuing.
ERROR: modules/mono/mono_gd/gd_mono.cpp:749 - .NET: Failed to unload assemblies. Please check https://github.com/godotengine/godot/issues/78513 for more information.
ERROR: modules/mono/csharp_script.cpp:674 - Condition "managed_callable->delegate_handle.value == nullptr" is true. Continuing.
ERROR: modules/mono/csharp_script.cpp:674 - Condition "managed_callable->delegate_handle.value == nullptr" is true. Continuing.
ERROR: modules/mono/csharp_script.cpp:674 - Condition "managed_callable->delegate_handle.value == nullptr" is true. Continuing.
ERROR: modules/mono/mono_gd/gd_mono.cpp:749 - .NET: Failed to unload assemblies. Please check https://github.com/godotengine/godot/issues/78513 for more information.
ERROR: modules/mono/mono_gd/gd_mono.cpp:733 - .NET: Giving up on assembly reloading. Please restart the editor if unloading was failing.
--- Debugging process stopped ---
This can happen when you build and/or when you run your game. Often times this happens when you are using a [Tool]
or third party libraries, though sometimes it just feels random.
If you’re like me, it’s not even clear how to begin debugging something like this (beyond some crude and cumbersome bisection perhaps?)
Thankfully, I just found a github comment by someone smarter than me who gave us a huge lead which finally helped me understand the problem a lot better.
If you immediately understand that github comment, then this article probably won’t be of much use to you and you can go ahead and close now.
Otherwise, I’m about show exactly how I used that information to debug my problem, and how you might do the same. This is a huge level-up for your debugging in general and will hopefully equip you to be able to handle these issues yourself in the future.
The Debugging Process
Setup WinDbg
The first step is to install WinDbg, which is a nice program for debugging Windows programs.
I’d never used WinDbg before, and haven’t really done much debugging like this in the past, so right away I was feeling skeptical and overwhelmed–worried that it would take a million years to understand. Fortunately that wasn’t the case!
Next, I needed to install a DLL that enables debugging .NET programs, called dotnet-sos
. Again, I’d never heard of this. This is what Claude has to say about it:
Dotnet-sos (SOS stands for “Son of Strike”) is a debugger extension that helps developers troubleshoot .NET Core and .NET applications. It provides commands that let you examine the internals of .NET applications when debugging, particularly useful for investigating crashes, memory issues, and other complex problems.
In order to install it, I had to open PowerShell:
PS> dotnet tool install -g dotnet-sos
and then
PS> dotnet-sos install
Once I did that, it will provided a .load
command which to use inside of WinDbg. We’ll get to that in a bit.
Attach the debugger
Now with WinDbg open, I opened the Godot project separately. Once I did that, I attached WinDbg to the Godot application.
In WinDbg, go to File > Attach to Process
. Then search for Godot and click “Attach”
The debugger then looked like this:
I didn’t really understand everything I was looking at but it seemed like it was working correctly. For a moment I was worried that Godot was frozen / unresponsive. Then I realized it’s because WinDbg has grabbed it and enabled a breakpoint (i.e. paused it) so it was all good.
At this point, I loaded my sos DLL in the WinDbg command console: .load C:\Users\wyatt\.dotnet\sos\sos.dll
Once it loaded, then I went ahead and clicked the big green “Go” button in the top left to proceed.
Finally, I once again made Godot trip the dreaded error:
ERROR: modules/mono/mono_gd/gd_mono.cpp:749 - .NET: Failed to unload assemblies. Please check https://github.com/godotengine/godot/issues/78513 for more information.
This time, I’m no longer afraid. I’m confronting my fears. That’s right. I’m PISSED.
Wielding the the WinDbg program, I ran the command:
!dumpheap -type LoaderAllocator
Which gave some output:
0:056> !dumpheap -type LoaderAllocator
Loading extension C:\Program Files\WindowsApps\Microsoft.WinDbg_1.2504.15001.0_x64__8wekyb3d8bbwe\amd64\winext\sos\extensions\Microsoft.Diagnostics.DataContractReader.dll
Loading extension C:\Program Files\WindowsApps\Microsoft.WinDbg_1.2504.15001.0_x64__8wekyb3d8bbwe\amd64\winext\sos\extensions\Microsoft.Diagnostics.DataContractReader.Extension.dll
Loading extension C:\Program Files\WindowsApps\Microsoft.WinDbg_1.2504.15001.0_x64__8wekyb3d8bbwe\amd64\winext\sos\extensions\Microsoft.Diagnostics.DebuggerCommands.dll
Address MT Size
01ba2700a638 7ffa68ab8ac8 48
01ba2700a668 7ffa68ab8bf0 24
Statistics:
MT Count TotalSize Class Name
7ffa68ab8bf0 1 24 System.Reflection.LoaderAllocatorScout
7ffa68ab8ac8 1 48 System.Reflection.LoaderAllocator
Total 2 objects, 72 bytes
To be honest with you, this means very little to me. I vaguely understand it, at least enough to infer that the most important part was the “Address” column with those two hex values:
Address
01ba2700a638
01ba2700a668
Then I ran the gcroot
command. Don’t ask me what it does. I could research it, but that would waste valuable brain space that can be filled with more important things like more Japanese kanji (I’ve been studying the language extensively lately, just in case you wanted to know. You didn’t, but I just told you anyway.)
0:056> !gcroot 01ba2700a638
Caching GC roots, this may take a while.
Subsequent runs of this command will be faster.
HandleTable:
000001ba17c713a8 (strong handle)
-> 01ba25004018 System.Object[]
-> 01ba2702d530 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.Loader.AssemblyLoadContext, System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>> (static variable: System.Runtime.CompilerServices.ConditionalWeakTable<System.Runtime.Loader.AssemblyLoadContext, System.Object>._alcsBeingUnloaded)
-> 01ba2702d8e8 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.Loader.AssemblyLoadContext, System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>>+Tables
-> 01ba2702d7a8 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.Loader.AssemblyLoadContext, System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>>+VolatileNode[]
-> 01ba27036a68 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.Loader.AssemblyLoadContext, System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>>+Node
-> 01ba27036848 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>
-> 01ba270a7c00 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>+Tables
-> 01ba270a7888 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>+VolatileNode[]
-> 01ba270f2cd8 System.Collections.Concurrent.ConcurrentDictionary<System.Runtime.InteropServices.GCHandle, System.Object>+Node
-> 01ba270f2c98 System.Func<Nebula.WorldRunner, Godot.ENetPacketPeer, Nebula.Serialization.HLBuffer, Godot.GodotObject, Godot.GodotObject>
-> 01ba270f2c80 Nebula.Serialization.ProtocolRegistry+<>c__DisplayClass31_0
-> 01ba270c3288 System.Reflection.RuntimeMethodInfo
-> 01ba270c3020 System.RuntimeType+RuntimeTypeCache
-> 01ba270e8390 System.RuntimeType+RuntimeTypeCache+MemberInfoCache<System.RuntimeType>
-> 01ba270e83c8 System.RuntimeType[]
-> 01ba2709b0b8 System.RuntimeType
-> 01ba2700a638 System.Reflection.LoaderAllocator
Found 1 unique roots.
Now this is something I’m more familiar with! To me it looks like some kind of call stack / stack trace. Sure enough, I can see two parts that are particularly relevant.
First, the label at the top (strong handle)
.
This jumped out at me because if you run Godot in a terminal, it can provide extra debug logs, and part of the logs it gave me in the terminal (which don’t appear in the app itself) was this:
Failed to unload assemblies. Possible causes: Strong GC handles, running threads, etc.
“Strong GC handles”. So I had pretty good evidence that this was at least one thing causing the assembly unload failure.
Second, I saw this section of the stack:
-> 01ba270f2c98 System.Func<Nebula.WorldRunner, Godot.ENetPacketPeer, Nebula.Serialization.HLBuffer, Godot.GodotObject, Godot.GodotObject>
-> 01ba270f2c80 Nebula.Serialization.ProtocolRegistry+<>c__DisplayClass31_0
That’s my code! Now that I knew WinDbg was accusing my code of being the problem, I promptly exited everything and reported WinDbg as faulty to Microsoft, swearing about how bad their products are… (winky emoji)
After that, I opened up my code and went to the part where I know the System.Func<Nebula.WorldRunner, Godot.ENetPacketPeer, Nebula.Serialization.HLBuffer, Godot.GodotObject, Godot.GodotObject>
type was defined.
MyDict[someKey] = Callable.From((WorldRunner currentWorld, NetPeer peer, HLBuffer buffer, GodotObject initialObject) => method.Invoke(null, [currentWorld, peer, buffer, initialObject]) as GodotObject);
The exact details of that code doesn’t really matter. The important thing to note is that Callable was being saved in a Dictionary
of a [Tool]
. The method
variable is actually a C# MethodInfo type, which allows me to call a static method on C# types via reflection, without knowing the exact class in advance.
I went to Claude (not a sponsor) and asked it why this might cause assembly unload failure. It explained in that sweet, slow, and patient way (perfect for a dumb dumb like me to understand) that the type was being stored in a dictionary, and it is a strong handle, so all references have to be cleared in order to dispose the object.
If you don’t already know, GodotObjects all implement IDisposable which means that you just need to put your cleanup tasks in there:
protected override void Dispose(bool disposing)
{
MyDict.Clear();
base.Dispose(disposing);
}
(I just put that function on the Node
that had MyDict
and the above Callable
assignment code.)
Yup, that’s it! The error completely disappeared!
Surely it won’t come back in some other form in the future…
The End…?
(Ominous outro music)