File: Commands\StopCommand.TrayProtocol.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.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Processes;
using Aspire.Cli.Resources;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands;
 
internal sealed partial class StopCommand
{
    private readonly IAuxiliaryBackchannelMonitor _backchannelMonitor;
    private readonly IProcessIdentityProvider _processIdentityProvider;
    private readonly TrayProtocolOutput _protocolOutput;
    private static readonly Option<int?> s_protocolVersionOption = new("--protocol-version") { Hidden = true };
    private static readonly Option<long?> s_startedAtOption = new("--started-at") { Hidden = true };
    private static readonly Option<OutputFormat> s_formatOption = new("--format") { Hidden = true };
 
    protected override bool IsJsonFormatRequested(ParseResult parseResult)
        => parseResult.GetValue(s_protocolVersionOption) is not null || base.IsJsonFormatRequested(parseResult);
 
    private async Task<CommandResult> ExecuteProtocolAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        TrayStopMessage response;
        try
        {
            response = await StopProtocolInstanceAsync(parseResult, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            response = StopResponse("stop_failed", CliExitCodes.Cancelled);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "The tray protocol stop request failed.");
            response = StopResponse("stop_failed", CliExitCodes.FailedToDotnetRunAppHost);
        }
 
        var json = JsonSerializer.Serialize(response, TrayCliJsonContext.Default.TrayStopMessage);
        // Cancellation is itself a typed stop failure; do not suppress its final response.
        await _protocolOutput.WriteLineAsync(json, CancellationToken.None).ConfigureAwait(false);
        return CommandResult.FromExitCode(response.ExitCode);
    }
 
    private async Task<TrayStopMessage> StopProtocolInstanceAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        var appHostFile = parseResult.GetValue(s_appHostOption.InnerOption);
        var appHostArgument = parseResult.GetResult(s_appHostOption.InnerOption)?.Tokens.SingleOrDefault()?.Value;
        var processId = parseResult.GetValue(s_pidOption);
        var startedAt = parseResult.GetValue(s_startedAtOption);
        if (parseResult.GetValue(s_protocolVersionOption) != TrayCliProtocol.Version ||
            parseResult.GetValue(s_formatOption) != OutputFormat.Json ||
            appHostFile is null || appHostArgument is null || !Path.IsPathFullyQualified(appHostArgument) ||
            processId is not > 0 || startedAt is not > 0 ||
            parseResult.GetResult(s_allOption) is { Implicit: false } ||
            parseResult.GetResult(s_forceOption) is { Implicit: false } ||
            parseResult.GetResult(s_volumesOption) is { Implicit: false })
        {
            InteractionService.DisplayError(StopCommandStrings.ProtocolRequiresExactIdentity);
            return StopResponse("invalid_request", CliExitCodes.InvalidCommand);
        }
 
        await _backchannelMonitor.ScanAsync(cancellationToken, pruneOrphanedSockets: false, throwOnDiscoveryFailure: true).ConfigureAwait(false);
        var matching = _backchannelMonitor.Connections
            .Where(connection => connection.AppHostInfo is { } info &&
                info.ProcessId == processId && GetAppHostPathComparer().Equals(info.AppHostPath, appHostFile.FullName))
            .ToArray();
        if (matching.Length == 0)
        {
            return StopResponse("not_found", CliExitCodes.FailedToFindProject);
        }
        if (matching.Length != 1)
        {
            return StopResponse("ambiguous", CliExitCodes.FailedToFindProject);
        }
 
        var connection = matching[0];
        var actualStartedAt = _processIdentityProvider.GetStartTimeUnixMilliseconds(processId.Value);
        if (actualStartedAt is not > 0)
        {
            return StopResponse("identity_unavailable", CliExitCodes.FailedToFindProject);
        }
        if (actualStartedAt != startedAt)
        {
            return StopResponse("identity_mismatch", CliExitCodes.FailedToFindProject);
        }
 
        // Both the lifetime check and subsequent exit observation refer to this selection.
        // Never reconnect or reuse project-wide resolution if the original RPC connection dies.
        var identity = new AppHostInformation
        {
            AppHostPath = appHostFile.FullName,
            ProcessId = processId.Value,
            StableStartedAt = DateTimeOffset.FromUnixTimeMilliseconds(actualStartedAt.Value)
        };
        var stopped = await _processShutdownService.StopAppHostByConnectionAsync(identity, connection.StopAppHostAsync, cancellationToken).ConfigureAwait(false);
        if (!stopped)
        {
            return StopResponse("stop_failed", CliExitCodes.FailedToDotnetRunAppHost);
        }
 
        connection.Socket.TryDelete();
        return StopResponse("stopped", CliExitCodes.Success);
    }
 
    private static TrayStopMessage StopResponse(string outcome, int exitCode) => new()
    {
        Version = TrayCliProtocol.Version,
        Outcome = outcome,
        ExitCode = exitCode
    };
}