|
| 1 | +# MSBuild App Host Support |
| 2 | + |
| 3 | +## Purpose |
| 4 | + |
| 5 | +Enable MSBuild to be invoked directly as a native executable (`MSBuild.exe` / `MSBuild`) instead of through `dotnet MSBuild.dll`, providing: |
| 6 | + |
| 7 | +- Better process identification (processes show as "MSBuild" not "dotnet") |
| 8 | +- Win32 manifest embedding support (**COM interop**) |
| 9 | +- Consistency with Roslyn compilers (`csc`, `vbc`) which already use app hosts |
| 10 | +- Simplified invocation model |
| 11 | + |
| 12 | +### Important consideration |
| 13 | +The .NET SDK currently invokes MSBuild in multiple ways: |
| 14 | + |
| 15 | +| Mode | Current Behavior | After App Host | |
| 16 | +|------|------------------|----------------| |
| 17 | +| **In-proc** | SDK loads `MSBuild.dll` directly | No change | |
| 18 | +| **Out-of-proc (exec)** | SDK launches `dotnet exec MSBuild.dll` | SDK *can* launch `MSBuild.exe` | |
| 19 | +| **Out-of-proc (direct)** | SDK launches `dotnet MSBuild.dll` (no `exec`) | SDK *can* launch `MSBuild.exe` | |
| 20 | + |
| 21 | +The AppHost introduction does not break SDK integration since we are not modifying the in-proc flow. The SDK will continue to load `MSBuild.dll` directly for in-proc scenarios. |
| 22 | + |
| 23 | +**SDK out-of-proc consideration**: The SDK can be configured to run MSBuild out-of-proc today via `DOTNET_CLI_RUN_MSBUILD_OUTOFPROC`, and this pattern will likely become more common as AOT work progresses for CLI commands that wrap MSBuild invocations. When the SDK does launch MSBuild out-of-proc, it *can* opt to use the new app host (`MSBuild.exe`) when available, but this is **not required**—the existing `dotnet MSBuild.dll` invocation pattern continues to work. Switching to the app host in the SDK is a simplification/cleanup opportunity enabled by this work, not a forced change. |
| 24 | + |
| 25 | +### Critical: COM Manifest for Out-of-Proc Host Objects |
| 26 | + |
| 27 | +A key driver for this work is enabling **registration-free COM** for out-of-proc task host objects. Currently, when running via `dotnet.exe`, we cannot embed the required manifest declarations - and even if we could, it would be the wrong level of abstraction for `dotnet.exe` to contain MSBuild-specific COM interface definitions. |
| 28 | + |
| 29 | +**Background**: Remote host objects (e.g., for accessing unsaved file changes from VS) must be registered in the [Running Object Table (ROT)](https://docs.microsoft.com/windows/desktop/api/objidl/nn-objidl-irunningobjecttable). The `ITaskHost` interface requires registration-free COM configuration in the MSBuild executable manifest. |
| 30 | + |
| 31 | +**Required manifest additions for `MSBuild.exe.manifest`:** |
| 32 | + |
| 33 | +```xml |
| 34 | +<!-- Location of the tlb, must be in same directory as MSBuild.exe --> |
| 35 | +<file name="Microsoft.Build.Framework.tlb"> |
| 36 | + <typelib |
| 37 | + tlbid="{D8A9BA71-4724-481D-9CA7-0DA23A1D615C}" |
| 38 | + version="15.1" |
| 39 | + helpdir=""/> |
| 40 | +</file> |
| 41 | + |
| 42 | +<!-- Registration-free COM for ITaskHost --> |
| 43 | +<comInterfaceExternalProxyStub |
| 44 | + iid="{9049A481-D0E9-414f-8F92-D4F67A0359A6}" |
| 45 | + name="ITaskHost" |
| 46 | + tlbid="{D8A9BA71-4724-481D-9CA7-0DA23A1D615C}" |
| 47 | + proxyStubClsid32="{00020424-0000-0000-C000-000000000046}" /> |
| 48 | +``` |
| 49 | + |
| 50 | +**Related interfaces:** |
| 51 | +- `ITaskHost` - **must be configured via MSBuild's manifest** (registration-free) |
| 52 | +This is part of the work for [allowing out-of-proc tasks to access unsaved changes](https://github.com/dotnet/project-system/issues/4406). |
| 53 | + |
| 54 | +## Background |
| 55 | + |
| 56 | +An **app host** is a small native executable that: |
| 57 | +1. Finds the .NET runtime |
| 58 | +2. Loads the CLR |
| 59 | +3. Calls the managed entry point (e.g., `MSBuild.dll`) |
| 60 | + |
| 61 | +It is functionally equivalent to `dotnet.exe MSBuild.dll`, but as a standalone executable. |
| 62 | + |
| 63 | +**Note**: The app host does NOT include .NET CLI functionality. (e.g. `MSBuild.exe nuget add` wouldn't work — those are CLI features, not app host features). |
| 64 | + |
| 65 | +### Reference Implementation |
| 66 | + |
| 67 | +Roslyn added app host support in [PR #80026](https://github.com/dotnet/roslyn/pull/80026). |
| 68 | + |
| 69 | +## Changes Required |
| 70 | + |
| 71 | +### 1. MSBuild Repository |
| 72 | + |
| 73 | +**Remove `UseAppHost=false` from `src/MSBuild/MSBuild.csproj`:** |
| 74 | + |
| 75 | +```xml |
| 76 | +<!-- REMOVE THIS LINE --> |
| 77 | +<UseAppHost>false</UseAppHost> |
| 78 | +``` |
| 79 | + |
| 80 | +The SDK will then produce both `MSBuild.dll` and `MSBuild.exe` (Windows) / `MSBuild` (Unix). |
| 81 | + |
| 82 | +### 2. VMR Changes (dotnet/dotnet - SDK component) |
| 83 | +The app host creation happens in the SDK layout targets within the VMR. Changes are made in the `sdk` component of `dotnet/dotnet` to simplify integration and avoid coordinated arcade SDK changes. Similar to how Roslyn app hosts are created (PR https://github.com/dotnet/dotnet/pull/3180). |
| 84 | + |
| 85 | +### 3. Node Launching Logic |
| 86 | + |
| 87 | +Update node provider to launch `MSBuild.exe` instead of `dotnet MSBuild.dll`: |
| 88 | +The path resolution logic remains the same, since MSBuild.exe will be shipped in every SDK version. |
| 89 | + |
| 90 | +### 4. Backward Compatibility (Critical) |
| 91 | + |
| 92 | +Because VS supports older SDKs, node launching must handle both scenarios: |
| 93 | + |
| 94 | +```csharp |
| 95 | +var appHostPath = Path.Combine(sdkPath, $"MSBuild{RuntimeHostInfo.ExeExtension}"); |
| 96 | + |
| 97 | +if (File.Exists(appHostPath)) |
| 98 | +{ |
| 99 | + // New: Use app host directly |
| 100 | + return (appHostPath, arguments); |
| 101 | +} |
| 102 | +else |
| 103 | +{ |
| 104 | + // Fallback: Use dotnet (older SDKs) |
| 105 | + return (dotnetPath, $"\"{msbuildDllPath}\" {arguments}"); |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +**Handshake consideration**: The packet version can be bumped to negotiate between old/new node launching during handshake. |
| 110 | +MSBuild knows how to handle it starting from https://github.com/dotnet/msbuild/pull/12753 |
| 111 | + |
| 112 | +## Runtime Discovery (the problem is solved in Roslyn app host this way) |
| 113 | + |
| 114 | +### The Problem |
| 115 | + |
| 116 | +App hosts find the runtime by checking (in order): |
| 117 | +1. `DOTNET_ROOT_X64` / `DOTNET_ROOT_X86` / `DOTNET_ROOT_ARM64` |
| 118 | +2. `DOTNET_ROOT` |
| 119 | +3. Well-known locations (`C:\Program Files\dotnet`, etc.) |
| 120 | + |
| 121 | +When running under the SDK, the runtime may be in a non-standard location. The SDK sets `DOTNET_HOST_PATH` to indicate which `dotnet` it's using. |
| 122 | + |
| 123 | +### Solution |
| 124 | + |
| 125 | +Before launching an app host process, set `DOTNET_ROOT` in the `ProcessStartInfo.Environment`. |
| 126 | + |
| 127 | +**Note**: This solution applies to MSBuild's internal node launching (worker nodes, task hosts). The SDK's entry-point invocation (`dotnet MSBuild.dll`, `dotnet exec MSBuild.dll`, or eventually `MSBuild.exe`) is a separate concern—if the SDK continues using `dotnet.exe MSBuild.dll` with `DOTNET_CLI_RUN_MSBUILD_OUTOFPROC`, that path still works and doesn't require `DOTNET_ROOT` handling (since `dotnet.exe` handles runtime discovery itself). |
| 128 | +```csharp |
| 129 | +// Derive DOTNET_ROOT from DOTNET_HOST_PATH |
| 130 | +var dotnetHostPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH"); |
| 131 | + |
| 132 | +if (string.IsNullOrEmpty(dotnetHostPath)) |
| 133 | +{ |
| 134 | + // DOTNET_HOST_PATH should always be set when running under the SDK. |
| 135 | + // If not set, fail fast rather than guessing - this indicates an unexpected environment. |
| 136 | + throw new InvalidOperationException("DOTNET_HOST_PATH is not set. Cannot determine runtime location."); |
| 137 | +} |
| 138 | + |
| 139 | +var dotnetRoot = Path.GetDirectoryName(dotnetHostPath); |
| 140 | + |
| 141 | +var startInfo = new ProcessStartInfo(appHostPath, arguments); |
| 142 | + |
| 143 | +// Set DOTNET_ROOT for the app host to find the runtime |
| 144 | +startInfo.Environment["DOTNET_ROOT"] = dotnetRoot; |
| 145 | + |
| 146 | +// Clear architecture-specific overrides that would take precedence over DOTNET_ROOT |
| 147 | +startInfo.Environment.Remove("DOTNET_ROOT_X64"); |
| 148 | +startInfo.Environment.Remove("DOTNET_ROOT_X86"); |
| 149 | +startInfo.Environment.Remove("DOTNET_ROOT_ARM64"); |
| 150 | +``` |
| 151 | + |
| 152 | +**Note**: Using `ProcessStartInfo.Environment` is thread-safe and scoped to the child process only, avoiding any need for locking or save/restore patterns on the parent process environment. |
| 153 | + |
| 154 | +### DOTNET_ROOT Propagation to Child Processes |
| 155 | + |
| 156 | +**Concern**: When MSBuild sets `DOTNET_ROOT` to launch a worker node, that environment variable propagates to any tools the worker node executes. This could change tool behavior if the tool relies on `DOTNET_ROOT` to find its runtime. |
| 157 | + |
| 158 | +**Solution**: The worker node (and out-of-proc task host nodes) should explicitly clear `DOTNET_ROOT` (and architecture-specific variants) after startup, restoring the original entry-point environment. |
| 159 | + |
| 160 | +**Applies to**: |
| 161 | +- Worker nodes (`OutOfProcNode`) |
| 162 | +- Out-of-proc task host nodes (`OutOfProcTaskHostNode`) |
| 163 | + |
| 164 | +```csharp |
| 165 | +// In OutOfProcNode.HandleNodeConfiguration (and similar location in OutOfProcTaskHostNode), |
| 166 | +// after setting BuildProcessEnvironment: |
| 167 | +
|
| 168 | +// Clear DOTNET_ROOT variants that were set only for app host bootstrap. |
| 169 | +// These should not leak to tools executed by this worker node. |
| 170 | +// Only clear if NOT present in the original build process environment. |
| 171 | +string[] dotnetRootVars = ["DOTNET_ROOT", "DOTNET_ROOT_X64", "DOTNET_ROOT_X86", "DOTNET_ROOT_ARM64"]; |
| 172 | +foreach (string varName in dotnetRootVars) |
| 173 | +{ |
| 174 | + if (!_buildParameters.BuildProcessEnvironment.ContainsKey(varName)) |
| 175 | + { |
| 176 | + Environment.SetEnvironmentVariable(varName, null); |
| 177 | + } |
| 178 | +} |
| 179 | +``` |
| 180 | + |
| 181 | +**Why this works**: |
| 182 | + |
| 183 | +1. `BuildProcessEnvironment` captures the environment from the **entry-point process** (e.g. VS). |
| 184 | +2. If the entry-point had `DOTNET_ROOT` set, the worker should also have it (passed via `BuildProcessEnvironment`). |
| 185 | +3. If the entry-point did NOT have `DOTNET_ROOT`, it was only added for app host bootstrap and should be cleared. |
| 186 | + |
| 187 | +**Alternative considered**: We could modify `NodeLauncher` to not inherit the parent environment and explicitly pass only `BuildProcessEnvironment` + `DOTNET_ROOT`. However, this is a larger change and may break other scenarios where environment inheritance is expected. |
| 188 | + |
| 189 | +**Implementation note**: Add a comment in the node-launching code explaining why `DOTNET_ROOT` is set and that the worker will clear it: |
| 190 | + |
| 191 | +```csharp |
| 192 | +// Set DOTNET_ROOT for app host bootstrap only. |
| 193 | +// The worker node will clear this after startup if it wasn't in the original BuildProcessEnvironment. |
| 194 | +// See OutOfProcNode.HandleNodeConfiguration. |
| 195 | +startInfo.Environment["DOTNET_ROOT"] = dotnetRoot; |
| 196 | +``` |
| 197 | + |
| 198 | +### Edge Cases |
| 199 | + |
| 200 | +| Issue | Solution | |
| 201 | +|-------|----------| |
| 202 | +| `DOTNET_HOST_PATH` not set | Fail with clear error. This should always be set by the SDK; if missing, it indicates an unexpected/unsupported environment. | |
| 203 | +| Architecture-specific vars override `DOTNET_ROOT` | Clear `DOTNET_ROOT_X64`, `DOTNET_ROOT_X86`, `DOTNET_ROOT_ARM64` in `ProcessStartInfo.Environment` (see code above) | |
| 204 | +| App host doesn't exist | Fall back to `dotnet MSBuild.dll` and **log a message** indicating fallback (e.g., for debugging older SDK scenarios) | |
0 commit comments