File: Backchannel\TerminalHostControlClient.cs
Web Access
Project: src\src\Aspire.Hosting\Aspire.Hosting.csproj (Aspire.Hosting)
// 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 Aspire.Shared.TerminalHost;
using StreamJsonRpc;
 
namespace Aspire.Hosting.Backchannel;
 
/// <summary>
/// AppHost-side client for a single terminal host process's control UDS. Opens a fresh
/// JsonRpc connection per call, invokes the requested method, and disposes the connection.
/// </summary>
/// <remarks>
/// <para>
/// Each <c>aspire.terminalhost</c> process serves one parent replica and exposes a single-session
/// control surface. To enumerate the full set of replicas for a target resource, the AppHost
/// fans out across each per-replica host's control UDS using this client.
/// </para>
/// <para>
/// Connection retries are bounded by the per-call <c>totalTimeout</c> with a short per-attempt
/// delay so that a control RPC issued shortly after the AppHost has asked DCP to launch the
/// host doesn't see a transient connection-refused error.
/// </para>
/// </remarks>
internal static class TerminalHostControlClient
{
    private static readonly TimeSpan s_defaultRetryDelay = TimeSpan.FromMilliseconds(150);
 
    /// <summary>
    /// Opens the control UDS at <paramref name="socketPath"/> and invokes
    /// <see cref="TerminalHostControlProtocol.GetSessionMethod"/>, returning the host's
    /// snapshot of its single session. Throws on transport failures and times out per
    /// <paramref name="totalTimeout"/>.
    /// </summary>
    public static async Task<TerminalHostSessionInfo> GetSessionAsync(
        string socketPath,
        TimeSpan totalTimeout,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(socketPath);
 
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(totalTimeout);
 
        using var rpc = await ConnectWithRetryAsync(socketPath, timeoutCts.Token).ConfigureAwait(false);
 
        return await rpc.InvokeWithCancellationAsync<TerminalHostSessionInfo>(
            TerminalHostControlProtocol.GetSessionMethod,
            arguments: null,
            timeoutCts.Token).ConfigureAwait(false);
    }
 
    private static async Task<JsonRpc> ConnectWithRetryAsync(string socketPath, CancellationToken cancellationToken)
    {
        // Bounded retry loop: keep trying to connect until the linked timeout fires.
        // The host's accept loop binds the UDS during StartAsync, but if the AppHost
        // races the host process startup we want to wait for it rather than fail-fast.
        SocketException? lastException = null;
        while (!cancellationToken.IsCancellationRequested)
        {
            var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            try
            {
                await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken).ConfigureAwait(false);
                var stream = new NetworkStream(socket, ownsSocket: true);
                var formatter = new SystemTextJsonFormatter();
                var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter);
                var rpc = new JsonRpc(handler);
                rpc.StartListening();
                return rpc;
            }
            catch (SocketException ex)
            {
                socket.Dispose();
                lastException = ex;
                try
                {
                    await Task.Delay(s_defaultRetryDelay, cancellationToken).ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                    break;
                }
            }
            catch
            {
                socket.Dispose();
                throw;
            }
        }
 
        cancellationToken.ThrowIfCancellationRequested();
        throw lastException ?? new SocketException();
    }
}