blog post image
Andrew Lock avatar

Andrew Lock

~10 min read

Please stop lying about .NET Standard 2.0 support!

This post is a bit of a rant about an issue I've been fighting against more and more recently: NuGet packages lying about supporting .NET Standard 2.0 when they don't, because they don't work on .NET Core 2.x/3.0.

A brief history lesson: .NET Standard 2.0

When Microsoft first released .NET Core 1.0, it only contained a small selection of the APIs available in .NET Framework at the time. In an effort to make it easier to write libraries that could be used in both .NET Framework and .NET Core (without needing to multi-target), they introduced the concept of .NET Standard.

Unlike .NET Core and .NET Framework which are platforms you can download and run on, .NET Standard is just an interface definition. Each version of .NET Standard contains a list of APIs a platform must support in order to implement that version of .NET Standard. For example, you can find the APIs in .NET Standard 1.0 here.

Each version of .NET Standard is a strict superset of the earlier versions, so all the APIs from earlier versions are available on later versions (let's just pretend .NET Standard 1.5/1.6 didn't happen😉). Similarly, if a platform implements, say, .NET Standard 1.4, then by definition, it implements .NET Standard 1.0-1.3 as well:

Each version of .NET Standard includes all the APIs from previous versions. The smaller the version of .NET Standard, the smaller the number of APIs. Taken from my book, ASP.NET Core in Action, Second Edition

You can also think of .NET Standard in terms of C# classes and interfaces. I like this metaphor from David Fowler.

.NET Standard 2.0 was released with .NET Core 2.0, and it introduced a lot of new APIs over the previous versions. It was heavily modelled on the surface area of .NET Framework 4.6.1. The upshot was that you should be able to write a library that targets .NET Standard 2.0 and it should work on .NET Framework 4.6.1+ and .NET Core 2.0+.

And that worked (mostly), until very recently, when it stopped working.

That's a lot of integration testing

In the Datadog APM tracer library, we multi-target for maximum compatibility with customers. We currently support .NET 4.6.1, .NET Standard 2.0, and .NET Core 3.1, which means we can run on any application that targets .NET Framework 4.6.1+ or .NET Core 2.1+.

The Datadog tracer integrates with a whole range of libraries to add APM tracing capabilities, in a wide range of old and new libraries, across all these frameworks. Obviously that means we need to do a lot of integration testing, so we run extensive integration tests for the packages we support, across a big range of TFMs:

<PropertyGroup>
    <!-- only run .NET Framework tests on Windows -->
    <TargetFrameworks Condition="'$(OS)' == 'Windows_NT'">net461;netcoreapp2.1;netcoreapp3.0;netcoreapp3.1;net5.0;net6.0</TargetFrameworks>
    <TargetFrameworks Condition="'$(OS)' != 'Windows_NT'">netcoreapp2.1;netcoreapp3.0;netcoreapp3.1;net5.0;net6.0</TargetFrameworks>
</PropertyGroup>

The Datadog tracer relies on internal implementation details of most of the libraries it supports, which means we have to be very careful to look for breaking changes. Consequently, we run tests against the latest minor version of each package, for all the supported framework versions. We programmatically generate this list for each library, based on our supported version range, and the frameworks that the library itself supports. For example, for Npgsql, we generate the following XUnit theory data:

public static IEnumerable<object[]> Npgsql =>
    new List<object[]>
    {
#if NET461
        new object[] { "4.0.12" },
        new object[] { "4.1.10" },
        new object[] { "5.0.12" },
        new object[] { "6.0.3" },
#endif
#if NETCOREAPP2_1
        new object[] { "4.0.12" },
        new object[] { "4.1.10" },
        new object[] { "5.0.12" },
#endif
#if NETCOREAPP3_0
        new object[] { "4.0.12" },
        new object[] { "4.1.10" },
        new object[] { "5.0.12" },
#endif
#if NETCOREAPP3_1
        new object[] { "4.0.12" },
        new object[] { "4.1.10" },
        new object[] { "5.0.12" },
        new object[] { "6.0.3" },
#endif
#if NET5_0
        new object[] { "4.0.12" },
        new object[] { "4.1.10" },
        new object[] { "5.0.12" },
        new object[] { "6.0.3" },
#endif
#if NET6_0
        new object[] { "4.0.12" },
        new object[] { "4.1.10" },
        new object[] { "5.0.12" },
        new object[] { "6.0.3" },
#endif
    }

The eagle-eyed among you may notice that the #if for .NET Core 2.1 and .NET Core 3.0 don't include a 6.x version of the Npgsql, even though Npgsql clearly shows it supports .NET Standard 2.0, and hence should support both .NET Core 2.1 and .NET Core 3.0:

Npgsql NuGet package, showing the supported framework versions

The problem is, it doesn't. So what gives?

When a package lies about .NET Standard 2.0

To be clear, it isn't Npgsql lying, it's one of its dependencies, System.Runtime.CompilerServices.Unsafe. This package claims to support

  • .NET Framework 4.6.1
  • .NET Standard 2.0
  • .NET Core 3.1
  • .NET 6

So you can quite happily install it in a .NET Core 2.1 or .NET Core 3.0 app thanks to the .NET Standard 2.0 target:

> dotnet add package System.Runtime.CompilerServices.Unsafe
  Determining projects to restore...
  Writing C:\Users\Sock\AppData\Local\Temp\tmp9167.tmp
info : Adding PackageReference for package 'System.Runtime.CompilerServices.Unsafe' into project 'C:\repos\temp\temp.csproj'.
info :   GET https://api.nuget.org/v3/registration5-gz-semver2/system.runtime.compilerservices.unsafe/index.json
info :   OK https://api.nuget.org/v3/registration5-gz-semver2/system.runtime.compilerservices.unsafe/index.json 423ms
info : Restoring packages for C:\repos\temp\temp.csproj...
info : Package 'System.Runtime.CompilerServices.Unsafe' is compatible with all the specified frameworks in project 'C:\repos\temp\temp.csproj'.
info : PackageReference for package 'System.Runtime.CompilerServices.Unsafe' version '6.0.0' added to file 'C:\repos\temp\temp.csproj'.
info : Committing restore...
info : Generating MSBuild file C:\repos\temp\temp5\obj\temp5.csproj.nuget.g.props.
info : Generating MSBuild file C:\repos\temp\temp5\obj\temp5.csproj.nuget.g.targets.
info : Writing assets file to disk. Path: C:\repos\temp\temp5\obj\project.assets.json
log  : Restored C:\repos\temp\temp.csproj (in 151 ms).

But if you run dotnet restore or dotnet build you suddenly get an error:

C:\Users\Sock\.nuget\packages\system.runtime.compilerservices.unsafe\6.0.0\buildTransitive\netcoreapp2.0\System.Runtime.CompilerServices.Unsafe.targets(4,5): 
error : System.Runtime.CompilerServices.Unsafe doesn't support netcoreapp2.1. Consider updating your TargetFramework to netcoreapp3.1 or later. [C:\repos\temp\temp.csproj]

So what's going on here? The package supports .NET Standard, why can't you use it in a .NET Standard compatible project?!

Well, you can. You just can't use in a .NET Core 2.1 or .NET Core 3.0 app. You can use it in a Xamarin app, a UWP app, a Unity app, anything else that implements .NET Standard 2.0. Just not .NET Core before .NET Core 3.1.

But... why?

The short answer is that .NET Core 2.1 and .NET Core 3.0 are now out of support. According to the original PR and this document about the change:

Continuing to build for all frameworks increases the complexity and size of a package. In the past, .NET solved this issue by building only for current frameworks and harvesting binaries for older frameworks. Harvesting means that during build, the earlier version of the package is downloaded and the binaries are extracted.

While consuming a harvested binary means that you can always update without worrying that a framework is dropped, it also means that you don't get any bug fixes or new features. In other words, harvested assets can't be serviced. That's hidden from you because you're able to keep updating the package to a later version even though you're consuming the same old binary that's no longer updated.

Starting with .NET 6, .NET no longer performs any form of harvesting to ensure that all shipped assets can be serviced.

On the one hand, this all seems reasonable to me. Out of support versions shouldn't be holding back development. They don't need to be supported, and don't need to receive any ongoing development.

But, if the package does still support .NET Standard 2.0 then you don't need to put any effort into supporting those out-of support versions, you get that support for free.

The whole point of .NET Standard 2.0 is that any platform that implements it can use packages targeting .NET Standard 2.0.

So you can absolutely remove all that complexity from the build, and if you're targeting .NET Standard 2.0, then it doesn't matter, you should still be able to use them! But instead, the package explicitly errors when you try to use it in a .NET Core 2.1/3.0 app.

I don't see anything in the document or PR that address why they need to error. All I can find is a reference to the fact you'll get errors at runtime, though in my limited testing, I haven't see any. 🤷‍♂️

Fundamentally, this feels like a big change in support policy. where "not supported" no longer means "you're on your own if it doesn't work" to "we are actively stopping you".

So does it really matter? Is it reasonable?

So the question is, am I getting het up about nothing here? So a bunch of NuGet packages (~50) imply they're compatible with .NET Core 2.1 but then error when you restore/build. Is that a big deal? If you're using an app that old, you will find out very quickly and can (hopefully) stay on the older version of the package.

I'd argue that there are 2 fundamental problems with this:

  • .NET Standard is now meaningless
  • These packages are often transitive dependencies

Taking the first point, this fundamentally breaks the contract that .NET Standard was meant to provide. Yes, .NET Standard is becoming less relevant with the combining of Xamarin/Maui into .NET proper, but that shouldn't mean Microsoft goes out of its way to be actively hostile to its whole purpose.

If they don't really support .NET Standard 2.0 (because they don't run on platforms that support .NET Standard 2.0), then I feel like they could/should have used more specific TFMs for the platforms they do support. Yes, that means more targets in the packages again, but at least those targets are correct.

But the real issue is when they're used as transitive dependencies. Take the Npgsql package, for example. As we've already seen, this supports .NET Standard 2.0, but due to a transitive dependency on System.Runtime.CompilerServices.Unsafe, it can no longer be used with .NET Core 2.1/3.0.

Now, the author of Npgsql, Shay Rojansky works for Microsoft, so he definitely understands the implications, and made the break on a major version, so fair enough. But what about other package authors?

  • The CouchbaseNetClient package removed support in version 3.2.6, when it works on .NET Core 2.1 in version 3.2.5.
  • StackExchange.Redis 2.2.x fails at runtime on .NET Core 2.1/3.0 with "The assembly for System.Runtime.CompilerServices.Unsafe could not be loaded".

I'm sure there's going to be more, and again, I'd like to reiterate that I have no expectation or desire for package authors to support these old frameworks. The problem is that .NET Standard doesn't mean anything any more.

And just to be clear, the first builds of .NET 7 show that .NET Core 3.1 and .NET 5 targets are going to be removed (as these will be out of support by November 2022). So bear that in mind in the future.

How are they even doing it anyway?

OK, so that's the way it is now. But some of you might be wondering how these packages are causing the errors when you restore/build. You can find the answer inside the NuGet package. As shown below, there's a buildTransitive folder which contains a netcoreapp2.0 folder with a .targets file, and an empty netcoreapp3.1 folder.

The contents of the nuget package

The buildTransitive folder allows you to add .targets and .props files that apply to both the consuming project, and any downstream projects that consume that project.

By including the .targets file in a netcoreapp2.0 folder and an empty netcoreapp3.1 folder, the .targets file will only apply to projects using one of the following target frameworks:

  • .NET Core 2.0
  • .NET Core 2.1
  • .NET Core 2.2
  • .NET Core 3.0

The .targets file itself simply writes an Error (unless the variable SuppressTfmSupportBuildWarnings is set)

<Project InitialTargets="NETStandardCompatError_System_Runtime_CompilerServices_Unsafe_netcoreapp3_1">
  <Target Name="NETStandardCompatError_System_Runtime_CompilerServices_Unsafe_netcoreapp3_1"
          Condition="'$(SuppressTfmSupportBuildWarnings)' == ''">
    <Error Text="System.Runtime.CompilerServices.Unsafe doesn't support $(TargetFramework). Consider updating your TargetFramework to netcoreapp3.1 or later." />
  </Target>
</Project>

This causes all restore/build operations to error, as we've already seen. It's pretty elegant, in its own way.

As you can see from the file above, you could try setting SuppressTfmSupportBuildWarnings to build and run using the .NET Standard 2.0 assets. From my (limited) testing, this seems to work fine on .NET Core 2.1 and .NET Core 3.0. But do you really want to risk it? 🤔

So, am I overreacting here?

Yeah, probably.

Summary

In this post I described how some NuGet packages that support .NET Standard 2.0, don't support .NET Core 2.1/.NET Core 3.0. You can install these NuGet packages, as they appear to be supported in .NET Core 2.1 projects. But when you run dotnet restore/dotnet build, you get an error saying the package isn't supported. In my opinion, this fundamentally breaks the promise of .NET Standard.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?