File: Backchannel\AppHostAuxiliaryBackchannel.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.Net.Sockets;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
 
namespace Aspire.Cli.Backchannel;
 
/// <summary>
/// Represents a connection to an AppHost instance via the auxiliary backchannel.
/// Encapsulates connection management and RPC method calls.
/// </summary>
internal sealed class AppHostAuxiliaryBackchannel : IDisposable
{
    private readonly ILogger? _logger;
    private JsonRpc? _rpc;
    private bool _disposed;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AppHostAuxiliaryBackchannel"/> class
    /// for an existing connection.
    /// </summary>
    /// <param name="hash">The hash identifier for this AppHost instance.</param>
    /// <param name="socketPath">The socket path for this connection.</param>
    /// <param name="rpc">The JSON-RPC proxy for communicating with the AppHost.</param>
    /// <param name="mcpInfo">The MCP connection information for the Dashboard.</param>
    /// <param name="appHostInfo">The AppHost information.</param>
    /// <param name="isInScope">Whether this AppHost is within the scope of the MCP server's working directory.</param>
    /// <param name="logger">Optional logger for diagnostic messages.</param>
    public AppHostAuxiliaryBackchannel(
        string hash,
        string socketPath,
        JsonRpc rpc,
        DashboardMcpConnectionInfo? mcpInfo,
        AppHostInformation? appHostInfo,
        bool isInScope,
        ILogger? logger = null)
    {
        Hash = hash;
        SocketPath = socketPath;
        _rpc = rpc;
        McpInfo = mcpInfo;
        AppHostInfo = appHostInfo;
        IsInScope = isInScope;
        ConnectedAt = DateTimeOffset.UtcNow;
        _logger = logger;
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="AppHostAuxiliaryBackchannel"/> class
    /// for a new connection that needs to be established.
    /// </summary>
    /// <param name="socketPath">The socket path to connect to.</param>
    /// <param name="logger">Optional logger for diagnostic messages.</param>
    private AppHostAuxiliaryBackchannel(string socketPath, ILogger? logger = null)
    {
        SocketPath = socketPath;
        Hash = string.Empty;
        ConnectedAt = DateTimeOffset.UtcNow;
        _logger = logger;
    }
 
    /// <summary>
    /// Gets the hash identifier for this AppHost instance.
    /// </summary>
    public string Hash { get; private set; }
 
    /// <summary>
    /// Gets the socket path for this connection.
    /// </summary>
    public string SocketPath { get; }
 
    /// <summary>
    /// Gets the MCP connection information for the Dashboard.
    /// </summary>
    public DashboardMcpConnectionInfo? McpInfo { get; private set; }
 
    /// <summary>
    /// Gets the AppHost information.
    /// </summary>
    public AppHostInformation? AppHostInfo { get; private set; }
 
    /// <summary>
    /// Gets a value indicating whether this AppHost is within the scope of the MCP server's working directory.
    /// </summary>
    public bool IsInScope { get; private set; }
 
    /// <summary>
    /// Gets the timestamp when this connection was established.
    /// </summary>
    public DateTimeOffset ConnectedAt { get; }
 
    /// <summary>
    /// Gets the JSON-RPC proxy for communicating with the AppHost.
    /// </summary>
    internal JsonRpc? Rpc => _rpc;
 
    /// <summary>
    /// Creates and connects a new auxiliary backchannel to the specified socket path.
    /// </summary>
    /// <param name="socketPath">The path to the Unix domain socket.</param>
    /// <param name="logger">Optional logger for diagnostic messages.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A connected AppHostAuxiliaryBackchannel instance.</returns>
    public static async Task<AppHostAuxiliaryBackchannel> ConnectAsync(
        string socketPath,
        ILogger? logger = null,
        CancellationToken cancellationToken = default)
    {
        var backchannel = new AppHostAuxiliaryBackchannel(socketPath, logger);
        await backchannel.ConnectInternalAsync(cancellationToken).ConfigureAwait(false);
        return backchannel;
    }
 
    private async Task ConnectInternalAsync(CancellationToken cancellationToken)
    {
        _logger?.LogDebug("Connecting to auxiliary backchannel at {SocketPath}", SocketPath);
 
        // Connect to the Unix socket
        var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
        var endpoint = new UnixDomainSocketEndPoint(SocketPath);
        
        await socket.ConnectAsync(endpoint, cancellationToken).ConfigureAwait(false);
 
        // Create JSON-RPC connection with proper formatter
        var stream = new NetworkStream(socket, ownsSocket: true);
        _rpc = new JsonRpc(new HeaderDelimitedMessageHandler(stream, stream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter()));
        _rpc.StartListening();
 
        _logger?.LogDebug("Connected to auxiliary backchannel at {SocketPath}", SocketPath);
 
        // Get the AppHost information
        AppHostInfo = await GetAppHostInformationAsync(cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Gets the AppHost information including process IDs and path.
    /// </summary>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The AppHost information, or null if unavailable.</returns>
    public async Task<AppHostInformation?> GetAppHostInformationAsync(CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_rpc is null)
        {
            throw new InvalidOperationException("Not connected to auxiliary backchannel.");
        }
 
        _logger?.LogDebug("Requesting AppHost information");
 
        var appHostInfo = await _rpc.InvokeWithCancellationAsync<AppHostInformation?>(
            "GetAppHostInformationAsync",
            [],
            cancellationToken).ConfigureAwait(false);
 
        return appHostInfo;
    }
 
    /// <summary>
    /// Requests the AppHost to stop gracefully.
    /// </summary>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A task that completes when the stop request has been sent.</returns>
    public async Task StopAppHostAsync(CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_rpc is null)
        {
            throw new InvalidOperationException("Not connected to auxiliary backchannel.");
        }
 
        _logger?.LogDebug("Requesting AppHost to stop");
 
        try
        {
            await _rpc.InvokeWithCancellationAsync(
                "StopAppHostAsync",
                [],
                cancellationToken).ConfigureAwait(false);
 
            _logger?.LogDebug("Stop request sent to AppHost");
        }
        catch (RemoteMethodNotFoundException ex)
        {
            // The RPC method may not be available on older AppHost versions.
            // This is a point-in-time fix - log the error but don't fail.
            _logger?.LogDebug(ex, "StopAppHostAsync RPC method not available on the remote AppHost. The AppHost may be running an older version.");
        }
    }
 
    /// <summary>
    /// Gets the Dashboard MCP connection information.
    /// </summary>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The MCP connection information, or null if unavailable.</returns>
    public async Task<DashboardMcpConnectionInfo?> GetDashboardMcpConnectionInfoAsync(CancellationToken cancellationToken = default)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);
        if (_rpc is null)
        {
            throw new InvalidOperationException("Not connected to auxiliary backchannel.");
        }
 
        _logger?.LogDebug("Requesting Dashboard MCP connection info");
 
        var mcpInfo = await _rpc.InvokeWithCancellationAsync<DashboardMcpConnectionInfo?>(
            "GetDashboardMcpConnectionInfoAsync",
            [],
            cancellationToken).ConfigureAwait(false);
 
        return mcpInfo;
    }
 
    /// <summary>
    /// Disposes the auxiliary backchannel connection.
    /// </summary>
    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }
 
        _disposed = true;
        _rpc?.Dispose();
        _rpc = null;
    }
}