File: TerminalReplica.cs
Web Access
Project: src\src\Aspire.TerminalHost\Aspire.TerminalHost.csproj (Aspire.TerminalHost)
// 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;
using System.Net.Sockets;
using Aspire.Shared;
using Aspire.Shared.TerminalHost;
using Hex1b;
using Hex1b.Reflow;
using Microsoft.Extensions.Logging;
 
namespace Aspire.TerminalHost;
 
/// <summary>
/// The single replica relay session inside a terminal host process. Each
/// <c>aspire.terminalhost</c> process owns exactly one <see cref="TerminalReplica"/>
/// — replica fan-out happens at the process level, not inside the host. The
/// replica's identity (which parent-resource replica it serves) is encoded in
/// the UDS paths and is opaque to the host.
///
/// The replica owns a recycle loop that builds successive <see cref="Hex1bTerminal"/>
/// instances over the lifetime of the host process.
///
/// Each iteration of the loop:
/// <list type="number">
///   <item>Builds a fresh Hex1bTerminal that LISTENS on the producer UDS and
///         serves on the consumer UDS.</item>
///   <item>Waits for DCP (the upstream PTY owner) to DIAL the producer UDS.
///         When that connection is accepted, <see cref="ProducerConnected"/>
///         flips to <c>true</c>.</item>
///   <item>Forwards bytes between producer and any number of viewers (Dashboard,
///         CLI) via Hex1b's HMP v1 multiplexing.</item>
///   <item>When the producer disconnects (process exit, transport error, etc.)
///         the inner <see cref="Hex1bTerminal.RunAsync(CancellationToken)"/>
///         returns. The terminal is disposed (which releases the UDS bindings
///         and tears down any attached viewer sessions), <see cref="LastExitCode"/>
///         is updated, <see cref="RestartCount"/> is incremented, and the loop
///         iterates to bind the same UDS paths again — ready for DCP to relaunch
///         the underlying process and dial back in.</item>
/// </list>
///
/// Connection direction note: the producer side has the terminal host LISTENING
/// and DCP DIALING, not the other way around. This guarantees the host is
/// receiving from the very first byte the PTY emits, and also lets the host
/// recycle without DCP having to re-coordinate; DCP just dials the same path
/// again and Hex1b's existing connect-retry semantics on the producer side
/// take care of the brief unbound window during a recycle.
/// </summary>
internal sealed class TerminalReplica : IAsyncDisposable
{
    private readonly ILogger<TerminalReplica> _logger;
    private readonly ILogger<DcpUpstreamAdapter> _upstreamLogger;
    private readonly ILogger<Hmp1UdsServerListenerFilter> _consumerListenerLogger;
    private readonly Task _runTask;
    private readonly CancellationTokenSource _stopCts;
    private readonly object _gate = new();
    private readonly Dictionary<string, TerminalHostPeerInfo> _peers = new(StringComparer.Ordinal);
    private Hex1bTerminal? _currentTerminal;
    private bool _producerConnected;
    private int? _lastExitCode;
    private int _restartCount;
    private int _currentColumns;
    private int _currentRows;
    private bool _disposed;
 
    public string ProducerUdsPath { get; }
    public string ConsumerUdsPath { get; }
    public int Columns { get; }
    public int Rows { get; }
 
    /// <summary>
    /// True while the current Hex1bTerminal has an attached producer (DCP has
    /// dialed in and the upstream PTY is delivering bytes). False between
    /// recycles, before the first producer ever connects, or after the
    /// replica has been torn down.
    /// </summary>
    public bool ProducerConnected
    {
        get { lock (_gate) { return _producerConnected; } }
    }
 
    /// <summary>
    /// Exit code from the most recently-completed Hex1bTerminal cycle, or
    /// <c>null</c> if no cycle has completed yet. Updated each time the
    /// producer disconnects.
    /// </summary>
    public int? LastExitCode
    {
        get { lock (_gate) { return _lastExitCode; } }
    }
 
    /// <summary>
    /// Number of completed Hex1bTerminal cycles (i.e. number of times the
    /// producer has connected and then disconnected). Useful as a diagnostic
    /// signal for "has this resource restarted unexpectedly?".
    /// </summary>
    public int RestartCount
    {
        get { lock (_gate) { return _restartCount; } }
    }
 
    /// <summary>
    /// Backwards-compatible alias for callers that historically asked
    /// "is this replica running?". Today that question really means "is the
    /// upstream producer currently attached?", which is what we report.
    /// The replica object itself outlives any single Hex1bTerminal cycle.
    /// </summary>
    public bool IsAlive => ProducerConnected;
 
    /// <summary>
    /// Backwards-compatible alias for <see cref="LastExitCode"/>.
    /// </summary>
    public int? ExitCode => LastExitCode;
 
    /// <summary>
    /// Current terminal grid width in columns, as last negotiated by the active HMP1
    /// primary peer (via <c>OnResized</c>). Initialized from the AppHost-configured
    /// width and updated on every downstream resize.
    /// </summary>
    public int CurrentColumns
    {
        get { lock (_gate) { return _currentColumns; } }
    }
 
    /// <summary>
    /// Current terminal grid height in rows. See <see cref="CurrentColumns"/>.
    /// </summary>
    public int CurrentRows
    {
        get { lock (_gate) { return _currentRows; } }
    }
 
    /// <summary>
    /// Number of HMP1 viewer peers currently attached to the consumer UDS.
    /// Maintained from <c>OnClientConnected</c> / <c>OnClientDisconnected</c> callbacks.
    /// Zero between cycles or before the first peer connects.
    /// </summary>
    public int AttachedPeerCount
    {
        get { lock (_gate) { return _peers.Count; } }
    }
 
    /// <summary>
    /// Snapshot of currently-attached HMP1 viewer peers, in dictionary order.
    /// </summary>
    public TerminalHostPeerInfo[] SnapshotPeers()
    {
        lock (_gate)
        {
            if (_peers.Count == 0)
            {
                return Array.Empty<TerminalHostPeerInfo>();
            }
            var snap = new TerminalHostPeerInfo[_peers.Count];
            var i = 0;
            foreach (var peer in _peers.Values)
            {
                snap[i++] = peer;
            }
            return snap;
        }
    }
 
    /// <summary>
    /// Task that completes when the replica's recycle loop exits (i.e. when
    /// the host is shutting down or the replica is being disposed).
    /// </summary>
    public Task RunTask => _runTask;
 
    private TerminalReplica(
        string producerUdsPath,
        string consumerUdsPath,
        int columns,
        int rows,
        ILoggerFactory loggerFactory,
        CancellationToken cancellationToken)
    {
        ProducerUdsPath = producerUdsPath;
        ConsumerUdsPath = consumerUdsPath;
        Columns = columns;
        Rows = rows;
        _currentColumns = columns;
        _currentRows = rows;
        _logger = loggerFactory.CreateLogger<TerminalReplica>();
        _upstreamLogger = loggerFactory.CreateLogger<DcpUpstreamAdapter>();
        _consumerListenerLogger = loggerFactory.CreateLogger<Hmp1UdsServerListenerFilter>();
        _stopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        _runTask = Task.Run(() => RecycleLoopAsync(_stopCts.Token), _stopCts.Token);
    }
 
    /// <summary>
    /// Builds the relay terminal and starts its recycle loop. The loop runs
    /// in the background and only exits on cancellation or dispose.
    /// </summary>
    public static TerminalReplica Start(
        string producerUdsPath,
        string consumerUdsPath,
        int columns,
        int rows,
        ILoggerFactory loggerFactory,
        CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(producerUdsPath);
        ArgumentException.ThrowIfNullOrWhiteSpace(consumerUdsPath);
        ArgumentNullException.ThrowIfNull(loggerFactory);
 
        // Use the same category the constructor will use so the "starting" line shows up under
        // the same logger as every subsequent replica-scope event.
        loggerFactory.CreateLogger<TerminalReplica>().LogInformation(
            "Starting replica: producer='{Producer}', consumer='{Consumer}'.",
            producerUdsPath, consumerUdsPath);
 
        return new TerminalReplica(
            producerUdsPath, consumerUdsPath, columns, rows, loggerFactory, cancellationToken);
    }
 
    /// <summary>
    /// Top-level loop. One iteration = one Hex1bTerminal lifetime. The loop
    /// only exits on cancellation; producer disconnects are an expected
    /// recoverable transition that triggers an immediate rebind.
    /// </summary>
    private async Task RecycleLoopAsync(CancellationToken ct)
    {
        var consecutiveFailures = 0;
        while (!ct.IsCancellationRequested)
        {
            Hex1bTerminal? terminal = null;
            int exitCode;
            var failed = false;
 
            try
            {
                try
                {
                    terminal = BuildTerminal();
                }
                catch (Exception ex)
                {
                    // Building the Hex1bTerminal can fail for transient reasons
                    // (UDS path temporarily unwritable, transient I/O during
                    // disposal of the previous instance, etc.). Treat as a
                    // failed cycle and let the backoff handle it instead of
                    // letting the exception kill the recycle loop.
                    _logger.LogError(ex, "Replica BuildTerminal threw.");
                    exitCode = -1;
                    failed = true;
                    goto AfterRun;
                }
 
                lock (_gate)
                {
                    _currentTerminal = terminal;
                    _producerConnected = false;
                }
 
                _logger.LogInformation(
                    "Replica cycle starting (cycle #{RestartCount}); consumer endpoint at '{ConsumerUdsPath}'.",
                    _restartCount, ConsumerUdsPath);
 
                try
                {
                    exitCode = await terminal.RunAsync(ct).ConfigureAwait(false);
                }
                catch (OperationCanceledException) when (ct.IsCancellationRequested)
                {
                    // Host is shutting down. Exit the loop without recording a cycle.
                    return;
                }
                catch (Exception ex)
                {
                    _logger.LogError(ex, "Replica cycle threw.");
                    exitCode = -1;
                    failed = true;
                }
 
                AfterRun:;
            }
            finally
            {
                if (terminal is not null)
                {
                    try
                    {
                        await terminal.DisposeAsync().ConfigureAwait(false);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogDebug(ex, "Replica terminal dispose threw (ignored).");
                    }
                }
 
                lock (_gate)
                {
                    _currentTerminal = null;
                    _producerConnected = false;
                    // Hex1b's HMP1 server fires OnClientDisconnected for every peer when the
                    // terminal is disposed, so _peers should already be empty here. Clear
                    // defensively to avoid carrying stale entries into the next cycle if any
                    // disconnect callback raced past dispose.
                    _peers.Clear();
                }
            }
 
            // We got here because RunAsync returned (producer disconnected or
            // an error occurred), not because we were cancelled.
            lock (_gate)
            {
                _lastExitCode = exitCode;
                _restartCount++;
            }
 
            if (failed)
            {
                consecutiveFailures++;
                _logger.LogInformation(
                    "Replica cycle ended with failure (consecutive={Count}); will rebind.",
                    consecutiveFailures);
            }
            else
            {
                consecutiveFailures = 0;
                _logger.LogInformation(
                    "Replica producer disconnected (exit code {ExitCode}); rebinding for next producer.",
                    exitCode);
            }
 
            if (ct.IsCancellationRequested)
            {
                return;
            }
 
            // Backoff: stay snappy on a clean producer-disconnect (typical case
            // when the resource is being restarted) but escalate when the cycle
            // keeps failing fast, so a wedged transport doesn't burn CPU/log.
            var delay = consecutiveFailures switch
            {
                0 => TimeSpan.FromMilliseconds(100),
                1 => TimeSpan.FromMilliseconds(250),
                2 => TimeSpan.FromMilliseconds(500),
                3 => TimeSpan.FromSeconds(1),
                4 => TimeSpan.FromSeconds(2),
                _ => TimeSpan.FromSeconds(5),
            };
 
            try
            {
                await Task.Delay(delay, ct).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                return;
            }
        }
    }
 
    /// <summary>
    /// Builds a fresh Hex1bTerminal bound to this replica's UDS paths. Each
    /// call returns an unrelated, undisposed instance; the recycle loop owns
    /// dispose timing.
    /// </summary>
    private Hex1bTerminal BuildTerminal()
    {
        int currentColumns;
        int currentRows;
        lock (_gate)
        {
            currentColumns = _currentColumns;
            currentRows = _currentRows;
        }
 
        // Pre-delete any stale UDS files at our paths before the listeners bind. Without
        // this, a previous host that crashed (or a stuck previous cycle that didn't get
        // to clean teardown) leaves a file at the same path, and binding would
        // fail with EADDRINUSE forever — the recycle loop would
        // then go into its 5-second back-off and stay there while still reporting "ready".
        // Symmetry with TerminalHostControlListener.StartAsync which already does this
        // for the control socket.
        TryDeleteUdsFile(ProducerUdsPath);
        TryDeleteUdsFile(ConsumerUdsPath);
 
        // Build the upstream workload adapter ourselves so we can plumb downstream
        // resize events (from the consumer-side multi-head server below) into
        // unconditional FrameResize writes upstream to DCP. See DcpUpstreamAdapter
        // for why Hex1b's stock Hmp1WorkloadAdapter cannot be used here (DCP is
        // a single-peer producer that doesn't speak multi-head, so the adapter's
        // IsPrimary gate would silently drop every resize forever).
        var upstream = new DcpUpstreamAdapter(
            async cct =>
            {
                _logger.LogInformation(
                    "Awaiting DCP producer connection on '{ProducerUdsPath}' (cols={Cols}, rows={Rows}).",
                    ProducerUdsPath, currentColumns, currentRows);
                var stream = await AcceptProducerAsync(ProducerUdsPath, cct).ConfigureAwait(false);
                int restartCount;
                lock (_gate)
                {
                    _producerConnected = true;
                    restartCount = _restartCount;
                }
                _logger.LogInformation(
                    "DCP producer connected on '{ProducerUdsPath}' (cycle #{RestartCount}).",
                    ProducerUdsPath, restartCount);
                return stream;
            },
            _upstreamLogger);
 
        upstream.Disconnected += () =>
        {
            lock (_gate)
            {
                _producerConnected = false;
            }
            // The recycle loop will rebind the producer UDS on the next iteration; count this
            // as a "recycle" because that's the next observable event. A steadily growing
            // counter on a single host process means DCP keeps reconnecting.
            TerminalHostTelemetry.UpstreamRecycles.Add(1);
            _logger.LogInformation("DCP producer disconnected; replica will rebind.");
        };
 
        // Hex1b's convenience HMP server creates its presentation adapter with an
        // 80x24 default, which then overrides WithDimensions during Build(). Construct
        // the adapter directly so both its Hello frames and the upstream PTY start with
        // the dimensions configured by WithTerminal.
        var downstream = CreateDownstream(upstream, currentColumns, currentRows);
        var listener = new Hmp1UdsServerListenerFilter(
            ConsumerUdsPath,
            downstream,
            _consumerListenerLogger,
            upstream.ReportConsumerListenerFailure);
 
        try
        {
            return Hex1bTerminal.CreateBuilder()
                .WithDimensions(currentColumns, currentRows)
                .WithWorkload(upstream)
                .WithPresentation(downstream)
                .WithScrollback(10000)
                .AddPresentationFilter(listener)
                .Build();
        }
        catch
        {
            listener.Dispose();
            throw;
        }
    }
 
    private Hmp1PresentationAdapter CreateDownstream(
        DcpUpstreamAdapter upstream,
        int currentColumns,
        int currentRows)
    {
        var downstream = new Hmp1PresentationAdapter(currentColumns, currentRows)
            .WithReflow(GhosttyReflowStrategy.Instance);
 
        // Track every HMP1 peer that connects/disconnects so the host can answer
        // "who's currently attached to this replica?" via the control RPC. PeerId is
        // assigned by Hex1b at handshake time and is unique per connection; DisplayName
        // is the optional ClientHello label (e.g. "aspire.cli:1234", "dashboard:abc12345").
        downstream.OnClientConnected = (e, _) =>
        {
            lock (_gate)
            {
                _peers[e.PeerId] = new TerminalHostPeerInfo
                {
                    PeerId = e.PeerId,
                    DisplayName = e.DisplayName,
                };
            }
            // Tag with peer attributes — viewer counts are low (single digits in
            // practice: one dashboard tab + maybe a CLI attach), so high-cardinality
            // worries don't apply here. Helps diagnose "which viewer is causing the
            // resize storm" in the dashboard metric explorer.
            var tags = new TagList
            {
                { "peer.id", e.PeerId },
                { "peer.name", e.DisplayName ?? "" },
            };
            TerminalHostTelemetry.ConsumerConnections.Add(1, tags);
            TerminalHostTelemetry.ConsumerPeersActive.Add(1, tags);
            _logger.LogInformation(
                "Consumer peer connected. PeerId={PeerId}, DisplayName='{DisplayName}'.",
                e.PeerId, e.DisplayName);
 
            return Task.CompletedTask;
        };
        downstream.OnClientDisconnected = (e, _) =>
        {
            string? displayName;
            lock (_gate)
            {
                _peers.TryGetValue(e.PeerId, out var existing);
                displayName = existing?.DisplayName;
                _peers.Remove(e.PeerId);
            }
            var tags = new TagList
            {
                { "peer.id", e.PeerId },
                { "peer.name", displayName ?? "" },
            };
            TerminalHostTelemetry.ConsumerDisconnections.Add(1, tags);
            TerminalHostTelemetry.ConsumerPeersActive.Add(-1, tags);
            _logger.LogInformation(
                "Consumer peer disconnected. PeerId={PeerId}.", e.PeerId);
 
            return Task.CompletedTask;
        };
 
        // Bridge downstream → upstream resize. The consumer-side multi-head
        // server fires OnResized whenever the current primary peer's dims
        // change (RequestPrimary or explicit Resize from primary). Forward
        // those dims as a raw FrameResize upstream so DCP runs ConPty.Resize
        // and the underlying workload sees the new TIOCSWINSZ value. Without
        // this hook the consumer-side presentation reflects the new dims but
        // the actual PTY stays at whatever DCP started it at.
        //
        // Also persist the latest dimensions so `aspire terminal ps` and the
        // dashboard can report the current grid size without round-tripping to
        // every attached viewer.
        downstream.OnResized = async (e, ct) =>
        {
            lock (_gate)
            {
                _currentColumns = e.Width;
                _currentRows = e.Height;
            }
 
            TerminalHostTelemetry.ResizeRequests.Add(1, new TagList
            {
                { "direction", "downstream" },
            });
            _logger.LogDebug(
                "Downstream resize received from primary peer: {Width}x{Height}.",
                e.Width, e.Height);
 
            try
            {
                await upstream.ResizeAsync(e.Width, e.Height, ct).ConfigureAwait(false);
                _logger.LogDebug(
                    "Replica: forwarded downstream resize ({Width}x{Height}) to upstream PTY.",
                    e.Width, e.Height);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested)
            {
                // Recycle loop is shutting down; drop quietly.
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex,
                    "Replica: forwarding downstream resize ({Width}x{Height}) upstream failed.",
                    e.Width, e.Height);
            }
        };
 
        return downstream;
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_disposed)
        {
            return;
        }
 
        _disposed = true;
 
        try
        {
            await _stopCts.CancelAsync().ConfigureAwait(false);
        }
        catch (ObjectDisposedException) { }
 
        try
        {
            await _runTask.ConfigureAwait(false);
        }
        catch (OperationCanceledException) { }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Replica recycle loop terminated with an unexpected error.");
        }
 
        // Post-delete the UDS files. Hex1b normally unlinks the socket file when its
        // listener is disposed, but a partial dispose (e.g. cancel mid-bind) can leave
        // the file behind. The AppHost also recursively deletes the per-run temp tree
        // on ApplicationStopped — this best-effort delete just keeps the per-replica
        // directory empty when the host process is recycled in-place by DCP without
        // tearing down the whole AppHost.
        TryDeleteUdsFile(ProducerUdsPath);
        TryDeleteUdsFile(ConsumerUdsPath);
 
        _stopCts.Dispose();
    }
 
    /// <summary>
    /// Accepts the single producer for one relay cycle on an owner-only socket.
    /// </summary>
    internal static async Task<Stream> AcceptProducerAsync(string path, CancellationToken ct)
    {
        // Hex1b's transport binds and listens together. Own the listener so permission
        // failures stop startup, and the socket is restricted before it accepts traffic.
        // Only one producer is accepted per cycle, just as when returning the first
        // stream from Hmp1Transports.ListenUnixSocket and disposing its enumerator.
        using var listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
        SocketPermissionHelper.Bind(listener, path);
        // Preserve the backlog used by Hex1b 0.168.0's Hmp1Transports.ListenUnixSocket.
        const int ListenBacklog = 16;
        listener.Listen(backlog: ListenBacklog);
        var socket = await listener.AcceptAsync(ct).ConfigureAwait(false);
        return new NetworkStream(socket, ownsSocket: true);
    }
 
    private void TryDeleteUdsFile(string path)
    {
        try
        {
            if (File.Exists(path))
            {
                File.Delete(path);
            }
        }
        catch (IOException ex)
        {
            // Best-effort: another process may hold an open handle, or the path may
            // already be gone. Either way the next Bind will surface a clearer error.
            _logger.LogDebug(ex, "Failed to delete stale UDS file '{Path}'.", path);
        }
        catch (UnauthorizedAccessException ex)
        {
            _logger.LogDebug(ex, "Failed to delete stale UDS file '{Path}' (access denied).", path);
        }
    }
}