File: ApplicationModel\ResourceTerminalCatalog.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.Globalization;
using Microsoft.Extensions.Logging;
 
#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// Discovers the terminals that belong to resources in the application model, and hands out
/// <see cref="AspireTerminal"/> handles for them.
/// </summary>
/// <remarks>
/// <para>
/// Resource terminals are not registered with <see cref="TerminalService"/> the way AppHost terminals are.
/// They come and go with their replicas, and the AppHost is not their owner, so the application model plus the
/// per-replica terminal hosts remain the source of truth and this type projects that into the same shape as
/// the terminals the AppHost owns.
/// </para>
/// <para>
/// Handles are cached per replica so that repeated lookups share one automation connection rather than opening
/// a socket per call. They are created eagerly on lookup but connect lazily, so a handle that is listed and
/// never automated costs nothing.
/// </para>
/// </remarks>
internal sealed class ResourceTerminalCatalog : IAsyncDisposable
{
    /// <summary>
    /// Prefix distinguishing a resource terminal id from the opaque identifier of an AppHost terminal.
    /// </summary>
    /// <remarks>
    /// AppHost terminal ids are random and must stay unguessable because they appear in websocket query
    /// strings. A resource terminal is addressed by something the user already knows — the resource name and
    /// replica index — which is what lets <c>aspire terminal attach</c> and automation refer to the same
    /// terminal across a replica's terminal host being recycled.
    /// </remarks>
    public const string IdPrefix = "resource:";
 
    private readonly Dictionary<string, ResourceAspireTerminal> _handles = new(StringComparer.Ordinal);
    private readonly HashSet<ResourceAspireTerminal> _retiringHandles = [];
    private readonly object _gate = new();
    private readonly DistributedApplicationModel _model;
    private readonly ILogger _logger;
    private bool _disposed;
 
    public ResourceTerminalCatalog(DistributedApplicationModel model, ILogger logger)
    {
        _model = model;
        _logger = logger;
    }
 
    /// <summary>
    /// Builds the stable identifier for a resource terminal.
    /// </summary>
    public static string BuildId(string resourceName, int replicaIndex)
        => string.Create(CultureInfo.InvariantCulture, $"{IdPrefix}{resourceName}:{replicaIndex}");
 
    /// <summary>
    /// Determines whether an id addresses a resource terminal rather than an AppHost terminal.
    /// </summary>
    public static bool IsResourceTerminalId(string terminalId)
        => terminalId.StartsWith(IdPrefix, StringComparison.Ordinal);
 
    /// <summary>
    /// Enumerates every terminal-enabled resource replica in the application model.
    /// </summary>
    /// <remarks>
    /// This reads only the application model, so it neither connects to a terminal host nor reports liveness.
    /// Callers that need per-replica health query the control socket separately; keeping the two apart is what
    /// lets a listing be produced without touching a socket.
    /// </remarks>
    public IReadOnlyList<ResourceTerminalEntry> List()
    {
        var entries = new List<ResourceTerminalEntry>();
 
        foreach (var resource in _model.Resources)
        {
            var annotation = resource.Annotations.OfType<TerminalAnnotation>().FirstOrDefault();
            if (annotation is null)
            {
                continue;
            }
 
            foreach (var host in annotation.TerminalHosts)
            {
                entries.Add(new ResourceTerminalEntry(
                    Id: BuildId(resource.Name, host.ParentReplicaIndex),
                    ResourceName: resource.Name,
                    ReplicaIndex: host.ParentReplicaIndex,
                    ReplicaCount: annotation.TerminalHosts.Count,
                    ConsumerUdsPath: host.Layout.ConsumerUdsPath,
                    ControlUdsPath: host.Layout.ControlUdsPath,
                    ConfiguredColumns: annotation.Options.Columns,
                    ConfiguredRows: annotation.Options.Rows));
            }
        }
 
        return entries;
    }
 
    /// <summary>
    /// Gets a handle for a resource terminal by its stable id.
    /// </summary>
    public bool TryGetTerminal(string terminalId, out AspireTerminal? terminal)
    {
        terminal = null;
 
        if (!IsResourceTerminalId(terminalId))
        {
            return false;
        }
 
        var entry = List().FirstOrDefault(e => string.Equals(e.Id, terminalId, StringComparison.Ordinal));
        if (entry is null)
        {
            return false;
        }
 
        lock (_gate)
        {
            if (_disposed)
            {
                return false;
            }
 
            // Disposing a handle releases only an automation peer, not the resource terminal. A later lookup
            // must therefore be able to acquire a fresh handle for the same still-running replica.
            if (!_handles.TryGetValue(entry.Id, out var handle) || handle.IsDisposed)
            {
                if (handle is not null)
                {
                    // IsDisposed is set before asynchronous teardown finishes. Keep replaced peers reachable
                    // until that teardown completes so catalog shutdown also waits for them.
                    _retiringHandles.Add(handle);
                    _ = RetireHandleAsync(handle);
                }
 
                handle = new ResourceAspireTerminal(entry.Id, entry.Title, entry.ConsumerUdsPath, _logger);
                _handles[entry.Id] = handle;
            }
 
            terminal = handle.Handle;
        }
 
        return true;
    }
 
    public async ValueTask DisposeAsync()
    {
        ResourceAspireTerminal[] handles;
        lock (_gate)
        {
            if (_disposed)
            {
                return;
            }
 
            _disposed = true;
            handles = [.. _handles.Values, .. _retiringHandles];
            _handles.Clear();
            _retiringHandles.Clear();
        }
 
        foreach (var handle in handles)
        {
            // Disposing a resource terminal handle disconnects the AppHost's automation peer; the resource's
            // own workload is unaffected, so there is nothing here that should delay shutdown.
            try
            {
                await handle.DisposeAsync().ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Disconnecting the automation peer for resource terminal {TerminalId} failed.", handle.Id);
            }
        }
 
    }
 
    private async Task RetireHandleAsync(ResourceAspireTerminal handle)
    {
        try
        {
            await handle.DisposeAsync().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            _logger.LogDebug(ex, "Disconnecting a replaced automation peer for resource terminal {TerminalId} failed.", handle.Id);
        }
        finally
        {
            lock (_gate)
            {
                _retiringHandles.Remove(handle);
            }
        }
    }
}
 
/// <summary>
/// One terminal-enabled resource replica, as described by the application model.
/// </summary>
internal sealed record ResourceTerminalEntry(
    string Id,
    string ResourceName,
    int ReplicaIndex,
    int ReplicaCount,
    string ConsumerUdsPath,
    string ControlUdsPath,
    int ConfiguredColumns,
    int ConfiguredRows)
{
    /// <summary>
    /// Gets the title shown for this terminal, qualified by replica only when the resource has more than one.
    /// </summary>
    public string Title => ReplicaCount > 1
        ? string.Create(CultureInfo.InvariantCulture, $"{ResourceName} (replica {ReplicaIndex})")
        : ResourceName;
}