Assembly resolving edge-cases in .NET Core
·Type.GetType
is an interesting API. I reliably forget that Type.GetType
can load types via its FullName
[1] only from the executing assembly and from mscorlib.dll/System.Private.CoreLib.dll
libraries. For types in any other assembly the API requires the assembly-qualified name. For the later case, Type.GetType
will also load said assembly if needed. Well, in most cases…
The unexpected behavior
NServiceBus (no worries, this post isn’t about NServiceBus) can call Type.GetType
to load user-defined message types. However, running some particular NServiceBus version 8 (preview) sample on .NET Core, I noticed Type.GetType
returning null
although the indicated assembly was very clearly sitting right next to the NServiceBus.Core.dll
in the applications bin folder.
At the same time, running the same sample on .NET Framework loaded the type (and assembly) perfectly well. Now, for people more familiar with this topic (not me), this won’t come as a surprise. That is the expected behavior, .NET Core applications don’t just resolve assemblies straight from disk. In .NET Core applications, those assemblies typically need to be listed in the deps.json
file, which they usually will be if you have a project reference in the .csproj
file. I intentionally did not have this reference, so .NET Core just wholly ignored the desired assembly sitting in front of its face.
There is helpful documentation on the official documentation about the default AssemblyLoadContext
’s probing behavior if you’re interested in more details. AssemblyLoadContext
is the new standard API you need to become more familiar with if you want to understand assembly loading in .NET Core.
The culprit
So the described behavior is expected and relatively well documented. But when I changed my sample to the current stable version of NServiceBus, 7.5, things got more curious. NServiceBus version 7 also runs on .NET Core as it targets .NET Standard 2.0.
With NServiceBus version 7, the type was found using Type.GetType
, and the containing assembly got loaded. There was no project reference to the said assembly or any entry in the deps.json
. That baffled me quite a bit. I couldn’t explain how the same call to Type.GetType
running on the same .NET Core application would work with one version of NServiceBus but not with another.
To better understand the resolving behavior, I used dotnet-trace
to collect detailed assembly loading logging from the runtime. The generated trace file can be loaded with PerfView and gives detailed insights into an application’s assembly loading/resolution behavior. In these event traces, I noticed that the NServiceBus 7 trace had an event which the version 8 trace did not have: AppDomainAssemblyResolveHandlerInvoked
. In the event’s data, I could see a Handler
property with the value LoadFromResolveHandler
. So apparently, there was a special assembly resolver registered that was able to resolve the assembly location. I was pretty sure that NServiceBus itself did not register such a handler.
Digging deeper, I found the handler in the runtime. Reading the behavior of this resolve handler, I realized the fairly small difference between NServiceBus 7 and 8 which completely changed the assembly probing behavior:
Since NServiceBus 7 is targeting .NET Standard, it doesn’t use the new .NET Core assembly loading APIs (using AssemblyLoadContext
) yet. Due to some reasons that are not interesting to explain in detail (in this post), NServiceBus 7 invokes Assembly.LoadFrom("NServiceBus.Core.dll")
to load itself (if you think this doesn’t make much sense, you’re right, but again, this is a different topic). Calling LoadFrom
seems to register that special AppDomain.AssemblyResolve
handler, while AssemblyLoadContext.LoadFromAssemblyPath("NServiceBus.Core.dll")
won’t do that.
Following the .NET Core assembly resolving algorithm, this handler will be invoked as the final attempt to resolve the requested assembly. And because we’re calling Type.GetType
from an assembly that has been loaded via Assembly.LoadFrom
, it will now also search the same folder of the requesting assembly (NServicebus.Core.dll
in this case).
A simple repro
Mixing in NServiceBus might make this sound quite complicated to digest. We can distill this down into a reasonably simple repro sample:
The following is a .NET Core application. It references another assembly, called TypeLoader.dll
. TypeLoader.dll
contains a single, trivial type:
public class Loader
{
public static Type GetDemoClass() =>
Type.GetType("AssemblyToLoad.DemoClass, AssemblyToLoad, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null");
}
There is another assembly in this sample AssemblyToLoad.dll
which contains an empty class DemoClass
which we are trying to load. The AssemblyToLoad.dll
is not referenced in the project file, but the assembly is copied (manually) to the main applications bin folder. Now the actual app looks like this:
static void Main(string[] args)
{
var t = Type.GetType("AssemblyToLoad.DemoClass, AssemblyToLoad, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); // this will be null
Console.WriteLine($"Type.GetType result: {t}");
var t2 = Loader.GetDemoClass(); // this will be null
Console.WriteLine($"Loader.GetDemoClass result: {t2}");
var x = Assembly.LoadFrom("TypeLoader.dll");
var t3 = Type.GetType("AssemblyToLoad.DemoClass, AssemblyToLoad, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); // this will be null
Console.WriteLine($"Type.GetType result: {t3}");
var t4 = Loader.GetDemoClass(); // this will be a type reference
Console.WriteLine($"Loader.GetDemoClass result: {t4}");
}
The app calls Type.GetType
with the assembly-qualified name of DemoClass
. It won’t find it because the assembly is not in the deps.json
file. The same applies if we try to load this via the Loader
type.
However, once we load the assembly containing the Loader
type, TypeLoader.dll
, via Assembly.LoadFrom
(which is absolutely unnecessary here because it’s already loaded), the situation changes: The app’s call to GetType
still doesn’t find the type and continues to return null, but Loader
now returns the type because the LoadFromResolveHandler
picked up the AppDomain.AssemblyResolve
event handler. The LoadFromResolveHandler
checks the TypeLoader.dll
’s folder (because we loaded this assembly via Assembly.LoadFrom
), our app’s bin folder, which also contains our target assembly. You can play with this sample yourself, I’ve uploaded it to this GitHub repository, remember to copy the AssemblyToLoad.dll
to the app’s bin folder.
Luckily, chances that you will ever run into issues like this are pretty low, but it was an unexpected change in behavior due to a seemingly meaningless API call. It also shows why you should avoid all Assembly.Load
APIs when running on .NET Core and instead use the AssemblyLoadContext
APIs, which aren’t easier to use but come with fewer surprises.
Assembly loading remains a non-trivial topic, and having a basic knowledge of the resolving algorithm and debugging/tracing options can save you days of debugging weird application behaviors.
[1]: The official documentation never mentions FullName
as the string format but describes it as the type’s name including its namespace. That all does very much sound like FullName
to me but I guess there might be some little differences?