From 21d8bae152fac5ca59aeca7c2016c1a31a5771f1 Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Wed, 22 Apr 2026 12:57:12 -0700 Subject: [PATCH 1/2] Gate AOT fast path behind DOTNET_CLI_ENABLEAOT and fix hostfxr initialization The AOT parser in NativeEntryPoint is now only invoked when DOTNET_CLI_ENABLEAOT=true. When unset, the bridge falls through to the managed CLI immediately. ManagedHost.RunApp now configures the runtime exactly as the muxer would for an SDK command: - Sets host_path in hostfxr_initialize_parameters so hostfxr uses the correct host executable identity instead of the current process. - Sets the HOSTFXR_PATH runtime property via hostfxr_set_runtime_property_value, matching the muxer's is_sdk_command=true behavior so the SDK can locate hostfxr directly. All debug configurations (launchSettings.json, vcxproj.user, debug-dn.cmd, tasks.code-workspace) updated to set the new env var. DESIGN.md updated to reflect the DOTNET_CLI_ENABLEAOT gate and the muxer's existing try_invoke_aot_sdk integration. Co-authored-by: Copilot --- src/Cli/dn/dn-native-debug.vcxproj | 3 +- src/Cli/dn/dn.csproj | 6 ++- src/Cli/dotnet-aot/DESIGN.md | 66 +++++++++++++++----------- src/Cli/dotnet-aot/ManagedHost.cs | 18 +++++-- src/Cli/dotnet-aot/NativeEntryPoint.cs | 19 ++++---- src/Cli/dotnet-aot/dotnet-aot.csproj | 1 + src/Common/EnvironmentVariableNames.cs | 1 + tasks.code-workspace | 3 +- 8 files changed, 71 insertions(+), 46 deletions(-) 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..5c740d45523c 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,14 @@ 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)) + { + Interop.hostfxr_set_runtime_property_value(handle, "HOSTFXR_PATH", hostfxrPath); + } + StatusCode appResult = Interop.hostfxr_run_app(handle); return (int)appResult; } @@ -144,6 +153,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" From 950ab426ba58bbdeca80e61df6d3a8db500050fe Mon Sep 17 00:00:00 2001 From: Jeremy Kuhne Date: Wed, 22 Apr 2026 13:15:21 -0700 Subject: [PATCH 2/2] Check hostfxr_set_runtime_property_value result and use constant for HOSTFXR_PATH Address PR review feedback: - Check the return value of hostfxr_set_runtime_property_value and throw on failure instead of silently ignoring it. - Use Constants.RuntimeProperty.HostFxrPath instead of a string literal. --- src/Cli/dotnet-aot/ManagedHost.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Cli/dotnet-aot/ManagedHost.cs b/src/Cli/dotnet-aot/ManagedHost.cs index 5c740d45523c..ed6905aa7373 100644 --- a/src/Cli/dotnet-aot/ManagedHost.cs +++ b/src/Cli/dotnet-aot/ManagedHost.cs @@ -140,7 +140,13 @@ public static int RunApp(string hostPath, string dotnetRoot, string hostfxrPath, // without relying on dlopen/LoadLibrary to find it. if (!string.IsNullOrEmpty(hostfxrPath)) { - Interop.hostfxr_set_runtime_property_value(handle, "HOSTFXR_PATH", 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);