diff --git a/src/Cli/dn/dn-native-debug.vcxproj b/src/Cli/dn/dn-native-debug.vcxproj
index 1bfaa1983a3d..ea0f3bc53b95 100644
--- a/src/Cli/dn/dn-native-debug.vcxproj
+++ b/src/Cli/dn/dn-native-debug.vcxproj
@@ -129,7 +129,8 @@
$(_DnExePath)
--info
$(_DnPublishDir)
- DOTNET_ROOT=$(_DotnetRootPath)
+ DOTNET_ROOT=$(_DotnetRootPath)
+DOTNET_CLI_ENABLEAOT=true
WindowsLocalDebugger
diff --git a/src/Cli/dn/dn.csproj b/src/Cli/dn/dn.csproj
index 541a4f20804e..c969c489389e 100644
--- a/src/Cli/dn/dn.csproj
+++ b/src/Cli/dn/dn.csproj
@@ -72,7 +72,8 @@
<_LaunchSettingsLines Include=" %22commandLineArgs%22: %22--info%22," />
<_LaunchSettingsLines Include=" %22workingDirectory%22: %22$(_DnCwdPath.Replace('\','\\'))%22," />
<_LaunchSettingsLines Include=" %22environmentVariables%22: {" />
- <_LaunchSettingsLines Include=" %22DOTNET_ROOT%22: %22$(_DotnetRootPath.Replace('\','\\'))%22" />
+ <_LaunchSettingsLines Include=" %22DOTNET_ROOT%22: %22$(_DotnetRootPath.Replace('\','\\'))%22," />
+ <_LaunchSettingsLines Include=" %22DOTNET_CLI_ENABLEAOT%22: %22true%22" />
<_LaunchSettingsLines Include=" }," />
<_LaunchSettingsLines Include=" %22nativeDebugging%22: true" />
<_LaunchSettingsLines Include=" }" />
@@ -93,7 +94,7 @@
<_VcxUserLines Include=" <LocalDebuggerCommand>$(_DnExePath)</LocalDebuggerCommand>" />
<_VcxUserLines Include=" <LocalDebuggerCommandArguments>--info</LocalDebuggerCommandArguments>" />
<_VcxUserLines Include=" <LocalDebuggerWorkingDirectory>$(_DnCwdPath)</LocalDebuggerWorkingDirectory>" />
- <_VcxUserLines Include=" <LocalDebuggerEnvironment>DOTNET_ROOT=$(_DotnetRootPath)</LocalDebuggerEnvironment>" />
+ <_VcxUserLines Include=" <LocalDebuggerEnvironment>DOTNET_ROOT=$(_DotnetRootPath)
DOTNET_CLI_ENABLEAOT=true</LocalDebuggerEnvironment>" />
<_VcxUserLines Include=" <DebuggerFlavor>WindowsLocalDebugger</DebuggerFlavor>" />
<_VcxUserLines Include=" </PropertyGroup>" />
<_VcxUserLines Include="</Project>" />
@@ -128,6 +129,7 @@
<_DebugScriptLines Include="REM (Debug > Windows > Breakpoints > New > Function, Language = All Languages)." />
<_DebugScriptLines Include="REM" />
<_DebugScriptLines Include="set DOTNET_ROOT=$(_DotnetRootPath)" />
+ <_DebugScriptLines Include="set DOTNET_CLI_ENABLEAOT=true" />
<_DebugScriptLines Include="devenv /debugexe %22$(_DnExePath)%22 --info" />
diff --git a/src/Cli/dotnet-aot/DESIGN.md b/src/Cli/dotnet-aot/DESIGN.md
index 9f939079ae39..9e75132c2944 100644
--- a/src/Cli/dotnet-aot/DESIGN.md
+++ b/src/Cli/dotnet-aot/DESIGN.md
@@ -5,14 +5,14 @@ to the .NET SDK CLI. The goal is to achieve near-instant startup for common
commands while preserving full functionality through the managed CLI.
The current implementation uses a standalone `dn.exe` host that lives alongside
-the existing `dotnet` CLI. `dn.exe` emulates functionality that will eventually
-be integrated into the dotnet host (muxer) itself — see
-[dotnet/runtime#126171](https://github.com/dotnet/runtime/issues/126171). Once
-the muxer gains native SDK resolution support, the AOT entry point will be
-called directly by the standard `dotnet` executable and `dn.exe` will remain as
-a local development and testing entry point. At that point, AOT-handled command
-implementations will likely be removed from the managed `dotnet.dll` package as
-well.
+the existing `dotnet` CLI. `dn.exe` emulates the muxer's `try_invoke_aot_sdk`
+function — see
+[dotnet/runtime#126171](https://github.com/dotnet/runtime/issues/126171). The
+muxer looks for `dotnet-aot` in the resolved SDK directory and, when found,
+calls `dotnet_execute` directly. `dn.exe` follows the same contract and serves
+as a local development and testing entry point. The AOT fast path is gated
+behind `DOTNET_CLI_ENABLEAOT=true`; when the variable is unset or false, the
+bridge falls through to the managed CLI immediately.
## Motivation
@@ -48,14 +48,16 @@ graph TD
subgraph L2["Layer 2 · dotnet-aot.dll (Native AOT Shared Library)"]
Entry["NativeEntryPoint.Execute()"]
+ AotCheck{"DOTNET_CLI_ENABLEAOT
enabled?"}
Parse["Parser.Parse(args)"]
Fast{"Command handled
by AOT path?"}
Invoke["Parser.Invoke()"]
- DebugCheck["Check for native debugger"]
HostInit["ManagedHost.RunApp()"]
- Entry --> Parse --> Fast
+ Entry --> AotCheck
+ AotCheck -- "Yes" --> Parse --> Fast
+ AotCheck -- "No" --> HostInit
Fast -- "Yes" --> Invoke
- Fast -- "No" --> DebugCheck --> HostInit
+ Fast -- "No" --> HostInit
end
Invoke --> Done["Return exit code"]
@@ -92,15 +94,18 @@ A NativeAOT shared library (`NativeLib=Shared`) that exports a single
`[UnmanagedCallersOnly]` entry point: `dotnet_execute`. This layer contains
the dual-path dispatch logic.
-**Fast path** — The AOT bridge compiles a minimal `Parser` (guarded by
-`#if CLI_AOT`) that handles simple commands (`--version`, `--info`) entirely in
-native code. If the parser recognizes the command, it executes immediately and
-returns.
+**Fast path** — When `DOTNET_CLI_ENABLEAOT=true`, the AOT bridge compiles a
+minimal `Parser` (guarded by `#if CLI_AOT`) that handles simple commands
+(`--version`, `--info`) entirely in native code. If the parser recognizes the
+command, it executes immediately and returns.
-**Slow path** — For any command not handled by the AOT parser, the bridge calls
-`ManagedHost.RunApp()`, which uses the hostfxr native hosting APIs
-(`hostfxr_initialize_for_dotnet_command_line` / `hostfxr_run_app`) to bootstrap
-CoreCLR and run `dotnet.dll`.
+**Slow path** — When `DOTNET_CLI_ENABLEAOT` is not set or the AOT parser does
+not handle the command, the bridge calls `ManagedHost.RunApp()`, which uses the
+hostfxr native hosting APIs (`hostfxr_initialize_for_dotnet_command_line` /
+`hostfxr_set_runtime_property_value` / `hostfxr_run_app`) to bootstrap CoreCLR
+and run `dotnet.dll`. The bridge passes through the `host_path`, `dotnet_root`,
+and `hostfxr_path` received from the caller so that the runtime is configured
+exactly as the muxer would configure it for an SDK command.
```mermaid
sequenceDiagram
@@ -113,13 +118,14 @@ sequenceDiagram
dn->>aot: dotnet_execute(hostPath, dotnetRoot, sdkDir, hostfxrPath, argc, argv)
aot->>aot: Parser.Parse(args)
- alt Command handled by AOT
+ alt DOTNET_CLI_ENABLEAOT=true and command handled by AOT
aot->>aot: Parser.Invoke(parseResult)
aot-->>dn: exit code
- else Command not handled
- aot->>hfxr: hostfxr_initialize_for_dotnet_command_line(args)
- hfxr->>clr: Load CoreCLR runtime
+ else Command not handled or AOT disabled
+ aot->>hfxr: hostfxr_initialize_for_dotnet_command_line(args, host_path, dotnet_root)
+ aot->>hfxr: hostfxr_set_runtime_property_value(handle, "HOSTFXR_PATH", hostfxrPath)
aot->>hfxr: hostfxr_run_app(handle)
+ hfxr->>clr: Load CoreCLR runtime
hfxr->>cli: Program.Main(args)
cli-->>hfxr: exit code
hfxr-->>aot: exit code
@@ -156,8 +162,9 @@ In the shared files:
- **`Program.cs`** — Under `#if CLI_AOT`, provides a simple `Main` that
delegates to the AOT parser. Under `#else`, provides the full CLI entry point
with telemetry, signal handlers, and workload checks.
-- **`CommandLineInfo.cs`** — Shared without conditional compilation; prints
- version and environment information.
+- **`CommandLineInfo.cs`** — Uses `#if CLI_AOT` to substitute lightweight
+ implementations for workload info, localized strings, and OS detection that
+ would otherwise pull in dependencies incompatible with AOT.
```mermaid
graph LR
@@ -442,10 +449,11 @@ dialog (or auto-attaches in configured environments).
## Future Work
-- **Muxer integration** — The `dn.exe` host emulates muxer behavior locally.
- Once [dotnet/runtime#126171](https://github.com/dotnet/runtime/issues/126171)
- lands, the dotnet muxer will call `dotnet_execute` directly. `dn.exe` will
- continue to serve as a local development and testing entry point.
+- **Muxer integration** — The muxer's `try_invoke_aot_sdk` function
+ ([dotnet/runtime#126171](https://github.com/dotnet/runtime/issues/126171))
+ already calls `dotnet_execute` from the resolved SDK directory, passing
+ `host_path`, `dotnet_root`, `sdk_dir`, and `hostfxr_path`. `dn.exe`
+ emulates this same contract for local development and testing.
- **Remove AOT commands from managed package** — After the AOT path is
validated and shipping, the `#if CLI_AOT` implementations in `Parser.cs`
and `Program.cs` can be removed from the managed `dotnet.dll` build.
diff --git a/src/Cli/dotnet-aot/ManagedHost.cs b/src/Cli/dotnet-aot/ManagedHost.cs
index 4677068dd3ea..ed6905aa7373 100644
--- a/src/Cli/dotnet-aot/ManagedHost.cs
+++ b/src/Cli/dotnet-aot/ManagedHost.cs
@@ -106,16 +106,17 @@ public int InvokeMethod(string assemblyPath, string typeName, string methodName)
/// Runs the managed application using the hostfxr command-line hosting path.
/// This is the simplest way to invoke dotnet.dll Program.Main(args).
///
+ /// Path to the host executable (e.g., dotnet.exe).
/// Path to the .NET installation root.
+ /// Path to the hostfxr library.
/// Command-line arguments (first element should be the app path).
/// The application exit code.
- public static int RunApp(string dotnetRoot, string[] args)
+ public static int RunApp(string hostPath, string dotnetRoot, string hostfxrPath, string[] args)
{
- nint handle;
-
var parameters = new Interop.hostfxr_initialize_parameters
{
size = sizeof(Interop.hostfxr_initialize_parameters),
+ host_path = PlatformStringMarshaller.ConvertToUnmanaged(hostPath),
dotnet_root = PlatformStringMarshaller.ConvertToUnmanaged(dotnetRoot),
};
@@ -125,7 +126,7 @@ public static int RunApp(string dotnetRoot, string[] args)
args.Length,
args,
in parameters,
- out handle);
+ out nint handle);
if (result != StatusCode.Success && handle == 0)
{
@@ -134,6 +135,20 @@ public static int RunApp(string dotnetRoot, string[] args)
try
{
+ // Set HOSTFXR_PATH property to match the muxer's behavior for SDK commands.
+ // The muxer sets this when is_sdk_command=true so the SDK can load hostfxr
+ // without relying on dlopen/LoadLibrary to find it.
+ if (!string.IsNullOrEmpty(hostfxrPath))
+ {
+ StatusCode propertyResult = Interop.hostfxr_set_runtime_property_value(
+ handle, Constants.RuntimeProperty.HostFxrPath, hostfxrPath);
+ if (propertyResult != StatusCode.Success)
+ {
+ throw new InvalidOperationException(
+ $"hostfxr_set_runtime_property_value failed for {Constants.RuntimeProperty.HostFxrPath}. Status: {propertyResult} (0x{(uint)propertyResult:X8})");
+ }
+ }
+
StatusCode appResult = Interop.hostfxr_run_app(handle);
return (int)appResult;
}
@@ -144,6 +159,7 @@ public static int RunApp(string dotnetRoot, string[] args)
}
finally
{
+ PlatformStringMarshaller.Free(parameters.host_path);
PlatformStringMarshaller.Free(parameters.dotnet_root);
}
}
diff --git a/src/Cli/dotnet-aot/NativeEntryPoint.cs b/src/Cli/dotnet-aot/NativeEntryPoint.cs
index e88955b29977..dc71475d4dbf 100644
--- a/src/Cli/dotnet-aot/NativeEntryPoint.cs
+++ b/src/Cli/dotnet-aot/NativeEntryPoint.cs
@@ -34,18 +34,19 @@ static int Execute(
args[i] = PlatformStringMarshaller.ConvertToManaged(argv[i]) ?? string.Empty;
}
- // Try the AOT-compiled path first for supported commands
- var parseResult = Parser.Parse(args);
- bool handled = parseResult.Errors.Count == 0;
-
- if (handled)
+ // Try the AOT-compiled path for supported commands (if enabled)
+ if (EnvironmentVariableParser.ParseBool(Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_ENABLEAOT), defaultValue: false))
{
- return Parser.Invoke(parseResult);
+ var parseResult = Parser.Parse(args);
+ if (parseResult.Errors.Count == 0)
+ {
+ return Parser.Invoke(parseResult);
+ }
}
// Fall back to the fully managed dotnet CLI by hosting .NET
- string dotnetDll = Path.Combine(sdkDir, "dotnet.dll");
- string runtimeConfig = Path.Combine(sdkDir, "dotnet.runtimeconfig.json");
+ string dotnetDll = Path.Join(sdkDir, "dotnet.dll");
+ string runtimeConfig = Path.Join(sdkDir, "dotnet.runtimeconfig.json");
if (File.Exists(dotnetDll) && File.Exists(runtimeConfig))
{
@@ -53,7 +54,7 @@ static int Execute(
string[] appArgs = new string[args.Length + 1];
appArgs[0] = dotnetDll;
Array.Copy(args, 0, appArgs, 1, args.Length);
- return ManagedHost.RunApp(dotnetRoot, appArgs);
+ return ManagedHost.RunApp(hostPath, dotnetRoot, hostfxrPath, appArgs);
}
// No managed fallback available
diff --git a/src/Cli/dotnet-aot/dotnet-aot.csproj b/src/Cli/dotnet-aot/dotnet-aot.csproj
index ef0c6d2a1708..87ccce2bf072 100644
--- a/src/Cli/dotnet-aot/dotnet-aot.csproj
+++ b/src/Cli/dotnet-aot/dotnet-aot.csproj
@@ -31,6 +31,7 @@
+
diff --git a/src/Common/EnvironmentVariableNames.cs b/src/Common/EnvironmentVariableNames.cs
index a2dce0704346..ff31b1fe7141 100644
--- a/src/Common/EnvironmentVariableNames.cs
+++ b/src/Common/EnvironmentVariableNames.cs
@@ -25,6 +25,7 @@ internal static class EnvironmentVariableNames
public static readonly string DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = nameof(DOTNET_ADD_GLOBAL_TOOLS_TO_PATH);
public static readonly string DOTNET_NOLOGO = nameof(DOTNET_NOLOGO);
public static readonly string DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK = nameof(DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK);
+ public static readonly string DOTNET_CLI_ENABLEAOT = nameof(DOTNET_CLI_ENABLEAOT);
public static readonly string DOTNET_CLI_TELEMETRY_SESSIONID = nameof(DOTNET_CLI_TELEMETRY_SESSIONID);
public static readonly string DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING = nameof(DOTNET_CLI_CONSOLE_USE_DEFAULT_ENCODING);
// Telemetry logging/debug/testing.
diff --git a/tasks.code-workspace b/tasks.code-workspace
index 923b82ffad87..16974cf0d4a4 100644
--- a/tasks.code-workspace
+++ b/tasks.code-workspace
@@ -19,7 +19,8 @@
"args": ["--info"],
"cwd": "${workspaceFolder}${/}artifacts${/}bin${/}dn${/}Debug${/}net11.0${/}win-x64${/}publish",
"environment": [
- { "name": "DOTNET_ROOT", "value": "${workspaceFolder}/.dotnet" }
+ { "name": "DOTNET_ROOT", "value": "${workspaceFolder}/.dotnet" },
+ { "name": "DOTNET_CLI_ENABLEAOT", "value": "true" }
],
"stopAtEntry": false,
"console": "integratedTerminal"