File: ApplicationModel\ResourceAspireTerminal.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 Hex1b;
using Hex1b.Automation;
using Hex1b.Reflow;
using Microsoft.Extensions.Logging;
 
#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// The resource-backed implementation of <see cref="AspireTerminal"/>.
/// </summary>
/// <remarks>
/// <para>
/// Unlike an AppHost terminal, the workload here runs in a per-replica terminal host process and the AppHost
/// is only ever a peer of it. Automation is served by joining that host's consumer socket as an ordinary HMP1
/// client — the same socket the CLI's <c>terminal attach</c> and the dashboard's resource terminal view dial —
/// and running the standard automator against the resulting terminal. The screen is replicated to every peer,
/// so a client-side terminal is a faithful mirror of the producer's.
/// </para>
/// <para>
/// The connection is made on first use rather than at construction. Listing terminals must not cost a socket
/// connection per replica, and the connection shows up in the host's peer roster, so an idle handle that
/// nobody is automating should leave no trace.
/// </para>
/// <para>
/// The peer always joins as <see cref="Hmp1Role.Secondary"/>. Only the primary peer's dimensions drive the
/// producer's PTY, and a secondary is still fully interactive, so automation can read and type without
/// resizing the grid out from under a human who is watching the same terminal.
/// </para>
/// </remarks>
internal sealed class ResourceAspireTerminal : ITerminalBackend
{
    /// <summary>
    /// How long to wait for the HMP1 handshake before treating the terminal host as unreachable.
    /// </summary>
    /// <remarks>
    /// The socket is local, so a healthy host completes the handshake in milliseconds. This bound exists for
    /// the case where the host process is gone but its socket file has not been cleaned up, where a connect
    /// would otherwise hang an automation call indefinitely.
    /// </remarks>
    private static readonly TimeSpan s_connectTimeout = TimeSpan.FromSeconds(10);
 
    private readonly string _consumerUdsPath;
    private readonly ILogger _logger;
    private readonly CancellationTokenSource _disposalCts = new();
    private readonly object _gate = new();
 
    private ConnectionAttempt? _connection;
    private Task? _disposeTask;
    private bool _disposed;
 
    public ResourceAspireTerminal(string id, string title, string consumerUdsPath, ILogger logger)
    {
        Id = id;
        Title = title;
        _consumerUdsPath = consumerUdsPath;
        _logger = logger;
        Handle = new(this);
    }
 
    // Repeated catalog lookups share this handle for as long as its cached automation peer is live.
    public AspireTerminal Handle { get; }
 
    public string Id { get; }
 
    public string Title { get; }
 
    public TerminalOwner Owner => TerminalOwner.Resource;
 
    public TerminalPlacement Placement => TerminalPlacement.ResourceView;
 
    internal bool IsDisposed
    {
        get
        {
            lock (_gate)
            {
                return _disposed;
            }
        }
    }
 
    /// <remarks>
    /// The workload is started by the resource it belongs to, so there is nothing for the AppHost to start.
    /// </remarks>
    public void Start()
    {
    }
 
    /// <remarks>
    /// A resource terminal is displayed on its own resource's terminal view, which the dashboard navigates to
    /// directly. There is no dock tab for this to activate.
    /// </remarks>
    public void Show()
    {
    }
 
    public async Task SendTextAsync(string text, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(text);
 
        var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
        await TerminalAutomation.SendTextAsync(connection.Automator, text, cancellationToken).ConfigureAwait(false);
    }
 
    public async Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default)
    {
        var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
        await TerminalAutomation.SendKeyAsync(connection.Terminal, connection.Automator, key, cancellationToken).ConfigureAwait(false);
    }
 
    public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(text);
 
        var connection = await EnsureConnectedAsync(cancellationToken).ConfigureAwait(false);
        await TerminalAutomation.WaitForTextAsync(connection.Automator, Id, text, timeout, cancellationToken).ConfigureAwait(false);
    }
 
    public string GetScreenText()
    {
        Hex1bTerminalAutomator? automator;
        lock (_gate)
        {
            automator = _connection?.Automator;
        }
 
        return TerminalAutomation.GetScreenText(automator);
    }
 
    /// <summary>
    /// Shares a live connection, replacing failed or disconnected peers on a later automation call.
    /// </summary>
    private async Task<TerminalConnection> EnsureConnectedAsync(CancellationToken cancellationToken)
    {
        while (true)
        {
            cancellationToken.ThrowIfCancellationRequested();
 
            ConnectionAttempt connection;
            bool disconnected;
            lock (_gate)
            {
                ObjectDisposedException.ThrowIf(_disposed, this);
 
                if (_connection is null)
                {
                    _connection = new ConnectionAttempt(_disposalCts.Token);
                    _connection.Completion = RunConnectionAsync(_connection);
                }
 
                connection = _connection;
                disconnected = connection.Disconnected;
            }
 
            if (!disconnected)
            {
                // A caller's cancellation only abandons its wait, not the connection shared by other callers.
                // Never replay an automation command: a failed attempt is surfaced to its original caller.
                return await connection.Ready.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
            }
 
            // Finish releasing the old peer before opening another. Keeping it registered until cleanup ends
            // also lets DisposeAsync await every peer, including a connection that failed during startup.
            await connection.Completion.WaitAsync(cancellationToken).ConfigureAwait(false);
            lock (_gate)
            {
                if (ReferenceEquals(_connection, connection))
                {
                    _connection = null;
                }
            }
        }
    }
 
    private async Task RunConnectionAsync(ConnectionAttempt connection)
    {
        // Building and running Hex1b must not happen inline under _gate.
        await Task.Yield();
 
        var cancellationToken = connection.Cancellation.Token;
        var connected = new TaskCompletionSource<(int Width, int Height)>(TaskCreationOptions.RunContinuationsAsynchronously);
        Hex1bTerminal? terminal = null;
        Task? runTask = null;
        Exception? failure = null;
        var expectedCancellation = false;
 
        try
        {
            cancellationToken.ThrowIfCancellationRequested();
            terminal = Hex1bTerminal.CreateBuilder()
                // The AppHost has no controlling terminal. Headless suppresses local console I/O but still
                // maintains the replicated screen used by automation.
                .WithHeadless()
                .WithReflow(GhosttyReflowStrategy.Instance)
                .WithDimensions(80, 24)
                .WithHmp1UdsClient(_consumerUdsPath, options =>
                {
                    options.DisplayName = $"apphost-automation:{Id}";
                    options.DefaultRole = Hmp1Role.Secondary;
 
                    options.OnConnected = (e, _) =>
                    {
                        Resize(terminal, e.Width, e.Height);
                        connected.TrySetResult((e.Width, e.Height));
                        return Task.CompletedTask;
                    };
 
                    options.OnRemoteResized = (e, _) =>
                    {
                        Resize(terminal, e.Width, e.Height);
                        return Task.CompletedTask;
                    };
 
                    options.OnDisconnected = _ =>
                    {
                        MarkDisconnected(connection);
                        connection.Cancellation.Cancel();
                        return Task.CompletedTask;
                    };
                })
                .Build();
 
            _logger.LogDebug("Connecting AppHost automation to resource terminal {TerminalId} at '{ConsumerPath}'.", Id, _consumerUdsPath);
            cancellationToken.ThrowIfCancellationRequested();
            runTask = terminal.RunAsync(cancellationToken);
 
            // A missing socket faults the pump before the handshake. Observe either result so an immediate
            // transport failure is not reported as a ten-second handshake timeout.
            var completed = await Task.WhenAny(connected.Task, runTask).WaitAsync(s_connectTimeout, cancellationToken).ConfigureAwait(false);
            if (completed == runTask)
            {
                await runTask.ConfigureAwait(false);
                throw new InvalidOperationException(
                    $"The connection to the terminal host for terminal '{Id}' closed before the terminal was ready.");
            }
 
            var (width, height) = await connected.Task.ConfigureAwait(false);
            // TODO: Await initial state replay before exposing readiness once Hex1b provides a public barrier.
            // Handshake completion alone can leave the first cursor key using the mirror's default mode.
            // https://github.com/mitchdenny/hex1b/issues/551
            var automator = new Hex1bTerminalAutomator(terminal, TerminalAutomation.DefaultTimeout);
            lock (_gate)
            {
                ObjectDisposedException.ThrowIf(_disposed, this);
                cancellationToken.ThrowIfCancellationRequested();
                if (connection.Disconnected)
                {
                    throw new InvalidOperationException($"The terminal host for terminal '{Id}' disconnected during initialization.");
                }
 
                connection.Automator = automator;
                connection.Ready.TrySetResult(new TerminalConnection(terminal, automator));
            }
 
            _logger.LogDebug("Connected to resource terminal {TerminalId} ({Width}x{Height}).", Id, width, height);
            await runTask.ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            failure = ex;
            expectedCancellation = ex is OperationCanceledException && cancellationToken.IsCancellationRequested;
        }
        finally
        {
            MarkDisconnected(connection);
            await connection.Cancellation.CancelAsync().ConfigureAwait(false);
            if (runTask is not null)
            {
                try
                {
                    await runTask.ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    // Preserve the handshake failure if one already occurred; otherwise report the pump's
                    // failure below. In both cases the task is observed before disposing its terminal.
                    failure ??= ex;
                }
            }
 
            if (terminal is not null)
            {
                try
                {
                    await terminal.DisposeAsync().ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Disposing the automation peer for resource terminal {TerminalId} failed unexpectedly.", Id);
                    failure ??= ex;
                }
            }
 
            connection.Cancellation.Dispose();
        }
 
        if (expectedCancellation)
        {
            _logger.LogDebug("AppHost automation peer for resource terminal {TerminalId} stopped after disposal or a terminal host disconnect.", Id);
            connection.Ready.TrySetCanceled(cancellationToken);
        }
        else if (failure is not null)
        {
            if (failure is TimeoutException)
            {
                failure = new InvalidOperationException(
                    $"Timed out connecting to the terminal host for terminal '{Id}' at '{_consumerUdsPath}'. The resource replica may not be running.", failure);
            }
 
            if (connection.Ready.TrySetException(failure))
            {
                _logger.LogDebug(failure, "Connecting the AppHost automation peer to resource terminal {TerminalId} failed; a later call can retry.", Id);
            }
            else
            {
                _logger.LogWarning(failure, "AppHost automation peer for resource terminal {TerminalId} ended unexpectedly; a later call can reconnect.", Id);
            }
        }
 
        static void Resize(Hex1bTerminal? target, int width, int height)
            => target?.Resize(Math.Max(1, width), Math.Max(1, height));
    }
 
    private void MarkDisconnected(ConnectionAttempt connection)
    {
        lock (_gate)
        {
            connection.Disconnected = true;
            connection.Automator = null;
        }
    }
 
    /// <summary>
    /// Disconnects the AppHost's automation peer. The resource's workload is unaffected.
    /// </summary>
    public ValueTask DisposeAsync()
    {
        lock (_gate)
        {
            _disposed = true;
            if (_connection is { } connection)
            {
                connection.Automator = null;
            }
 
            return new ValueTask(_disposeTask ??= DisposeCoreAsync(_connection));
        }
    }
 
    private async Task DisposeCoreAsync(ConnectionAttempt? connection)
    {
        await Task.Yield();
        await _disposalCts.CancelAsync().ConfigureAwait(false);
 
        if (connection is not null)
        {
            await connection.Completion.ConfigureAwait(false);
        }
 
        _disposalCts.Dispose();
    }
 
    private sealed class ConnectionAttempt
    {
        public ConnectionAttempt(CancellationToken disposalToken)
        {
            Cancellation = CancellationTokenSource.CreateLinkedTokenSource(disposalToken);
            // Every caller may abandon its wait before connection fails. Observe that failure even when
            // nobody remains to await Ready; future calls still replace the failed attempt.
            _ = Ready.Task.ContinueWith(
                static task => _ = task.Exception,
                CancellationToken.None,
                TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default);
        }
 
        public CancellationTokenSource Cancellation { get; }
        public TaskCompletionSource<TerminalConnection> Ready { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
        public Task Completion { get; set; } = Task.CompletedTask;
        public Hex1bTerminalAutomator? Automator { get; set; }
        public bool Disconnected { get; set; }
    }
 
    private sealed record TerminalConnection(Hex1bTerminal Terminal, Hex1bTerminalAutomator Automator);
}