Upgrading to .NET 8: Part 3 - Previews 1-5

Highlights from upgrading to .NET 8 previews 1-5

12 July 2023 by Martin Costello |
12 July 2023 by Martin Costello

In the previous post of this series I described how with some GitHub Actions workflows we can reduce the amount of manual work required to test each preview of .NET 8 in our projects. With the infrastructure to do that set up we can now dig into some highlights of the things we found in our testing of .NET 8 itself in the preview releases available so far this year!

Preview 1

Preview 1 as I've found in the past is often the way with the first preview of a new major release, was pretty uneventful.

The first preview typically contains changes that were added after the previous major release was branched and considered feature complete. There's usually not yet much in the way of new features to try out. As such it's mostly just useful as a first step for a new year of testing and to get things ready for the more exciting changes that will come later in the release.

One item of note was that a number of new ASP.NET Core analysers were added which broke a few of our builds (we typically use TreatWarningsAsErrors=true for our builds). These were trivial to fix, but did identify two false positives that we reported to the ASP.NET Core team (1, 2).

These issues are a good example of why prerelease testing is useful to the .NET product teams - it exposes their changes to a wider body of code and can help identify patterns that might not have been considered as part of the tests added when the changes were first made.

A final small (but important!) change was that the default port used for HTTP for container images was changed from 80 to 8080. This subtle change required us to update the port mappings in some of our Kubernetes configuration files so that the application still worked correctly when deployed to an Elastic Kubernetes Service (EKS) cluster.

Preview 2

An interesting new change added in preview 2, again for containers, was the ability to run the application "rootless". By making a small change to the Dockerfile for the application before the ENTRYPOINT is defined, we can improve the default security of the application by running it as a non-root user with USER app.

Otherwise the release was again pretty uneventful.

Preview 3

Preview 3 was a little more interesting, with three interesting changes.

Container User ID Environment Variable

The first relates to the previous change for containers, but changes it in a way that's more secure than the previous way by using USER $APP_UID.

Instead of explicitly referencing the user by name, we can now use the APP_UID environment variable that is explicitly set in the Dockerfile to the UID of the app user. This avoids us having to hard-code a magic string - much nicer.

Artifacts output

The second change was a new feature that allows the output path for the build to be simplified. This opt-in change allows you to change from having a separate bin and obj folder per project to instead have a single artifacts folder in the root of the solution directory that instead contains all of the output from your build and publish steps.

The new artifacts output folder

This makes it much easier to find the output artifacts for your application and process them, such as when deploying an application to a remote server or publishing a NuGet package. Trying this out did uncover an issue in for library library repositories though - there was a bug where if you had enabled NuGet package validation then dotnet pack would fail.

I have this enabled in all my library projects, so I put testing this feature on hold until preview 4 as otherwise all of their continuous integration builds fail 😅.

Request Delegate Generator

Minimal APIs, which were introduced in .NET 6, are a great way to write simple HTTP API endpoints using Lambda expressions without the overhead and ceremony of an MVC Controller and Actions.

For example, you could write a simple API endpoint to return the current time as JSON like this:

app.MapGet("/now", () => new { utcNow = DateTimeOffset.UtcNow });

The way this is implemented at runtime though is that additional code is compiled when the applicaion starts up to provide the "glue" that wires up the Lambda expression to the HTTP request pipeline.

However, when an application is using Ahead-of-Time (AOT) compilation, this additional code cannot be compiled as the infrastructure to do so cannot be used. This creates a dilemma - how do we use Minimal APIs and Native AOT compilation together?

The answer to this is the new Request Delegate Generator (RDG).

This changes the way that Minimal APIs are implemented so that the additional code that is required is instead compiled into the application at build time using a Roslyn Source Generator. By moving the compilation to build time this side-steps the restrictions of AOT compilation, but it also increases the start-up time performance of an application not using AOT because the work no longer needs to be done at runtime.

Many applications I'm testing .NET 8 with are using Minimal APIs, but they also use functionality that isn't going to be supported by .NET AOT in the .NET 8 release, such as Razor Pages. Support is for these scenarios is likely to come in either .NET 9 (🤞) or .NET 10.

As it is still useful for applications without native AOT, I thought it would still be good to try out. This lead to the first issue I found with RDG - there was some scenarios that still weren't supported that lead to the compiler throwing an exception. Let's revisit RDG in preview 4.

Preview 4

With the release of preview 4 there was a bunch of new shiny things to play with, as well as fixes for some of the issues I'd found in preview 3. Let's take a look at some of the highlights.

Request Delegate Generator (redux)

With the initial blocking issue I found fixed, I re-enabled the support for it in a number of different projects. For the majority of them there were no issues, but some of them did flush out a number of edge cases:

Again, I think this really highlights the benefits of testing existing codebases with the preview releases to the product teams. This isn't to say that the code being delivered by the teams is buggy - they work dilligently to ensure quality - but as with anything, the wider the range of use cases you encounter, the more likely you are to find issues hiding in the edge cases and real world usage scenarios.

For me the most interesting one was Request Delegate Generator code counts as user code for code coverage issue. The ASP.NET Core team care about the coverage of their own code, but the impact of changes on users' own code isn't neccessarily something that's going to come about from their own internal testing. However, being a former quality assurance professional, this is the sort of thing I care about deeply for my own codebases. This issue was also something that was easy to overlook, but also really easy to fix - so I did!

That's one of the great things about open source software - anyone can contribute and help make it better. It's also a great way to side-step things like prioritisation backlogs - if you care deeply about an issue, you can make fixing it your own priority. Of course the change has to be reviewed and accepted by the team, but doing the initial work to get the ball rolling is a great way to get things moving.

A First-Class Time Abstraction

Dealing with the flow of time is one of the most common reasons to need to introduce abstractions and mocks into a codebase for testing. Properties like DateTimeOffset.UtcNow just aren't easily testable, so if you have code that uses them, you need to introduce abstractions to make it testable. If you have code that behaves differently based on the date and/or time, maybe something that's sensitive to weekends or time zone changes, then you need a way to test that whenever you want, rather than waiting for the right time to come around or changing the time on your computer back and forth.

Many abstractions for time already exist, such as Noda Time, and you can always roll your own, but the lack of a first-class time abstraction within the framework itself has always been a bit of a pain point as it makes code that depends on the time within the framework itself (such as timers) hard(er) to test.

Now .NET provides its own abstraction for time in the form of the TimeProvider abstraction. This is a great fit for existing applications, particularly for those like my own where I've pulled in the entire of NodaTime just to provide a comon time abstraction.

With TimeProvider I can now remove the need to ship all of the additional dependencies that NodaTime brings with it, such as time zone data, and instead just use TimeProvider in these applications. This makes the dependency graph of my applications much simpler, and also makes them smaller and faster to start up.

Preview 5

With the release of preview 5 in June, there were two things that I thought were of interest.

Dynamic PGO 🤝 Playwright .NET

Something that came up in a few of my applications after updating to preview 5 was that for some of the applications, their Playwright UI tests were starting to fail with a NullReferenceException. This was a bit of a head-scratcher as it consistently failed in GitHub Actions CI, but not locally in Visual Studio when those tests were run individually. After spending more time that I'd like debugging things and running the tests locally through Playwright .NET's own source code with some debugging from the console.log() school of thought, I was able to determine that something weird was going on with some asynchronous code inside Playwright. This lead me to open an issue in the .NET runtime repository, thinking that maybe something really weird with threads and tasks had been broken.

A member of the .NET team investigated the issue, and found that actually the triggering factor for the bug was that Dynamic Profile Guided Optimization (PGO) was enabled by default in preview 5 for Release builds. This made the way the issue didn't always happen make sense. When running tests in Debug mode, PGO isn't enabled. When only one test is run, then the JIT doesn't have enough time to optimise the code, so the issue doesn't happen either.

This was raised as an issue with the Playwright team, as the code was breaking due to the way that the stack was being walked to find the names of a caller of a method as part of Playwright's tracing functionality. When PGO inlined the code, the names were lost, and the code didn't handle that and would then hit null references that it didn't expect.

My colleague Stuart Lang saw the issue after I'd originally asked him to assure me I wasn't doing something silly before I opened the GitHub issue. He dug into the issue further and found a way to fix the issue by disabling inlining in various places inside the code so that the code that walks the stack to not be broken by Dynamic PGO.

By using the 1.36.0-beta-1 of Playwright .NET, the issue is resolved and now the UI tests work again!

TimeProvider Testing

Preview 5 also introduced a new testing package you can use with the TimeProvider abstraction to make it easy to mock the time in your tests. This is a great addition and it means you can more easily set up tests for code that depends on the time without having to configure the behaviour with a library such as Moq yourself.

I've been contributing to the release of Polly v8 over the last few months, where we've also been working on consuming the new TimeProvider API to make it easier to test Polly itself. As part of this work, I thought I would try out the new testing package to see whether it would reduce the amount of boilerplate code in our tests to set up mocks for the clock.

Unfortunately, in adopting it I found an issue in the runtime itself where if code that used an infinite timeout was run with a mocked TimeProvider, then an exception would be thrown due to an integer overflow.

I'm hoping to re-adopt the testing package into Polly's .NET 8 branch once this issue is resolved in a future preview.

Summary

I hope you've found this run down of our experiences and the issues we found with previews 1-5 of .NET 8 interesting.

As you can see several issues were found during our testing, with them all being raised as issues in the relevant repositories and at the time of writing all of them either fixed or with a fix in development.

In the next post in this series, we'll take a look at upgrading to .NET 8 Preview 6: Part 4 - Preview 6.

Upgrading to .NET 8 Series Links

You can find links to the other posts in this series below.