// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.CommandLine;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Diagnostics;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Processes;
using Aspire.Cli.Profiling;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Commands;
/// <summary>
/// Encapsulates the logic for launching an AppHost in detached (background) mode.
/// Used by both RunCommand (--detach) and StartCommand (no resource).
/// When adding new launch options, add them here and wire them in both commands.
/// </summary>
internal sealed class AppHostLauncher(
IProjectLocator projectLocator,
IAppHostProjectFactory projectFactory,
CliExecutionContext executionContext,
IInteractionService interactionService,
IAuxiliaryBackchannelMonitor backchannelMonitor,
ICliHostEnvironment hostEnvironment,
AspireCliTelemetry telemetry,
ProfilingTelemetry profilingTelemetry,
FileLoggerProvider fileLoggerProvider,
ProcessTreeGracefulShutdownService processShutdownService,
IProcessExecutionFactory processExecutionFactory,
IConfiguration configuration,
ILogger<AppHostLauncher> logger,
TimeProvider timeProvider)
{
private const string DefaultDiscoverySelectionOrigin = "default-discovery";
private const string UserSelectionOrigin = "user-selection";
private const int MaxDisplayedChildLogLines = 80;
private const int MaxParentLogReplayLines = 200;
private static readonly TimeSpan s_legacyDetachedStartupStabilityWindow = TimeSpan.FromSeconds(2);
private static readonly TimeSpan s_legacyDetachedStartupProbeInterval = TimeSpan.FromMilliseconds(100);
/// <summary>
/// Shared option for the AppHost project file path.
/// </summary>
internal static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
/// <summary>
/// Shared option for output format (JSON or table) in detached AppHost mode.
/// </summary>
internal static readonly Option<OutputFormat> s_formatOption = new("--format")
{
Description = SharedCommandStrings.FormatOptionDescription
};
/// <summary>
/// Shared option for isolated AppHost mode.
/// </summary>
internal static readonly Option<bool> s_isolatedOption = new("--isolated")
{
Description = SharedCommandStrings.IsolatedOptionDescription
};
/// <summary>
/// Shared option for selecting the AppHost launch profile.
/// </summary>
internal static readonly Option<string?> s_launchProfileOption = new("--launch-profile", "-lp")
{
Description = SharedCommandStrings.LaunchProfileOptionDescription
};
/// <summary>
/// Adds the detached launch options to a command so they appear in --help.
/// Called by both RunCommand and StartCommand to keep options in sync.
/// </summary>
internal static void AddLaunchOptions(Command command)
{
command.Options.Add(s_appHostOption);
command.Options.Add(s_formatOption);
command.Options.Add(s_isolatedOption);
command.Options.Add(s_launchProfileOption);
}
/// <summary>
/// Gets the explicitly supplied isolated option value, or <see langword="null"/> when omitted.
/// </summary>
internal static bool? GetExplicitIsolated(ParseResult parseResult)
{
return parseResult.GetResult(s_isolatedOption) is { Implicit: false }
? parseResult.GetValue(s_isolatedOption)
: null;
}
/// <summary>
/// Gets the isolated option value to propagate to another CLI invocation.
/// </summary>
internal static bool? ResolveIsolatedOption(bool? explicitIsolated)
{
return explicitIsolated;
}
/// <summary>
/// Resolves whether the AppHost should run isolated from the explicit CLI option.
/// </summary>
internal static bool ResolveIsolated(ParseResult parseResult)
{
return ResolveIsolatedOption(GetExplicitIsolated(parseResult)) ?? false;
}
/// <summary>
/// Launches an AppHost in detached mode, waits for the backchannel, and displays the result.
/// </summary>
/// <param name="passedAppHostProjectFile">The project file passed via --project, or null to auto-discover.</param>
/// <param name="format">The output format (JSON or table).</param>
/// <param name="isolated">The explicitly supplied isolated option value, or <see langword="null"/> when omitted.</param>
/// <param name="launchProfile">The explicitly selected launch profile, or <see langword="null"/> when omitted.</param>
/// <param name="isExtensionHost">Whether running inside VS Code extension.</param>
/// <param name="waitForDebugger">Whether the AppHost is waiting for a debugger to attach.</param>
/// <param name="timeoutSeconds">The maximum number of seconds to wait for AppHost startup.</param>
/// <param name="globalArgs">Global CLI args to forward to child process.</param>
/// <param name="additionalArgs">Additional unmatched args to forward.</param>
/// <param name="stopAfterLaunchDelay">Optional delay after launch before stopping the AppHost.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <see cref="CommandResult"/> indicating success or failure.</returns>
public async Task<CommandResult> LaunchDetachedAsync(
FileInfo? passedAppHostProjectFile,
OutputFormat? format,
bool? isolated,
string? launchProfile,
bool isExtensionHost,
bool waitForDebugger,
int timeoutSeconds,
IEnumerable<string> globalArgs,
IEnumerable<string> additionalArgs,
TimeSpan? stopAfterLaunchDelay,
CancellationToken cancellationToken)
{
// In JSON mode or non-interactive mode, avoid interactive prompts.
var multipleAppHostBehavior = format == OutputFormat.Json || !hostEnvironment.SupportsInteractiveInput
? MultipleAppHostProjectsFoundBehavior.Throw
: MultipleAppHostProjectsFoundBehavior.Prompt;
// Failure mode 1: Project not found
AppHostProjectSearchResult searchResult;
try
{
searchResult = await projectLocator.UseOrFindAppHostProjectFileAsync(
passedAppHostProjectFile,
multipleAppHostBehavior,
createSettingsFile: false,
cancellationToken);
}
catch (ProjectLocatorException ex)
{
return BaseCommand.HandleProjectLocatorException(ex, interactionService, telemetry);
}
var effectiveAppHostFile = searchResult.SelectedProjectFile;
if (effectiveAppHostFile is null)
{
return CommandResult.Failure(CliExitCodes.FailedToFindProject);
}
if (!string.IsNullOrEmpty(launchProfile))
{
var project = projectFactory.TryGetProject(effectiveAppHostFile);
if (project is null)
{
return CommandResult.Failure(CliExitCodes.FailedToFindProject, "Unrecognized app host type.");
}
if (GetLaunchProfileValidationError(project, launchProfile) is { } launchProfileError)
{
return CommandResult.Failure(CliExitCodes.InvalidCommand, launchProfileError);
}
}
// The detached child receives the selected file rather than the directory that caused the
// prompt, so carry the prompt provenance explicitly to persist the user's choice.
var appHostSelectionOrigin = searchResult.WasExplicitDirectorySelectionPrompted
? UserSelectionOrigin
: configuration[KnownConfigNames.CliAppHostSelectionOrigin];
if (string.IsNullOrEmpty(appHostSelectionOrigin) && passedAppHostProjectFile is null)
{
appHostSelectionOrigin = DefaultDiscoverySelectionOrigin;
}
logger.LogDebug("Starting AppHost in background: {AppHostPath}", effectiveAppHostFile.FullName);
var isolatedOption = ResolveIsolatedOption(isolated);
// Check for running instance and stop it if found (same behavior as regular run)
await StopExistingInstancesAsync(effectiveAppHostFile, cancellationToken);
// Build child process arguments
var childLogFile = GenerateChildLogFilePath(executionContext.LogsDirectory.FullName, timeProvider);
executionContext.AppHostCliLogFilePath = childLogFile;
var (executablePath, childArgs) = BuildChildProcessArgs(effectiveAppHostFile, childLogFile, isolatedOption, globalArgs, additionalArgs);
// Compute the expected socket prefix for backchannel detection. The AppHost keys its
// auxiliary backchannel socket file on the symlink-resolved AppHost path, so the primary
// hash we wait on must also be computed from the resolved path (see ComputeDetachedMatchHashes).
var socketKeyPath = PathNormalizer.ResolveSymlinks(effectiveAppHostFile.FullName);
var expectedSocketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(
socketKeyPath,
executionContext.HomeDirectory.FullName);
var (expectedHash, legacyHashes) = ComputeDetachedMatchHashes(
effectiveAppHostFile.FullName,
executionContext.HomeDirectory.FullName);
logger.LogDebug("Waiting for socket with prefix: {SocketPrefix}, Hash: {Hash}", expectedSocketPrefix, expectedHash);
if (legacyHashes.Length > 0)
{
logger.LogDebug("Also searching for legacy hash(es): {LegacyHashes}", string.Join(", ", legacyHashes));
}
// If --wait-for-debugger is active, show a message so the user knows the AppHost
// is paused. In detached mode we don't have the AppHost PID (stdout is suppressed),
// so we show a generic message without a PID.
if (waitForDebugger)
{
interactionService.DisplayMessage(
KnownEmojis.Bug,
InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost);
}
// Start the child process and wait for the backchannel
LaunchResult launchResult;
try
{
launchResult = await interactionService.ShowDynamicStatusAsync(
RunCommandStrings.StartingAppHostInBackground,
updateStatus => LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, legacyHashes, appHostSelectionOrigin, TimeSpan.FromSeconds(timeoutSeconds), updateStatus, cancellationToken));
}
catch (OperationCanceledException)
{
return CommandResult.Cancelled(CliExitCodes.Success);
}
// Handle failure cases
if (launchResult.Backchannel is null || launchResult.ChildProcess is null)
{
return HandleLaunchFailure(launchResult, childLogFile, timeoutSeconds);
}
// Display results
DisplayLaunchResult(launchResult, effectiveAppHostFile, childLogFile, format, isExtensionHost);
if (stopAfterLaunchDelay is not null)
{
await StopLaunchedAppHostAsync(launchResult, stopAfterLaunchDelay.Value, cancellationToken).ConfigureAwait(false);
}
return CommandResult.Success();
}
internal static string? GetLaunchProfileValidationError(IAppHostProject project, string? launchProfile)
{
if (string.IsNullOrEmpty(launchProfile))
{
return null;
}
if (!project.SupportsLaunchProfiles)
{
return string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.LaunchProfileNotSupported, project.DisplayName);
}
// Missing or malformed launch settings must reach `dotnet run`, which owns the authoritative
// discovery and diagnostic behavior for explicit profiles.
return null;
}
private async Task StopLaunchedAppHostAsync(LaunchResult result, TimeSpan delay, CancellationToken cancellationToken)
{
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
if (result.Backchannel is not null)
{
// Reuse the shared "RPC stop + wait for termination" flow so capture mode follows the
// same teardown path as socket-discovered running-instance stops.
var manager = new RunningInstanceManager(logger, interactionService, timeProvider, profilingTelemetry);
await manager.StopAndMonitorAsync(result.Backchannel, cancellationToken).ConfigureAwait(false);
}
if (result.ChildProcess is { HasExited: false } childProcess)
{
// Safety net for the hidden capture path: if the RPC stop did not bring the spawned
// child CLI down within the grace period, terminate the process tree so we never
// leave an orphaned AppHost behind.
try
{
await childProcess.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (TimeoutException)
{
childProcess.Kill(entireProcessTree: true);
}
catch (OperationCanceledException) when (!childProcess.HasExited)
{
childProcess.Kill(entireProcessTree: true);
throw;
}
}
}
/// <summary>
/// Computes the primary and fallback auxiliary-backchannel socket hashes used to match
/// a detached AppHost's backchannel connection during launch.
/// </summary>
/// <param name="appHostPath">The AppHost project file or assembly path as supplied to the CLI.</param>
/// <param name="homeDirectory">The user's home directory.</param>
/// <returns>
/// The primary expected hash (the compact AppHost id of the resolved path) and the de-duplicated
/// fallback hashes to also search: the compact AppHost id of the raw path plus the legacy hex
/// hashes of both the resolved and raw paths (including any Windows drive-letter casing variants).
/// </returns>
internal static (string ExpectedHash, string[] FallbackHashes) ComputeDetachedMatchHashes(string appHostPath, string homeDirectory)
{
var socketKeyPath = PathNormalizer.ResolveSymlinks(appHostPath);
var expectedHash = AppHostHelper.ExtractHashFromSocketPath(
AppHostHelper.ComputeAuxiliarySocketPrefix(socketKeyPath, homeDirectory))!;
// Current socket file names embed the compact AppHost id (a different hash space than the
// legacy hex hashes below), so include the raw path's compact id explicitly.
// This is what matches a still-running AppHost that keyed its socket on the unresolved path before the
// AppHost side started resolving symlinks.
var rawCompactHash = AppHostHelper.ExtractHashFromSocketPath(
AppHostHelper.ComputeAuxiliarySocketPrefix(appHostPath, homeDirectory))!;
var fallbackHashes = new[] { rawCompactHash }
.Concat(AppHostHelper.ComputeLegacyHashes(socketKeyPath))
.Concat(AppHostHelper.ComputeLegacyHashes(appHostPath))
.Where(h => !string.Equals(h, expectedHash, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal)
.ToArray();
return (expectedHash, fallbackHashes);
}
private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, CancellationToken cancellationToken)
{
var existingSockets = AppHostHelper.FindMatchingNonOrphanedSockets(
effectiveAppHostFile.FullName,
executionContext.HomeDirectory.FullName,
Environment.ProcessId,
logger);
if (existingSockets.Length > 0)
{
logger.LogDebug("Found {Count} running instance(s) for this AppHost, stopping them first.", existingSockets.Length);
var manager = new RunningInstanceManager(logger, interactionService, timeProvider, profilingTelemetry);
var stopTasks = existingSockets.Select(socket =>
manager.StopRunningInstanceAsync(socket, cancellationToken));
await Task.WhenAll(stopTasks).ConfigureAwait(false);
}
}
private (string ExecutablePath, List<string> ChildArgs) BuildChildProcessArgs(
FileInfo effectiveAppHostFile,
string childLogFile,
bool? isolated,
IEnumerable<string> globalArgs,
IEnumerable<string> additionalArgs)
{
var args = new List<string>
{
"run",
"--non-interactive",
s_appHostOption.Name,
effectiveAppHostFile.FullName,
"--log-file",
childLogFile
};
args.AddRange(globalArgs);
if (isolated is not null)
{
args.Add(s_isolatedOption.Name);
if (!isolated.Value)
{
args.Add("false");
}
}
foreach (var token in additionalArgs)
{
args.Add(token);
}
var dotnetPath = Environment.ProcessPath ?? "dotnet";
var isDotnetHost = dotnetPath.EndsWith("dotnet", StringComparison.OrdinalIgnoreCase) ||
dotnetPath.EndsWith("dotnet.exe", StringComparison.OrdinalIgnoreCase);
var entryAssemblyPath = Environment.GetCommandLineArgs().FirstOrDefault();
var childArgs = new List<string>();
if (isDotnetHost && !string.IsNullOrEmpty(entryAssemblyPath) && entryAssemblyPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
{
childArgs.Add(entryAssemblyPath);
}
childArgs.AddRange(args);
// childArgs ends with the caller's "-- <appHostArgs>" tail, which is user-supplied AppHost
// input rather than CLI options, so it must not be written to the log verbatim.
logger.LogDebug("Spawning child CLI: {Executable} (isDotnetHost={IsDotnetHost}) with args: {Args}",
dotnetPath, isDotnetHost, AppHostArgumentRedactor.RedactToString(childArgs));
logger.LogDebug("Working directory: {WorkingDirectory}", executionContext.WorkingDirectory.FullName);
return (dotnetPath, childArgs);
}
/// <summary>
/// Prefix for environment variables that configure extension-host mode.
/// Any environment variable starting with this prefix is removed from
/// detached child processes to prevent them from entering extension mode.
/// Keep the DEBUG_SESSION_* and DCP session variables intact because the launched AppHost
/// still relies on them for IDE execution and dashboard integration.
/// </summary>
internal const string ExtensionEnvironmentVariablePrefix = "ASPIRE_EXTENSION_";
/// <summary>
/// Returns <see langword="true"/> if the specified environment variable name
/// should be removed from detached child CLI processes.
/// </summary>
internal static bool IsExtensionEnvironmentVariable(string name) =>
name.StartsWith(ExtensionEnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Builds the environment for the detached child CLI.
/// </summary>
/// <param name="activity">The spawn activity whose profiling context the child should join.</param>
/// <param name="appHostSelectionOrigin">
/// How this invocation's AppHost target was selected (<see cref="KnownConfigNames.CliAppHostSelectionOrigin"/>),
/// or <see langword="null"/> when nothing selected it explicitly. The detached child continues
/// the same invocation, so it must make the same workspace-default persistence decision.
/// </param>
internal static Dictionary<string, string> CreateDetachedChildEnvironment(Activity? activity, string? appHostSelectionOrigin)
{
var environment = new Dictionary<string, string> { [KnownConfigNames.CliRunDetached] = "true" };
// Record the foreground launcher's identity (PID + start time) so the detached child can watch
// it during startup and tear the AppHost tree down if the launcher is killed before the app
// reaches readiness. Without this, killing `aspire start`/`aspire run --detach` mid-start (for
// example a test runner timing it out) leaks the AppHost + dashboard as orphaned processes.
OrphanDetectionEnvironment.ApplyCurrentProcess(environment, KnownConfigNames.CliLauncherProcessId, KnownConfigNames.CliLauncherProcessStarted);
if (!string.IsNullOrEmpty(appHostSelectionOrigin))
{
environment[KnownConfigNames.CliAppHostSelectionOrigin] = appHostSelectionOrigin;
}
ProfilingTelemetry.AddActivityContextToEnvironment(activity, environment);
ProfileCaptureEnvironment.AddCurrentToEnvironment(environment);
return environment;
}
private record LaunchResult(IProcessExecution? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, DashboardUrlsState? DashboardUrls, bool ChildExitedEarly, int ChildExitCode, DateTimeOffset? ChildStartedAt = null);
private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
string executablePath,
List<string> childArgs,
string expectedHash,
IReadOnlyList<string> legacyHashes,
string? appHostSelectionOrigin,
TimeSpan timeout,
Action<string> updateStatus,
CancellationToken cancellationToken)
{
IProcessExecution? childProcess = null;
using (var spawnActivity = profilingTelemetry.StartDetachedSpawnChild(executablePath, childArgs, "run"))
{
try
{
var options = new ProcessInvocationOptions
{
Detached = true,
IsolateConsole = true,
EnvironmentVariableFilter = IsExtensionEnvironmentVariable
};
childProcess = processExecutionFactory.CreateExecution(
executablePath,
childArgs.ToArray(),
CreateDetachedChildEnvironment(Activity.Current, appHostSelectionOrigin),
executionContext.WorkingDirectory,
options);
await childProcess.StartAsync(cancellationToken).ConfigureAwait(false);
spawnActivity.SetProcessId(childProcess.ProcessId);
}
catch (OperationCanceledException)
{
await DisposeAfterFailedStartAsync(childProcess).ConfigureAwait(false);
throw;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await DisposeAfterFailedStartAsync(childProcess).ConfigureAwait(false);
spawnActivity.SetError(ex.Message);
logger.LogError(ex, "Failed to start child CLI process");
return new LaunchResult(null, null, null, false, 0);
}
}
if (childProcess is null)
{
throw new InvalidOperationException("Failed to create child CLI process execution.");
}
var childStartedAt = childProcess.StartTime;
logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.ProcessId);
var startTime = timeProvider.GetUtcNow();
using var waitForBackchannelActivity = profilingTelemetry.StartDetachedWaitForBackchannel(childProcess.ProcessId, expectedHash, legacyHashes.Count > 0);
var scanCount = 0;
IAppHostAuxiliaryBackchannel? connection = null;
DashboardUrlsState? dashboardUrls = null;
string? launchFailureMessage = null;
var childExitTask = childProcess.WaitForExitAsync(CancellationToken.None);
ObserveFaults(childExitTask);
try
{
while (timeProvider.GetUtcNow() - startTime < timeout)
{
if (cancellationToken.IsCancellationRequested)
{
cancellationToken.ThrowIfCancellationRequested();
}
if (childProcess.HasExited)
{
return CreateChildExitedLaunchResult(childProcess, waitForBackchannelActivity, childStartedAt);
}
await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
scanCount++;
connection ??= backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault()
?? legacyHashes.SelectMany(backchannelMonitor.GetConnectionsByHash).FirstOrDefault();
if (connection is not null)
{
waitForBackchannelActivity.SetBackchannelScanCount(scanCount);
waitForBackchannelActivity.AddStartAppHostBackchannelConnectedEvent();
if (dashboardUrls is null)
{
using var getDashboardUrlsActivity = profilingTelemetry.StartDetachedGetDashboardUrls();
try
{
dashboardUrls = await connection.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
getDashboardUrlsActivity.SetAppHostDashboardUrls(dashboardUrls);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
getDashboardUrlsActivity.SetError(ex.Message);
logger.LogDebug(ex, "Failed to retrieve dashboard URLs from backchannel connection. Continuing without dashboard URLs.");
}
}
var remainingTimeout = timeout - (timeProvider.GetUtcNow() - startTime);
if (remainingTimeout <= TimeSpan.Zero)
{
break;
}
using var readinessCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var readinessTask = WaitForAppHostReadyAsync(connection, readinessCts.Token);
var timeoutTask = Task.Delay(remainingTimeout, timeProvider, cancellationToken);
var completedTask = await Task.WhenAny(readinessTask, childExitTask, timeoutTask).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (completedTask == readinessTask)
{
bool? appHostReady;
try
{
appHostReady = await readinessTask.ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
launchFailureMessage = "Failed while waiting for AppHost startup readiness.";
logger.LogDebug(ex, "Failed while waiting for AppHost startup readiness from auxiliary backchannel.");
if (childProcess.HasExited)
{
return CreateChildExitedLaunchResult(childProcess, waitForBackchannelActivity, childStartedAt);
}
updateStatus(RunCommandStrings.AppHostConnectionLostWaitingForExit);
await childExitTask.WaitAsync(cancellationToken).ConfigureAwait(false);
return CreateChildExitedLaunchResult(childProcess, waitForBackchannelActivity, childStartedAt);
}
if (appHostReady is null)
{
logger.LogDebug(
"AppHost does not support startup readiness RPC. Probing legacy startup state for {StabilityWindow} before detaching.",
s_legacyDetachedStartupStabilityWindow);
if (!await WaitForLegacyDetachedStartupStabilityAsync(connection, childExitTask, remainingTimeout, timeProvider, cancellationToken).ConfigureAwait(false))
{
await childExitTask.ConfigureAwait(false);
return CreateChildExitedLaunchResult(childProcess, waitForBackchannelActivity, childStartedAt);
}
return new LaunchResult(childProcess, connection, dashboardUrls, false, 0, childStartedAt);
}
if (appHostReady == true)
{
return new LaunchResult(childProcess, connection, dashboardUrls, false, 0, childStartedAt);
}
}
else
{
readinessCts.Cancel();
ObserveFaults(readinessTask);
if (completedTask == childExitTask)
{
await childExitTask.ConfigureAwait(false);
return CreateChildExitedLaunchResult(childProcess, waitForBackchannelActivity, childStartedAt);
}
break;
}
}
var waitTask = Task.Delay(TimeSpan.FromMilliseconds(500), timeProvider, cancellationToken);
var completedWaitTask = await Task.WhenAny(childExitTask, waitTask).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (completedWaitTask == childExitTask)
{
await childExitTask.ConfigureAwait(false);
return CreateChildExitedLaunchResult(childProcess, waitForBackchannelActivity, childStartedAt);
}
}
}
catch (OperationCanceledException)
{
await RequestGracefulShutdownThenForceKillAsync(childProcess, childStartedAt).ConfigureAwait(false);
throw;
}
waitForBackchannelActivity.SetBackchannelScanCount(scanCount);
waitForBackchannelActivity.SetError(launchFailureMessage ?? "Timed out waiting for AppHost startup readiness.");
await RequestGracefulShutdownThenForceKillAsync(childProcess, childStartedAt).ConfigureAwait(false);
return new LaunchResult(childProcess, null, dashboardUrls, false, 0, childStartedAt);
}
private async ValueTask DisposeAfterFailedStartAsync(IProcessExecution? childProcess)
{
if (childProcess is null)
{
return;
}
try
{
await childProcess.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Failed to dispose child CLI process execution after start failure.");
}
}
private Task RequestGracefulShutdownThenForceKillAsync(IProcessExecution childProcess, DateTimeOffset? childStartedAt)
{
return processShutdownService.StopProcessTreeAsync(
childProcess.ProcessId,
childStartedAt,
includeStartTimeForDcp: true,
CancellationToken.None);
}
private LaunchResult CreateChildExitedLaunchResult(IProcessExecution childProcess, ProfilingTelemetry.ActivityScope waitForBackchannelActivity, DateTimeOffset? childStartedAt)
{
var exitCode = childProcess.ExitCode;
waitForBackchannelActivity.SetProcessExitCode(exitCode);
if (IsSuccessfulDetachedEarlyExit(exitCode))
{
logger.LogInformation("Child CLI process exited successfully before AppHost readiness was observed.");
}
else
{
waitForBackchannelActivity.SetError($"Child CLI exited with code {exitCode}.");
logger.LogWarning("Child CLI process exited with code {ExitCode}", exitCode);
}
return new LaunchResult(childProcess, null, null, true, exitCode, ChildStartedAt: childStartedAt);
}
internal static async Task<bool?> WaitForAppHostReadyAsync(IAppHostAuxiliaryBackchannel connection, CancellationToken cancellationToken)
{
var startupState = await connection.WaitForAppHostReadyAsync(cancellationToken).ConfigureAwait(false);
return startupState?.IsReady;
}
internal static async Task<bool> WaitForLegacyDetachedStartupStabilityAsync(
IAppHostAuxiliaryBackchannel connection,
Task childExitTask,
TimeSpan remainingTimeout,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
var stabilityWindow = remainingTimeout < s_legacyDetachedStartupStabilityWindow
? remainingTimeout
: s_legacyDetachedStartupStabilityWindow;
if (connection.SupportsV2)
{
return await WaitForLegacyDetachedStartupResourceSnapshotProbeAsync(
connection,
childExitTask,
stabilityWindow,
timeProvider,
cancellationToken).ConfigureAwait(false);
}
var completedTask = await Task.WhenAny(
childExitTask,
Task.Delay(stabilityWindow, timeProvider, cancellationToken)).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
return completedTask != childExitTask;
}
private static async Task<bool> WaitForLegacyDetachedStartupResourceSnapshotProbeAsync(
IAppHostAuxiliaryBackchannel connection,
Task childExitTask,
TimeSpan stabilityWindow,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
// Older AppHosts do not expose the explicit readiness RPC. Resource snapshots are the
// best available V2 probe because this call depends on the AppHost model/notification
// services being available, unlike dashboard URL or process-info calls that can succeed
// as soon as the auxiliary server socket is listening.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var timeoutTask = Task.Delay(stabilityWindow, timeProvider, cancellationToken);
while (true)
{
Task<List<ResourceSnapshot>> probeTask;
try
{
probeTask = connection.GetResourceSnapshotsAsync(includeHidden: false, probeCts.Token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
probeTask = Task.FromException<List<ResourceSnapshot>>(ex);
}
var completedTask = await Task.WhenAny(probeTask, childExitTask, timeoutTask).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (completedTask == childExitTask)
{
probeCts.Cancel();
ObserveFaults(probeTask);
return false;
}
if (completedTask == timeoutTask)
{
probeCts.Cancel();
ObserveFaults(probeTask);
return true;
}
try
{
await probeTask.ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
throw;
}
catch
{
var delayTask = Task.Delay(s_legacyDetachedStartupProbeInterval, timeProvider, cancellationToken);
completedTask = await Task.WhenAny(delayTask, childExitTask, timeoutTask).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
if (completedTask == childExitTask)
{
return false;
}
if (completedTask == timeoutTask)
{
return true;
}
}
}
}
private static void ObserveFaults(Task task)
{
_ = task.ContinueWith(
static completedTask => _ = completedTask.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
}
private CommandResult HandleLaunchFailure(LaunchResult result, string childLogFile, int timeoutSeconds)
{
if (result.ChildProcess is null)
{
interactionService.DisplayError(RunCommandStrings.FailedToStartAppHost);
return CommandResult.Failure(CliExitCodes.FailedToDotnetRunAppHost);
}
if (result.ChildExitedEarly && IsSuccessfulDetachedEarlyExit(result.ChildExitCode))
{
return CommandResult.Success();
}
string? failureMessage;
if (result.ChildExitedEarly)
{
failureMessage = GetDetachedFailureMessage(result.ChildExitCode);
}
else
{
failureMessage = string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeout);
}
interactionService.DisplayError(RunCommandStrings.FailedToStartAppHost);
DisplayChildLogTail(childLogFile, result.ChildProcess.ProcessId);
if (failureMessage is not null && !string.Equals(failureMessage, RunCommandStrings.FailedToStartAppHost, StringComparison.Ordinal))
{
interactionService.DisplayError(failureMessage);
}
return CommandResult.Failure(CliExitCodes.FailedToDotnetRunAppHost);
}
private void DisplayChildLogTail(string childLogFile, int childProcessId)
{
IReadOnlyList<CliLogFormat.FileLogEntry> replayEntries;
IReadOnlyList<string> displayLines;
try
{
replayEntries = ReadChildLogReplayTail(childLogFile, MaxParentLogReplayLines);
displayLines = ReadChildLogTail(childLogFile, MaxDisplayedChildLogLines);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to read child CLI log file {ChildLogFile}", childLogFile);
return;
}
if (replayEntries.Count == 0 && displayLines.Count == 0)
{
return;
}
if (displayLines.Count > 0)
{
interactionService.DisplayMessage(KnownEmojis.Information, $"{RunCommandStrings.RecentAppHostStartupOutput}:");
interactionService.DisplayLines(displayLines.Select(line => (OutputLineStream.StdOut, line)));
}
if (replayEntries.Count > 0)
{
ReplayChildLogTailToParentLog(childLogFile, childProcessId, replayEntries);
}
}
private void ReplayChildLogTailToParentLog(string childLogFile, int childProcessId, IReadOnlyList<CliLogFormat.FileLogEntry> entries)
{
fileLoggerProvider.WriteLog(
timeProvider.GetUtcNow(),
LogLevel.Information,
nameof(AppHostLauncher),
$"Begin detached AppHost startup log excerpt from child process {childProcessId}.");
foreach (var entry in entries)
{
if (CliLogFormat.TryGetLogLevelFromFileToken(entry.Level, out var logLevel))
{
fileLoggerProvider.WriteLog(timeProvider.GetUtcNow(), logLevel, CliLogFormat.GetDetachedAppHostCategory(entry.Category), entry.Message);
}
}
fileLoggerProvider.WriteLog(
timeProvider.GetUtcNow(),
LogLevel.Information,
nameof(AppHostLauncher),
$"End detached AppHost startup log excerpt. Child log: {childLogFile}");
}
private void DisplayLaunchResult(
LaunchResult result,
FileInfo effectiveAppHostFile,
string childLogFile,
OutputFormat? format,
bool isExtensionHost)
{
var appHostInfo = result.Backchannel!.AppHostInfo;
var dashboardUrls = result.DashboardUrls;
var pid = appHostInfo?.ProcessId ?? result.ChildProcess!.ProcessId;
if (format == OutputFormat.Json)
{
var jsonResult = new DetachOutputInfo(
effectiveAppHostFile.FullName,
pid,
result.ChildProcess!.ProcessId,
dashboardUrls?.BaseUrlWithLoginToken,
childLogFile);
var json = JsonSerializer.Serialize(jsonResult, RunCommandJsonContext.RelaxedEscaping.DetachOutputInfo);
interactionService.DisplayRawText(json, ConsoleOutput.Standard);
}
else
{
var appHostRelativePath = Path.GetRelativePath(executionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName);
RunCommand.RenderAppHostSummary(
interactionService,
appHostRelativePath,
dashboardUrls?.BaseUrlWithLoginToken,
codespacesUrl: null,
childLogFile,
isExtensionHost,
pid);
interactionService.DisplayEmptyLine();
interactionService.DisplaySuccess(RunCommandStrings.AppHostStartedSuccessfully);
}
}
/// <summary>
/// Creates a user-facing error message for detached child process failures.
/// </summary>
internal static string GetDetachedFailureMessage(int childExitCode)
{
return childExitCode switch
{
CliExitCodes.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild,
_ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode)
};
}
internal static bool IsSuccessfulDetachedEarlyExit(int childExitCode)
=> childExitCode == CliExitCodes.Success;
/// <summary>
/// Generates a unique log file path for a detached child CLI process.
/// </summary>
internal static string GenerateChildLogFilePath(string logsDirectory, TimeProvider timeProvider)
{
var timestamp = timeProvider.GetUtcNow().ToString("yyyyMMddTHHmmssfff", CultureInfo.InvariantCulture);
var uniqueId = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
var fileName = $"cli_{timestamp}_detach-child_{uniqueId}.log";
return Path.Combine(logsDirectory, fileName);
}
internal static IReadOnlyList<string> ReadChildLogTail(string childLogFile, int maxLines = 80)
{
if (maxLines <= 0 || !File.Exists(childLogFile))
{
return [];
}
var lines = new Queue<string>(maxLines);
var guestCommandLines = new Queue<string>(maxLines);
IReadOnlyList<string>? failedGuestCommandLines = null;
var trackingGuestCommand = false;
using var reader = File.OpenText(childLogFile);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (!CliLogFormat.TryParseFileLogLine(line, out var entry))
{
continue;
}
if (IsGuestCommandStart(entry))
{
trackingGuestCommand = true;
guestCommandLines.Clear();
continue;
}
if (trackingGuestCommand && TryFormatGuestCommandOutputForDisplay(entry, out var guestCommandLine))
{
EnqueueBounded(guestCommandLines, guestCommandLine, maxLines);
continue;
}
if (IsGuestAppHostExit(entry))
{
if (trackingGuestCommand && guestCommandLines.Count > 0)
{
failedGuestCommandLines = guestCommandLines.ToArray();
}
trackingGuestCommand = false;
guestCommandLines.Clear();
continue;
}
if (!TryFormatChildLogEntryForDisplay(entry, out var displayLine))
{
continue;
}
EnqueueBounded(lines, displayLine, maxLines);
}
if (failedGuestCommandLines is not null)
{
return failedGuestCommandLines;
}
if (trackingGuestCommand && guestCommandLines.Count > 0)
{
return guestCommandLines.ToArray();
}
return lines.ToArray();
}
internal static IReadOnlyList<CliLogFormat.FileLogEntry> ReadChildLogReplayTail(string childLogFile, int maxLines = 200)
{
if (maxLines <= 0 || !File.Exists(childLogFile))
{
return [];
}
var entries = new Queue<CliLogFormat.FileLogEntry>(maxLines);
using var reader = File.OpenText(childLogFile);
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (!TryParseChildLogLineForReplay(line, out var entry))
{
continue;
}
if (entries.Count == maxLines)
{
entries.Dequeue();
}
entries.Enqueue(entry);
}
return entries.ToArray();
}
private static bool TryParseChildLogLineForReplay(string line, out CliLogFormat.FileLogEntry entry)
{
if (!CliLogFormat.TryParseFileLogLine(line, out entry))
{
return false;
}
if (string.IsNullOrWhiteSpace(entry.Message))
{
return false;
}
if (entry.Category is CliLogFormat.Categories.Stdout or CliLogFormat.Categories.Stderr)
{
return false;
}
if (entry.Category is CliLogFormat.Categories.Build or CliLogFormat.Categories.AppHost || entry.Category.StartsWith(CliLogFormat.Categories.AppHostPrefix, StringComparison.Ordinal))
{
return true;
}
if (entry.Category is CliLogFormat.Categories.GuestAppHostProject
&& entry.Message.StartsWith(CliLogFormat.MessagePrefixes.Executing, StringComparison.Ordinal))
{
return true;
}
if (entry.Level is CliLogFormat.FileLevelTokens.Warning or CliLogFormat.FileLevelTokens.Error or CliLogFormat.FileLevelTokens.Critical
&& entry.Category is not CliLogFormat.Categories.AspireCliTelemetry)
{
return true;
}
return false;
}
private static bool TryFormatChildLogEntryForDisplay(CliLogFormat.FileLogEntry entry, out string displayLine)
{
displayLine = string.Empty;
if (string.IsNullOrWhiteSpace(entry.Message))
{
return false;
}
if (entry.Category is CliLogFormat.Categories.Stdout or CliLogFormat.Categories.Stderr)
{
return false;
}
if (entry.Category is CliLogFormat.Categories.Build or CliLogFormat.Categories.AppHost || entry.Category.StartsWith(CliLogFormat.Categories.AppHostPrefix, StringComparison.Ordinal))
{
displayLine = entry.Message;
return true;
}
if (entry.Category is CliLogFormat.Categories.GuestAppHostProject
&& entry.Message.StartsWith("AppHost server process has exited.", StringComparison.Ordinal))
{
return false;
}
if (entry.Level is CliLogFormat.FileLevelTokens.Warning or CliLogFormat.FileLevelTokens.Error or CliLogFormat.FileLevelTokens.Critical
&& entry.Category is not CliLogFormat.Categories.AspireCliTelemetry)
{
displayLine = $"{entry.Category}: {entry.Message}";
return true;
}
return false;
}
private static bool TryFormatGuestCommandOutputForDisplay(CliLogFormat.FileLogEntry entry, out string displayLine)
{
displayLine = string.Empty;
if (string.IsNullOrWhiteSpace(entry.Message))
{
return false;
}
if (entry.Category is CliLogFormat.Categories.AppHost)
{
displayLine = entry.Message;
return true;
}
return false;
}
private static bool IsGuestCommandStart(CliLogFormat.FileLogEntry entry)
=> entry.Category is CliLogFormat.Categories.GuestAppHostProject
&& entry.Level is CliLogFormat.FileLevelTokens.Debug
&& entry.Message.StartsWith(CliLogFormat.MessagePrefixes.Executing, StringComparison.Ordinal);
private static bool IsGuestAppHostExit(CliLogFormat.FileLogEntry entry)
=> entry.Category is CliLogFormat.Categories.GuestAppHostProject
&& entry.Message.Contains(" apphost exited with code ", StringComparison.Ordinal);
private static void EnqueueBounded(Queue<string> lines, string line, int maxLines)
{
if (lines.Count == maxLines)
{
lines.Dequeue();
}
lines.Enqueue(line);
}
}