PSA: Don’t change the assembly name for published NuGet packages
Somebody published a new version of a NuGet package with a different assembly name. You’ll never guess what happens next.
A while ago I wrote about transitive dependencies and how, in the current day and age, MSBuild and classic NuGet (using packages.config
, colloquially known as ‘Visual Studio 2015 style’) are pretty bad at this, while the new dotnet
tooling (using PackageReference
, known as ‘Visual Studio 2017 style’) is much better at it.
TL;DR – Changing the assembly file name for a published NuGet package causes upgraders to experience difficult and frustrating errors.
Recently, I ran into an issue. We use Datadog to record and graph metrics on our systems and environment. They’ve helpfully provided a library to emit metrics from C# applications. They’ve even published it on NuGet! Of course, we don’t want to tie our application code directly to Datadog’s API. One day, somebody might get a bright idea and say that, instead, we should be storing our metrics in Microsoft Application Insights. At that point we don’t want to have to rewrite large parts of our code. So we’ve defined an interface called Metrics
and created a private NuGet package that provides an implementation of that interface. If we switch implementations, all we have to do is switch out that library, rewrite some configuration code, and we’re good to go. So far, so good.
Upgrade to dotnet
I happened to be upgrading a project from MSBuild tooling to dotnet
tooling. The process for that is pretty much as follows:
- Open your
.csproj
file, and replace it with this minimal XML:
<Project Sdk="Microsoft.NET.Sdk"> <!-- Or "Microsoft.NET.SDK.Web" for web apps -->
<PropertyGroup>
<TargetFramework>net462</TargetFramework>
</PropertyGroup>
</Project>
- Let your IDE reload the project and start inspecting the errors, adding NuGet and project references as you go.
- When all the errors are gone, try running it.
- Commit your changes.
While I was going through the errors, I saw there was a direct dependency on Datadog’s library to be able to configure it. I opened the ‘Manage NuGet Packages’ tool window and installed the DogStatsD-CSharp-Client
package. The version installed was 3.1.0
. A bit later I came across some code that used our Metrics
implementation, so I installed our private NuGet package. It had a dependency on version 3.0.0
of the same DogStatsD-CSharp-Client
package. Because we know the dotnet
tooling can deal with this version conflict, I didn’t even give it a second thought.
Error time
Finally, it was time to run the code. I was presented with a very disturbing error.
Unhandled Exception: System.IO.FileNotFoundException:
Could not load file or assembly 'DogStatsD-CSharp-Client, Version=3.0.0.94, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
The system cannot find the file specified.
at OurMetricsAbstraction.Datadog.DatadogMetrics.IncrementCounter(String name, String[] tags)
at WereGonnaMakeSoMuchMoneyWithThis.Program.Main(String[] args)
Wait, what?
The runtime cannot load version 3.0.0.94
of the DogStatsD-CSharp-Client
library. I have just upgraded the version, so could that be a problem? It should’t be, because the build system should handle all of this for me. Maybe there’s an issue with the binding redirects. The new build system automatically generates these based on the references and stores them in MyAssemblyName.dll.config
(or .exe.config
, of course).
Lots of binding redirects there, but none for DogStatsD-CSharp-Client
.
File not found
Wait a minute. It’s not a version mismatch. It says The system cannot find the file specified
. If it had been a version mismatch, it would have looked like this:
System.IO.FileLoadException: Could not load file or assembly 'DogStatsD-CSharp-Client, Version=3.0.0.94, Culture=neutral, PublicKeyToken=null' or one of its dependencies.
The located assembly's manifest definition does not match the assembly reference. (Exception from HRESULT: 0x80131040)
So, alright, definitely a missing file. Let’s look at the ‘bin’ directory. There are hundreds of files there, but, indeed, DogStatsD-CSharp-Client.dll
is not one of them. What gives? I have a direct dependency on the package, so it should be there, right? Is there somehow an issue with the NuGet package? If there were, I would expect to get build errors, though.
CSI NuGet
I’m using the excellent NuGet Package Explorer to examine the contents of NuGet packages.
Well, well, well. The file in the 3.1.0
version of the NuGet package is called StatsdClient.dll
. Just to drive the point home, what does the 3.0.0
version look like?
Right. Our NuGet package implementing the Metrics
interface using the Datadog client was compiled against DogStatsD-CSharp-Client
, version 3.0.0
, so it refers to methods in DogStatsD-CSharp-Client.dll
.
The hosting application, however, has a direct reference to version 3.1.0
of the package, so it refers to methods in StatsdClient.dll
. Let me show you some IL code to illustrate this.
Let’s take the following C# code:
StatsdClient.DogStatsd.Configure(new StatsdClient.StatsdConfig());
In the NuGet package, the IL for that looks like this:
newobj instance void ['DogStatsD-CSharp-Client']StatsdClient.StatsdConfig::.ctor()
call void ['DogStatsD-CSharp-Client']StatsdClient.DogStatsd::Configure(class ['DogStatsD-CSharp-Client']StatsdClient.StatsdConfig)
For those of you not fluent in IL (which includes myself), the second line is saying ‘call a static method Configure
from the type StatsdClient.DogStatsd
, located in the assembly DogStatsD-CSharp-Client
’.
In the hosting application, the IL for the exact same code looks like this:
newobj instance void [StatsdClient]StatsdClient.StatsdConfig::.ctor()
call void [StatsdClient]StatsdClient.DogStatsd::Configure(class [StatsdClient]StatsdClient.StatsdConfig)
The exact same method is being called, but declared as coming from a different assembly.
So let’s recap what’s happening.
- The NuGet package has a dependency on
DogStatsD-CSharp-Client
, version3.0.0
or, more formally,>= 3.0.0
. - The hosting application has a dependency on the same package, but version
>= 3.1.0
. - The final resolved version of the package is
3.1.0
, because it satisfies both requirements. - Because of this, the only file that ends up being copied to the output directory of the project is that of version
3.1.0
,StatsdClient.dll
. - At runtime, the code in the NuGet package instructs the runtime to load
DogStatsD-CSharp-Client.dll
, which doesn’t exist, causing an exception to be thrown.
Dangerous Subtleties
In this case, because the conflict is located in a NuGet package, the result is a file not being found, which is a pretty visible problem that also manifests itself in a clear way.
If, however, the implementation for the Metrics
interface had been a project in the same solution, the issue would have been much more subtle and might never have been discovered.
In my experiments to recreate the issue for this blog post, I initially created exactly that situation. A class library project that references version 3.0.0
of the Datadog NuGet package, and then a console application project that references version 3.1.0
and the class library project.
Visual Studio 2017 nicely highlights the different versions, and coincidentally, also the different file names.
In my console application, I call out to the library project, which calls out to DogStatsD-CSharp-Client.dll
. It should cause an exception, right? I thought so too, but it doesn’t. Looking in the bin directory, we can see why.
MSBuild has a lot more insight into what the library project is referencing, so it dutifully copies DogStatsD-CSharp-Client.dll
into the bin directory of the main project. The library’s code is linked to that, so when that code is executed, the runtime loads DogStatsD-CSharp-Client.dll
and runs its code. However, when the main project uses code from the NuGet package – to configure the client, for example – the runtime also loads StatsdClient.dll
and runs its code.
Why is this bad? Well, because of the different assembly names, the runtime considers these to be different modules, which causes all kinds of ‘strange’ behavior.
For example, static
members defined in DogStatsD-CSharp-Client.dll
are not the same as those in StatsdClient.dll
. This means that I can ‘configure’ the client in the main project, but the code in the library project doesn’t see any of that.
Let’s see this in practice. One of the settings that can be configured is the prefix that automatically gets added to metric names. Because this is a private piece of data, I wrote some code that extracts it by hacking reflection.
private static string GetPrefix()
{
var serviceField = typeof(DogStatsd).GetField("_dogStatsdService", BindingFlags.Static | BindingFlags.NonPublic);
var service = (DogStatsdService) serviceField.GetValue(null);
var prefixField = typeof(DogStatsdService).GetField("_prefix", BindingFlags.Instance | BindingFlags.NonPublic);
return (string) prefixField.GetValue(service);
}
I configured the prefix to be "test"
in the main project, and when I run the method from both projects, you can see it doesn’t work as we intended:
(Main) Prefix: test
(Library) Prefix: (null)
Now, this is just a metric prefix, but you can imagine what might happen if you configure, for example, a connection string in this manner.
Dependency injection might also suffer from this problem. As you can see in the code snippet I use to extract the static prefix, there is a type called DogStatsdService
. This is actually a public type that is the non-static version of the API. It also implements an interface called IDogStatsd
.
I can write a class Foo
(because naming is difficult) in my library project that has a dependency on IDogStatsd
. In my hosting application, I’ll register DogStatsdService
to be the implementation for IDogStatsd
. Using SimpleInjector, trying to resolve Foo
will result in the following exception:
SimpleInjector.ActivationException:
No registration for type Foo could be found and an implicit registration could not be made.
The constructor of type Foo contains the parameter with name 'service' and type IDogStatsd that is not registered.
Please ensure IDogStatsd is registered, or change the constructor of Foo.
Note that there exists a registration for a different type StatsdClient.IDogStatsd while the requested type is StatsdClient.IDogStatsd.
This is both highly informative and confusing. On the one hand, it tells us that there is no registration for IDogStatsd
, which is confusing. On the other hand, it does give us a clue by mentioning that there exists a registration for a different type StatsdClient.IDogStatsd while the requested type is StatsdClient.IDogStatsd
.
It still requires a fair bit of investigation to resolve.
Conclusion
The assembly names of a NuGet package are much more than just file names. They’re a contract, really. As with most contracts, changing the names will cause issues. Changing them without informing users is not so nice. Changing them without incrementing the major version of the package, which indicates a change that is not backwards compatible, is very unfriendly.
In general, just don’t change the assembly name. Ever. Stick with the one you used when you first published the package.