Create custom apphost for .NET application
- Posted in:
- .NET
As I wrote at the beginning of this post, we do not install .NET runtime on customers' computers when installing our application and I explained our reasons for this. However, it creates certain issues for some of our tools that are standalone executables because the .exe file that is generated by the C# build tools does not know how where to find the .NET runtime.
Also, we have another issue. In the .NET Framework, it was possible to specify probing directories. Basically, these are additional directories that .NET runtime is checking for assemblies. It is very helpful if you don’t want to have a single directory with a lot of assemblies. I think it is possible to resolve direct dependencies with file .deps.json but as far as I know it is not possible to resolve indirect dependencies.
But in the .NET core world, there is no such thing as additional probing directories. There is a setting named additionalAssemblyPaths in the .runtimeconfig.json file but it does not work as intended.
In this post, we will solve all these issues, and also I will show how can Visual Studio or MSBuild use your version of the apphost during compilation instead of the standard one. Let's start.
Firstly, let me explain what is apphost. The apphost.exe is a file located here:
C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Host.win-x64 and then in the subdirectory there is a version for each framework version. For example, for my .NET 6 project, that file is located here: 6.0.33\runtimes\win-x64\native\apphost.exe.
This is a native C++ executable that loads and initializes the .NET runtime and then loads assembly, passes all command line arguments, and then transfers control to the Main function of that assembly.
Then during the build process, build tools will add the assembly version, its description, etc to this file. Then build tools change the Windows subsystem (console or GUI app) for this file according to what is used for the C# project itself and copy the result to the output directory of your C# project.
Let's explain it with an example. Imagine we created the C# console application called MyApp which is located at C:\MyApp. When you compile it in the debug configuration for .NET 6 then at the end you will see 5 files in C:\MyApp\Debug\net6.0:
MyApp.runtimeconfig.json
MyApp.deps.json
MyApp.exe
MyApp.dll
MyApp.pdb
MyApp.exe is an apphost file that was branded to look like your application but it is a native C++ application. The file MyApp.runtimeconfig.json tells the apphost what runtime you need and what additional frameworks you need. For example, you may need ASP.NET or Windows Desktop frameworks. Lastly, MyApp.deps.json explains the additional dependencies of MyApp.dll. To be honest I don’t know much about that file.
If you are curious, the source code for this app is located here but most files are located one level up because they are shared between different types of hosts. But make sure you are checking the branch for the appropriate version of NET. For example, for version 8 it is located here.
I checked the source code quite carefully, and then I debugged it quite a lot but I didn’t find any way to achieve the tasks that I outlined at the beginning of this post. As a result, I decided to write my own version of the apphost.
The idea is very simple. Create a C or C++ console application (or use any other language). All you need is the file hostfx.dll which must have the same version (or higher) as the .NET runtime you want to run. In my case, it will be near my executable but for you, it can be different.
Then load this file using LoadLibrary. Then you need to use the function GetProcAddress to get addresses of the following functions:
hostfxr_initialize_for_dotnet_command_line
hostfxr_set_runtime_property_value
hostfxr_run_app
hostfxr_close
Then you need to assign argv[0] that is passed from the command line of your C main function to the name of the assembly dll you want to run. In the beginning, you can just hardcode string C:\MyApp\Debug\net6.0\MyApp.dll here. I will explain how to do it properly later. Then you need to initialize parameters like this:
hostfxr_initialize_parameters initParameters = { 0 };
initParameters.size = sizeof(initParameters);
initParameters.host_path =appPath;
initParameters.dotnet_root =dotNetPath;
The appPath is the path to your executable file. The best way to get it is to call GetModuleFileName. For example like this:
GetModuleFileName(NULL, result, static_cast<DWORD>(resultSize))
The dotNetPath is the directory path that contains the shared directory that in turn contains frameworks your application uses. Copy their names from the C:\Program Files\dotnet\shared and create a directory with that name inside of your shared directory and then copy the content from the directory of version you need to that directory.
For example, it can be a directory that contains Microsoft.NETCore.App\8.0.5 and its content is copied from C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.5. As a result, the dotNetPath can be something like C:\MyApp\Runtime. The framework will be located at C:\MyApp\Runtime\shared\Microsoft.NETCore.App\8.0.5 and its content is copied from C:\Program Files\dotnet\shared\Microsoft.NETCore.App\8.0.5. Copy other frameworks you may need in the same way.
Then you need to call hostfxr_initialize_for_dotnet_command_line
Something like this:
int rc = init_for_cmd_line_fptr(argc, argv, &initParameters, & ctx);
if (rc != 0 || hostfxrState.ctx == nullptr)…
Then you need to call the function hostfxr_set_runtime_property_value:
int rc = set_runtime_property_value_fptr(ctx, L"APP_PATHS", probingPaths);
if (rc != 0)
The probingPaths is a string that contains a semicolon-separated list of additional probing paths. In my case they we fully qualified paths but I think relative paths will work too. If you don’t have any additional probing paths then you don’t need to call this function and get its address.
Then you need to run your application using the function hostfxr_run_app:
errorCode =run_app_fptr(ctx);
The errorCode is what will be returned by your Main C# function and in turn it should be the exit code of your application of your main C function.
Lastly, you need to close hosting using the function hostfxr_close:
int rc = close_fptr(ctx);
And that is basically it.
Because I want to use that application in multiple places, I decided to have a configuration file that contains a relative path to hostfxe.dll, a relative path to runtime, and a list of additional probing directories. My version of apphost gets the name of the executable, changes its extension, and reads the content of that file. Then it resolves relative paths to absolute ones.
Now, let me explain how you get the name of the assembly to execute. You need to open Microsoft’s source code and find the function is_exe_enabled_for_execution in the corehost.cpp. It tells you to declare this:
static char embed[EMBED_MAX] = EMBED_HASH_FULL_UTF8;
The build tools scan your executable and search for that particular set of bytes. Effectively they will modify your executable and write an assembly name into this array. Keep in mind that it is a UTF-8 text. All you need is to read it and here is a relative path of assembly to run. This path is relative to the .exe file.
Then add the following block to your .csproj file:
<PropertyGroup>
<AppHostSourcePath>C:\MyAppHost\MyRunner.exe</AppHostSourcePath>
</PropertyGroup>
Check MyApp.exe size and then rebuild your C# application. The build tools will take C:\MyAppHost\MyRunner.exe and copy it to the output directory of your project, then add the version, and description of your assembly, and then write the name of assembly into this file. If everything is correct size of the MyApp.exe should change. It should be the same as the size of MyRunner.exe plus around 2 kilobytes more.
In the example above, it will copy file C:\MyAppHost\MyRunner.exe to C:\MyApp\Debug\net6.0\MyApp.exe, set the MyApp info File description and Product name, and set 1.0.0.0 as the File version and 1.0.0 as the Product version. Then write MyApp.dll UTD-8 text inside of MyApp.exe.
Also, the build tools will change the subsystem of your application. If this is a C# console application it will stay as a console. But if this is a desktop application then build tools will change the subsystem of the MyApp.exe to GUI.
Why should you care? Because you will need to report any errors appropriately. If this is a console application then you can just output them into the console. But for a GUI application, you need to show a message box. You can use is_gui_application function from here.
Another thing to watch is that everything in your version of the apphost should be statically linked because a target PC may not have C++ runtime installed and it is better to be safe and link everything statically.
If you need a specific version of the apphost only for a single project it is probably enough and you will stop here. But I went further and created a Nuget package. Here is the content of the package :
<?xml version="1.0"?>
<package >
<metadata>
<id>Me.MyRunner</id>
<version>1.0.0</version>
<authors>Me</authors>
<owners>Me</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>Replacement of apphost to run .NET assembly that needs location of hostfxr.dll and .NET runtime.</description>
<releaseNotes>Initial release.</releaseNotes>
</metadata>
<files>
<file src="Files\MyRunner.exe" target="build/" />
</files>
</package>
Then you need to pack and publish it to the same NuGet server or some local directory that can be used as a source of NuGet packages. Then you need to add this to your project replacing the previous PropertyGroup with AppHostSourcePath:
<ItemGroup>
<PackageReference Include="Me.MyRunner" Version="1.0.0" GeneratePathProperty="true" />
</ItemGroup>
<PropertyGroup>
AppHostSourcePath>$(PkgMe_MyRunner)\build\MyRunner.exe</AppHostSourcePath>
</PropertyGroup>
Please note that after prefix Pkg all dots need to be changed to underscores.
And rebuild your C# project. It will restore that package, and because the attribute GeneratePathProperty is set, the build tools will set PkgMe_MyRunner to the path of the extracted content of your package.
But before you are done, you need to verify that your application will load your version of .NET runtime and not something else that is already installed on your PC. This is important because often when you provide incorrect parameters to the hostfxr will not able to load your version of .NET Framework and instead it will fall back to what is installed on your PC. You will assume that everything is fine but when your application is deployed to a computer without any .NET runtime installed it will fail
You can check loaded modules in any debugger. But there is a caveat. If the minor version of your .NET runtime is lower than the minor version installed on your PC then a more modern version will be used. For example, if your runtime is 6.0.1 but you have installed 6.0.2 and 8.0.1 then 6.0.2 will be used. The hostfxr will not jump to a new major version by default.
To fix it, you will need to add "rollForward": "Disable", after "tfm": "net6.0", in the C:\MyApp\Debug\net6.0\MyApp.runtimeconfig.json file to make sure that .NET will not try to use a higher version. But keep in mind that it will get overwritten on rebuild.
I hope it helps someone.