File: Backchannel\AppHostConnectionResolver.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.Diagnostics.CodeAnalysis;
using Aspire.Cli.Interaction;
using Aspire.Cli.Utils;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Backchannel;
 
/// <summary>
/// Result of resolving an AppHost connection.
/// </summary>
internal sealed class AppHostConnectionResult
{
    public AppHostAuxiliaryBackchannel? Connection { get; init; }
 
    [MemberNotNullWhen(true, nameof(Connection))]
    public bool Success => Connection is not null;
 
    public string? ErrorMessage { get; init; }
}
 
/// <summary>
/// Helper for resolving connections to running AppHosts.
/// Used by commands that need to connect to a running AppHost (stop, resources, logs, etc.).
/// </summary>
internal sealed class AppHostConnectionResolver(
    IAuxiliaryBackchannelMonitor backchannelMonitor,
    IInteractionService interactionService,
    CliExecutionContext executionContext,
    ILogger logger)
{
    /// <summary>
    /// Resolves an AppHost connection using socket-first discovery.
    /// </summary>
    /// <param name="projectFile">Optional project file. If specified, uses fast path to find matching socket.</param>
    /// <param name="scanningMessage">Message to display while scanning for AppHosts.</param>
    /// <param name="selectPrompt">Prompt to display when multiple AppHosts are found.</param>
    /// <param name="noInScopeMessage">Message to display when no in-scope AppHosts are found but others exist.</param>
    /// <param name="notFoundMessage">Message to display when no AppHosts are found.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The resolved connection, or null with an error message.</returns>
    public async Task<AppHostConnectionResult> ResolveConnectionAsync(
        FileInfo? projectFile,
        string scanningMessage,
        string selectPrompt,
        string noInScopeMessage,
        string notFoundMessage,
        CancellationToken cancellationToken)
    {
        // Fast path: If --project was specified, check directly for its socket
        if (projectFile is not null)
        {
            var targetPath = projectFile.FullName;
            var matchingSockets = AppHostHelper.FindMatchingSockets(
                targetPath,
                executionContext.HomeDirectory.FullName);
 
            // Try each matching socket until we get a connection
            foreach (var socketPath in matchingSockets)
            {
                try
                {
                    var connection = await AppHostAuxiliaryBackchannel.ConnectAsync(
                        socketPath, logger, cancellationToken).ConfigureAwait(false);
                    if (connection is not null)
                    {
                        return new AppHostConnectionResult { Connection = connection };
                    }
                }
                catch (Exception ex)
                {
                    logger.LogDebug(ex, "Failed to connect to socket at {SocketPath}", socketPath);
                }
            }
 
            return new AppHostConnectionResult { ErrorMessage = notFoundMessage };
        }
 
        // Socket-first approach: Scan for running AppHosts via their sockets
        // This is fast because it only looks at ~/.aspire/backchannels/ directory
        // rather than recursively searching the entire directory tree for project files
        var connections = await interactionService.ShowStatusAsync(
            scanningMessage,
            async () =>
            {
                await backchannelMonitor.ScanAsync(cancellationToken).ConfigureAwait(false);
                return backchannelMonitor.Connections.ToList();
            });
 
        if (connections.Count == 0)
        {
            return new AppHostConnectionResult { ErrorMessage = notFoundMessage };
        }
 
        // Filter to in-scope AppHosts (within working directory)
        var workingDirectory = executionContext.WorkingDirectory.FullName;
        var inScopeConnections = connections.Where(c => c.IsInScope).ToList();
        var outOfScopeConnections = connections.Where(c => !c.IsInScope).ToList();
 
        AppHostAuxiliaryBackchannel? selectedConnection = null;
 
        if (inScopeConnections.Count == 1)
        {
            // Only one in-scope AppHost, use it
            selectedConnection = inScopeConnections[0];
        }
        else if (inScopeConnections.Count > 1)
        {
            // Multiple in-scope AppHosts running, prompt for selection
            // Order by most recently started first
            var choices = inScopeConnections
                .OrderByDescending(c => c.AppHostInfo?.StartedAt ?? DateTimeOffset.MinValue)
                .Select(c =>
                {
                    var appHostPath = c.AppHostInfo?.AppHostPath ?? "Unknown";
                    var relativePath = Path.GetRelativePath(workingDirectory, appHostPath);
                    return (Display: relativePath, Connection: c);
                })
                .ToList();
 
            var selectedDisplay = await interactionService.PromptForSelectionAsync(
                selectPrompt,
                choices.Select(c => c.Display).ToArray(),
                c => c,
                cancellationToken);
 
            selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
        }
        else if (outOfScopeConnections.Count > 0)
        {
            // No in-scope AppHosts, but there are out-of-scope ones - let user pick
            interactionService.DisplayMessage("information", noInScopeMessage);
 
            // Order by most recently started first
            var choices = outOfScopeConnections
                .OrderByDescending(c => c.AppHostInfo?.StartedAt ?? DateTimeOffset.MinValue)
                .Select(c =>
                {
                    var path = c.AppHostInfo?.AppHostPath ?? "Unknown";
                    return (Display: path, Connection: c);
                })
                .ToList();
 
            var selectedDisplay = await interactionService.PromptForSelectionAsync(
                selectPrompt,
                choices.Select(c => c.Display).ToArray(),
                c => c,
                cancellationToken);
 
            selectedConnection = choices.FirstOrDefault(c => c.Display == selectedDisplay).Connection;
        }
 
        if (selectedConnection is null)
        {
            return new AppHostConnectionResult { ErrorMessage = notFoundMessage };
        }
 
        return new AppHostConnectionResult { Connection = selectedConnection };
    }
}