Blog post header image with the title "How dotnet.exe resolves and loads the hostfxr library"

How dotnet.exe resolves and loads the hostfxr library – Exploring the .NET muxer

In this post, we will continue our journey into the functionality and implementation of dotnet.exe, specifically focusing on how the hostfxr library is resolved and loaded. This post follows part one of this series, “A Brief Introduction to the .NET Muxer (aka 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.

The next phase for dotnet.exe is to hand control to the hostfxr library. This component of the hosting mechanism is crucial for finding and resolving the runtime and the framework the app needs. Its name is an abbreviation of “.NET Host Framework Resolver”. It was introduced as far back as .NET Core 2.0 in 2017 to improve the separation of concerns when hosting .NET applications and to allow for the “servicing of the logic in hostfxr without the need to stop all instances of the executable host currently running”.

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.

The correct version must first be resolved before dotnet.exe can hand control to hostfxr. For this, the hostfxr_resolver_t class is used. The constructor is called passing in the app_root variable, representing the current executable’s path. The full constructor implementation can be found here.

hostfxr_resolver_t::hostfxr_resolver_t(const pal::string_t& app_root)
{
    fxr_resolver::search_location search_location = fxr_resolver::search_location_default;
    pal::string_t app_relative_dotnet;
    pal::string_t app_relative_dotnet_path;
    if (!try_get_dotnet_search_options(search_location, app_relative_dotnet))
    {
        m_status_code = StatusCode::AppHostExeNotBoundFailure;
        return;
    }
    …
}

The constructor’s first task is to determine the search location for the hostfxr.dll file. A flags enum search_location defines the potential options. Inside the constructor, the default value of zero, search_location_default is assigned to the variable search_location. Two string variables are then defined. The first of these, app_relative_dotnet, may be set as a result of calling the try_get_dotnet_search_options function.

At the top of the file, a well-known placeholder is defined. This placeholder is formed of the SHA-256 of “dotnet-search” in UTF-8. A single constant named EMBED_DOTNET_SEARCH_FULL_UTF8 contains the placeholder string at compile time, prefixed with two null characters.

Inside the try_get_dotnet_search_options function, a static char array named embed contains the placeholder value at compile time or the actual app-relative .NET path written by the SDK during the dotnet publish process. In our current scenario, focusing on the .NET muxer (dotnet.exe), it will hold the default placeholder value.

The first byte of the embed array is cast to the search_location enum and assigned to out_search_location. A bitwise AND operation checks out_search_location to see if the search_location_app_relative flag is set. If the result of this expression is zero (which it will be in the scenario we’re investigating), the app relative flag has not been set. The try_get_dotnet_search_options function returns with a value of true in this case.

Back in the constructor, a trace message is written, recording the search_location flag that was set. try_get_dotnet_search_options may have set the out variable app_relative_dotnet. The next block of code checks if app_relative_dotnet is empty, and if it isn’t, it builds up the search path using the current app_root with the appended value. In our muxer scenario, app_relative_dotnet will be empty, so this block doesn’t execute.

The following function that gets invoked is try_get_path defined in the fxr_resolver namespace. Much of the code in this function only applies when compiled for FEATURE_APPHOST or FEATURE_LIBHOST, which is not the case in our scenario. The only code we care about today is:

// For non-apphost and non-libhost (i.e. muxer), root_path is expected to be the full path to the host
pal::string_t host_dir;
host_dir.assign(get_directory(root_path));

out_dotnet_root->assign(host_dir);
return fxr_resolver::try_get_path_from_dotnet_root(*out_dotnet_root, out_fxr_path);

This gets the directory where the current muxer executable is located as the start of the search location. This is passed into the try_get_path_from_dotnet_root function.

bool fxr_resolver::try_get_path_from_dotnet_root(const pal::string_t& dotnet_root, pal::string_t* out_fxr_path)
{
    pal::string_t fxr_dir = dotnet_root;
    append_path(&fxr_dir, _X("host"));
    append_path(&fxr_dir, _X("fxr"));
    if (!pal::directory_exists(fxr_dir))
    {
        trace::error(_X("Error: [%s] does not exist"), fxr_dir.c_str());
        return false;
    }

    return get_latest_fxr(std::move(fxr_dir), out_fxr_path);
}

This function appends the expected folder structure “host\fxr” onto the directory. On Windows, the final directory will generally be “C:\Program Files\dotnet\host\fxr”. You can check this location on your PC and likely see one or more versioned sub-directories below it. The exact versions depend on which runtimes /SDKs you have installed.

Screenshot from Windows Explorer showing an example of the subdirectories available inside the C:\Program Files\dotnet\host\fxr directory.

Much of the remaining logic we’ll review today is found inside the get_latest_fxr function. I’ll include some of the code here, but you can view the complete code on GitHub.

std::vector<pal::string_t> list;
pal::readdir_onlydirectories(fxr_root, &list);
fx_ver_t max_ver;
for (const auto& dir : list)
{
    trace::info(_X("Considering fxr version=[%s]..."), dir.c_str());
    pal::string_t ver = get_filename(dir);
    fx_ver_t fx_ver;
    if (fx_ver_t::parse(ver, &fx_ver, /* parse_only_production */ false))
    {
        max_ver = std::max(max_ver, fx_ver);
    }
}

This function finds all sub-directories under the …\host\fxr directory and loops over their names. Each name is parsed into an instance of the fx_ver_t struct representing a semver 2.0 version number. The implementation of fx_ver_t includes various equality operators and a compare function. After each filename is parsed to a version, std::max is called, passing the current “max” version and the latest parsed value. Whichever version has the highest is then returned and stored. This loop continues until all folders have been evaluated to find the folder with the highest version number.

if (file_exists_in_dir(fxr_root, LIBFXR_NAME, out_fxr_path))
{
    trace::info(_X("Resolved fxr [%s]..."), out_fxr_path->c_str());
    return true;
}

Once the highest version directory is found, the preceding code verifies that the hostfxr library exists in the folder. The file_exists_in_dir function is called, passing the full path of the highest version directory as the first argument. The second argument is the name of the file to locate. This comes from the LIBFXR_NAME macro. The macros are defined in pal.h, and compiler pre-processor directives make different prefixes and suffixes available per platform.

#if defined(TARGET_WINDOWS)
#define LIB_PREFIX ""
#define LIB_FILE_EXT ".dll"
#elif defined(TARGET_OSX)
#define LIB_PREFIX "lib"
#define LIB_FILE_EXT ".dylib"
#else
#define LIB_PREFIX "lib"
#define LIB_FILE_EXT ".so"
#endif

#define _STRINGIFY(s) _X(s)

#define LIB_FILE_NAME(NAME) LIB_PREFIX NAME LIB_FILE_EXT
#define LIB_FILE_NAME_X(NAME) _STRINGIFY(LIB_FILE_NAME(NAME))

#define LIBFXR_NAME LIB_FILE_NAME_X("hostfxr")

Depending on the target platform, LIBFXR_NAME will resolve to different values:

  • On Windows: LIBFXR_NAME would resolve to “hostfxr.dll”.
  • On macOS: LIBFXR_NAME would resolve to “libhostfxr.dylib”.
  • On Linux: LIBFXR_NAME would resolve to “libhostfxr.so”.

After file_exists_in_dir validates that the hostfxr library file is present in the directory, the full path is assigned to the out_fxr_path out variable, and the function returns true.

The flow eventually bubbles back up to hostfxr_resolver.cpp, which then attempts to load the hostfxr library. This uses the platform abstraction layer to provide a platform-specific implementation as needed. On Windows, loading the library calls LoadLibraryExW, which loads the specified module into the address space of the calling process. Once loaded, the module is pinned to ensure it remains loaded into memory. Assuming everything works as expected and the module is loaded and pinned, the status code on hostfxr_resolver_t will be set to StatusCode::Success.

The control returns to exe_start in corehost.cpp, where the status code is checked. At this point, if the status is not equal to Success, the exe_start function returns with the status code, ultimately becoming the exit code for the dotnet.exe.

When the hostfxr library is loaded successfully, exe_start continues onto the next phase, where it hands control to hostfxr. That’s a topic for the next blog post in this series.

We can see hostfxr resolution in action by reviewing the corehost trace logs when running dotnet.exe –info:

…
.NET root search location options: 0
Reading fx resolver directory=[C:\Program Files\dotnet\host\fxr]
Considering fxr version=[6.0.35]...
Considering fxr version=[7.0.20]...
Considering fxr version=[8.0.10]...
Considering fxr version=[9.0.0-rc.1.24431.7]...
Considering fxr version=[9.0.0-rc.2.24473.5]...
Detected latest fxr version=[C:\Program Files\dotnet\host\fxr\9.0.0-rc.2.24473.5]...
Resolved fxr [C:\Program Files\dotnet\host\fxr\9.0.0-rc.2.24473.5\hostfxr.dll]...
Loaded library from C:\Program Files\dotnet\host\fxr\9.0.0-rc.2.24473.5\hostfxr.dll
...

In the above output, we see the steps dotnet.exe took to resolve the latest hostfxr version and load it. Each versioned directory is evaluated with ‘9.0.0-rc.2.24473.5’ finally chosen as the highest version number. From there, the DLL is located and the library loaded.

We can see more detail if we use Process Explorer to capture the activity for the above command.

We begin to see activity (red box) around the ‘C:\Program Files\dotnet\host\fxr’ directory as the hostfxr resolution begins, including events to list directory etc. Then, some activity (blue box) occurs inside the latest version directory where the presence of the expected DLL is confirmed. Finally (green box), we see the hostfxr.dll being loaded.

That concludes the topic of today’s blog post. This has been a deep dive into the runtime codebase for .NET, focusing on how the muxer (dotnet.exe) resolves and loads the latest hostfxr library into memory. We’ll pick this up again in part 3, exploring what happens once control is handed to hostfxr.

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