File: Commands\RunCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// 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 System.Text.Json.Serialization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Certificates;
using Aspire.Cli.Configuration;
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 Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Spectre.Console;
using Spectre.Console.Rendering;
using StreamJsonRpc;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Represents information about a detached AppHost for JSON serialization.
/// </summary>
// `aspire start --format json` and `aspire run --detach --format json` use this shape;
// keep docs/specs/cli-output-formats.md in sync when changing it.
internal sealed record DetachOutputInfo(
    string AppHostPath,
    int AppHostPid,
    int CliPid,
    string? DashboardUrl,
    string LogFile);
 
[JsonSerializable(typeof(DetachOutputInfo))]
[JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal sealed partial class RunCommandJsonContext : JsonSerializerContext
{
    private static RunCommandJsonContext? s_relaxedEscaping;
 
    /// <summary>
    /// Gets a context with relaxed JSON escaping for non-ASCII character support.
    /// </summary>
    public static RunCommandJsonContext RelaxedEscaping => s_relaxedEscaping ??= new(new JsonSerializerOptions
    {
        WriteIndented = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    });
}
 
internal sealed class RunCommand : BaseCommand
{
    internal override HelpGroup HelpGroup => HelpGroup.AppCommands;
 
    private readonly IDotNetCliRunner _runner;
    private readonly ICertificateService _certificateService;
    private readonly IProjectLocator _projectLocator;
    private readonly IConfiguration _configuration;
    private readonly IServiceProvider _serviceProvider;
    private readonly IFeatures _features;
    private readonly ILogger<RunCommand> _logger;
    private readonly IAppHostProjectFactory _projectFactory;
    private readonly AppHostLauncher _appHostLauncher;
    private readonly FileLoggerProvider _fileLoggerProvider;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly ProfilingTelemetry _profilingTelemetry;
    private readonly ProfileCaptureState _profileCaptureState;
    private readonly TimeProvider _timeProvider;
    private bool _isDetachMode;
    private const int MaxDisplayedAppHostStartupOutputLines = 80;
    // Match BackchannelLoggerProvider's 1,000-entry replay buffer.
    private const int MaxRememberedAppHostLogSequenceNumbers = 1000;
 
    private static readonly TimeSpan s_appHostStartupCancellationTimeout = TimeSpan.FromSeconds(5);
 
    // Graceful shutdown budget for `aspire run`. DCP gets a cooperative window to drain
    // resources before the central drain budget arms and ladders escalate to forceful kill.
    // 5s is comfortably enough for a default starter template (apphost + 2 services +
    // dashboard) once CTRL+C actually reaches the AppHost — measured graceful exits run
    // ~700ms end-to-end. Earlier observations of ~6s drains were an artifact of the
    // inherited "ignore CTRL+C" attribute (cleared in Program.cs Main on Windows) which
    // caused DCP to fall back to its internal 6s SIGKILL deadline because the kernel was
    // silently dropping the CTRL_C_EVENT.
    internal static readonly TimeSpan s_gracefulShutdownBudget = TimeSpan.FromSeconds(5);
 
    // Detached-start children do not have a parent process left to finish cleanup after they exit.
    // Give the AppHost run task enough time to consume the full graceful budget, escalate to kill,
    // and drain the signaler so the child does not recreate the process leak that the backstop is
    // meant to prevent.
    private static readonly TimeSpan s_detachedAppHostTeardownTimeout = s_gracefulShutdownBudget + TimeSpan.FromSeconds(3);
 
    // Guest AppHosts can bring up the temporary server/backchannel and then fail immediately
    // afterward when the guest startup process hits a syntax, pre-execute, or model validation
    // error. Keep guest AppHost startup waits alive briefly so those failures are reported instead of hidden.
    private static readonly TimeSpan s_startupFailureObservationWindow = TimeSpan.FromSeconds(2);
 
    // A wedged extension must degrade to legacy output instead of stalling AppHost log capture.
    private static readonly TimeSpan s_structuredLogSupportProbeTimeout = TimeSpan.FromSeconds(2);
 
    protected override bool UpdateNotificationsEnabled => !_isDetachMode;
 
    protected override TimeSpan GracefulShutdownBudget => s_gracefulShutdownBudget;
 
    internal override void PrepareForExecution(ParseResult parseResult)
    {
        // The spawned child runs without --detach, so its environment marker preserves detach-only
        // behavior such as suppressing update notifications and package metadata prefetching.
        _isDetachMode = parseResult.GetValue(s_detachOption) || IsDetachedStartChild();
    }
 
    private static readonly Option<bool> s_detachOption = new("--detach")
    {
        Description = RunCommandStrings.DetachArgumentDescription
    };
    private static readonly Option<bool> s_noBuildOption = new("--no-build")
    {
        Description = RunCommandStrings.NoBuildArgumentDescription
    };
 
    public RunCommand(
        IDotNetCliRunner runner,
        ICertificateService certificateService,
        IProjectLocator projectLocator,
        IConfiguration configuration,
        IServiceProvider serviceProvider,
        ILogger<RunCommand> logger,
        IAppHostProjectFactory projectFactory,
        AppHostLauncher appHostLauncher,
        FileLoggerProvider fileLoggerProvider,
        ICliHostEnvironment hostEnvironment,
        ProfilingTelemetry profilingTelemetry,
        ProfileCaptureState profileCaptureState,
        TimeProvider timeProvider,
        CommonCommandServices services)
        : base("run", RunCommandStrings.Description, services)
    {
        _runner = runner;
        _certificateService = certificateService;
        _projectLocator = projectLocator;
        _configuration = configuration;
        _serviceProvider = serviceProvider;
        _features = services.Features;
        _logger = logger;
        _projectFactory = projectFactory;
        _appHostLauncher = appHostLauncher;
        _fileLoggerProvider = fileLoggerProvider;
        _hostEnvironment = hostEnvironment;
        _profilingTelemetry = profilingTelemetry;
        _profileCaptureState = profileCaptureState;
        _timeProvider = timeProvider;
 
        Options.Add(s_detachOption);
        Options.Add(s_noBuildOption);
        AppHostLauncher.AddLaunchOptions(this);
 
        TreatUnmatchedTokensAsErrors = false;
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var passedAppHostProjectFile = parseResult.GetValue(AppHostLauncher.s_appHostOption);
        var detach = parseResult.GetValue(s_detachOption);
        var noBuild = parseResult.GetValue(s_noBuildOption);
        var format = parseResult.GetValue(AppHostLauncher.s_formatOption);
        var launchProfile = parseResult.GetValue(AppHostLauncher.s_launchProfileOption);
        var isExtensionHost = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _);
        var captureProfile = parseResult.GetValue(RootCommand.CaptureProfileOption);
        var captureProfileDelay = TimeSpan.FromSeconds(parseResult.GetValue(RootCommand.CaptureProfileDelayOption));
        var startDebugSession = false;
        if (isExtensionHost)
        {
            startDebugSession = parseResult.GetValue(RootCommand.StartDebugSessionOption);
        }
 
        // Validate that --format is only used with --detach
        if (format == OutputFormat.Json && !detach)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, RunCommandStrings.FormatRequiresDetach);
        }
 
        // Validate that --no-build is not used when watch mode would be enabled.
        // The extension terminal path enables watch mode by delegating to VS Code
        // before an Aspire debug session exists. Once VS Code starts the session,
        // the child CLI has ASPIRE_EXTENSION_DEBUG_SESSION_ID and can honor
        // forwarded options from the original terminal command without recursing.
        var extensionTerminalRunWithoutDebugSession = isExtensionHost
            && !startDebugSession
            && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId]);
        var watchModeEnabled = _features.IsFeatureEnabled(KnownFeatures.DefaultWatchEnabled, defaultValue: false) || extensionTerminalRunWithoutDebugSession;
        if (noBuild && watchModeEnabled)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, RunCommandStrings.NoBuildNotSupportedWithWatchMode);
        }
 
        if (!AppHostStartupTimeout.TryGetTimeoutSeconds(_configuration, InteractionService, out var timeoutSeconds))
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
 
        // Handle detached mode - spawn child process and exit
        if (detach)
        {
            return await ExecuteDetachedAsync(parseResult, passedAppHostProjectFile, isExtensionHost, timeoutSeconds, cancellationToken);
        }
 
        // A user may run `aspire run` in an Aspire terminal in VS Code. In this case, intercept and prompt
        // VS Code to start a debug session using the current directory.
        // Skip this when running in non-interactive mode (e.g. as a child of `aspire start`)
        // to avoid delegating back to the extension instead of launching the AppHost directly.
        var nonInteractive = parseResult.GetValue(RootCommand.NonInteractiveOption);
        if (!nonInteractive
            && ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)
            && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId]))
        {
            var debugSessionArguments = ParseResultHelper.GetForwardedArguments(
                parseResult,
                AppHostLauncher.s_appHostOption.InnerOption,
                AppHostLauncher.s_appHostOption.LegacyOption,
                AppHostLauncher.s_formatOption,
                s_detachOption,
                RootCommand.StartDebugSessionOption,
                RootCommand.NonInteractiveOption);
            extensionInteractionService.DisplayConsolePlainText(string.Format(CultureInfo.CurrentCulture, startDebugSession ? RunCommandStrings.StartingDebugSessionInExtension : RunCommandStrings.StartingRunSessionInExtension, "run"));
            await extensionInteractionService.StartDebugSessionAsync(
                ExecutionContext.WorkingDirectory.FullName,
                passedAppHostProjectFile?.FullName,
                startDebugSession,
                new DebugSessionOptions
                {
                    Command = "run",
                    Args = [.. debugSessionArguments.Tokens],
                    AppHostSelectionOrigin = passedAppHostProjectFile is not null
                        ? DebugSessionOptions.ExplicitCliAppHostSelectionOrigin
                        : DebugSessionOptions.DefaultDiscoveryAppHostSelectionOrigin
                });
            _profileCaptureState.MarkTransferred();
            return CommandResult.Success();
        }
 
        AppHostProjectContext? context = null;
        Activity? runActivity = null;
        LauncherLivenessMonitor? launcherMonitor = null;
        Task<int>? runTask = null;
        CancellationTokenSource? runCts = null;
 
        try
        {
            // Start a reported telemetry activity for the app host run early so that
            // all failure paths (project not found, incompatible version, etc.) are captured.
            runActivity = Telemetry.StartReportedActivity(name: TelemetryConstants.Activities.RunAppHost);
            runActivity?.SetTag(TelemetryConstants.Tags.AppHostDetached, _configuration.GetBool(KnownConfigNames.CliRunDetached) is true);
 
            using var activity = _profilingTelemetry.StartRunCommand();
 
            var multipleAppHostBehavior = _hostEnvironment.SupportsInteractiveInput
                ? MultipleAppHostProjectsFoundBehavior.Prompt
                : MultipleAppHostProjectsFoundBehavior.Throw;
 
            AppHostProjectSearchResult searchResult;
            using (var findAppHostActivity = _profilingTelemetry.StartRunAppHostFindAppHost(passedAppHostProjectFile))
            {
                searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(
                    passedAppHostProjectFile,
                    multipleAppHostBehavior,
                    createSettingsFile: true,
                    cancellationToken);
            }
            var effectiveAppHostFile = searchResult.SelectedProjectFile;
 
            if (effectiveAppHostFile is null)
            {
                runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "project_not_found");
                return CommandResult.Failure(CliExitCodes.FailedToFindProject);
            }
 
            var isolated = AppHostLauncher.ResolveIsolated(parseResult);
            runActivity?.SetTag(TelemetryConstants.Tags.AppHostIsolated, isolated);
 
            // Resolve the language for this file and get the appropriate handler
            var project = _projectFactory.TryGetProject(effectiveAppHostFile);
            if (project is null)
            {
                runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "project_not_found");
                return CommandResult.Failure(CliExitCodes.FailedToFindProject, "Unrecognized app host type.");
            }
 
            if (AppHostLauncher.GetLaunchProfileValidationError(project, launchProfile) is { } launchProfileError)
            {
                return CommandResult.Failure(
                    CliExitCodes.InvalidCommand,
                    launchProfileError);
            }
 
            runActivity?.SetTag(TelemetryConstants.Tags.AppHostLanguage, project.LanguageId);
 
            // Check for running instance — even if we fail to stop we won't
            // block the apphost starting to make sure we don't ever break flow.
            // It should mostly stop just fine though.
            RunningInstanceResult runningInstanceResult;
            using (var stopRunningInstanceActivity = _profilingTelemetry.StartRunAppHostStopExistingInstance())
            {
                runningInstanceResult = await project.FindAndStopRunningInstanceAsync(effectiveAppHostFile, ExecutionContext.HomeDirectory, cancellationToken);
                stopRunningInstanceActivity.SetAppHostRunningInstanceResult(runningInstanceResult);
            }
 
            // If in isolated mode and a running instance was stopped, warn the user
            if (isolated && runningInstanceResult == RunningInstanceResult.InstanceStopped)
            {
                InteractionService.DisplayMessage(KnownEmojis.Warning, RunCommandStrings.IsolatedModeRunningInstanceWarning);
            }
 
            // The completion sources are the contract between RunCommand and IAppHostProject
            var buildCompletionSource = new TaskCompletionSource<bool>();
            var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>();
            var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption);
 
            context = new AppHostProjectContext
            {
                AppHostFile = effectiveAppHostFile,
                Watch = false,
                Debug = parseResult.GetValue(RootCommand.DebugOption),
                NoBuild = noBuild,
                NoRestore = noBuild, // --no-build implies --no-restore
                WaitForDebugger = waitForDebugger,
                Isolated = isolated,
                StartDebugSession = startDebugSession,
                LaunchProfile = launchProfile,
                EnvironmentVariables = new Dictionary<string, string>(),
                UnmatchedTokens = parseResult.UnmatchedTokens.ToArray(),
                WorkingDirectory = ExecutionContext.WorkingDirectory,
                BuildCompletionSource = buildCompletionSource,
                BackchannelCompletionSource = backchannelCompletionSource,
            };
            ProfilingTelemetry.AddCurrentContextToEnvironment(context.EnvironmentVariables);
            if (captureProfile)
            {
                ProfileCaptureEnvironment.AddCurrentToEnvironment(context.EnvironmentVariables);
            }
 
            // Start the project run as a pending task - we'll handle UX while it runs
            var startupTimeout = TimeSpan.FromSeconds(timeoutSeconds);
            var startupStartTimestamp = _timeProvider.GetTimestamp();
            runCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
 
            // When this is a detached child, watch the foreground launcher during startup. If the launcher
            // is killed before the app is ready, cancel the run so the AppHost tree is torn down instead of leaking.
            // The monitor is disarmed as soon as the AppHost backchannel is established (see
            // onBackchannelEstablished below); from that point the AppHost's own orphan detector anchors to
            // this child, so the launcher's normal exit after observing readiness must not affect us.
            Func<ValueTask>? onBackchannelEstablished = null;
            if (IsDetachedStartChild())
            {
                // Use launcher monitor to ensure that if the launcher fails or is killed,
                // the child process(es) are not leaked.
                launcherMonitor = LauncherLivenessMonitor.StartIfConfigured(_configuration, runCts, _timeProvider, _logger);
                if (launcherMonitor is { } armedMonitor)
                {
                    // Disarm at the earliest safe point. DisposeAsync is idempotent.
                    onBackchannelEstablished = () => armedMonitor.DisposeAsync();
                }
            }
 
            using (_profilingTelemetry.StartRunAppHostStartProject(project.LanguageId, noBuild, waitForDebugger))
            {
                runTask = project.RunAsync(context, runCts.Token);
            }
 
            // Wait for the build to complete first (project handles its own build status spinners)
            bool buildSuccess;
            using (var waitForBuildActivity = _profilingTelemetry.StartRunAppHostWaitForBuild())
            {
                try
                {
                    buildSuccess = await buildCompletionSource.Task.WaitAsync(GetRemainingStartupTimeout(startupStartTimestamp, startupTimeout), _timeProvider, cancellationToken);
                }
                catch (TimeoutException)
                {
                    runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout");
                    await CancelAppHostStartupAsync(runCts, runTask, cancellationToken).ConfigureAwait(false);
                    return CreateStartupTimeoutResult(timeoutSeconds);
                }
 
                waitForBuildActivity.SetAppHostBuildSuccess(buildSuccess);
            }
            if (!buildSuccess)
            {
                runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "build_failed");
                // Build failed - display captured output and return exit code
                if (context.OutputCollector is { } outputCollector)
                {
                    InteractionService.DisplayLines(outputCollector.GetLines());
                }
                return CommandResult.Failure(await runTask, InteractionServiceStrings.ProjectCouldNotBeBuilt);
            }
            var appHostStartupOutputStartIndex = context.OutputCollector?.GetLines().Count() ?? 0;
 
            // If --wait-for-debugger, display a message so the user knows the AppHost is paused.
            if (waitForDebugger)
            {
                InteractionService.DisplayMessage(KnownEmojis.Bug, InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost);
            }
 
            using var logCaptureCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            var pendingLogCapture = Task.CompletedTask;
 
            try
            {
                AppHostStartupResult startup;
                try
                {
                    startup = await WaitForAppHostStartupAsync(
                        runTask,
                        backchannelCompletionSource,
                        onBackchannelEstablished,
                        logCaptureCancellationSource,
                        context.OutputCollector,
                        appHostStartupOutputStartIndex,
                        startupStartTimestamp,
                        startupTimeout,
                        cancellationToken).ConfigureAwait(false);
                }
                catch (TimeoutException)
                {
                    runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "startup_timeout");
                    await CancelAppHostStartupAsync(runCts, runTask, cancellationToken).ConfigureAwait(false);
                    return CreateStartupTimeoutResult(timeoutSeconds);
                }
 
                var backchannel = startup.Backchannel;
                var dashboardUrls = startup.DashboardUrls;
                pendingLogCapture = startup.PendingLogCapture;
 
                if (dashboardUrls.DashboardHealthy is false)
                {
                    InteractionService.DisplayMessage(KnownEmojis.Warning, RunCommandStrings.DashboardFailedToStart);
                }
 
                // Display the UX
                var appHostRelativePath = Path.GetRelativePath(ExecutionContext.WorkingDirectory.FullName, effectiveAppHostFile.FullName);
                var longestLocalizedLengthWithColon = RenderAppHostSummary(
                    InteractionService,
                    appHostRelativePath,
                    dashboardUrls.BaseUrlWithLoginToken,
                    dashboardUrls.CodespacesUrlWithLoginToken,
                    _fileLoggerProvider.LogFilePath,
                    isExtensionHost);
 
                if (ExtensionHelper.IsExtensionHost(InteractionService, out var extInteractionService, out _))
                {
                    if (dashboardUrls.DashboardHealthy is true)
                    {
                        extInteractionService.DisplayDashboardUrls(dashboardUrls);
                    }
 
                    extInteractionService.NotifyAppHostStartupCompleted();
                }
 
                // Handle remote environments (Codespaces, Remote Containers, SSH)
                var isCodespaces = dashboardUrls.CodespacesUrlWithLoginToken is not null;
                var isRemoteContainers = string.Equals(_configuration["REMOTE_CONTAINERS"], "true", StringComparison.OrdinalIgnoreCase);
                var isSshRemote = _configuration["VSCODE_IPC_HOOK_CLI"] is not null
                                  && _configuration["SSH_CONNECTION"] is not null;
                var isRemoteEnvironment = isCodespaces || isRemoteContainers || isSshRemote;
 
                var profileStopRequested = false;
                if (captureProfile)
                {
                    profileStopRequested = await RequestAppHostStopForProfileAsync(backchannel, runTask, captureProfileDelay, _profilingTelemetry, cancellationToken).ConfigureAwait(false);
                }
                else if (!isRemoteEnvironment)
                {
                    AppendCtrlCMessage(longestLocalizedLengthWithColon);
                }
                else
                {
                    // We want to display resource information in remote environments.
                    // Resources update over time so we'll use a live display.
                    // It is used to show discovered endpoints as they come in over the backchannel.
                    var discoveredEndpoints = new List<(string Resource, string Endpoint)>();
                    var endpointsLocalizedString = RunCommandStrings.Endpoints;
                    var showCtrlC = !ExtensionHelper.IsExtensionHost(InteractionService, out _, out _);
 
                    IRenderable BuildLiveRenderable()
                    {
                        var rows = new List<IRenderable>();
 
                        if (discoveredEndpoints.Count > 0)
                        {
                            var endpointsGrid = new Grid();
                            endpointsGrid.AddColumn();
                            endpointsGrid.AddColumn();
                            endpointsGrid.Columns[0].Width = longestLocalizedLengthWithColon;
                            endpointsGrid.AddRow(Text.Empty, Text.Empty);
 
                            for (var i = 0; i < discoveredEndpoints.Count; i++)
                            {
                                var (resource, endpoint) = discoveredEndpoints[i];
                                endpointsGrid.AddRow(
                                    i == 0
                                        ? new Align(new Markup($"[bold green]{endpointsLocalizedString}[/]:"), HorizontalAlignment.Right)
                                        : Text.Empty,
                                    new Markup($"[bold]{resource.EscapeMarkup()}[/] [grey]has endpoint[/] {MarkupHelpers.SafeLink(InteractionService, endpoint)}")
                                );
                            }
 
                            rows.Add(new Padder(endpointsGrid, new Padding(3, 0)));
                        }
 
                        if (showCtrlC)
                        {
                            rows.Add(BuildCtrlCRenderable(longestLocalizedLengthWithColon));
                        }
 
                        return rows.Count > 0 ? new Rows(rows) : Text.Empty;
                    }
 
                    try
                    {
                        await InteractionService.DisplayLiveAsync(BuildLiveRenderable(), async updateTarget =>
                        {
                            var resourceStates = backchannel.GetResourceStatesAsync(cancellationToken);
                            await foreach (var resourceState in resourceStates.WithCancellation(cancellationToken))
                            {
                                ProcessResourceState(resourceState, (resource, endpoint) =>
                                {
                                    discoveredEndpoints.Add((resource, endpoint));
                                    updateTarget(BuildLiveRenderable());
                                });
                            }
                        });
                    }
                    catch (ConnectionLostException) when (cancellationToken.IsCancellationRequested)
                    {
                        // Orderly shutdown
                    }
                }
 
                using (var lifetimeActivity = _profilingTelemetry.StartRunAppHostLifetime())
                {
                    runActivity?.Stop();
 
                    try
                    {
                        await pendingLogCapture;
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Failed to capture logs from AppHost");
                        InteractionService.DisplayMessage(KnownEmojis.Warning, "No longer receiving logs from AppHost.");
                    }
                    finally
                    {
                        pendingLogCapture = Task.CompletedTask;
                    }
 
                    var exitCode = await runTask;
                    lifetimeActivity.SetProcessExitCode(exitCode);
 
                    // Capture mode intentionally turns a long-running AppHost startup into a finite command.
                    // Some AppHost implementations, including guest AppHosts, report the teardown exit code
                    // from a helper process that the CLI stops after the AppHost has already started; on
                    // Unix-like systems that surfaces as 128 + signal (e.g., 130 SIGINT, 137 SIGKILL, 143
                    // SIGTERM). These are AppHost process exit codes (not CLI exit codes), so use the raw
                    // signal-based literals here instead of CLI exit-code constants. Treat the known teardown
                    // codes as a successful capture, but propagate any other non-zero exit code so a
                    // genuine AppHost crash during shutdown is not masked.
                    if (profileStopRequested)
                    {
                        return exitCode is 0 or 130 or 137 or 143
                            ? CommandResult.Success()
                            : CommandResult.FromExitCode(exitCode);
                    }
 
                    // Cancelled by user (e.g., Ctrl+C) - treat as successful exit since the user intentionally stopped the AppHost.
                    return exitCode == CliExitCodes.Cancelled
                        ? CommandResult.Cancelled(CliExitCodes.Success)
                        : CommandResult.FromExitCode(exitCode);
                }
            }
            catch (OperationCanceledException ex) when (ex.CancellationToken == runCts.Token && cancellationToken.IsCancellationRequested)
            {
                runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled");
 
                // User Ctrl+C is the normal exit path for `aspire run`; surface as success.
                // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather
                // than flowing through this catch, so we don't need to distinguish failure codes
                // here.
                return CommandResult.Cancelled(CliExitCodes.Success);
            }
            finally
            {
                logCaptureCancellationSource.Cancel();
                try
                {
                    await pendingLogCapture.ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "AppHost log capture ended while the run command was exiting early.");
                }
            }
        }
        catch (OperationCanceledException ex) when (ex.CancellationToken == cancellationToken || ex is ExtensionOperationCanceledException)
        {
            runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "canceled");
 
            // User Ctrl+C is the normal exit path for `aspire run`; surface as success.
            // Internal failures `return X` directly from GuestAppHostProject.RunAsync rather
            // than flowing through this catch.
            return CommandResult.Cancelled(CliExitCodes.Success);
        }
        catch (ProjectLocatorException ex)
        {
            runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "project_not_found");
            return HandleProjectLocatorException(ex, InteractionService, Telemetry);
        }
        catch (AppHostIncompatibleException ex)
        {
            runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "incompatible_version");
            Telemetry.RecordError(ex.Message, ex);
            return CommandResult.FromExitCode(InteractionService.DisplayIncompatibleVersionError(ex, ex.AspireHostingVersion ?? ex.RequiredCapability));
        }
        catch (FailedToConnectBackchannelConnection ex)
        {
            // The AppHost process exited before the backchannel could connect. This is an
            // AppHost startup failure (e.g. the user's code crashed), not a CLI infrastructure
            // error. WaitForAppHostStartupAsync normally wraps this in AppHostExitedDuringStartupException
            // with the real exit code; this catch is a defensive fallback for edge-case races.
            runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, "backchannel_connection_failed");
            _logger.LogDebug(ex, "AppHost exited before backchannel connected.");
            var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message);
            return CommandResult.Failure(CliExitCodes.FailedToDotnetRunAppHost, errorMessage);
        }
        catch (ConnectionLostException) when (isExtensionHost)
        {
            // When the extension manages the AppHost lifecycle (e.g., VS Code debug session),
            // it terminates the process on stop/restart, causing the backchannel to drop.
            return CommandResult.Success();
        }
        catch (AppHostExitedDuringStartupException ex)
        {
            return CreateRunExitResult(ex.ExitCode, ex.FailureMessage);
        }
        catch (Exception ex)
        {
            runActivity?.SetTag(TelemetryConstants.Tags.ErrorType, ex.GetType().FullName);
            var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message);
            Telemetry.RecordError(errorMessage, ex);
            return CommandResult.Failure(CliExitCodes.FailedToDotnetRunAppHost, errorMessage);
        }
        finally
        {
            if (IsDetachedStartChild() && runTask is { IsCompleted: false } detachedAppHostRun)
            {
                // If the runTask is still running here, that is an abnormal exit. 
                // Cancel the run and wait for the AppHost to teardown so we don't leak child processes.
                try
                {
                    runCts?.Cancel();
                    // CancellationToken.None is deliberate: root token is already cancelled.
                    await detachedAppHostRun.WaitAsync(s_detachedAppHostTeardownTimeout, _timeProvider, CancellationToken.None).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Detached child timed out or failed while awaiting AppHost teardown during early exit.");
                }
            }
 
            if (launcherMonitor is not null)
            {
                await launcherMonitor.DisposeAsync().ConfigureAwait(false);
            }
 
            runCts?.Dispose();
            runActivity?.Dispose();
        }
    }
 
    private bool IsDetachedStartChild() => _configuration.GetBool(KnownConfigNames.CliRunDetached) is true;
 
    private static void DisplayRecentAppHostStartupOutput(IInteractionService interactionService, OutputCollector? outputCollector, int startupOutputStartIndex)
    {
        var outputLines = outputCollector?.GetLines()
            .Skip(startupOutputStartIndex)
            .Where(static line => line.Stream == OutputLineStream.StdErr)
            .TakeLast(MaxDisplayedAppHostStartupOutputLines)
            .ToArray();
        if (outputLines is null || outputLines.Length == 0)
        {
            return;
        }
 
        interactionService.DisplayMessage(KnownEmojis.Information, $"{RunCommandStrings.RecentAppHostStartupOutput}:");
        interactionService.DisplayLines(outputLines);
    }
 
    private static CommandResult CreateRunExitResult(int exitCode, string? errorMessage = null)
    {
        if (exitCode == CliExitCodes.Cancelled)
        {
            return CommandResult.Cancelled(CliExitCodes.Success);
        }
 
        return errorMessage is null
            ? CommandResult.FromExitCode(exitCode)
            : CommandResult.Failure(exitCode, errorMessage);
    }
 
    private static async Task<int?> ObserveEarlyDetachedStartupExitAsync(Task<int> pendingRun, CancellationToken cancellationToken)
    {
        var completedTask = await Task.WhenAny(
            pendingRun,
            Task.Delay(s_startupFailureObservationWindow, cancellationToken)).ConfigureAwait(false);
 
        cancellationToken.ThrowIfCancellationRequested();
 
        if (completedTask == pendingRun)
        {
            return await GetAppHostStartupExitCodeAsync(pendingRun).ConfigureAwait(false);
        }
 
        return null;
    }
 
    private static async Task<AppHostExitResolution> ResolveAppHostExitCodeAsync(Task<int> appHostFailureTask, CancellationToken cancellationToken)
    {
        try
        {
            var exitCode = await (cancellationToken.CanBeCanceled
                ? appHostFailureTask.WaitAsync(cancellationToken)
                : appHostFailureTask).ConfigureAwait(false);
            return new AppHostExitResolution(exitCode, FaultException: null);
        }
        catch (OperationCanceledException)
        {
            // Honor user-initiated cancellation by propagating; callers expect to see it.
            throw;
        }
        catch (Exception ex)
        {
            // appHostFailureTask faulted instead of returning a clean exit code (e.g. an unexpected
            // exception bubbled out of project.RunAsync). Treat that as a generic AppHost failure
            // so the caller can still display captured output and surface the failure uniformly,
            // and carry the fault exception forward so the caller can wrap it with the localized
            // UnexpectedErrorOccurred template for display alongside any captured output.
            return new AppHostExitResolution(CliExitCodes.FailedToDotnetRunAppHost, FaultException: ex);
        }
    }
 
    private readonly record struct AppHostExitResolution(int ExitCode, Exception? FaultException);
 
    private static void ObserveFaults(Task task)
    {
        _ = task.ContinueWith(
            static completedTask => _ = completedTask.Exception,
            CancellationToken.None,
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }
 
    private static async Task<int> GetAppHostStartupExitCodeAsync(Task<int> pendingRun)
    {
        try
        {
            return await pendingRun.ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            throw new AppHostExitedDuringStartupException(CliExitCodes.FailedToDotnetRunAppHost, ex);
        }
    }
 
    private async Task<AppHostStartupResult> WaitForAppHostStartupAsync(
        Task<int> pendingRun,
        TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
        Func<ValueTask>? onBackchannelEstablished,
        CancellationTokenSource logCaptureCancellationSource,
        OutputCollector? outputCollector,
        int appHostStartupOutputStartIndex,
        long startupStartTimestamp,
        TimeSpan startupTimeout,
        CancellationToken cancellationToken)
    {
        using var startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        var happyPathTask = RunStartupHappyPathAsync(backchannelCompletionSource, onBackchannelEstablished, logCaptureCancellationSource, pendingRun, startupStartTimestamp, startupTimeout, startupCts.Token);
 
        // Race the startup readiness signal against the AppHost system task. The AppHost
        // system is owned by the project and tears itself down (via an internal escalation
        // CTS that listens for BackchannelCompletionSource faults) whenever the server or
        // guest dies, so once happyPathTask faults the AppHost is on its way down too.
        if (await Task.WhenAny(happyPathTask, pendingRun).ConfigureAwait(false) == pendingRun)
        {
            ObserveFaults(happyPathTask);
            await startupCts.CancelAsync().ConfigureAwait(false);
            // pendingRun is already complete (it won the race), so there is nothing for a
            // cancellation token to interrupt - pass CancellationToken.None explicitly.
            var resolution = await ResolveAppHostExitCodeAsync(pendingRun, CancellationToken.None).ConfigureAwait(false);
            DisplayRecentAppHostStartupOutput(InteractionService, outputCollector, appHostStartupOutputStartIndex);
            // If the AppHost faulted (e.g. an unexpected exception bubbled out of RunAsync
            // before it could return an exit code), wrap the fault reason with the localized
            // UnexpectedErrorOccurred template so the user sees a consistent
            // "An unexpected error occurred: <reason>" line. When the AppHost returned a
            // real exit code we already have whatever output it produced and don't need a
            // generic wrapper - the captured output is the narrative.
            var failureMessage = resolution.FaultException is { } faultException
                ? string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, faultException.Message)
                : null;
            throw new AppHostExitedDuringStartupException(resolution.ExitCode, resolution.FaultException, failureMessage);
        }
 
        try
        {
            return await happyPathTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException ex) when (ex.CancellationToken == startupCts.Token && cancellationToken.IsCancellationRequested)
        {
            throw new OperationCanceledException(ex.Message, ex, cancellationToken);
        }
        catch (AppHostExitedDuringStartupException)
        {
            // Intentional detached-startup signal from RunStartupHappyPathAsync: the AppHost
            // exited cleanly during the detached-start early-exit observation window. The
            // exit code and captured AppHost output already describe the outcome, so let it
            // propagate as-is and avoid wrapping it with the generic "unexpected error"
            // template (the AppHost exit isn't really unexpected at this point).
            DisplayRecentAppHostStartupOutput(InteractionService, outputCollector, appHostStartupOutputStartIndex);
            throw;
        }
        catch (TimeoutException)
        {
            // Bubble startup-timeout signal up to ExecuteAsync so it can cancel the run
            // and emit the localized timeout guidance. Must not be wrapped by the generic
            // catch below.
            throw;
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            var failureMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message);
            DisplayRecentAppHostStartupOutput(InteractionService, outputCollector, appHostStartupOutputStartIndex);
 
            if (AppHostFollowDisconnectHelpers.IsExpectedDisconnect(ex))
            {
                // The backchannel connection itself died, so the AppHost is dying too. Wait for it
                // to exit so we can surface its real exit code/captured output rather than a
                // generic CLI-side failure. BackchannelCompletionSource is already RanToCompletion
                // here so the project's escalation hook can't tear things down for us; show a
                // status that tells the user how to break out of the wait.
                var resolution = await InteractionService.ShowStatusAsync(
                    RunCommandStrings.AppHostConnectionLostWaitingForExit,
                    () => ResolveAppHostExitCodeAsync(pendingRun, cancellationToken)).ConfigureAwait(false);
                throw new AppHostExitedDuringStartupException(resolution.ExitCode, ex, failureMessage);
            }
 
            // Server-side RPC handler faulted (e.g. GetDashboardUrlsAsync threw) but the channel
            // is still alive and the AppHost may keep running indefinitely. The RPC fault payload
            // is already the real cause, so surface it immediately and let normal command teardown
            // shut the AppHost down. This mirrors pre-PR behavior for these failures.
            throw new AppHostExitedDuringStartupException(CliExitCodes.FailedToDotnetRunAppHost, ex, failureMessage);
        }
    }
 
    private async Task<AppHostStartupResult> RunStartupHappyPathAsync(
        TaskCompletionSource<IAppHostCliBackchannel> backchannelCompletionSource,
        Func<ValueTask>? onBackchannelEstablished,
        CancellationTokenSource logCaptureCancellationSource,
        Task<int> pendingRun,
        long startupStartTimestamp,
        TimeSpan startupTimeout,
        CancellationToken cancellationToken)
    {
        IAppHostCliBackchannel backchannel;
        using (var waitForBackchannelActivity = _profilingTelemetry.StartRunAppHostWaitForBackchannel())
        {
            backchannel = await InteractionService.ShowStatusAsync(
                RunCommandStrings.ConnectingToAppHost,
                async () => await backchannelCompletionSource.Task.WaitAsync(GetRemainingStartupTimeout(startupStartTimestamp, startupTimeout), _timeProvider, cancellationToken).ConfigureAwait(false));
            waitForBackchannelActivity.SetAppHostBackchannelConnected(true);
        }
 
        if (onBackchannelEstablished is not null)
        {
            await onBackchannelEstablished().ConfigureAwait(false);
        }
 
        // Start log capture early so any output produced while we wait for dashboard URLs is
        // routed into the unified CLI log file. The task is returned to the caller so the run
        // command can await/cancel it during teardown.
        var pendingLogCapture = CaptureAppHostLogsAsync(_fileLoggerProvider, backchannel, InteractionService, logCaptureCancellationSource.Token);
        // Observe faults in case the caller never gets to await it - e.g., if a subsequent
        // step in this method throws, the local task goes out of scope without being returned
        // through AppHostStartupResult. CaptureAppHostLogsAsync already handles OCE and
        // ConnectionLostException-during-cancellation cleanly, but any other failure (or a
        // ConnectionLostException that fires before the outer finally cancels log capture) would
        // otherwise surface as an unobserved task exception.
        ObserveFaults(pendingLogCapture);
 
        DashboardUrlsState dashboardUrls;
        using (var getDashboardUrlsActivity = _profilingTelemetry.StartRunAppHostGetDashboardUrls())
        {
            dashboardUrls = await InteractionService.ShowStatusAsync(
                RunCommandStrings.StartingDashboard,
                async () => await backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false));
            getDashboardUrlsActivity.SetAppHostDashboardHealthy(dashboardUrls.DashboardHealthy);
        }
 
        if (IsDetachedStartChild())
        {
            var observedExitCode = await ObserveEarlyDetachedStartupExitAsync(pendingRun, cancellationToken).ConfigureAwait(false);
            if (observedExitCode is { } exitCode)
            {
                throw new AppHostExitedDuringStartupException(exitCode);
            }
        }
 
        await backchannel.NotifyAppHostReadyAsync(cancellationToken).ConfigureAwait(false);
 
        return new AppHostStartupResult(backchannel, dashboardUrls, pendingLogCapture);
    }
 
    private sealed record AppHostStartupResult(
        IAppHostCliBackchannel Backchannel,
        DashboardUrlsState DashboardUrls,
        Task PendingLogCapture);
 
    private sealed class AppHostExitedDuringStartupException(int exitCode, Exception? innerException = null, string? failureMessage = null) : Exception("The AppHost exited during startup.", innerException)
    {
        public int ExitCode { get; } = exitCode;
 
        /// <summary>
        /// Optional user-facing message describing why startup failed. Populated when the failure
        /// originated from the CLI-side startup happy path (e.g., a backchannel timeout or RPC
        /// fault) rather than from output the AppHost itself wrote to stderr, so that the message
        /// can be surfaced through the normal command-result error path.
        /// </summary>
        public string? FailureMessage { get; } = failureMessage;
    }
 
    private static IRenderable BuildCtrlCRenderable(int longestLocalizedLengthWithColon)
    {
        var ctrlCGrid = new Grid();
        ctrlCGrid.AddColumn();
        ctrlCGrid.AddColumn();
        ctrlCGrid.Columns[0].Width = longestLocalizedLengthWithColon;
        ctrlCGrid.AddRow(Text.Empty, Text.Empty);
        ctrlCGrid.AddRow(new Text(string.Empty), new Markup(RunCommandStrings.PressCtrlCToStopAppHost) { Overflow = Overflow.Ellipsis });
 
        return new Padder(ctrlCGrid, new Padding(3, 0));
    }
 
    private void AppendCtrlCMessage(int longestLocalizedLengthWithColon)
    {
        if (ExtensionHelper.IsExtensionHost(InteractionService, out _, out _))
        {
            return;
        }
 
        InteractionService.DisplayRenderable(BuildCtrlCRenderable(longestLocalizedLengthWithColon));
    }
 
    private static async Task<bool> RequestAppHostStopForProfileAsync(
        IAppHostCliBackchannel backchannel,
        Task<int> pendingRun,
        TimeSpan delay,
        ProfilingTelemetry profilingTelemetry,
        CancellationToken cancellationToken)
    {
        // The AppHost exports profiling spans through the batched OTLP exporter. Keep the process
        // alive briefly after startup so late server-side spans (for example dashboard readiness)
        // have time to flush before the CLI requests shutdown and exports the capture archive.
        if (delay > TimeSpan.Zero)
        {
            using (profilingTelemetry.StartProfileCaptureDelay(delay))
            {
                var delayTask = Task.Delay(delay, cancellationToken);
                var completedTask = await Task.WhenAny(delayTask, pendingRun).ConfigureAwait(false);
                if (completedTask == pendingRun)
                {
                    return false;
                }
 
                await delayTask.ConfigureAwait(false);
            }
        }
 
        if (!pendingRun.IsCompleted)
        {
            await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
            return true;
        }
 
        return false;
    }
 
    /// <summary>
    /// Renders the AppHost summary grid with AppHost path, dashboard URL, logs path, and optionally PID.
    /// </summary>
    /// <param name="console">The console to write to.</param>
    /// <param name="appHostRelativePath">The relative path to the AppHost file.</param>
    /// <param name="dashboardUrl">The dashboard URL with login token, or null if not available.</param>
    /// <param name="codespacesUrl">The codespaces URL with login token, or null if not in codespaces.</param>
    /// <param name="logFilePath">The full path to the log file.</param>
    /// <param name="pid">The process ID to display, or null to omit the PID row.</param>
    /// <param name="isExtensionHost">Whether the AppHost is running in the Aspire extension.</param>
    /// <returns>The column width used, for subsequent grid additions.</returns>
    internal static int RenderAppHostSummary(
        IInteractionService console,
        string appHostRelativePath,
        string? dashboardUrl,
        string? codespacesUrl,
        string logFilePath,
        bool isExtensionHost,
        int? pid = null)
    {
        console.DisplayEmptyLine();
        var grid = new Grid();
        grid.AddColumn();
        grid.AddColumn();
 
        var appHostLabel = RunCommandStrings.AppHost;
        var dashboardLabel = RunCommandStrings.Dashboard;
        var logsLabel = RunCommandStrings.Logs;
        var pidLabel = RunCommandStrings.ProcessId;
 
        // Calculate column width based on labels that will actually be displayed
        var labels = new List<string> { appHostLabel, logsLabel };
        if (!isExtensionHost)
        {
            labels.Add(dashboardLabel);
        }
        if (pid.HasValue)
        {
            labels.Add(pidLabel);
        }
        var longestLabelLength = labels.Max(s => s.Length) + 1; // +1 for colon
 
        grid.Columns[0].Width = longestLabelLength;
 
        // In the extension's debug console, right-aligned labels and the surrounding padding
        // render as visible left indentation, and the empty separator rows show up as blank
        // lines that just push real content further down. Use a flush, single-spaced layout
        // for the extension and keep the spaced-out look only for direct terminal output.
        IRenderable LabelMarkup(string label)
        {
            var markup = new Markup($"[bold green]{label}[/]:");
            return isExtensionHost ? markup : new Align(markup, HorizontalAlignment.Right);
        }
 
        // AppHost row
        grid.AddRow(LabelMarkup(appHostLabel), new Text(appHostRelativePath));
        if (!isExtensionHost)
        {
            grid.AddRow(Text.Empty, Text.Empty);
        }
 
        if (!isExtensionHost)
        {
            // Dashboard row
            if (!string.IsNullOrEmpty(dashboardUrl))
            {
                grid.AddRow(
                    LabelMarkup(dashboardLabel),
                    new Markup(MarkupHelpers.SafeLink(console, dashboardUrl)));
 
                // Codespaces URL (if available)
                if (!string.IsNullOrEmpty(codespacesUrl))
                {
                    grid.AddRow(Text.Empty, new Markup(MarkupHelpers.SafeLink(console, codespacesUrl)));
                }
            }
            else
            {
                grid.AddRow(
                    LabelMarkup(dashboardLabel),
                    new Markup("[dim]N/A[/]"));
            }
            grid.AddRow(Text.Empty, Text.Empty);
        }
 
        // Logs row
        grid.AddRow(LabelMarkup(logsLabel), new Markup(MarkupHelpers.SafeFileLink(console, logFilePath)));
 
        // PID row (if provided)
        if (pid.HasValue)
        {
            if (!isExtensionHost)
            {
                grid.AddRow(Text.Empty, Text.Empty);
            }
            grid.AddRow(LabelMarkup(pidLabel), new Text(pid.Value.ToString(CultureInfo.InvariantCulture)));
        }
 
        IRenderable summary = isExtensionHost ? grid : new Padder(grid, new Padding(3, 0));
        console.DisplayRenderable(summary);
 
        return longestLabelLength;
    }
 
    internal static async Task CaptureAppHostLogsAsync(FileLoggerProvider fileLoggerProvider, IAppHostCliBackchannel backchannel, IInteractionService interactionService, CancellationToken cancellationToken)
    {
        try
        {
            await Task.Yield();
 
            // Start the probe without awaiting it so extension responsiveness never delays
            // subscription to the AppHost stream or writes to the diagnostic log file.
            var structuredLogSupportProbe = ExtensionHelper.IsExtensionHost(interactionService, out var extensionInteractionService, out var extensionBackchannel)
                ? SupportsStructuredAppHostLogsAsync(fileLoggerProvider, extensionBackchannel, cancellationToken)
                : Task.FromResult(false);
 
            var logEntries = backchannel.GetAppHostLogEntriesAsync(cancellationToken);
            bool? extensionSupportsStructuredLogs = null;
            var pendingExtensionEntries = new List<BackchannelLogEntry>();
            var recentSequenceIdentities = new HashSet<(Guid GenerationId, long SequenceNumber)>(MaxRememberedAppHostLogSequenceNumbers);
            var sequenceIdentityOrder = new Queue<(Guid GenerationId, long SequenceNumber)>(MaxRememberedAppHostLogSequenceNumbers);
 
            try
            {
                using var enumerationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
                await using var enumerator = logEntries.GetAsyncEnumerator(enumerationCancellationSource.Token);
                Task<bool>? moveNextTask = null;
 
                try
                {
                    while (true)
                    {
                        moveNextTask ??= enumerator.MoveNextAsync().AsTask();
 
                        // Once an entry is buffered, keep one MoveNextAsync in flight and observe the
                        // capability probe alongside it. This preserves file capture throughput while
                        // allowing an idle stream to flush as soon as the extension answers.
                        if (extensionSupportsStructuredLogs is null && pendingExtensionEntries.Count > 0)
                        {
                            var probeCompletedFirst = structuredLogSupportProbe.IsCompleted ||
                                await Task.WhenAny(moveNextTask, structuredLogSupportProbe).ConfigureAwait(false) == structuredLogSupportProbe;
                            if (probeCompletedFirst)
                            {
                                extensionSupportsStructuredLogs = await structuredLogSupportProbe.ConfigureAwait(false);
                                ForwardPendingAppHostLogEntriesToExtension(
                                    extensionInteractionService!,
                                    extensionSupportsStructuredLogs.Value,
                                    pendingExtensionEntries);
                                continue;
                            }
                        }
 
                        var hasEntry = await moveNextTask.ConfigureAwait(false);
                        moveNextTask = null;
                        if (!hasEntry)
                        {
                            break;
                        }
 
                        var entry = enumerator.Current;
 
                        // A reconnect replays the AppHost's 1,000-entry buffer. Remember exact
                        // generation/sequence pairs to preserve delayed delivery and distinguish a
                        // replacement AppHost. Sequence zero comes from older AppHosts and has no
                        // stable identity.
                        if (entry.SequenceNumber > 0)
                        {
                            var sequenceIdentity = (entry.GenerationId, entry.SequenceNumber);
                            if (!recentSequenceIdentities.Add(sequenceIdentity))
                            {
                                continue;
                            }
 
                            sequenceIdentityOrder.Enqueue(sequenceIdentity);
                            if (sequenceIdentityOrder.Count > MaxRememberedAppHostLogSequenceNumbers)
                            {
                                recentSequenceIdentities.Remove(sequenceIdentityOrder.Dequeue());
                            }
                        }
 
                        var shortCategory = FileLoggerProvider.GetShortCategoryName(entry.CategoryName);
                        var message = string.IsNullOrEmpty(entry.Exception)
                            ? entry.Message
                            : $"{entry.Message}{Environment.NewLine}{entry.Exception}";
                        fileLoggerProvider.WriteLog(entry.Timestamp, entry.LogLevel, $"AppHost/{shortCategory}", message);
 
                        // Preserve the previous RPC volume. Trace and Debug still arrive through the
                        // AppHost console provider and are styled by the extension.
                        if (extensionInteractionService is null || entry.LogLevel is LogLevel.Trace or LogLevel.Debug)
                        {
                            continue;
                        }
 
                        if (extensionSupportsStructuredLogs is null)
                        {
                            pendingExtensionEntries.Add(entry);
                            continue;
                        }
 
                        ForwardAppHostLogEntryToExtension(extensionInteractionService, extensionSupportsStructuredLogs.Value, entry);
                    }
                }
                finally
                {
                    if (moveNextTask is not null)
                    {
                        // Forwarding can fail while the stream read is still pending. Cancel and
                        // settle that read before DisposeAsync touches the enumerator.
                        await enumerationCancellationSource.CancelAsync().ConfigureAwait(false);
                        try
                        {
                            await moveNextTask.ConfigureAwait(false);
                        }
                        catch (Exception) when (enumerationCancellationSource.IsCancellationRequested)
                        {
                        }
                    }
                }
            }
            catch (Exception ex) when (AppHostFollowDisconnectHelpers.IsExpectedDisconnect(ex))
            {
                // The AppHost process exited and the backchannel connection was lost. This is
                // expected during orderly shutdown, but buffered records still need to be flushed.
            }
 
            if (!cancellationToken.IsCancellationRequested && extensionInteractionService is not null && pendingExtensionEntries.Count > 0)
            {
                extensionSupportsStructuredLogs ??= await structuredLogSupportProbe.ConfigureAwait(false);
                ForwardPendingAppHostLogEntriesToExtension(
                    extensionInteractionService,
                    extensionSupportsStructuredLogs.Value,
                    pendingExtensionEntries);
            }
        }
        catch (OperationCanceledException)
        {
            // Swallow the exception if the operation was cancelled.
            return;
        }
        catch (Exception ex) when (AppHostFollowDisconnectHelpers.IsExpectedDisconnect(ex))
        {
            // The AppHost process exited and the backchannel connection was lost. This is
            // expected during orderly shutdown — the connection drops before the cancellation
            // token fires because logCaptureCancellationSource.Cancel() runs in the finally
            // block after the AppHost process has already exited.
            return;
        }
    }
 
    private static void ForwardPendingAppHostLogEntriesToExtension(
        IExtensionInteractionService extensionInteractionService,
        bool extensionSupportsStructuredLogs,
        List<BackchannelLogEntry> pendingExtensionEntries)
    {
        foreach (var pendingEntry in pendingExtensionEntries)
        {
            ForwardAppHostLogEntryToExtension(extensionInteractionService, extensionSupportsStructuredLogs, pendingEntry);
        }
 
        pendingExtensionEntries.Clear();
    }
 
    private static void ForwardAppHostLogEntryToExtension(
        IExtensionInteractionService extensionInteractionService,
        bool extensionSupportsStructuredLogs,
        BackchannelLogEntry entry)
    {
        // Older AppHosts deserialize the added sequence as 0. Only numbered records have
        // the identity needed to suppress reconnect replays safely.
        if (extensionSupportsStructuredLogs && entry.SequenceNumber > 0)
        {
            extensionInteractionService.WriteAppHostLogEntry(new ExtensionAppHostLogEntry
            {
                GenerationId = entry.GenerationId,
                SequenceNumber = entry.SequenceNumber,
                LogLevel = entry.LogLevel.ToString(),
                Message = entry.Message,
                CategoryName = entry.CategoryName,
                EventId = entry.EventId.Id,
                Exception = entry.Exception,
            });
        }
        else
        {
            // Older extensions only accept plain debug-session messages.
            extensionInteractionService.WriteDebugSessionMessage(entry.Message, entry.LogLevel is not LogLevel.Error and not LogLevel.Critical, "\x1b[2m");
        }
    }
 
    private static async Task<bool> SupportsStructuredAppHostLogsAsync(
        FileLoggerProvider fileLoggerProvider,
        IExtensionBackchannel extensionBackchannel,
        CancellationToken cancellationToken)
    {
        try
        {
            return await extensionBackchannel.HasCapabilityAsync(KnownCapabilities.AppHostLogOutput, cancellationToken)
                .WaitAsync(s_structuredLogSupportProbeTimeout, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (Exception ex)
        {
            fileLoggerProvider.WriteLog(
                DateTimeOffset.UtcNow,
                LogLevel.Debug,
                "Aspire.Cli",
                "Structured AppHost log capability probe failed; using legacy debug console output.",
                ex);
            return false;
        }
    }
 
    private readonly Dictionary<string, RpcResourceState> _resourceStates = new();
 
    public void ProcessResourceState(RpcResourceState resourceState, Action<string, string> endpointWriter)
    {
        if (_resourceStates.TryGetValue(resourceState.Resource, out var existingResourceState))
        {
            if (resourceState.Endpoints.Except(existingResourceState.Endpoints) is { } endpoints && endpoints.Any())
            {
                foreach (var endpoint in endpoints)
                {
                    endpointWriter(resourceState.Resource, endpoint);
                }
            }
 
            _resourceStates[resourceState.Resource] = resourceState;
        }
        else
        {
            if (resourceState.Endpoints is { } endpoints && endpoints.Any())
            {
                foreach (var endpoint in endpoints)
                {
                    endpointWriter(resourceState.Resource, endpoint);
                }
            }
 
            _resourceStates[resourceState.Resource] = resourceState;
        }
    }
 
    /// <summary>
    /// Executes the run command in detached mode by spawning a child CLI process.
    /// The parent waits for the auxiliary backchannel to become available, displays a summary, then exits
    /// while the child continues running.
    /// </summary>
    /// <remarks>
    /// <para><b>Failure Modes:</b></para>
    /// <list type="number">
    /// <item><b>Project not found</b>: No AppHost project found in the current directory or specified path.
    /// Returns <see cref="CliExitCodes.FailedToFindProject"/>.</item>
    /// <item><b>Failed to spawn child process</b>: Process.Start fails (e.g., executable not found).
    /// Returns <see cref="CliExitCodes.FailedToDotnetRunAppHost"/>.</item>
    /// <item><b>Child process exits early</b>: The child 'aspire run' process exits before the backchannel
    /// is established (e.g., build failure, configuration error). Detected via WaitForExitAsync racing
    /// with the poll delay. Shows exit code and log file path.
    /// Returns <see cref="CliExitCodes.FailedToDotnetRunAppHost"/>.</item>
    /// <item><b>Timeout waiting for backchannel</b>: The auxiliary backchannel socket doesn't appear
    /// within the configured startup timeout. The child process is killed. Shows timeout message and log file path.
    /// Returns <see cref="CliExitCodes.FailedToDotnetRunAppHost"/>.</item>
    /// </list>
    /// <para>On any failure, the log file path is displayed so the user can investigate.</para>
    /// </remarks>
    private Task<CommandResult> ExecuteDetachedAsync(ParseResult parseResult, FileInfo? passedAppHostProjectFile, bool isExtensionHost, int timeoutSeconds, CancellationToken cancellationToken)
    {
        var format = parseResult.GetValue(AppHostLauncher.s_formatOption);
        var isolated = AppHostLauncher.GetExplicitIsolated(parseResult);
        var noBuild = parseResult.GetValue(s_noBuildOption);
        var launchProfile = parseResult.GetValue(AppHostLauncher.s_launchProfileOption);
        var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption);
        var globalArgs = RootCommand.GetChildProcessArgs(parseResult);
        var appHostArgs = parseResult.UnmatchedTokens;
        var additionalArgs = new List<string>();
        var captureProfile = parseResult.GetValue(RootCommand.CaptureProfileOption);
        var stopAfterLaunchDelay = captureProfile
            ? TimeSpan.FromSeconds(parseResult.GetValue(RootCommand.CaptureProfileDelayOption))
            : (TimeSpan?)null;
 
        if (noBuild)
        {
            additionalArgs.Add("--no-build");
        }
 
        if (!string.IsNullOrEmpty(launchProfile))
        {
            additionalArgs.Add($"{AppHostLauncher.s_launchProfileOption.Name}={launchProfile}");
        }
 
        if (appHostArgs.Count > 0)
        {
            additionalArgs.Add("--");
            additionalArgs.AddRange(appHostArgs);
        }
 
        return _appHostLauncher.LaunchDetachedAsync(
            passedAppHostProjectFile,
            format,
            isolated,
            launchProfile,
            isExtensionHost,
            waitForDebugger,
            timeoutSeconds,
            globalArgs,
            additionalArgs,
            stopAfterLaunchDelay,
            cancellationToken);
    }
 
    private TimeSpan GetRemainingStartupTimeout(long startupStartTimestamp, TimeSpan startupTimeout)
    {
        var elapsed = _timeProvider.GetElapsedTime(startupStartTimestamp);
        return elapsed >= startupTimeout ? TimeSpan.Zero : startupTimeout - elapsed;
    }
 
    private async Task CancelAppHostStartupAsync(CancellationTokenSource runCancellationTokenSource, Task<int> pendingRun, CancellationToken cancellationToken)
    {
        runCancellationTokenSource.Cancel();
 
        try
        {
            // The timeout is a safety net for the startup-timeout path (no Ctrl+C). When the user
            // presses Ctrl+C, cancellationToken fires and WaitAsync exits immediately via the token
            // rather than waiting for the full timeout duration.
            await pendingRun.WaitAsync(s_appHostStartupCancellationTimeout, _timeProvider, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (runCancellationTokenSource.IsCancellationRequested || cancellationToken.IsCancellationRequested)
        {
        }
        catch (TimeoutException ex)
        {
            _logger.LogDebug(ex, "Timed out waiting for AppHost startup cancellation to complete.");
            _ = ObserveAppHostRunFailureAsync(pendingRun);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "AppHost run failed after startup cancellation.");
        }
    }
 
    private async Task ObserveAppHostRunFailureAsync(Task<int> pendingRun)
    {
        try
        {
            await pendingRun.ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "AppHost run failed after startup cancellation timeout.");
        }
    }
 
    private static CommandResult CreateStartupTimeoutResult(int timeoutSeconds)
    {
        return CommandResult.Failure(
            CliExitCodes.FailedToDotnetRunAppHost,
            string.Format(CultureInfo.CurrentCulture, RunCommandStrings.TimeoutWaitingForAppHost, timeoutSeconds, CliConfigNames.AppHostStartupTimeout));
    }
 
}