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 Aspire.Shared.TerminalHost;
using Hex1b;
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 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>();
        _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()
    {
        // Pre-delete any stale UDS files at our paths before Hex1b tries to 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 Hmp1Transports.ListenUnixSocket
        // / WithHmp1UdsServer 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);
 
        // Hex1b binds the producer and consumer sockets lazily inside RunAsync, so we
        // cannot chmod them synchronously here. Kick off a background task that polls
        // for file existence and applies 0600 the moment each socket appears. The poll
        // window is tiny (microseconds in practice) — the parent ~/.aspire/trmnl/ dir
        // is already 0700 so even during the window the sockets are unreachable by
        // other local users. This per-file chmod is defense-in-depth, mirroring the
        // explicit 0600 that TerminalHostControlListener applies to the control socket.
        _ = ApplyRestrictiveSocketPermissionsAsync(ProducerUdsPath, _stopCts.Token);
        _ = ApplyRestrictiveSocketPermissionsAsync(ConsumerUdsPath, _stopCts.Token);
 
        // 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, Columns, Rows);
                await foreach (var stream in Hmp1Transports.ListenUnixSocket(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;
                }
                throw new OperationCanceledException("Producer UDS listener was cancelled before any client connected.");
            },
            _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.");
        };
 
        return Hex1bTerminal.CreateBuilder()
            .WithDimensions(Columns, Rows)
            .WithWorkload(upstream)
            .WithHmp1UdsServer(
                ConsumerUdsPath,
                srvOpts =>
                {
                    // 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").
                    srvOpts.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;
                    };
                    srvOpts.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.
                    srvOpts.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);
                        }
                    };
                })
            .Build();
    }
 
    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();
    }
 
    private async Task ApplyRestrictiveSocketPermissionsAsync(string path, CancellationToken ct)
    {
        if (OperatingSystem.IsWindows())
        {
            // Unix file mode is a no-op on Windows; user-profile ACLs handle isolation.
            return;
        }
 
        // Poll for up to ~2s for the file to appear, then chmod 0600. We can't race-free
        // chmod between bind() and listen() from outside Hex1b, but the parent directory
        // is 0700 so the window is harmless.
        var deadline = Environment.TickCount64 + 2_000;
        try
        {
            while (Environment.TickCount64 < deadline && !ct.IsCancellationRequested)
            {
                if (File.Exists(path))
                {
                    try
                    {
                        File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
                        return;
                    }
                    catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
                    {
                        _logger.LogDebug(ex, "Failed to chmod terminal socket '{Path}'.", path);
                        return;
                    }
                }
 
                try
                {
                    await Task.Delay(10, ct).ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                    return;
                }
            }
        }
        catch (Exception ex)
        {
            // Never let a perms-tightening helper crash the recycle loop.
            _logger.LogDebug(ex, "Unexpected error while applying restrictive permissions to '{Path}'.", path);
        }
    }
 
    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);
        }
    }
}