.NET Tools How-To's

How Rider Hot Reload Works Under the Hood

The release of .NET 6 introduced Hot Reload. With it, you can make changes to (managed) code while your application is running and apply them without having to pause or stop the application.

We covered how to work with Hot Reload in Rider 2021.3 and make changes without having to constantly stop and re-run your apps in a previous post.

But how does this all work under the hood? It’s time to find out! We sat down with Artem Sazonov from the Rider developer team to talk about how Hot Reload works in .NET and how Rider integrates with it.

Sit back, grab a hot beverage – and let’s dive in!

What does Hot Reload do?

The idea behind Hot Reload is simple – while your application is running, you can make changes to the code and apply them to the running application. No recompilation is needed, and when possible, the state of your application is kept intact.

Note: Edit and Continue (EnC) and Hot Reload support most types of code changes within method bodies. Changes outside method bodies, and a few changes within method bodies, cannot be applied while running. Check out the list of supported changes if you’d like to learn more.

When you make a supported change, Rider will show a notification bar at the top of your editor and a lightbulb on the line that was edited. Both let you apply the change to your running application:

Hot Reload in a .NET application

After clicking Apply changes, Rider will update your running application by changing the in-memory assembly metadata and Intermediate Language (IL) code. If you are in a debugging session, it will change the debugger information (PDB).

The compiler workspace

Let’s take a step back and look at the Roslyn compiler. It comes with a set of APIs to analyze and compile C# and Visual Basic source code. For simple compilation tasks, you can parse C# syntax from a string, create a new Compilation and add MetadataReference to the .NET assemblies, and then compile and write out an executable to disk:

using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

var syntaxTree = CSharpSyntaxTree.ParseText(@"using System;
       namespace HelloWorld
       {
           public static class Program
           {
               public static void Main() {
                   Console.WriteLine(""Hello!"");
               }
           }
       }");

var options = new CSharpCompilationOptions(OutputKind.ConsoleApplication);

var compilation = CSharpCompilation
   .Create("HelloWorld", options: options)
   .AddReferences(
       MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
       MetadataReference.CreateFromFile(typeof(Console).GetTypeInfo().Assembly.Location),
       MetadataReference.CreateFromFile(
           Path.Combine(Path.GetDirectoryName(typeof(object).GetTypeInfo().Assembly.Location), "System.Runtime.dll")))
   .AddSyntaxTrees(syntaxTree);

compilation.Emit(File.OpenWrite("hello.exe"));

For this example, it’s relatively easy to add the correct references. But imagine doing this for your full-blown .NET application…

Luckily for us, the compiler provides the Workspace API. It performs all of the heavy lifting that is needed to parse source code, resolve metadata references, and more. Several implementations of the workspace API exist, for example, MSBuildWorkspace, which can build a workspace from a .NET solution with projects, SDK and package references, custom MSBuild targets, and more. Here’s an example of loading a solution and getting a Compilation for every project in it:

using Microsoft.CodeAnalysis.MSBuild;

using (var workspace = MSBuildWorkspace.Create())
{
   var solution = await workspace.OpenSolutionAsync("MySolution.sln");
  
   foreach (var project in solution.Projects)
   {
       var projectCompilation = await project.GetCompilationAsync();
      
       // ...
   }
}

New files (Documents) can be added, and the source code of existing documents can be updated. A new Compilation can then be created at any time, and the differences can be applied to the running .NET application.

Rider doesn’t use this MSBuildWorkspace, though. Since Rider already has information about your current solution, project files, references and code, there’s no use in having a second workspace for the same solution. We have implemented our own workspace instead, called RiderWorkspace.

When running your application, the RiderWorkspace is hooked up to the compiler through Roslyn’s IEditAndContinueWorkspaceService.

When you make changes to the source code, Rider immediately updates its own workspace. When you Apply changes in Rider, it informs the IEditAndContinueWorkspaceService through the EmitSolutionUpdateAsync method. After compiling changes and calculating a diff of the old and new Compilation, it returns EmitSolutionUpdateResults.

The result that is returned contains information about whether the update succeeded or not, together with compilation diagnostics. If there are compilation errors in the updated code, Rider can use that information and display it to you.

If the update is successful, the differences are applied to the running .NET application. This is where things get interesting…

Applying changes to the running .NET application

The specific approach taken when applying the changes to the running application depends on how the application was launched.

When debugging, this is done using the Just-in-Time (JIT) compiler, just like when using Edit and Continue (EnC). Without the debugger, a new .NET 6 API is used to apply the changes. You could say that Hot Reload is based on Edit and Continue.

Edit and Continue (EnC)

When you start debugging your application (or attach the debugger to an already running process), changes are applied using the debugger. All the delta and metadata information are collected from the compiler and then added to your application using the Just-in-Time (JIT) compiler and the .NET debugger’s ICorDebugModule2::ApplyChanges method.

This is how Edit and Continue (EnC) works. Hot Reload is pretty much a rebranded version of it.

Note that Edit and Continue is only supported on Windows. This is a limitation of .NET and means that Hot Reload during debugging is currently only available on Windows.

Hot Reload agent and MetadataUpdater.Update

In .NET 6 applications, when no debugger is attached, a Hot Reload agent is added to your application. This Hot Reload agent is an assembly that we provide. It is loaded into your application process using the host startup hook.

Setting the DOTNET_STARTUP_HOOKS environment variable causes our Hot Reload agent to be loaded and started. The agent communicates with Rider over named pipes. When an update to the running assembly is needed, the Hot Reload agent calls the new MetadataUpdater.ApplyUpdate method that now is part of your application process. This new API is the magic sauce of Hot Reload in .NET 6!

The ApplyUpdate method takes 4 arguments: the assembly being updated, a binary update to the assembly metadata, a binary update to the IL code, and a binary update to the PDB information. Exactly the information supplied by the EmitSolutionUpdateAsync method we discussed earlier!

Note: This new .NET 6 method is also used by Hot Reload in the dotnet-watch tool. A HotReloadAgent listens for updated compilations and applies them to the running application when possible. This command line tool also supports Hot Reload for Blazor, which is not yet implemented in Rider.

When no debugger is attached, Hot Reload is only available for .NET 6 and beyond – the MetadataUpdater.ApplyUpdate method is not available in older .NET versions.

Updating code with Hot Reload

While your application is running, the runtime keeps track of the state: the method being executed, call stacks, and pointers indicating where methods are implemented in memory. When changes are applied successfully, this state will be updated.

When you change a method body, things may get confusing… In the internal implementation of the API in .NET 6, the virtual machine completely recompiles the method and then replaces its implementation in the running process. However, this replacement only takes place when that method is not currently being executed.

For example, consider the following code. In this infinite loop, we write “Hello, World!” to the console:

while (true)
{
    Console.WriteLine("Hello, World!");
}

If you change the text written in Console.WriteLine and apply the changes using Hot Reload without the debugger, nothing happens. The “old” version keeps running because the control code is still running.

If you want to be able to update the text, you will have to rewrite the code as follows:

while (true)
{
    Print();
}

void Print()
{
    Console.WriteLine("Hello, World!");
}

When you apply changes, the next iteration of the loop will print the updated string to the console. The virtual machine recompiles the Print method and updates its implementation at the end of the loop iteration in while (true).

Conclusion

Edit and Continue (EnC) powers a lot of the functionality of Hot Reload. It provides a delta of the Intermediate Language (IL) code and assembly metadata, which can be applied to a running application.

When debugging on Windows, Hot Reload uses the debugger to replace the code being executed. On .NET 6, and when not debugging, an extra assembly is loaded that can use the new MetadataUpdater.ApplyUpdate method to apply changes from within your running application. This assembly is loaded by setting the DOTNET_STARTUP_HOOKS environment variable.

In future versions of Rider, we want to add support for automatically refreshing your web browser when using Hot Reload in ASP.NET or Blazor applications. To make this possible, a small JavaScript snippet will have to be included in your web application that listens for reload requests coming from Rider.

We hope this look behind the scenes of Hot Reload helped you to understand some of its inner workings. Try it out in the latest Rider 2021.3 EAP!

image description