// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Caching;
using Aspire.Cli.Commands;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
using NuGetPackage = Aspire.Shared.NuGetPackageCli;
namespace Aspire.Cli.DotNet;
internal interface IDotNetCliRunner
{
Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, string[] targets, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary<string, string>? env, TaskCompletionSource<IAppHostCliBackchannel>? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> RunAppHostCommandAsync(FileInfo projectFile, string command, DirectoryInfo workingDirectory, string[] args, IDictionary<string, string>? env, TaskCompletionSource<IAppHostCliBackchannel>? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> RestoreAsync(FileInfo projectFilePath, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> BuildAsync(FileInfo projectFilePath, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool exactMatch, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<(int ExitCode, IReadOnlyList<FileInfo> Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, ProcessInvocationOptions options, CancellationToken cancellationToken);
Task<int> InitUserSecretsAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken);
}
internal sealed class ProcessInvocationOptions
{
public Action<string>? StandardOutputCallback { get; set; }
public Action<string>? StandardErrorCallback { get; set; }
public bool NoLaunchProfile { get; set; }
public string? LaunchProfile { get; set; }
public bool StartDebugSession { get; set; }
public bool Debug { get; set; }
/// <summary>
/// When true, suppresses logging of process output to the logger.
/// Useful for background operations like NuGet package cache refreshes.
/// </summary>
public bool SuppressLogging { get; set; }
/// <summary>
/// Controls whether cancellation should terminate the whole process tree or only the root process.
/// </summary>
public bool KillEntireProcessTreeOnCancel { get; set; } = true;
/// <summary>
/// When <c>true</c>, the spawned process is given its own hidden console group (Windows) so the
/// shutdown ladder in <see cref="ProcessExecution"/> can target the child with DCP's
/// <c>stop-process-tree</c> CTRL+C dance without also signalling the CLI.
/// </summary>
/// <remarks>
/// Pair with <see cref="GracefulShutdownSignaler"/> and <see cref="ShutdownService"/> for
/// graceful shutdown. Pair with <see cref="KillOnParentExit"/> when the child should also be
/// bound to the Windows kill-on-close job as a crash-time safety net.
/// Leaving the signaler/service unset means cancellation falls back to
/// <see cref="ProcessExecution"/>'s force-kill mode, preserving back-compat
/// for the many non-Run callers (build, restore, package add, layout, etc.).
/// </remarks>
public bool IsolateConsole { get; set; }
/// <summary>
/// When <c>true</c>, the child is bound to the CLI's Windows kill-on-close job so the OS terminates
/// it when the CLI exits unexpectedly (crash / SIGKILL), even if the child does not react
/// to cancellation request. This is an OS-level, Windows-only crash-time safety net
/// for background helpers that must never outlive their parent — the <c>aspire-managed</c> NuGet
/// helper, the standalone dashboard, and the profiling collector — and, unlike
/// <see cref="IsolateConsole"/>, it does not give the child a new console group.
/// </summary>
/// <remarks>
/// On non-Windows hosts this is a no-op; process-group signalling plus the in-child
/// parent-liveness watchdog (which self-terminates when <c>ASPIRE_CLI_PID</c> disappears) provide
/// the cross-platform equivalent. The two layers are complementary: the job is the instant,
/// guaranteed backstop on Windows, and the watchdog is the graceful, cross-platform mechanism.
/// </remarks>
public bool KillOnParentExit { get; set; }
/// <summary>
/// When <c>true</c>, the process is launched as a detached child that survives the launching CLI.
/// </summary>
public bool Detached { get; set; }
/// <summary>
/// Test hook for overriding the DCP executable used to launch detached Unix processes.
/// </summary>
internal string? DetachedUnixLauncherPathOverride { get; set; }
/// <summary>
/// Optional predicate for inherited environment variable names that should be removed before applying caller-supplied variables.
/// </summary>
public Func<string, bool>? EnvironmentVariableFilter { get; set; }
/// <summary>
/// Index of the first argument that is user-supplied AppHost input rather than a CLI-owned
/// option, for invocations whose argument list has no <c>--</c> separator to key off. Leave
/// <see langword="null"/> when the separator is present or the whole list is CLI-owned.
/// </summary>
/// <remarks>
/// Launching a built AppHost directly (rather than through <c>dotnet run</c>) appends the
/// forwarded arguments straight onto the executable's command line, so the redaction boundary
/// has to travel with the invocation instead of being recovered from the arguments.
/// </remarks>
internal int? AppHostArgumentStartIndex { get; set; }
/// <summary>
/// Issues the graceful shutdown signal during the shutdown ladder (DCP
/// <c>stop-process-tree</c> on Windows, SIGTERM on Unix). When <c>null</c>, the cancellation
/// path uses <see cref="ProcessExecution"/>'s force-kill mode.
/// </summary>
public IProcessTreeGracefulShutdownSignaler? GracefulShutdownSignaler { get; set; }
/// <summary>
/// The central graceful-shutdown window whose
/// <see cref="ConsoleCancellationManager.GracefulShutdownToken"/> bounds the shutdown ladder. When
/// <c>null</c>, the cancellation path uses <see cref="ProcessExecution"/>'s
/// force-kill mode.
/// </summary>
public IGracefulShutdownWindow? ShutdownService { get; set; }
/// <summary>
/// Creates a shallow copy so a caller-supplied instance can be layered with additional settings
/// without mutating the original, which the caller may reuse across invocations. The delegate and
/// service references are intentionally shared with the copy.
/// </summary>
public ProcessInvocationOptions Clone() => new()
{
StandardOutputCallback = StandardOutputCallback,
StandardErrorCallback = StandardErrorCallback,
NoLaunchProfile = NoLaunchProfile,
LaunchProfile = LaunchProfile,
StartDebugSession = StartDebugSession,
Debug = Debug,
SuppressLogging = SuppressLogging,
KillEntireProcessTreeOnCancel = KillEntireProcessTreeOnCancel,
IsolateConsole = IsolateConsole,
KillOnParentExit = KillOnParentExit,
Detached = Detached,
DetachedUnixLauncherPathOverride = DetachedUnixLauncherPathOverride,
EnvironmentVariableFilter = EnvironmentVariableFilter,
AppHostArgumentStartIndex = AppHostArgumentStartIndex,
GracefulShutdownSignaler = GracefulShutdownSignaler,
ShutdownService = ShutdownService,
};
}
internal sealed class DotNetCliRunner(
ILogger<DotNetCliRunner> logger,
IServiceProvider serviceProvider,
AspireCliTelemetry telemetry,
ProfilingTelemetry profilingTelemetry,
IConfiguration configuration,
IDiskCache diskCache,
IFeatures features,
IInteractionService interactionService,
CliExecutionContext executionContext,
IProcessExecutionFactory executionFactory,
IEnvironment environment) : IDotNetCliRunner
{
private readonly IDiskCache _diskCache = diskCache;
// Retry configuration for NuGet package search operations
private const int MaxSearchRetries = 3;
private static long s_binlogSequence;
private static readonly TimeSpan[] s_searchRetryDelays = [TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(2)];
internal static string GetBackchannelSocketPath()
{
return CliPathHelper.CreateUnixDomainSocketPath("cli.sock");
}
private async Task<int> ExecuteAsync(
string[] args,
IDictionary<string, string>? env,
FileInfo? projectFile,
DirectoryInfo workingDirectory,
TaskCompletionSource<IAppHostCliBackchannel>? backchannelCompletionSource,
ProcessInvocationOptions options,
CancellationToken cancellationToken,
string? commandOverride = null)
{
var dotnetCommand = commandOverride is null
? args.Length > 0 ? args[0] : "execute"
: Path.GetFileNameWithoutExtension(CommandPathResolver.NormalizeRunCommand(commandOverride));
var backchannelParentContext = Activity.Current?.Context ?? default;
using var processActivity = profilingTelemetry.StartDotNetProcess(dotnetCommand, projectFile, workingDirectory, options);
// Build the final environment variables by merging caller-provided env with dotnet-specific settings.
var finalEnv = env?.ToDictionary() ?? new Dictionary<string, string>();
ConfigureDotNetEnvironment(finalEnv);
AddAspireCliPathEnvironment(finalEnv, projectFile);
processActivity.AddContextToEnvironment(finalEnv);
var command = commandOverride is null ? null : CommandPathResolver.NormalizeRunCommand(commandOverride);
string processFileName;
string[] effectiveArgs;
if (command is null || IsDotNetMuxerCommand(command))
{
// Resolve the dotnet executable path, preferring the private SDK installation if available.
processFileName = ResolveDotNetPath(finalEnv);
effectiveArgs = AddBinlogArgumentIfConfigured(args, dotnetCommand, projectFile, workingDirectory, processActivity);
}
else
{
// This part is Aspire-specific rather than copied from the SDK. The SDK can rely on its
// own host environment, but a native apphost executable launched by Aspire still needs
// our private-SDK DOTNET_ROOT/PATH setup to locate the same shared framework that the
// CLI-selected dotnet muxer would use. Launch the apphost executable itself to avoid
// the `dotnet run` command path.
_ = ResolveDotNetPath(finalEnv);
processFileName = command;
effectiveArgs = args;
}
processActivity.SetDotNetResolvedExecutable(
processFileName,
effectiveArgs,
options.AppHostArgumentStartIndex,
finalEnv.TryGetValue("DOTNET_CLI_USE_MSBUILD_SERVER", out var msBuildServerValue) ? msBuildServerValue : null);
processActivity.SetDotNetArgsCount(effectiveArgs.Length);
var outputCounters = new ProcessOutputCounters();
var instrumentedOptions = CreateInstrumentedProcessOptions(options, processActivity, outputCounters);
// Do not use 'await using' here: StartBackchannelAsync runs fire-and-forget and
// accesses execution.HasExited / ExitCode after this method returns. Disposing
// the underlying Process while the backchannel task is still polling would
// cause ObjectDisposedException. We intentionally never dispose this execution:
// IAsyncDisposable.DisposeAsync is not called by the finalizer, but the Process's
// native handles are still reclaimed by the SafeHandle finalizers, so this is not
// a resource leak.
var execution = executionFactory.CreateExecution(processFileName, effectiveArgs, finalEnv, workingDirectory, instrumentedOptions);
// Get socket path from env if present
string? socketPath = null;
finalEnv.TryGetValue(KnownConfigNames.UnixSocketPath, out socketPath);
// Handle extension-based launch for app hosts with backchannel
if (backchannelCompletionSource is not null)
{
if (ExtensionHelper.IsExtensionHost(interactionService, out var extensionInteractionService, out var extensionBackchannel)
&& projectFile is not null
&& await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.Project, cancellationToken))
{
await extensionInteractionService.LaunchAppHostAsync(
projectFile.FullName,
execution.Arguments.ToList(),
execution.EnvironmentVariables.Select(kvp => new EnvVar { Name = kvp.Key, Value = kvp.Value }).ToList(),
options.StartDebugSession);
await StartBackchannelAsync(null, socketPath!, backchannelCompletionSource, backchannelParentContext, cancellationToken).ConfigureAwait(false);
var backchannel = await backchannelCompletionSource.Task.ConfigureAwait(false);
// The extension launched the AppHost process, so there is no local Process to await.
// Keep this CLI process alive because it owns the AppHost backchannel; the extension
// will stop the CLI through the RPC endpoint when the managed debug session ends,
// or the AppHost backchannel will disconnect if the AppHost exits first.
await backchannel.WaitForDisconnectAsync(cancellationToken).ConfigureAwait(false);
return CliExitCodes.Success;
}
}
var started = await execution.StartAsync(cancellationToken).ConfigureAwait(false);
processActivity.AddDotNetProcessStartResult(started, started ? execution.ProcessId : null);
if (!started)
{
processActivity.SetError("Process failed to start.");
return CliExitCodes.FailedToDotnetRunAppHost;
}
if (backchannelCompletionSource is not null && socketPath is not null)
{
_ = StartBackchannelAsync(execution, socketPath, backchannelCompletionSource, backchannelParentContext, cancellationToken);
}
var exitCode = await execution.WaitForExitAsync(cancellationToken);
processActivity.SetDotNetCompleted(exitCode, outputCounters.StdoutLineCount, outputCounters.StderrLineCount);
return exitCode;
}
private static bool IsDotNetMuxerCommand(string command)
=> string.Equals(Path.GetFileNameWithoutExtension(CommandPathResolver.NormalizeRunCommand(command)), "dotnet", StringComparison.OrdinalIgnoreCase);
private string[] AddBinlogArgumentIfConfigured(
string[] args,
string dotnetCommand,
FileInfo? projectFile,
DirectoryInfo workingDirectory,
ProfilingTelemetry.ActivityScope processActivity)
{
var binlogDirectory = configuration[KnownConfigNames.CliDotnetBinlogDirectory];
if (string.IsNullOrWhiteSpace(binlogDirectory))
{
return args;
}
if (!SupportsBinlog(dotnetCommand))
{
// Some dotnet subcommands are not MSBuild entry points and reject /bl.
processActivity.SetDotNetBinlogSkippedUnsupportedCommand();
return args;
}
var fullBinlogDirectory = Path.IsPathFullyQualified(binlogDirectory)
? binlogDirectory
: Path.GetFullPath(Path.Combine(workingDirectory.FullName, binlogDirectory));
Directory.CreateDirectory(fullBinlogDirectory);
var binlogPath = Path.Combine(fullBinlogDirectory, CreateBinlogFileName(dotnetCommand, projectFile, workingDirectory));
processActivity.SetDotNetBinlogPath(binlogPath);
return [.. args, $"/bl:{binlogPath}"];
}
private static bool SupportsBinlog(string dotnetCommand)
{
return dotnetCommand is "build" or "msbuild" or "restore" or "publish" or "test";
}
private static string CreateBinlogFileName(string dotnetCommand, FileInfo? projectFile, DirectoryInfo workingDirectory)
{
var sequence = Interlocked.Increment(ref s_binlogSequence);
var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture);
var scope = projectFile is not null ? Path.GetFileNameWithoutExtension(projectFile.Name) : workingDirectory.Name;
return $"{timestamp}-{Environment.ProcessId}-{sequence:D4}-{SanitizeFileNamePart(dotnetCommand)}-{SanitizeFileNamePart(scope)}.binlog";
}
private static string SanitizeFileNamePart(string value)
{
var builder = new StringBuilder(value.Length);
foreach (var ch in value)
{
builder.Append(char.IsAsciiLetterOrDigit(ch) || ch is '-' or '_' or '.' ? ch : '-');
}
var sanitized = builder.ToString().Trim('-');
if (string.IsNullOrEmpty(sanitized))
{
return "dotnet";
}
return sanitized.Length <= 80 ? sanitized : sanitized[..80];
}
private static ProcessInvocationOptions CreateInstrumentedProcessOptions(
ProcessInvocationOptions options,
ProfilingTelemetry.ActivityScope activity,
ProcessOutputCounters outputCounters)
{
return new ProcessInvocationOptions
{
NoLaunchProfile = options.NoLaunchProfile,
StartDebugSession = options.StartDebugSession,
Debug = options.Debug,
SuppressLogging = options.SuppressLogging,
KillEntireProcessTreeOnCancel = options.KillEntireProcessTreeOnCancel,
// Forward the Run-path shutdown ladder opt-ins. Forgetting any of these silently
// demotes the run to the force-kill fallback: IsolateConsole=false skips console
// isolation, KillOnParentExit=false removes the Windows crash-time job safety net, and the
// null signaler/service pair causes ProcessExecution's OCE catch
// (DotNet/ProcessExecution.cs) to route through its force-kill mode (best-effort SIGTERM
// then kill) instead of its graceful ladder. Build/restore/etc. callers leave these unset
// and intentionally keep the force-kill path.
IsolateConsole = options.IsolateConsole,
KillOnParentExit = options.KillOnParentExit,
Detached = options.Detached,
DetachedUnixLauncherPathOverride = options.DetachedUnixLauncherPathOverride,
EnvironmentVariableFilter = options.EnvironmentVariableFilter,
// Without this the redaction boundary is lost between the runner and the process
// factory, and a direct AppHost launch would log its forwarded arguments verbatim.
AppHostArgumentStartIndex = options.AppHostArgumentStartIndex,
GracefulShutdownSignaler = options.GracefulShutdownSignaler,
ShutdownService = options.ShutdownService,
StandardOutputCallback = line =>
{
var lineCount = Interlocked.Increment(ref outputCounters.StdoutLineCount);
if (lineCount == 1)
{
activity.AddDotNetFirstStdoutEvent();
}
options.StandardOutputCallback?.Invoke(line);
},
StandardErrorCallback = line =>
{
var lineCount = Interlocked.Increment(ref outputCounters.StderrLineCount);
if (lineCount == 1)
{
activity.AddDotNetFirstStderrEvent();
}
options.StandardErrorCallback?.Invoke(line);
}
};
}
private sealed class ProcessOutputCounters
{
public int StdoutLineCount;
public int StderrLineCount;
}
/// <summary>
/// Configures dotnet-specific environment variables for CLI process executions.
/// </summary>
private void ConfigureDotNetEnvironment(IDictionary<string, string> env)
{
// Stamp the CLI's identity (PID + start time) so the AppHost's CliOrphanDetector can verify
// which process it is monitoring and exit once that process is gone. Verifying both PID and
// start time avoids acting on a recycled PID. If the variables are absent the detector exits.
OrphanDetectionEnvironment.ApplyCurrentProcess(env);
// Pass the CLI log file path so that querying CLI processes (e.g., aspire resource, aspire stop)
// can surface it to help users diagnose issues in the managing CLI process.
env[KnownConfigNames.CliLogFilePath] = executionContext.LogFilePath;
// Always set MSBUILDTERMINALLOGGER=false for all dotnet command executions to ensure consistent terminal logger behavior
env[KnownConfigNames.MsBuildTerminalLogger] = "false";
// Suppress the .NET welcome message that appears on first run
env["DOTNET_NOLOGO"] = "1";
// Set debug session info if available
var debugSessionInfo = configuration[KnownConfigNames.DebugSessionInfo];
if (!string.IsNullOrEmpty(debugSessionInfo))
{
env[KnownConfigNames.DebugSessionInfo] = debugSessionInfo;
}
}
/// <summary>
/// Resolves the dotnet executable path, preferring a private SDK installation if available.
/// When a private SDK is found, the appropriate environment variables (DOTNET_ROOT, PATH, etc.)
/// are set on the provided dictionary.
/// </summary>
/// <returns>The path to the dotnet executable.</returns>
private string ResolveDotNetPath(IDictionary<string, string> env)
{
var sdkVersion = DotNetSdkInstaller.GetEffectiveMinimumSdkVersion(configuration);
var sdksDirectory = executionContext.SdksDirectory.FullName;
var sdkInstallPath = Path.Combine(sdksDirectory, "dotnet", sdkVersion);
var dotnetExecutablePath = Path.Combine(
sdkInstallPath,
environment.IsWindows() ? "dotnet.exe" : "dotnet"
);
if (Directory.Exists(sdkInstallPath))
{
env["DOTNET_ROOT"] = sdkInstallPath;
env["DOTNET_MULTILEVEL_LOOKUP"] = "0";
// Prepend the private SDK path to PATH. Check if the caller already provided a PATH override.
var currentPath = env.TryGetValue("PATH", out var userPath)
? userPath
: environment.GetEnvironmentVariable("PATH") ?? string.Empty;
env["PATH"] = $"{sdkInstallPath}{Path.PathSeparator}{currentPath}";
logger.LogDebug("Using private SDK installation at {SdkPath}", sdkInstallPath);
return dotnetExecutablePath;
}
return "dotnet";
}
private async Task StartBackchannelAsync(
IProcessExecution? execution,
string socketPath,
TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
ActivityContext parentContext,
CancellationToken cancellationToken)
{
using var activity = profilingTelemetry.StartBackchannelConnect(socketPath, parentContext);
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50));
var backchannel = serviceProvider.GetRequiredService<IAppHostCliBackchannel>();
var connectionAttempts = 0;
logger.LogDebug("Starting backchannel connection to AppHost at {SocketPath}", socketPath);
var startTime = DateTimeOffset.UtcNow;
var connectionTimeout = AppHostStartupTimeout.GetBackchannelConnectionTimeout(configuration);
do
{
try
{
logger.LogTrace("Attempting to connect to AppHost backchannel at {SocketPath} (attempt {Attempt})", socketPath, connectionAttempts);
if (connectionAttempts == 0 || connectionAttempts % 10 == 0)
{
activity.AddBackchannelConnectAttemptEvent(connectionAttempts);
}
await backchannel.ConnectAsync(socketPath, connectionAttempts, cancellationToken).ConfigureAwait(false);
activity.SetBackchannelRetryCount(connectionAttempts);
activity.AddBackchannelConnectedEvent();
backchannelCompletionSource.SetResult(backchannel);
// Note: We intentionally do not call Environment.Exit when the backchannel disconnects.
// The CLI should complete normally and return the appropriate exit code based on the
// deployment result. Calling Environment.Exit here would bypass the normal exit code
// logic and always return success (0), even when the deployment failed.
logger.LogDebug("Connected to AppHost backchannel at {SocketPath}", socketPath);
return;
}
catch (Exception ex) when ((ex is SocketException or RemoteRpcException) && execution is { HasExited: true })
{
// Log at Debug level - this is expected when AppHost crashes, the real error is in AppHost output
logger.LogDebug(ex, "AppHost process has exited with code {ExitCode}. Unable to connect to backchannel at {SocketPath}", execution.ExitCode, socketPath);
var message = execution.ExitCode == CliExitCodes.Success
? "The AppHost process exited"
: $"The AppHost process exited unexpectedly with exit code {execution.ExitCode}";
var backchannelException = new FailedToConnectBackchannelConnection(message, ex);
backchannelCompletionSource.SetException(backchannelException);
return;
}
catch (Exception ex) when (ex is SocketException or RemoteRpcException)
{
// If the process is taking a long time to open a back channel but
// it has not exited then it probably means that its a larger build
// (remember it has to build the apphost and its dependencies).
// In that case, after 30 seconds we just slow down the polling to
// once per second.
var waitingFor = DateTimeOffset.UtcNow - startTime;
if (execution is null && waitingFor >= connectionTimeout)
{
logger.LogError("Timed out waiting for AppHost backchannel after {Timeout} seconds", connectionTimeout.TotalSeconds);
var timeoutException = new TimeoutException($"Timed out waiting for AppHost backchannel after {connectionTimeout.TotalSeconds} seconds. Check the debug logs for more details.");
backchannelCompletionSource.SetException(timeoutException);
return;
}
if (waitingFor > TimeSpan.FromSeconds(10))
{
logger.LogTrace(ex, "Slow polling for backchannel connection (attempt {Attempt})", connectionAttempts);
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
else
{
// We don't want to spam the logs with our early connection attempts.
}
}
catch (AppHostIncompatibleException ex)
{
logger.LogError(
ex,
"The app host is incompatible with the CLI and must be updated to a version that supports the {RequiredCapability} capability.",
ex.RequiredCapability
);
// If the app host is incompatible then there is no point
// trying to reconnect, we should propagate the exception
// up to the code that needs to back channel so it can display
// and error message to the user.
backchannelCompletionSource.SetException(ex);
throw;
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred while trying to connect to the backchannel.");
backchannelCompletionSource.SetException(ex);
throw;
}
finally
{
connectionAttempts++;
}
} while (await timer.WaitForNextTickAsync(cancellationToken));
}
private static void AddAspireCliPathEnvironment(Dictionary<string, string> env, FileInfo? projectFile)
{
var processPath = Environment.ProcessPath;
if (projectFile is null || env.ContainsKey("AspireCliPath") || string.IsNullOrWhiteSpace(processPath) || !ShouldForwardProcessPathAsAspireCliPath(processPath))
{
return;
}
env["AspireCliPath"] = processPath;
}
internal static bool ShouldForwardProcessPathAsAspireCliPath(string processPath)
{
if (IsDotNetMuxerPath(processPath))
{
return false;
}
var cliDirectory = Path.GetDirectoryName(processPath);
if (string.IsNullOrEmpty(cliDirectory))
{
return false;
}
if (IsUnbundledFrameworkDependentCliPath(cliDirectory))
{
return false;
}
// Users often invoke the dogfood CLI through a symlink such as
// ~/bin/aspire -> artifacts/bin/Aspire.Cli/Debug/net10.0/aspire. Resolve the
// link before forwarding so a symlinked raw build cannot stamp stale
// bundle metadata through ResolveAspireCliBundle's AspireCliPath path.
var resolvedProcessPath = PathNormalizer.ResolveSymlinks(processPath);
if (!string.Equals(resolvedProcessPath, processPath, StringComparison.Ordinal))
{
var resolvedCliDirectory = Path.GetDirectoryName(resolvedProcessPath);
if (!string.IsNullOrEmpty(resolvedCliDirectory) && IsUnbundledFrameworkDependentCliPath(resolvedCliDirectory))
{
return false;
}
}
return true;
}
private static bool IsDotNetMuxerPath(string processPath)
{
return string.Equals(Path.GetFileNameWithoutExtension(processPath), "dotnet", StringComparison.OrdinalIgnoreCase);
}
private static bool IsUnbundledFrameworkDependentCliPath(string cliDirectory)
{
var cliAssemblyPath = Path.Combine(cliDirectory, "aspire.dll");
if (!File.Exists(cliAssemblyPath))
{
return false;
}
// Raw `dotnet build` outputs place the apphost next to aspire.dll, but
// they do not contain a bundle layout. If we forward that path as
// AspireCliPath, ResolveAspireCliBundle treats it as an explicit CLI and
// falls through to ASPIRE_HOME, which can stamp stale bundle metadata.
return !HasInstallSidecar(cliDirectory) && !HasAdjacentBundleLayout(cliDirectory);
}
private static bool HasInstallSidecar(string cliDirectory)
{
return File.Exists(Path.Combine(cliDirectory, ".aspire-install.json"));
}
private static bool HasAdjacentBundleLayout(string cliDirectory)
{
return HasBundleRoot(cliDirectory) || HasBundleRoot(Path.Combine(cliDirectory, "bundle"));
}
private static bool HasBundleRoot(string bundleRoot)
{
return BundleDiscovery.TryDiscoverDcpFromDirectory(bundleRoot, out _, out _, out _)
&& BundleDiscovery.TryDiscoverManagedFromDirectory(bundleRoot, out _);
}
// Cache expiry/max age handled inside DiskCache implementation.
public async Task<(int ExitCode, bool IsAspireHost, string? AspireHostingVersion)> GetAppHostInformationAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
// Get both properties and PackageReference items to determine Aspire.Hosting version
var (exitCode, jsonDocument) = await GetProjectItemsAndPropertiesAsync(
projectFile,
["PackageReference", "AspireProjectOrPackageReference", "PackageVersion"],
["IsAspireHost", "AspireHostingSDKVersion"],
targets: [],
options,
cancellationToken);
if (exitCode == 0 && jsonDocument != null)
{
var rootElement = jsonDocument.RootElement;
if (!rootElement.TryGetProperty("Properties", out var properties))
{
return (exitCode, false, null);
}
if (!properties.TryGetProperty("IsAspireHost", out var isAspireHostElement))
{
return (exitCode, false, null);
}
if (isAspireHostElement.GetString() == "true")
{
// Try to get Aspire.Hosting version from PackageReference items
string? aspireHostingVersion = null;
if (rootElement.TryGetProperty("Items", out var items))
{
// Check PackageReference items first
if (items.TryGetProperty("PackageReference", out var packageReferences))
{
aspireHostingVersion = GetPackageVersion(packageReferences, "Aspire.Hosting") ??
GetPackageVersion(packageReferences, "Aspire.Hosting.AppHost");
}
// Fallback to AspireProjectOrPackageReference items if not found
if (aspireHostingVersion == null && items.TryGetProperty("AspireProjectOrPackageReference", out var aspireProjectOrPackageReferences))
{
aspireHostingVersion = GetPackageVersion(aspireProjectOrPackageReferences, "Aspire.Hosting") ??
GetPackageVersion(aspireProjectOrPackageReferences, "Aspire.Hosting.AppHost");
}
// Fallback to PackageVersion items for Central Package Management if not found
if (aspireHostingVersion == null && items.TryGetProperty("PackageVersion", out var packageVersions))
{
aspireHostingVersion = GetPackageVersion(packageVersions, "Aspire.Hosting") ??
GetPackageVersion(packageVersions, "Aspire.Hosting.AppHost");
}
}
// If no package version found, fallback to SDK version
if (aspireHostingVersion == null && properties.TryGetProperty("AspireHostingSDKVersion", out var aspireHostingSdkVersionElement))
{
aspireHostingVersion = aspireHostingSdkVersionElement.GetString();
}
return (exitCode, true, aspireHostingVersion);
}
else
{
return (exitCode, false, null);
}
}
else
{
return (exitCode, false, null);
}
}
private static string? GetPackageVersion(JsonElement items, string packageId)
{
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("Identity", out var identity) &&
identity.GetString() == packageId &&
item.TryGetProperty("Version", out var version))
{
return version.GetString();
}
}
return null;
}
public async Task<(int ExitCode, JsonDocument? Output)> GetProjectItemsAndPropertiesAsync(FileInfo projectFile, string[] items, string[] properties, string[] targets, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
var isSingleFileAppHost = projectFile.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase);
// If we are a single file app host then we use the build command instead of msbuild command.
var cliArgsList = new List<string> { isSingleFileAppHost ? "build" : "msbuild" };
if (properties.Length > 0)
{
// HACK: MSBuildVersion here because if you ever invoke `dotnet msbuild -getproperty with just a single
// property it will not be returned as JSON. I've reported this as a problem to MSBuild but obviously
// we need to work around it:
//
// https://github.com/dotnet/msbuild/issues/12490
//
cliArgsList.Add($"-getProperty:MSBuildVersion,{string.Join(",", properties)}");
}
if (items.Length > 0)
{
cliArgsList.Add($"-getItem:{string.Join(",", items)}");
}
if (targets.Length > 0)
{
// Request MSBuild to actually run these targets before evaluating -getProperty / -getItem.
// Some run-related properties (RunCommand, RunArguments, RunWorkingDirectory) are only
// populated after the ComputeRunArguments target executes, so the direct-launch path
// matches what `dotnet run` would have produced.
// https://learn.microsoft.com/visualstudio/msbuild/msbuild-command-line-reference#switches
cliArgsList.Add($"-t:{string.Join(";", targets)}");
}
cliArgsList.Add(projectFile.FullName);
string[] cliArgs = [.. cliArgsList];
// These probes parse dotnet/msbuild stdout as machine-readable JSON. Disable
// telemetry and workload-update notifications for every property/item probe so
// unrelated first-run or workload messages cannot corrupt the JSON stream. This
// is intentionally broader than `aspire ls`: the same probe path is used by
// AppHost validation in commands such as `run`.
var env = new Dictionary<string, string>
{
[KnownConfigNames.DotnetCliTelemetryOptOut] = "1",
[KnownConfigNames.DotnetCliWorkloadUpdateNotifyDisable] = "1",
[KnownConfigNames.SuppressCliRunHook] = "true"
};
var existingStandardOutputCallback = options.StandardOutputCallback;
var existingStandardErrorCallback = options.StandardErrorCallback;
// Retry when MSBuild returns success but produces no output, which can happen
// due to MSBuild server contention (e.g. when another AppHost build is running).
const int maxRetries = 3;
for (var attempt = 0; attempt < maxRetries; attempt++)
{
var stdoutBuilder = new StringBuilder();
options.StandardOutputCallback = (line) =>
{
stdoutBuilder.AppendLine(line);
existingStandardOutputCallback?.Invoke(line);
};
var stderrBuilder = new StringBuilder();
options.StandardErrorCallback = (line) =>
{
stderrBuilder.AppendLine(line);
existingStandardErrorCallback?.Invoke(line);
};
var exitCode = await ExecuteAsync(
args: cliArgs,
env: env,
projectFile: projectFile,
workingDirectory: projectFile.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
var stdout = stdoutBuilder.ToString();
var stderr = stderrBuilder.ToString();
if (exitCode != 0)
{
logger.LogError(
"Failed to get items and properties from project. Exit code was: {ExitCode}. See debug logs for more details. Stderr: {Stderr}, Stdout: {Stdout}",
exitCode,
stderr,
stdout
);
return (exitCode, null);
}
if (string.IsNullOrWhiteSpace(stdout))
{
if (attempt < maxRetries - 1)
{
logger.LogWarning(
"dotnet msbuild returned exit code 0 but produced no output (attempt {Attempt}/{MaxRetries}). Retrying after delay. Stderr: {Stderr}",
attempt + 1,
maxRetries,
stderr);
await Task.Delay(TimeSpan.FromSeconds(attempt + 1), cancellationToken).ConfigureAwait(false);
continue;
}
logger.LogWarning(
"dotnet msbuild returned exit code 0 but produced no output after {MaxRetries} attempts. Stderr: {Stderr}",
maxRetries,
stderr);
return (exitCode, null);
}
var json = JsonDocument.Parse(stdout);
return (exitCode, json);
}
// Should not be reached, but return failure as a safety net
return (1, null);
}
public async Task<int> RunAsync(FileInfo projectFile, bool watch, bool noBuild, bool noRestore, string[] args, IDictionary<string, string>? env, TaskCompletionSource<IAppHostCliBackchannel>? backchannelCompletionSource, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
if (watch && noBuild)
{
var ex = new InvalidOperationException(ErrorStrings.CantUseBothWatchAndNoBuild);
backchannelCompletionSource?.SetException(ex);
throw ex;
}
var isSingleFile = projectFile.Extension.Equals(".cs", StringComparison.OrdinalIgnoreCase);
var watchOrRunCommand = watch ? "watch" : "run";
var noBuildSwitch = noBuild ? "--no-build" : string.Empty;
var noRestoreSwitch = noRestore && !noBuild ? "--no-restore" : string.Empty; // --no-build implies --no-restore
var noProfileSwitch = options.NoLaunchProfile ? "--no-launch-profile" : string.Empty;
var launchProfile = options.NoLaunchProfile ? null : options.LaunchProfile;
string[] launchProfileSwitch = !string.IsNullOrEmpty(launchProfile)
? [$"--launch-profile={launchProfile}"]
: [];
var suppressCliRunHookProperty = $"/p:{KnownConfigNames.SuppressCliRunHook}=true";
// Add --non-interactive flag when using watch to prevent interactive prompts during automation
var nonInteractiveSwitch = watch ? "--non-interactive" : string.Empty;
// Add --verbose flag when using watch and debug is enabled
var verboseSwitch = watch && options.Debug ? "--verbose" : string.Empty;
string[] cliOptions = isSingleFile switch
{
false => [watchOrRunCommand, nonInteractiveSwitch, verboseSwitch, noBuildSwitch, noRestoreSwitch, noProfileSwitch, .. launchProfileSwitch, "--project", projectFile.FullName],
// File-based dotnet run only recomputes RunCommand during build. Omit --no-build
// for single-file AppHosts so the suppression property is applied before launch
// and a CLI-launched AppHost cannot recursively enter the run hook.
true => ["run", noRestoreSwitch, noProfileSwitch, .. launchProfileSwitch, suppressCliRunHookProperty, "--file", projectFile.FullName]
};
// Empty extension-owned entries represent omitted optional switches, while empty or
// whitespace-only entries after "--" are valid AppHost arguments. Filter only the
// option prefix so application argument boundaries survive CLI-to-extension delegation.
string[] cliArgs = [.. cliOptions.Where(arg => !string.IsNullOrWhiteSpace(arg)), "--", .. args];
var finalEnv = CreateRunEnvironment(
env,
watch,
// Aspire CLI launches AppHosts by shelling out to dotnet run. If the AppHost SDK run hook
// is enabled, every CLI-launched AppHost must suppress the hook to avoid recursive
// dotnet run -> aspire run -> dotnet run loops.
suppressCliRunHook: true,
backchannelCompletionSource);
return await ExecuteAsync(
args: cliArgs,
env: finalEnv,
projectFile: projectFile,
workingDirectory: projectFile.Directory!,
backchannelCompletionSource: backchannelCompletionSource,
options: options,
cancellationToken: cancellationToken);
}
public async Task<int> RunAppHostCommandAsync(
FileInfo projectFile,
string command,
DirectoryInfo workingDirectory,
string[] args,
IDictionary<string, string>? env,
TaskCompletionSource<IAppHostCliBackchannel>? backchannelCompletionSource,
ProcessInvocationOptions options,
CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
var finalEnv = CreateRunEnvironment(
env,
watch: false,
suppressCliRunHook: false,
backchannelCompletionSource);
return await ExecuteAsync(
args: args,
env: finalEnv,
projectFile: projectFile,
workingDirectory: workingDirectory,
backchannelCompletionSource: backchannelCompletionSource,
options: options,
cancellationToken: cancellationToken,
commandOverride: command);
}
private Dictionary<string, string> CreateRunEnvironment(
IDictionary<string, string>? env,
bool watch,
bool suppressCliRunHook,
TaskCompletionSource<IAppHostCliBackchannel>? backchannelCompletionSource)
{
// We copy the dictionary here because we don't want to mutate the input.
var finalEnv = env?.ToDictionary() ?? new Dictionary<string, string>();
// Check if update notifications are disabled and set version check environment variable
if (!features.IsFeatureEnabled(KnownFeatures.UpdateNotificationsEnabled, defaultValue: true))
{
// Only set the environment variable if it's not already set by the user
if (!finalEnv.ContainsKey(KnownConfigNames.VersionCheckDisabled))
{
finalEnv[KnownConfigNames.VersionCheckDisabled] = "true";
}
}
// Set DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER when watch is enabled to prevent launching browser
if (watch)
{
// Only set the environment variable if it's not already set by the user
if (!finalEnv.ContainsKey("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"))
{
finalEnv["DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"] = "true";
}
}
if (suppressCliRunHook)
{
finalEnv[KnownConfigNames.SuppressCliRunHook] = "true";
}
// Set the backchannel socket path when backchannel is configured
if (backchannelCompletionSource is not null)
{
var socketPath = GetBackchannelSocketPath();
finalEnv[KnownConfigNames.UnixSocketPath] = socketPath;
}
return finalEnv;
}
public async Task<(int ExitCode, string? TemplateVersion)> InstallTemplateAsync(string packageName, string version, FileInfo? nugetConfigFile, string? nugetSource, bool force, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client);
// NOTE: The change to @ over :: for template version separator (now enforced in .NET 10.0 SDK).
var workingDirectory = nugetConfigFile?.Directory ?? executionContext.WorkingDirectory;
var localPackagePath = ResolveLocalTemplatePackagePath(packageName, version, nugetSource, workingDirectory);
// dotnet new install <path>.nupkg --force can register duplicate template packages for the same
// local file. Refresh local packages by uninstalling first, then reinstalling without --force.
if (localPackagePath is not null && force)
{
await UninstallTemplateAsync(packageName, workingDirectory, cancellationToken);
force = false;
}
List<string> cliArgs = ["new", "install", localPackagePath?.FullName ?? $"{packageName}@{version}"];
if (force)
{
cliArgs.Add("--force");
}
if (localPackagePath is null && nugetSource is not null)
{
cliArgs.Add("--nuget-source");
cliArgs.Add(nugetSource);
}
var stdoutBuilder = new StringBuilder();
var existingStandardOutputCallback = options.StandardOutputCallback; // Preserve the existing callback if it exists.
options.StandardOutputCallback = (line) =>
{
stdoutBuilder.AppendLine(line);
existingStandardOutputCallback?.Invoke(line);
};
var stderrBuilder = new StringBuilder();
var existingStandardErrorCallback = options.StandardErrorCallback; // Preserve the existing callback if it exists.
options.StandardErrorCallback = (line) =>
{
stderrBuilder.AppendLine(line);
existingStandardErrorCallback?.Invoke(line);
};
// The dotnet new install command does not support the --configfile option so if we
// are installing packages based on a channel config we'll be passing in a nuget config
// file which is dynamically generated in a temporary folder. We'll use that temporary
// folder as the working directory for the command. If we are using an implicit channel
// then we just use the current execution context for the CLI and inherit whatever
// NuGet.configs that may or may not be laying around.
var exitCode = await ExecuteAsync(
args: [.. cliArgs],
env: new Dictionary<string, string>
{
// Force English output for consistent parsing.
// See NOTE: below
[KnownConfigNames.DotnetCliUiLanguage] = "en-US"
},
projectFile: null,
workingDirectory: workingDirectory,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
var stdout = stdoutBuilder.ToString();
var stderr = stderrBuilder.ToString();
if (exitCode != 0)
{
logger.LogError(
"Failed to install template {PackageName} with version {Version}. See debug logs for more details. Stderr: {Stderr}, Stdout: {Stdout}",
packageName,
version,
stderr,
stdout
);
return (exitCode, null);
}
else
{
if (localPackagePath is not null)
{
return (exitCode, version);
}
if (stdout is null)
{
logger.LogError("Failed to read stdout from the process. This should never happen.");
return (CliExitCodes.FailedToInstallTemplates, null);
}
// NOTE: This parsing logic is hopefully temporary and in the future we'll
// have structured output:
//
// See: https://github.com/dotnet/sdk/issues/46345
//
if (!TryParsePackageVersionFromStdout(stdout, out var parsedVersion))
{
logger.LogError("Failed to parse template version from stdout.");
// Throwing here because this should never happen - we don't want to return
// the zero exit code if we can't parse the version because its possibly a
// signal that the .NET SDK has changed.
throw new InvalidOperationException(ErrorStrings.FailedToParseTemplateVersionFromStdout);
}
return (exitCode, parsedVersion);
}
}
private async Task UninstallTemplateAsync(string packageName, DirectoryInfo workingDirectory, CancellationToken cancellationToken)
{
var exitCode = await ExecuteAsync(
args: ["new", "uninstall", packageName],
env: new Dictionary<string, string>
{
[KnownConfigNames.DotnetCliUiLanguage] = "en-US"
},
projectFile: null,
workingDirectory: workingDirectory,
backchannelCompletionSource: null,
options: new ProcessInvocationOptions
{
SuppressLogging = true
},
cancellationToken: cancellationToken);
if (exitCode != 0)
{
logger.LogDebug("dotnet new uninstall {PackageName} returned {ExitCode} before local reinstall.", packageName, exitCode);
}
}
private static FileInfo? ResolveLocalTemplatePackagePath(string packageName, string version, string? nugetSource, DirectoryInfo workingDirectory)
{
if (string.IsNullOrWhiteSpace(nugetSource))
{
return null;
}
string sourcePath;
if (Uri.TryCreate(nugetSource, UriKind.Absolute, out var uri))
{
if (!uri.IsFile)
{
return null;
}
sourcePath = uri.LocalPath;
}
else
{
sourcePath = Path.GetFullPath(nugetSource, workingDirectory.FullName);
}
if (File.Exists(sourcePath) && string.Equals(Path.GetExtension(sourcePath), ".nupkg", StringComparison.OrdinalIgnoreCase))
{
return new FileInfo(sourcePath);
}
if (!Directory.Exists(sourcePath))
{
return null;
}
var expectedFileName = $"{packageName}.{version}.nupkg";
var packagePath = Directory.EnumerateFiles(sourcePath, "*.nupkg", SearchOption.TopDirectoryOnly)
.FirstOrDefault(path => string.Equals(Path.GetFileName(path), expectedFileName, StringComparison.OrdinalIgnoreCase));
return packagePath is null ? null : new FileInfo(packagePath);
}
internal static bool TryParsePackageVersionFromStdout(string stdout, [NotNullWhen(true)] out string? version)
{
var lines = stdout.Split(Environment.NewLine);
var successLine = lines.SingleOrDefault(x => x.StartsWith("Success: Aspire.ProjectTemplates", StringComparison.Ordinal));
if (successLine is null)
{
version = null;
return false;
}
var templateVersion = successLine.Split(" ") switch
{ // Break up the success line.
{ Length: > 2 } chunks => chunks[1].Split("@") switch
{ // Break up the template+version string (@ separator for .NET 10.0+)
{ Length: 2 } versionChunks => versionChunks[1], // The version in the second chunk
_ => chunks[1].Split("::") switch
{ // Fallback to :: separator for older SDK versions
{ Length: 2 } versionChunks => versionChunks[1],
_ => null
}
},
_ => null
};
if (templateVersion is not null)
{
version = templateVersion;
return true;
}
else
{
version = null;
return false;
}
}
public async Task<int> NewProjectAsync(string templateName, string name, string outputPath, string[] extraArgs, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string[] cliArgs = ["new", templateName, "--name", name, "--output", outputPath, .. extraArgs];
return await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: null,
workingDirectory: new DirectoryInfo(Environment.CurrentDirectory),
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
}
public async Task<int> RestoreAsync(FileInfo projectFilePath, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string[] cliArgs = ["restore", projectFilePath.FullName];
return await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: projectFilePath,
workingDirectory: projectFilePath.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
}
public async Task<int> BuildAsync(FileInfo projectFilePath, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
var noRestoreSwitch = noRestore ? "--no-restore" : string.Empty;
string[] cliArgs = ["build", noRestoreSwitch, projectFilePath.FullName];
cliArgs = [.. cliArgs.Where(arg => !string.IsNullOrWhiteSpace(arg))];
return await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: projectFilePath,
workingDirectory: projectFilePath.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
}
public async Task<int> AddPackageAsync(FileInfo projectFilePath, string packageName, string packageVersion, string? nugetSource, bool noRestore, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
var cliArgsList = new List<string>
{
"package"
};
// For single-file AppHost (apphost.cs), use --file switch instead of positional argument
var isSingleFileAppHost = projectFilePath.Name.Equals("apphost.cs", StringComparison.OrdinalIgnoreCase);
if (isSingleFileAppHost)
{
cliArgsList.Add("add");
cliArgsList.AddRange(["--file", projectFilePath.FullName]);
// For single-file AppHost, use packageName@version format
cliArgsList.Add($"{packageName}@{packageVersion}");
}
else
{
cliArgsList.Add("add");
// For non single-file scenarios, use separate --version flag
cliArgsList.Add(packageName);
cliArgsList.Add("--version");
cliArgsList.Add(packageVersion);
cliArgsList.Add("--project");
cliArgsList.Add(projectFilePath.FullName);
}
if (noRestore)
{
cliArgsList.Add("--no-restore");
}
if (!string.IsNullOrEmpty(nugetSource))
{
cliArgsList.Add("--source");
cliArgsList.Add(nugetSource);
}
string[] cliArgs = [.. cliArgsList];
logger.LogInformation("Adding package {PackageName} with version {PackageVersion} to project {ProjectFilePath}", packageName, packageVersion, projectFilePath.FullName);
var result = await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: projectFilePath,
workingDirectory: projectFilePath.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
if (result != 0)
{
logger.LogError("Failed to add package {PackageName} with version {PackageVersion} to project {ProjectFilePath}. See debug logs for more details.", packageName, packageVersion, projectFilePath.FullName);
}
else
{
logger.LogInformation("Package {PackageName} with version {PackageVersion} added to project {ProjectFilePath}", packageName, packageVersion, projectFilePath.FullName);
}
return result;
}
public async Task<int> AddProjectToSolutionAsync(FileInfo solutionFile, FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string[] cliArgs = ["sln", solutionFile.FullName, "add", projectFile.FullName];
logger.LogInformation("Adding project {ProjectFilePath} to solution {SolutionFilePath}", projectFile.FullName, solutionFile.FullName);
var result = await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: null,
workingDirectory: solutionFile.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
if (result != 0)
{
logger.LogError("Failed to add project {ProjectFilePath} to solution {SolutionFilePath}. See debug logs for more details.", projectFile.FullName, solutionFile.FullName);
}
else
{
logger.LogInformation("Project {ProjectFilePath} added to solution {SolutionFilePath}", projectFile.FullName, solutionFile.FullName);
}
return result;
}
public async Task<string> ComputeNuGetConfigHierarchySha256Async(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
// The purpose of this method is to compute a hash that can be used as a substitute for an explicitly passed
// in NuGet.config file hash. This is useful for when `aspire add` is invoked and we present options from the
// implicit feed where we effectively are presenting cached options based on the NuGet.config config in the
// current working directory. If any NuGet.config in the hierarchy of NuGet.config files is touched then the
// cache will be invalidated and we'll do a live search instead of using the cache. This is necessary for
// implicit channel searches which generally provide the best choice to users in the case of `aspire add`.
ArgumentNullException.ThrowIfNull(workingDirectory);
using var activity = telemetry.StartDiagnosticActivity();
var (exitCode, configPaths) = await GetNuGetConfigPathsAsync(workingDirectory, options, cancellationToken);
if (exitCode != 0)
{
logger.LogError("Failed to get NuGet config paths. Exit code was: {ExitCode}.", exitCode);
return string.Empty;
}
if (configPaths.Length == 0)
{
return string.Empty;
}
var hashes = new List<string>();
foreach (var configPath in configPaths)
{
if (string.IsNullOrWhiteSpace(configPath))
{
continue;
}
var filePath = Path.IsPathRooted(configPath)
? configPath
: Path.Combine(workingDirectory.FullName, configPath);
if (!File.Exists(filePath))
{
logger.LogDebug("NuGet config file not found at path: {Path}", filePath);
continue;
}
try
{
using var stream = File.OpenRead(filePath);
var bytes = await SHA256.HashDataAsync(stream, cancellationToken);
var hex = Convert.ToHexString(bytes);
hashes.Add(hex);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
logger.LogDebug(ex, "Failed to read or hash NuGet config file at path: {Path}", filePath);
continue;
}
}
var result = string.Join("|", hashes);
return result;
}
public async Task<(int ExitCode, NuGetPackage[]? Packages)> SearchPackagesAsync(DirectoryInfo workingDirectory, string query, bool exactMatch, bool prerelease, int take, int skip, FileInfo? nugetConfigFile, bool useCache, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string? rawKey = null;
var cacheEnabled = useCache;
if (useCache)
{
try
{
// Compute optional hash of the nuget.config file contents (if any)
string nugetConfigHash = string.Empty;
if (nugetConfigFile is not null && nugetConfigFile.Exists)
{
using var stream = nugetConfigFile.OpenRead();
var bytes = await SHA256.HashDataAsync(stream, cancellationToken);
nugetConfigHash = Convert.ToHexString(bytes);
}
else
{
nugetConfigHash = await ComputeNuGetConfigHierarchySha256Async(workingDirectory, options, cancellationToken);
}
// Build a cache key using the main discriminators, including CLI version.
// Use the identity version so emulated identities (ASPIRE_CLI_VERSION / sidecar)
// don't share cached search results with the physical binary's version.
var cliVersion = executionContext.IdentityVersion;
rawKey = $"query={query}|exactMatch={exactMatch}|prerelease={prerelease}|take={take}|skip={skip}|nugetConfigHash={nugetConfigHash}|cliVersion={cliVersion}";
var cached = await _diskCache.GetAsync(rawKey, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
try
{
var foundPackages = PackageUpdateHelpers.ParsePackageSearchResults(cached);
return (0, foundPackages.ToArray());
}
catch (JsonException ex)
{
logger.LogDebug(ex, "Failed to parse cached package search JSON; performing live search.");
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// Fail open – treat as cache miss.
logger.LogDebug(ex, "Failed to probe package search disk cache; proceeding without cache.");
cacheEnabled = false; // disable write attempt as well
}
}
List<string> cliArgs = [
"package",
"search",
query,
"--format",
"json"
];
if (exactMatch) // search for all versions that match the query exactly
{
cliArgs.Add("--exact-match");
}
else // 'exact-match' flag causes the take and skip arguments to be ignored
{
cliArgs.AddRange([
"--take",
take.ToString(CultureInfo.InvariantCulture),
"--skip",
skip.ToString(CultureInfo.InvariantCulture),
]);
}
if (nugetConfigFile is not null)
{
cliArgs.Add("--configfile");
cliArgs.Add(nugetConfigFile.FullName);
}
if (prerelease)
{
cliArgs.Add("--prerelease");
}
int result = 0;
string stdout = string.Empty;
string stderr = string.Empty;
// Capture original callbacks before the retry loop to avoid mutation/chaining
var originalStdoutCallback = options.StandardOutputCallback;
var originalStderrCallback = options.StandardErrorCallback;
for (int attempt = 1; attempt <= MaxSearchRetries; attempt++)
{
var stdoutBuilder = new StringBuilder();
options.StandardOutputCallback = (line) =>
{
stdoutBuilder.AppendLine(line);
originalStdoutCallback?.Invoke(line);
};
var stderrBuilder = new StringBuilder();
options.StandardErrorCallback = (line) =>
{
stderrBuilder.AppendLine(line);
originalStderrCallback?.Invoke(line);
};
result = await ExecuteAsync(
args: cliArgs.ToArray(),
env: null,
projectFile: null,
workingDirectory: workingDirectory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
stdout = stdoutBuilder.ToString();
stderr = stderrBuilder.ToString();
if (result == 0)
{
// Success - exit retry loop
break;
}
if (attempt < MaxSearchRetries)
{
// Use defensive bounds check in case MaxSearchRetries is changed without updating delay array
var delayIndex = Math.Min(attempt - 1, s_searchRetryDelays.Length - 1);
var delay = s_searchRetryDelays[delayIndex];
logger.LogDebug(
"NuGet package search failed (attempt {Attempt}/{MaxRetries}), exit code {ExitCode}. Retrying in {DelaySeconds}s...",
attempt,
MaxSearchRetries,
result,
delay.TotalSeconds);
await Task.Delay(delay, cancellationToken);
}
}
if (result != 0)
{
logger.LogError(
"Failed to search for packages after {MaxRetries} attempts. Query: {Query}, ConfigFile: {ConfigFile}, Stderr: {Stderr}, Stdout: {Stdout}",
MaxSearchRetries,
query,
nugetConfigFile?.FullName ?? "(default)",
stderr,
stdout);
return (result, null);
}
else
{
if (stdout is null)
{
logger.LogError("Failed to read stdout from the process. This should never happen.");
return (CliExitCodes.FailedToAddPackage, null);
}
try
{
var foundPackages = PackageUpdateHelpers.ParsePackageSearchResults(stdout);
// Attempt to persist the raw stdout JSON for future lookups when cache enabled
if (cacheEnabled && rawKey is not null)
{
await _diskCache.SetAsync(rawKey, stdout, cancellationToken).ConfigureAwait(false);
}
return (result, foundPackages.ToArray());
}
catch (JsonException ex)
{
logger.LogError($"Failed to read JSON returned by the package search. {ex.Message}");
return (CliExitCodes.FailedToAddPackage, null);
}
}
}
public async Task<(int ExitCode, string[] ConfigPaths)> GetNuGetConfigPathsAsync(DirectoryInfo workingDirectory, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string[] cliArgs = ["nuget", "config", "paths"];
var stdoutLines = new List<string>();
var existingStandardOutputCallback = options.StandardOutputCallback; // Preserve the existing callback if it exists.
options.StandardOutputCallback = (line) =>
{
stdoutLines.Add(line);
existingStandardOutputCallback?.Invoke(line);
};
var stderrLines = new List<string>();
var existingStandardErrorCallback = options.StandardErrorCallback; // Preserve the existing callback if it exists.
options.StandardErrorCallback = (line) =>
{
stderrLines.Add(line);
existingStandardErrorCallback?.Invoke(line);
};
var exitCode = await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: null,
workingDirectory: workingDirectory,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
if (exitCode != 0)
{
logger.LogError("Failed to get NuGet config paths. Exit code was: {ExitCode}.", exitCode);
return (exitCode, Array.Empty<string>());
}
else
{
return (exitCode, stdoutLines.ToArray());
}
}
public async Task<(int ExitCode, IReadOnlyList<FileInfo> Projects)> GetSolutionProjectsAsync(FileInfo solutionFile, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string[] cliArgs = ["sln", solutionFile.FullName, "list"];
var stdoutLines = new List<string>();
var existingStandardOutputCallback = options.StandardOutputCallback;
options.StandardOutputCallback = (line) =>
{
stdoutLines.Add(line);
existingStandardOutputCallback?.Invoke(line);
};
var stderrLines = new List<string>();
var existingStandardErrorCallback = options.StandardErrorCallback;
options.StandardErrorCallback = (line) =>
{
stderrLines.Add(line);
existingStandardErrorCallback?.Invoke(line);
};
var exitCode = await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: null,
workingDirectory: solutionFile.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
if (exitCode != 0)
{
logger.LogError("Failed to list solution projects. Exit code was: {ExitCode}.", exitCode);
return (exitCode, Array.Empty<FileInfo>());
}
// Parse output - skip header lines (Project(s) and ----------)
var projects = new List<FileInfo>();
var startParsing = false;
foreach (var line in stdoutLines)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
// Skip header lines
if (line.StartsWith("Project(s)", StringComparison.OrdinalIgnoreCase) ||
line.StartsWith("----------", StringComparison.Ordinal))
{
startParsing = true;
continue;
}
if (startParsing && line.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
{
var projectPath = Path.IsPathRooted(line)
? line
: Path.Combine(solutionFile.Directory!.FullName, line);
projects.Add(new FileInfo(projectPath));
}
}
return (exitCode, projects);
}
public async Task<int> AddProjectReferenceAsync(FileInfo projectFile, FileInfo referencedProject, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
using var activity = telemetry.StartDiagnosticActivity();
string[] cliArgs = ["add", projectFile.FullName, "reference", referencedProject.FullName];
logger.LogInformation("Adding project reference from {ProjectFile} to {ReferencedProject}", projectFile.FullName, referencedProject.FullName);
var result = await ExecuteAsync(
args: cliArgs,
env: null,
projectFile: projectFile,
workingDirectory: projectFile.Directory!,
backchannelCompletionSource: null,
options: options,
cancellationToken: cancellationToken);
if (result != 0)
{
logger.LogError("Failed to add project reference from {ProjectFile} to {ReferencedProject}. See debug logs for more details.", projectFile.FullName, referencedProject.FullName);
}
else
{
logger.LogInformation("Project reference added from {ProjectFile} to {ReferencedProject}", projectFile.FullName, referencedProject.FullName);
}
return result;
}
public Task<int> InitUserSecretsAsync(FileInfo projectFile, ProcessInvocationOptions options, CancellationToken cancellationToken)
{
return ExecuteAsync(["user-secrets", "init", "--project", projectFile.FullName], env: null, projectFile: null, projectFile.Directory!, backchannelCompletionSource: null, options, cancellationToken);
}
}