Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .vsts-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ extends:
# arm64 linker and the RID-specific ILCompiler package handle the cross-link; no sysroot is
# required). This leg therefore BUILDS on the existing win-x64 pool with /p:CrossBuild=true and
# produces the dotnet-aot .dll plus its mstat/dgml size-analysis artifacts (that step is gated
# on runAoTTests). It is build-only (runTests: false): there is no wired windows arm64 Helix
# on the AoT category). It is build-only (runTests: false): there is no wired windows arm64 Helix
# queue yet, so running the *.AoT.Tests on arm64 hardware is a follow-up pending a
# windows.11.arm64 queue. Runs for internal PRs and test builds.
- ${{ if or(eq(parameters.runTestBuild, true), eq(variables['Build.Reason'], 'PullRequest')) }}:
Expand All @@ -199,10 +199,9 @@ extends:
# 'LNK1322: cannot avoid potential ARM hazard (Cortex-A53 MPCore processor bug #843419)'
# because the ILCompiler-produced object isn't split into per-function sections, so the
# MSVC arm64 linker cannot apply the erratum fixup. This leg has never passed. Re-enable
# once the ILCompiler fix lands.
# once the ILCompiler fix lands. Tracked by dotnet/sdk#55298.
- categoryName: AoT
disableJob: true
runAoTTests: true
targetArchitecture: arm64
runtimeIdentifier: win-arm64
osProperties: /p:CrossBuild=true
Expand Down Expand Up @@ -316,7 +315,6 @@ extends:
linuxJobParameterSets:
- categoryName: AoT
container: azureLinuxCrossArm64
runAoTTests: true
targetArchitecture: arm64
runtimeIdentifier: linux-arm64
osProperties: $(linuxOsglibcProperties) /p:CrossBuild=true
Expand Down
5 changes: 2 additions & 3 deletions .vsts-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,14 @@ stages:
# dotnet-aot is built with NativeAOT, which can cross-compile win-x64 -> win-arm64 (the MSVC arm64
# linker and the RID-specific ILCompiler package handle the cross-link; no sysroot is required).
# This leg BUILDS on the win-x64 pool with /p:CrossBuild=true and produces the dotnet-aot .dll plus
# its mstat/dgml size-analysis artifacts (that step is gated on runAoTTests). It is build-only
# its mstat/dgml size-analysis artifacts (that step is gated on the AoT category). It is build-only
# (runTests: false): there is no wired windows arm64 Helix queue yet, so running the *.AoT.Tests on
# arm64 hardware is a follow-up pending a windows.11.arm64 queue.
# TEMPORARILY DISABLED (disableJob: true): the win-arm64 NativeAOT cross-link fails with
# 'LNK1322: cannot avoid potential ARM hazard (Cortex-A53 MPCore processor bug #843419)' because
# the ILCompiler-produced object isn't split into per-function sections, so the MSVC arm64 linker
# cannot apply the erratum fixup. This leg has never passed. Re-enable once the ILCompiler fix lands.
# Tracked by dotnet/sdk#55298.
- template: /eng/pipelines/templates/jobs/sdk-job-matrix.yml@self
parameters:
pool:
Expand All @@ -86,7 +87,6 @@ stages:
windowsJobParameterSets:
- categoryName: AoT
disableJob: true
runAoTTests: true
targetArchitecture: arm64
runtimeIdentifier: win-arm64
osProperties: /p:CrossBuild=true
Expand Down Expand Up @@ -119,7 +119,6 @@ stages:
linuxJobParameterSets:
- categoryName: AoT
container: azureLinuxCrossArm64
runAoTTests: true
targetArchitecture: arm64
runtimeIdentifier: linux-arm64
osProperties: $(linuxOsglibcProperties) /p:CrossBuild=true
Expand Down
16 changes: 10 additions & 6 deletions eng/pipelines/templates/jobs/sdk-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ parameters:
timeoutInMinutes: ''
### ENV VARS ###
testFullMSBuild: false
# Enables the Blazor WebAssembly AoT tests (test/Microsoft.NET.Sdk.BlazorWebAssembly.AoT.Tests)
# on Helix via the RunAoTTests env var.
runAoTTests: false
# Runs the NativeAOT CLI tests (test/dotnet-aot.Tests) on the build agent after the build.
# These publish the test assembly as a NativeAOT binary and run it on the same architecture,
# validating the AOT CLI entry-point code under NativeAOT compilation. This is distinct from
# runAoTTests, which runs the Blazor WebAssembly AoT tests on Helix.
# validating the AOT CLI entry-point code under NativeAOT compilation. Only enabled where the
# agent architecture matches the target (the produced native binary must run on the agent);
# cross-arch AoT legs leave it false.
runNativeAotCliTests: false
### MSBUILD ###
targetArchitecture: x64
Expand Down Expand Up @@ -273,10 +276,11 @@ jobs:
continueOnError: true
condition: always()

# Only the AoT legs publish the dotnet-aot NativeAOT library, so only they produce
# the size-analysis files. Gate these steps so they don't run (and upload empty
# artifacts) on non-AoT legs like the TestBuild/FullFramework jobs.
- ${{ if eq(parameters.runAoTTests, true) }}:
# Only the AoT legs build the dotnet-aot NativeAOT library, so only they produce the
# size-analysis files. Gate on the AoT category so these steps run on every AoT leg (including
# the build-only cross-arch legs) and never upload empty artifacts on non-AoT legs like the
# TestBuild/FullFramework jobs.
- ${{ if eq(parameters.categoryName, 'AoT') }}:
- task: CopyFiles@2
displayName: 🟣 Copy NativeAOT Size Analysis Files
inputs:
Expand Down
58 changes: 42 additions & 16 deletions src/Cli/dotnet-aot/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,27 @@ 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.
as a local development and testing entry point. The AOT fast path is enabled by
default on all platforms (see [Opting out](#opting-out-dotnet_cli_enableaot));
setting `DOTNET_CLI_ENABLEAOT=false` (or `0`/`no`/`off`) opts out, and the bridge
falls through to the managed CLI immediately.

## Opting out (`DOTNET_CLI_ENABLEAOT`)

The AOT command-handling fast path is **enabled by default on all platforms**. The
fast path is designed to be behaviorally identical to the managed CLI, transparently
deferring to it for anything it cannot handle, so it should require no action from users.

If you need to bypass the AOT path entirely — for example to diagnose a suspected parity
issue — set the `DOTNET_CLI_ENABLEAOT` environment variable to a falsy value before
invoking `dotnet`:

- Disable: `false`, `0`, `no`, or `off`
- Enable: `true`, `1`, `yes`, or `on`
- Unset (default): enabled

When disabled, every invocation is routed straight to the managed CLI, exactly as it
behaved before the AOT fast path was enabled by default.

## Motivation

Expand Down Expand Up @@ -48,7 +66,7 @@ graph TD

subgraph L2["Layer 2 · dotnet-aot.dll (Native AOT Shared Library)"]
Entry["NativeEntryPoint.Execute()"]
AotCheck{"DOTNET_CLI_ENABLEAOT<br/>enabled?"}
AotCheck{"DOTNET_CLI_ENABLEAOT<br/>not disabled?"}
Parse["Parser.Parse(args)"]
Fast{"Command handled<br/>by AOT path?"}
Invoke["Parser.Invoke()"]
Expand Down Expand Up @@ -94,7 +112,8 @@ 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** — When `DOTNET_CLI_ENABLEAOT=true`, the AOT bridge builds the
**Fast path** — Unless `DOTNET_CLI_ENABLEAOT` is explicitly disabled, the AOT
bridge builds the
**full** command tree (the same `DotNetCommandDefinition` used by the managed
CLI) so that parsing and `--help` match the managed CLI exactly. Commands that
can run entirely in AOT (`--version`, `--info`, and the AOT-capable `sln`
Expand All @@ -120,7 +139,8 @@ its full resolver set, deferral produces identical user-facing behavior. Out-of-
process invocation only happens after a non-null spec, so a command is never
executed twice.

**Slow path** — When `DOTNET_CLI_ENABLEAOT` is not set or the AOT bridge does
**Slow path** — When `DOTNET_CLI_ENABLEAOT` is disabled (`false`/`0`/`no`/`off`)
or the AOT bridge 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
Expand All @@ -138,17 +158,23 @@ sequenceDiagram
participant cli as dotnet.dll (Layer 3)

dn->>aot: dotnet_execute(hostPath, dotnetRoot, sdkDir, hostfxrPath, argc, argv)
aot->>aot: Parser.Parse(args)

alt DOTNET_CLI_ENABLEAOT=true and built-in command handled by AOT
aot->>aot: Parser.Invoke(parseResult)
aot-->>dn: exit code
else External command that resolves in AOT (tool / PATH / app-base)
aot->>aot: TryInvokeExternalCommand → TryResolveCommandSpec
aot->>tool: Command.Execute() (out of process)
tool-->>aot: exit code
aot-->>dn: exit code
else Command not handled, unresolved, file-based app, or AOT disabled
alt DOTNET_CLI_ENABLEAOT enabled (default)
aot->>aot: Parser.Parse(args)
alt Built-in command handled by AOT
aot->>aot: Parser.Invoke(parseResult)
aot-->>dn: exit code
else External command that resolves in AOT (tool / PATH / app-base)
aot->>aot: TryInvokeExternalCommand → TryResolveCommandSpec
aot->>tool: Command.Execute() (out of process)
tool-->>aot: exit code
aot-->>dn: exit code
else Command not handled, unresolved, or file-based app
aot->>cli: ManagedHost.RunApp(args) → managed fallback (see below)
cli-->>aot: exit code
aot-->>dn: exit code
end
else DOTNET_CLI_ENABLEAOT disabled (opt-out)
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)
Expand Down
53 changes: 35 additions & 18 deletions src/Cli/dotnet-aot/NativeEntryPoint.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ internal static int ExecuteCore(

int exitCode = 1;
bool success = false;
// True once a command has been handled entirely in AOT (no managed fallback). Gates the
// terminal telemetry emission below: the managed fallback child writes its own disk log, so
// we must only write ours when we did not fall back.
bool aotHandledInProcess = false;

try
{
Expand All @@ -173,8 +177,7 @@ internal static int ExecuteCore(
// can use it instead of re-probing PATH / environment for the dotnet installation.
DotnetRoot = string.IsNullOrEmpty(dotnetRoot) ? null : dotnetRoot;

// Try the AOT-compiled path for supported commands (if enabled)
if (EnvironmentVariableParser.ParseBool(Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_ENABLEAOT), defaultValue: false))
if (EnvironmentVariableParser.ParseBool(Environment.GetEnvironmentVariable(EnvironmentVariableNames.DOTNET_CLI_ENABLEAOT), defaultValue: true))
{
ParseResult? parseResult = null;
using (var parse = Activities.Source.StartActivity("parse"))
Expand Down Expand Up @@ -205,22 +208,22 @@ internal static int ExecuteCore(
{
if (parseResult.CanBeInvoked())
{
try
// Parse errors here usually mean the command is contributed dynamically by the managed
// CLI (e.g. NuGet's `package update`/`why`) and absent from the static AOT tree, so defer.
if (parseResult.Errors.Count == 0)
{
// Invoke the built-in command in-process using the shared CommandInvocation
// helper, identical to the managed CLI: same exit-code handling, including the
// "new" command's 127 adjustment and Parser.ExceptionHandler. This keeps the
// two entry points in parity.
exitCode = CommandInvocation.ExecuteInternalCommand(parseResult);
success = true;
// The built-in command ran in-process (no managed fallback), so emit the same
// top-level parser telemetry the managed CLI sends from Program.ProcessArgsAndExecute.
SendAotParserTelemetry(parseResult, globalJsonState);
return exitCode;
}
catch (CommandNotAvailableInAotException)
{
// The parsed command requires the managed CLI — fall through to the managed fallback below.
try
{
exitCode = CommandInvocation.ExecuteInternalCommand(parseResult);
success = true;
aotHandledInProcess = true;
SendAotParserTelemetry(parseResult, globalJsonState);
return exitCode;
}
catch (CommandNotAvailableInAotException)
{
// The parsed command requires the managed CLI — fall through to the managed fallback below.
}
}
}
// An unrecognized top-level token is either an external command (`dotnet ef`, a global
Expand All @@ -229,14 +232,15 @@ internal static int ExecuteCore(
// project tools, and anything that does not resolve to the managed CLI.
else if (parseResult is not null && TryInvokeExternalCommand(parseResult, args, sdkDirectory, mainActivity, globalJsonState, out exitCode, out success))
{
aotHandledInProcess = true;
return exitCode;
}
}
}

// Fall back to the fully managed dotnet CLI by hosting .NET.
// Set a best-effort display name from args when we have not done a full parse
// (i.e. DOTNET_CLI_ENABLEAOT was not set or the command fell through without calling SetDisplayName).
// (i.e. DOTNET_CLI_ENABLEAOT was explicitly disabled or the command fell through without calling SetDisplayName).
if (mainActivity is not null && mainActivity.DisplayName == "main")
{
var fallbackName = args.Length > 0 ? $"dotnet {args[0]}" : "dotnet";
Expand Down Expand Up @@ -264,10 +268,23 @@ internal static int ExecuteCore(
}
finally
{
if (aotHandledInProcess && TelemetryClient is Telemetry.TelemetryClient telemetryClient)
{
// Mirror the managed CLI behavior for compat (Program.cs finally)
telemetryClient.ThreadBlockingTrackEvent("command/finish", new Dictionary<string, string?> { { "exitCode", exitCode.ToString() } });
}

mainActivity?.AddTag("process.exit.code", exitCode);
mainActivity?.SetStatus(success ? ActivityStatusCode.Ok : ActivityStatusCode.Error);
mainActivity?.Stop();
Telemetry.TelemetryClient.FlushProviders();

if (aotHandledInProcess)
{
// The command ran entirely in AOT, so there is no managed fallback child process to persist
// the telemetry disk log. Write it here, mirroring Program.cs's TelemetryClient.WriteLogIfNecessary().
Telemetry.TelemetryClient.WriteLogIfNecessary();
}
}
}

Expand Down
35 changes: 10 additions & 25 deletions src/Cli/dotnet-aot/dotnet-aot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@
<PropertyGroup>
<TargetFramework>$(SdkTargetFramework)</TargetFramework>
<AssemblyName>dotnet-aot</AssemblyName>
<!--
Present the CLI as "dotnet" in help/usage output. As a NativeAOT native library this
component has no managed entry point (Assembly.GetEntryAssembly() is null), so
System.CommandLine.RootCommand resolves its executable name from the
"System.CommandLine.ExecutableName" runtimeconfig/AppContext value emitted by the
System.CommandLine build targets, which defaults to $(AssemblyName) ("dotnet-aot").
Override that escape hatch here so usage lines read "dotnet <command>" (matching the
managed muxer) instead of "dotnet-aot <command>".
-->
<_SystemCommandLineExecutableName>dotnet</_SystemCommandLineExecutableName>
<OutputType>Library</OutputType>
<NativeLib>Shared</NativeLib>
<PublishAot>true</PublishAot>
Expand Down Expand Up @@ -96,31 +106,6 @@
Trim="true" />
</ItemGroup>

<!--
On Apple platforms, NativeAOT unconditionally statically links libSystem.Security.Cryptography.Native.Apple.a
into every output, including NativeLib=Shared (dylib). This static archive contains Swift binding classes
(HashBox, X25519KeyBox) that register with the ObjC runtime. When the host process also has these classes
(from the shared framework's copy of the same library), macOS emits duplicate class warnings to stderr,
which breaks tests that assert stderr is empty.

The AOT CLI DOES use cryptography during first-run (ASP.NET Core developer-certificate generation), but it
does not need this archive statically linked: because the CLI is loaded as a shared library (NativeLib=Shared)
by a full .NET host, its crypto P/Invokes resolve dynamically against the host's already-loaded copy of
libSystem.Security.Cryptography.Native.Apple.dylib. Removing the static archive (and its DirectPInvoke) therefore
keeps cert generation working while avoiding the duplicate ObjC class registration and its stderr warnings.

This is a workaround for a NativeAOT issue tracked at https://github.com/dotnet/runtime/issues/128867
("Two CLR-based apps cannot coexist on Apple platforms"). Once that is fixed (e.g. by guarding the
NetCoreAppNativeLibrary/DirectPInvoke items on '$(NativeLib)' != 'Shared', or by reusing the
already-loaded libSystem.*.Native libraries), this target can be removed.
-->
<Target Name="RemoveAppleCryptoFromNativeAotLink" AfterTargets="SetupOSSpecificProps">
<ItemGroup>
<NativeLibrary Remove="$(IlcFrameworkNativePath)libSystem.Security.Cryptography.Native.Apple.a" />
<DirectPInvoke Remove="System.Security.Cryptography.Native.Apple" />
</ItemGroup>
</Target>

<Import Project="$(RepositoryEngineeringDir)common\native\LocateNativeCompiler.targets"
Condition="'$(UseCommonLocateNativeCompilerTarget)' == 'true'" />

Expand Down
Loading
Loading