I'm currently debugging my company's CLR profiler (over ASP.NET 4.7.3282.0, .NET framework 4.7.2), and seeing a scenario in which the CLR unloads a generic class, but the ClassUnloadStarted callback is not called.
In a nutshell, our profiler keeps track of loaded classes based on ClassIDs, following the ClassLoadStarted, ClassLoadFinished and ClassUnloadStarted callbacks. At some point, the class gets unloaded (along with its relevant module), but the ClassUnloadStarted callback is not called for the relevant ClassID. Therefore, we're left with a stall ClassID, thinking that the class is still loaded. Later on, when we try to query that ClassID, the CLR unsurprisingly crashes (since it now points to junk memory).
My question, considering the detailed scenario below:
- Why is ClassUnloadStarted not called for my (generic) class?
- Is this an expected, edge case behaviour of the CLR, or possibly a CLR/Profiling API bug?
I couldn't find any documentation or reasoning regarding this behaviour specifically, of ClassUnloadStarted not being called. No hints I could find in the CoreCLR code, too. Thanks in advance for any help!
The Detailed Scenario:
This is the class in question (IComparable(T)
with T=ClassFromModuleFoo
):
System/IComparable`1<ClassFromModuleFoo>
While the application runs, the issue manifests after some modules have been unloaded.
Here's the exact load/unload callbacks flow, based on debug prints added:
- The class
System/IComparable'1(ClassFromModuleFoo)
, of mscorlib, is loaded. - Immediately afterwards, the class
ClassFromModuleFoo
, of the module Foo, is loaded into assembly #1. - Module Foo finishes to load into assembly #1.
- Then, module Foo is loaded again into a different assembly, #2.
- The
IComparable
andClassFromModuleFoo
are loaded again, this time in assembly #2. Now there are two instances of each class: one in Foo loaded in assembly #1, and one in Foo loaded in assembly #2. - Module Foo begins to unload from assembly #1.
ClassUnloadStarted
callback is called forClassFromModuleFoo
in assembly #1.- Module Foo finished to unload from assembly #1.
ClassUnloadStarted
is not called forSystem/IComparable'1(ClassFromModuleFoo)
of assembly #1 anytime later (even though its module unloaded and its ClassID points to now thrashed memory).
Some additional information:
- The issue also reproduces with the latest .NET framework version, 4.8 preview.
- I've disabled native images by adding
COR_PRF_DISABLE_ALL_NGEN_IMAGES
to the profiler event mask, thinking it may impact the ClassLoad* callbacks, but it didn't made any difference. I verified thatmscorlib.dll
in indeed loaded instead of its native image.
Edit:
Thanks to my very smart colleague, I was able to reproduce the issue with a small example project, that simulates this scenario by loading and unloading of AppDomains. Here it is:
https://github.com/shaharv/dotnet/tree/master/testers/module-load-unload
The crash occurs for this class in the test, which is unloaded, and for which the CLR didn't call the unload callback:
Loop/MyGenList`1<System/String>
Here's the relevant code, which is loaded and unloaded a few times:
namespace Loop
{
public class MyGenList<T>
{
public List<T> _tList;
public MyGenList(List<T> tList)
{
_tList = tList;
}
}
class MyGenericTest
{
public void TestFunc()
{
MyGenList<String> genList = new MyGenList<String>(new List<string> { "A", "B", "C" });
try
{
throw new Exception();
}
catch (Exception)
{
}
}
}
}
At some point, the profiler crashes trying to query the ClassID of that class - thinking it's still valid, since the unload callback was not called for it.
On a side note, I tried porting this example to .NET Core for investigating further, but couldn't figure out how, since .NET Core doesn't support secondary AppDomains (and I'm not very sure it supports on-demand assembly unloading in general).