File: Commands\AppHostLauncher.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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 Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Processes;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
using Spectre.Console;
 
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,
    CliExecutionContext executionContext,
    IFeatures features,
    IInteractionService interactionService,
    IAnsiConsole ansiConsole,
    IAuxiliaryBackchannelMonitor backchannelMonitor,
    ILogger<AppHostLauncher> logger,
    TimeProvider timeProvider)
{
 
    /// <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>
    /// 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);
    }
 
    /// <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">Whether to run in isolated mode.</param>
    /// <param name="isExtensionHost">Whether running inside VS Code extension.</param>
    /// <param name="globalArgs">Global CLI args to forward to child process.</param>
    /// <param name="additionalArgs">Additional unmatched args to forward.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Exit code indicating success or failure.</returns>
    public async Task<int> LaunchDetachedAsync(
        FileInfo? passedAppHostProjectFile,
        OutputFormat? format,
        bool isolated,
        bool isExtensionHost,
        IEnumerable<string> globalArgs,
        IEnumerable<string> additionalArgs,
        CancellationToken cancellationToken)
    {
        // In JSON mode, avoid interactive prompts to keep stdout parseable.
        var multipleAppHostBehavior = format == OutputFormat.Json
            ? MultipleAppHostProjectsFoundBehavior.Throw
            : MultipleAppHostProjectsFoundBehavior.Prompt;
 
        // Failure mode 1: Project not found
        var searchResult = await projectLocator.UseOrFindAppHostProjectFileAsync(
            passedAppHostProjectFile,
            multipleAppHostBehavior,
            createSettingsFile: false,
            cancellationToken);
 
        var effectiveAppHostFile = searchResult.SelectedProjectFile;
 
        if (effectiveAppHostFile is null)
        {
            return ExitCodeConstants.FailedToFindProject;
        }
 
        logger.LogDebug("Starting AppHost in background: {AppHostPath}", effectiveAppHostFile.FullName);
 
        // 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);
        var (executablePath, childArgs) = BuildChildProcessArgs(effectiveAppHostFile, childLogFile, isolated, globalArgs, additionalArgs);
 
        // Compute the expected socket prefix for backchannel detection
        var expectedSocketPrefix = AppHostHelper.ComputeAuxiliarySocketPrefix(
            effectiveAppHostFile.FullName,
            executionContext.HomeDirectory.FullName);
        var expectedHash = AppHostHelper.ExtractHashFromSocketPath(expectedSocketPrefix)!;
 
        logger.LogDebug("Waiting for socket with prefix: {SocketPrefix}, Hash: {Hash}", expectedSocketPrefix, expectedHash);
 
        // Start the child process and wait for the backchannel
        var launchResult = await LaunchAndWaitForBackchannelAsync(executablePath, childArgs, expectedHash, cancellationToken);
 
        // Handle failure cases
        if (launchResult.Backchannel is null || launchResult.ChildProcess is null)
        {
            return HandleLaunchFailure(launchResult, childLogFile);
        }
 
        // Display results
        await DisplayLaunchResultAsync(launchResult, effectiveAppHostFile, childLogFile, format, isExtensionHost, cancellationToken);
 
        return ExitCodeConstants.Success;
    }
 
    private async Task StopExistingInstancesAsync(FileInfo effectiveAppHostFile, CancellationToken cancellationToken)
    {
        var runningInstanceDetectionEnabled = features.IsFeatureEnabled(KnownFeatures.RunningInstanceDetectionEnabled, defaultValue: true);
        var existingSockets = AppHostHelper.FindMatchingSockets(
            effectiveAppHostFile.FullName,
            executionContext.HomeDirectory.FullName);
 
        if (runningInstanceDetectionEnabled && 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);
            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)
        {
            args.Add(s_isolatedOption.Name);
        }
 
        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);
 
        logger.LogDebug("Spawning child CLI: {Executable} (isDotnetHost={IsDotnetHost}) with args: {Args}",
            dotnetPath, isDotnetHost, string.Join(" ", childArgs));
        logger.LogDebug("Working directory: {WorkingDirectory}", executionContext.WorkingDirectory.FullName);
 
        return (dotnetPath, childArgs);
    }
 
    private record LaunchResult(Process? ChildProcess, IAppHostAuxiliaryBackchannel? Backchannel, bool ChildExitedEarly, int ChildExitCode);
 
    private async Task<LaunchResult> LaunchAndWaitForBackchannelAsync(
        string executablePath,
        List<string> childArgs,
        string expectedHash,
        CancellationToken cancellationToken)
    {
        Process? childProcess = null;
        var childExitedEarly = false;
        var childExitCode = 0;
 
        async Task<IAppHostAuxiliaryBackchannel?> WaitForBackchannelAsync()
        {
            try
            {
                childProcess = DetachedProcessLauncher.Start(
                    executablePath,
                    childArgs,
                    executionContext.WorkingDirectory.FullName);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Failed to start child CLI process");
                return null;
            }
 
            logger.LogDebug("Child CLI process started with PID: {PID}", childProcess.Id);
 
            var startTime = timeProvider.GetUtcNow();
            var timeout = TimeSpan.FromSeconds(120);
 
            while (timeProvider.GetUtcNow() - startTime < timeout)
            {
                cancellationToken.ThrowIfCancellationRequested();
 
                if (childProcess.HasExited)
                {
                    childExitedEarly = true;
                    childExitCode = childProcess.ExitCode;
                    logger.LogWarning("Child CLI process exited with code {ExitCode}", childExitCode);
                    return null;
                }
 
                await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
 
                var connection = backchannelMonitor.GetConnectionsByHash(expectedHash).FirstOrDefault();
                if (connection is not null)
                {
                    return connection;
                }
 
                try
                {
                    await childProcess.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false);
                }
                catch (TimeoutException)
                {
                    // Expected - the 500ms delay elapsed without the process exiting
                }
            }
 
            return null;
        }
 
        var backchannel = await interactionService.ShowStatusAsync(
            RunCommandStrings.StartingAppHostInBackground,
            WaitForBackchannelAsync);
 
        return new LaunchResult(childProcess, backchannel, childExitedEarly, childExitCode);
    }
 
    private int HandleLaunchFailure(LaunchResult result, string childLogFile)
    {
        if (result.ChildProcess is null)
        {
            interactionService.DisplayError(RunCommandStrings.FailedToStartAppHost);
            return ExitCodeConstants.FailedToDotnetRunAppHost;
        }
 
        if (result.ChildExitedEarly)
        {
            interactionService.DisplayError(GetDetachedFailureMessage(result.ChildExitCode));
        }
        else
        {
            interactionService.DisplayError(RunCommandStrings.TimeoutWaitingForAppHost);
 
            if (!result.ChildProcess.HasExited)
            {
                try
                {
                    result.ChildProcess.Kill();
                }
                catch
                {
                    // Ignore errors when killing
                }
            }
        }
 
        interactionService.DisplayMessage("magnifying_glass_tilted_right", string.Format(
            CultureInfo.CurrentCulture,
            RunCommandStrings.CheckLogsForDetails,
            childLogFile));
 
        return ExitCodeConstants.FailedToDotnetRunAppHost;
    }
 
    private async Task DisplayLaunchResultAsync(
        LaunchResult result,
        FileInfo effectiveAppHostFile,
        string childLogFile,
        OutputFormat? format,
        bool isExtensionHost,
        CancellationToken cancellationToken)
    {
        var appHostInfo = result.Backchannel!.AppHostInfo;
        var dashboardUrls = await result.Backchannel.GetDashboardUrlsAsync(cancellationToken).ConfigureAwait(false);
        var pid = appHostInfo?.ProcessId ?? result.ChildProcess!.Id;
 
        if (format == OutputFormat.Json)
        {
            var jsonResult = new DetachOutputInfo(
                effectiveAppHostFile.FullName,
                pid,
                result.ChildProcess!.Id,
                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(
                ansiConsole,
                appHostRelativePath,
                dashboardUrls?.BaseUrlWithLoginToken,
                codespacesUrl: null,
                childLogFile,
                isExtensionHost,
                pid);
            ansiConsole.WriteLine();
 
            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
        {
            ExitCodeConstants.FailedToBuildArtifacts => RunCommandStrings.AppHostFailedToBuild,
            _ => string.Format(CultureInfo.CurrentCulture, RunCommandStrings.AppHostExitedWithCode, childExitCode)
        };
    }
 
    /// <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);
    }
}