Blog post header image with the title "A Brief Introduction to the .NET Muxer (aka dotnet.exe)"

A Brief Introduction to the .NET Muxer (aka dotnet.exe)

This post marks the start of what I expect will be a long-term effort to explore the inner workings of .NET, expose the “magic” behind the scenes, and explain the mechanisms and underlying components of the .NET execution model. Today, we begin with a brief introduction to the .NET muxer (dotnet.exe).

Note: These posts are a deep dive into .NET internals and won’t typically apply to day-to-day development. While they might not have direct coding applications, understanding these concepts can aid in debugging complex issues or optimizing performance. This series primarily serves as a tool to expand my own understanding of .NET at a deeper level.

You will have used the .NET muxer regularly without paying it much attention. When using an IDE, it will automate using the muxer, but I expect that nearly all .NET developers who have worked with .NET (Core and 5+) will have also manually interacted with it. You will know it by its more familiar name, dotnet.exe. In some older documentation, you may also see it referred to as CoreHost (or variations thereof).

The .NET executable (dotnet.exe) is the workhorse for much of what we do as .NET developers. The term ‘muxer’ is short for ‘multiplexer’ and refers to a tool that acts as a single entry point to manage multiple versions of the .NET SDK and runtime. It directs commands to the appropriate version for building, running, and managing .NET applications, simplifying version management and usage. dotnet.exe serves as the “.NET command-line interface (CLI) entry point,” routing commands to the appropriate .NET functionality.

When you install the .NET runtime and/or the .NET SDK on your PC, a single instance of dotnet.exe is installed. By default, it lives in the root of the .NET installation directory, ‘C:\Program Files\dotnet’ on my Windows PC. This is true even when you have multiple side-by-side versions of the runtime/SDK installed. Generally, the version matches the newest SDK or runtime that you have installed. This dotnet executable selects the appropriate SDK or runtime version based on the application’s configuration or the global settings.

The code for the muxer lives inside the runtime repository as part of the native code base. Let’s begin our journey in corehost.cpp with the main entry point. This file resides in the .NET runtime repository and is part of the native codebase that interacts with the .NET runtime and app hosts.

NOTE: I’m using the .NET 9 RC2 tag as the most current release at the time of writing this post. I recommend following along with the code open in another browser tab. I’ll include some truncated code in this blog post, but I will avoid copying large portions.

In the main function, we can observe some preprocessor directives referring to FEATURE_APPHOST. In addition to being compiled as the dotnet executable, significant portions of this code are reused to create the ‘apphost‘, which allows .NET applications to run as a (potentially standalone) executable, giving them a native executable wrapper. You will likely have seen an executable produced alongside the DLL(s) created at build time by modifying a common base apphost executable. We won’t get into the apphost concept today, but the core logic for locating the correct runtime version is common to both scenarios. I’ll skip over the code intended for the apphost scenario for now.

trace::setup();

if (trace::is_enabled())
{
    trace::info(_X("--- Invoked %s [version: %s] main = {"), CURHOST_TYPE, get_host_version_description().c_str());
    for (int i = 0; i < argc; ++i)
    {
        trace::info(_X("%s"), argv[i]);
    }
    trace::info(_X("}"));
}

...

int exit_code = exe_start(argc, argv);

trace::flush();

...

return exit_code;

The bulk of the logic lives inside exe_start. Before calling that, the preceding code from the main method sets up tracing if enabled. Tracing is not enabled by default, but we can set the COREHOST_TRACE environment variable to turn it on. When enabled, we will see detailed trace information as dotnet.exe does its work. This can be used for debugging .NET applications or troubleshooting runtime issues. By default, the trace output is sent to stderr, but its destination can be configured by setting COREHOST_TRACEFILE to provide a file location instead. We can also use the COREHOST_TRACE_VERBOSITY environment variable to control how detailed the tracing is. By default, level 4 is used, which captures the most detailed output, but you can opt to dial this down to smaller values to reduce the verbosity.

For example, we can enable this in Windows PowerShell for the current terminal session:

$Env:COREHOST_TRACE = 1

If we then run dotnet without any command line arguments, we see the following output (truncated for brevity):

Tracing enabled @ Wed Oct 23 06:44:22 2024 GMT
--- Invoked dotnet [version: 9.0.0-rc.2.24473.5 @Commit: 990ebf52fc408ca45929fd176d2740675a67fab8] main = {
C:\Program Files\dotnet\dotnet.exe
}

Usage: dotnet [options]
Usage: dotnet [path-to-application]
…

After invoking dotnet.exe without any arguments, we see some basic instructions on its use. Before that, the initial tracing output shows that we successfully enabled tracing and invoked the main method.

Let’s continue our journey and turn our focus to the exe_start function. It first resolves the executable’s (host’s) path, dealing with symlinks if they apply. It then performs a basic security check to ensure the executable name (without the extension) matches “dotnet”. This security feature is implemented to prevent tampering with the dotnet.exe name and ensure authenticity. Microsoft signs dotnet.exe, so they want to avoid someone renaming the executable (perhaps to bundle it with their application), which would then make the signature a little misleading. 

If we try to rename the exe and run it, we see the following error:

Tracing enabled @ Wed Oct 23 07:25:33 2024 GMT
--- Invoked dotnet [version: 9.0.0-rc.2.24473.5 @Commit: 990ebf52fc408ca45929fd176d2740675a67fab8] main = {
C:\Program Files\dotnet2.exe
}
Error: cannot execute dotnet when renamed to dotnet2.

The executable terminates at this point with a core host entry point failure and an exit code of 0x80008084.

Next, exe_start checks the number of arguments passed to it. If there is less than one argument, some hardcoded usage guidance is printed to the terminal, instructing the user to provide options, CLI command or a path to a .NET DLL to load and run. In this case, the executable terminates with an invalid argument length exit code (0x80008081).

At this point, the next phase of the muxer logic begins. That’s where I’ll leave this short intro post. In the next post, we’ll work towards the inner details of host_fxr and learn how the hostfxr.dll is located and loaded.

Other posts in this series:


Have you enjoyed this post and found it useful? If so, please consider supporting me:

Buy me a coffeeBuy me a coffee Donate with PayPal

Steve Gordon

Steve Gordon is a Pluralsight author, 7x Microsoft MVP, and a .NET engineer at Elastic where he maintains the .NET APM agent and related libraries. Steve is passionate about community and all things .NET related, having worked with ASP.NET for over 21 years. Steve enjoys sharing his knowledge through his blog, in videos and by presenting talks at user groups and conferences. Steve is excited to participate in the active .NET community and founded .NET South East, a .NET Meetup group based in Brighton. He enjoys contributing to and maintaining OSS projects. You can find Steve on most social media platforms as @stevejgordon