|
// 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);
}
}
|