File: ApplicationModel\TerminalService.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.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Aspire.Hosting.Utils;
using Hex1b;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
 
#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// Manages terminals created and owned by the AppHost.
/// </summary>
/// <remarks>
/// <para>
/// Two experiences share this service: terminals displayed by <see cref="IInteractionService.PromptTerminalAsync"/>
/// and terminals shown as tabs in the dashboard's terminal dock. They differ only in
/// <see cref="TerminalPlacement"/>; the lifetime, transport, and automation machinery is identical.
/// </para>
/// <para>
/// This is distinct from the terminal host, which exists solely to surface terminals for DCP-owned processes.
/// Those are owned by the resource, reachable over a Unix domain socket, and are not tracked here.
/// </para>
/// <para>
/// Resolve it from the built AppHost's service provider:
/// <c>app.Services.GetRequiredService&lt;TerminalService&gt;()</c>. Only creation and lookup are public;
/// the members the dashboard uses to attach transports and watch the dock's tab list are internal, because
/// they are transport plumbing rather than something an AppHost author calls.
/// </para>
/// <para>
/// Each dashboard metadata watcher buffers up to 64 updates by default. Set
/// <c>ASPIRE_TERMINAL_WATCH_BUFFER_CAPACITY</c> to a positive integer in the AppHost's configuration
/// before starting the application to tune this limit. Overflow replaces queued changes with a current
/// snapshot while preserving the latest pending request to show the dock.
/// </para>
/// </remarks>
[Experimental(TerminalDiagnostics.DiagnosticId, UrlFormat = TerminalDiagnostics.UrlFormat)]
public sealed class TerminalService : IAsyncDisposable
{
    internal const int DefaultDockUpdateBufferCapacity = 64;
 
    private readonly ConcurrentDictionary<string, Hex1bAspireTerminal> _terminals = new(StringComparer.Ordinal);
    // Closed terminals leave discovery immediately, but shutdown must still await their teardown.
    private readonly HashSet<Hex1bAspireTerminal> _retiringTerminals = [];
    private readonly ILogger<TerminalService> _logger;
    private readonly int _dockUpdateBufferCapacity;
    private readonly object _syncLock = new();
    private ImmutableHashSet<Channel<TerminalUpdate>> _outgoingChannels = [];
    private int _disposed;
    private Task? _disposeTask;
 
    internal TerminalService(ILogger<TerminalService> logger, IConfiguration configuration)
    {
        _logger = logger;
        _dockUpdateBufferCapacity = configuration.GetValue(KnownConfigNames.TerminalWatchBufferCapacity, DefaultDockUpdateBufferCapacity);
        if (_dockUpdateBufferCapacity <= 0)
        {
            throw new InvalidOperationException($"Configuration '{KnownConfigNames.TerminalWatchBufferCapacity}' must be greater than zero.");
        }
    }
 
    /// <summary>
    /// Gets or sets the catalog consulted for terminals that belong to resources rather than to the AppHost.
    /// </summary>
    /// <remarks>
    /// Assigned once the application model exists. It is settable rather than a constructor argument because
    /// this service is created before the model is built, and it stays null in tests that exercise only
    /// AppHost terminals.
    /// </remarks>
    internal ResourceTerminalCatalog? ResourceTerminals { get; set; }
 
    /// <summary>
    /// Creates a terminal. The workload does not start until something needs it: the first viewer attaching,
    /// or the first automation call.
    /// </summary>
    /// <param name="options">Describes the terminal to create.</param>
    /// <returns>
    /// The terminal. Disposing it cancels the workload and removes the terminal from the dashboard; a dock
    /// terminal that is meant to outlive the call that created it should be left undisposed, and is torn down
    /// when the AppHost shuts down.
    /// </returns>
    /// <remarks>
    /// The options, including the argument and environment variable collections, are captured during this call.
    /// Changing or reusing <paramref name="options"/> afterward does not affect the created terminal.
    /// The process still inherits the AppHost's environment when it starts.
    /// </remarks>
    /// <exception cref="ArgumentNullException">
    /// <paramref name="options"/> or its <see cref="TerminalLaunchOptions.Title"/> is <see langword="null"/>.
    /// </exception>
    /// <exception cref="ArgumentException">
    /// The <see cref="TerminalLaunchOptions.Title"/> is empty or consists only of white-space characters.
    /// </exception>
    /// <exception cref="ArgumentOutOfRangeException">
    /// The placement in <paramref name="options"/> is not <see cref="TerminalPlacement.Dock"/>,
    /// <see cref="TerminalPlacement.Dialog"/>, or <see cref="TerminalPlacement.None"/>.
    /// </exception>
    /// <exception cref="IOException">
    /// On Windows, the PTY socket directory does not use a supported Aspire layout or cannot be created.
    /// </exception>
    /// <exception cref="UnauthorizedAccessException">
    /// On Windows, the PTY socket directory permissions cannot be applied.
    /// </exception>
    /// <exception cref="InvalidOperationException">
    /// On Windows, no user profile directory is available for the default PTY socket directory.
    /// </exception>
    public AspireTerminal CreateTerminal(TerminalLaunchOptions options)
    {
        ArgumentNullException.ThrowIfNull(options);
 
        return CreateTerminal(options.Title, options.Placement, CreateBuilder(options), options.Columns, options.Rows);
    }
 
    /// <summary>
    /// Translates Aspire's terminal description into a configured Hex1b builder.
    /// </summary>
    /// <remarks>
    /// This is the single point where Hex1b enters the picture, which is what keeps it out of the public API.
    /// The process options overload is used rather than <c>WithPtyProcess(file, args)</c> so the working
    /// directory and environment can be set; <c>InheritEnvironment</c> is left at its default of
    /// <see langword="true"/>, so <see cref="TerminalLaunchOptions.EnvironmentVariables"/> layers over the AppHost's
    /// environment rather than replacing it. Interactive workloads need an inherited PATH/HOME/TERM to behave
    /// like a normal shell.
    /// </remarks>
    private static Hex1bTerminalBuilder CreateBuilder(TerminalLaunchOptions options)
    {
        // Own the launch settings before configuring Hex1b. Its current callback runs immediately, but
        // keeping caller-owned options out of the callback makes our snapshot boundary explicit.
        var executable = options.Executable;
        string[] arguments = [.. options.Arguments];
        var workingDirectory = options.WorkingDirectory;
        var environment = options.EnvironmentVariables.Count > 0
            ? new Dictionary<string, string>(options.EnvironmentVariables, StringComparer.Ordinal)
            : null;
 
        Hex1bPtySocketHelper.Configure();
 
        return Hex1bTerminal.CreateBuilder()
            .WithPtyProcess(process =>
            {
                process.FileName = executable;
                process.Arguments = arguments;
                process.WorkingDirectory = workingDirectory;
 
                if (environment is not null)
                {
                    process.Environment = environment;
                }
            });
    }
 
    /// <summary>
    /// Creates a terminal from an already-configured Hex1b builder.
    /// </summary>
    /// <remarks>
    /// Internal because the builder is a Hex1b type. This is the path used by workloads that a
    /// <see cref="TerminalLaunchOptions"/> cannot describe — notably the dock's built-in terminal, which runs an
    /// in-process Hex1b app rather than a child process.
    /// </remarks>
    internal AspireTerminal CreateTerminal(string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, int columns, int rows)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(title);
        ArgumentNullException.ThrowIfNull(builder);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columns);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(rows);
 
        // AppHost-owned terminals have no resource view. Validate both creation paths here before registration,
        // while retaining None for terminals driven only through automation.
        if (placement is not (TerminalPlacement.Dock or TerminalPlacement.Dialog or TerminalPlacement.None))
        {
            throw new ArgumentOutOfRangeException(nameof(placement), placement,
                $"AppHost-owned terminals must use {nameof(TerminalPlacement.Dock)}, {nameof(TerminalPlacement.Dialog)}, or {nameof(TerminalPlacement.None)} placement.");
        }
 
        // Terminal ids are opaque to the dashboard and appear in websocket query strings, so use a
        // non-guessable value rather than a sequence number.
        var id = Guid.NewGuid().ToString("n");
        Hex1bAspireTerminal terminal;
 
        // Registration, publication, snapshots, and shutdown share one boundary. Otherwise a subscriber
        // can see both a snapshot entry and its Added event, or shutdown can miss a new registration.
        lock (_syncLock)
        {
            ObjectDisposedException.ThrowIf(_disposed != 0, this);
            terminal = new Hex1bAspireTerminal(this, id, title, placement, builder, columns, rows, _logger);
            _terminals[id] = terminal;
 
            if (terminal.Placement == TerminalPlacement.Dock)
            {
                Publish(new TerminalChange(TerminalChangeType.Added, terminal.Descriptor));
            }
        }
 
        _logger.LogDebug("Created {Placement} terminal {TerminalId} ({Title}).", placement, id, title);
 
        return terminal.Handle;
    }
 
    /// <summary>
    /// Attaches a viewer transport to a terminal.
    /// </summary>
    /// <returns>
    /// A task that completes after this viewer disconnects or the terminal ends, once all operations on the
    /// caller's transport have finished. Cancellation disconnects only this viewer.
    /// </returns>
    internal Task AttachAsync(string terminalId, Stream clientStream, Func<CancellationToken, Task> onEnded, CancellationToken cancellationToken)
    {
        if (!_terminals.TryGetValue(terminalId, out var terminal))
        {
            throw new InvalidOperationException($"There is no terminal with id '{terminalId}'.");
        }
 
        return terminal.AttachAsync(clientStream, onEnded, cancellationToken);
    }
 
    /// <summary>
    /// Gets a terminal by id.
    /// </summary>
    /// <param name="terminalId">The <see cref="AspireTerminal.Id"/> of the terminal to find.</param>
    /// <param name="terminal">The terminal, if one with that id exists.</param>
    /// <returns><see langword="true"/> if the terminal was found.</returns>
    /// <remarks>
    /// Resolves terminals the AppHost owns as well as those belonging to resources, so automation code can
    /// drive either kind through the same handle without knowing which it has.
    /// </remarks>
    public bool TryGetTerminal(string terminalId, [NotNullWhen(true)] out AspireTerminal? terminal)
    {
        ArgumentNullException.ThrowIfNull(terminalId);
 
        if (_terminals.TryGetValue(terminalId, out var found))
        {
            terminal = found.Handle;
            return true;
        }
 
        if (ResourceTerminals?.TryGetTerminal(terminalId, out var resourceTerminal) == true)
        {
            terminal = resourceTerminal!;
            return true;
        }
 
        terminal = null;
        return false;
    }
 
    /// <summary>
    /// Lists every terminal in the AppHost, whether the AppHost or a resource owns it.
    /// </summary>
    /// <remarks>
    /// This is what makes a single listing possible: dock tabs, terminals being shown in an interaction
    /// dialog, terminals driven only by automation, and each terminal-enabled resource replica all appear
    /// here. Resource entries are read from the application model and carry no liveness — obtaining that
    /// requires a round trip to each replica's terminal host, which a listing should not force on callers
    /// that only want to know what exists.
    /// </remarks>
    internal IReadOnlyList<TerminalListing> ListAll()
    {
        var listings = new List<TerminalListing>();
 
        foreach (var terminal in _terminals.Values)
        {
            listings.Add(new TerminalListing(
                terminal.Id,
                terminal.Title,
                TerminalOwner.AppHost,
                terminal.Placement,
                ResourceName: null,
                ReplicaIndex: null,
                ConsumerUdsPath: null,
                ControlUdsPath: null));
        }
 
        foreach (var entry in ResourceTerminals?.List() ?? [])
        {
            listings.Add(new TerminalListing(
                entry.Id,
                entry.Title,
                TerminalOwner.Resource,
                TerminalPlacement.ResourceView,
                entry.ResourceName,
                entry.ReplicaIndex,
                entry.ConsumerUdsPath,
                entry.ControlUdsPath));
        }
 
        return listings;
    }
 
    /// <summary>
    /// Subscribes to the dock's terminal list, returning the current set followed by changes or recovery snapshots.
    /// </summary>
    /// <remarks>
    /// The snapshot and the subscription are produced under the same lock so a terminal created concurrently
    /// is either in the snapshot or in the change stream, never dropped and never duplicated.
    /// </remarks>
    internal TerminalSubscription SubscribeDockTerminals()
    {
        lock (_syncLock)
        {
            var channel = Channel.CreateBounded<TerminalUpdate>(new BoundedChannelOptions(_dockUpdateBufferCapacity)
            {
                AllowSynchronousContinuations = false,
                // Publish also drains a full queue before replacing it with a snapshot.
                SingleReader = false,
                SingleWriter = true,
                FullMode = BoundedChannelFullMode.Wait
            });
 
            if (_disposed != 0)
            {
                channel.Writer.TryComplete();
            }
            else
            {
                ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Add(c), channel);
            }
 
            var initial = GetDockSnapshot();
 
            return new TerminalSubscription(initial, StreamChanges())
            {
                // The channel is registered above, before the caller has a chance to enumerate. StreamChanges is an
                // async iterator, so its finally only runs once someone calls MoveNextAsync. A caller that faults
                // during the initial snapshot write must still be able to release the channel and its buffer.
                Unsubscribe = () => Unsubscribe(channel)
            };
 
            async IAsyncEnumerable<TerminalUpdate> StreamChanges([EnumeratorCancellation] CancellationToken cancellationToken = default)
            {
                try
                {
                    while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
                    {
                        TerminalUpdate? change;
                        lock (_syncLock)
                        {
                            // Do not let the reader take a newer activation between entries drained by Publish,
                            // then receive a recovery snapshot that replays an older activation.
                            channel.Reader.TryRead(out change);
                        }
 
                        if (change is not null)
                        {
                            yield return change;
                        }
                    }
                }
                finally
                {
                    Unsubscribe(channel);
                }
            }
        }
    }
 
    private ImmutableArray<TerminalDescriptor> GetDockSnapshot()
        => _terminals.Values
            .Where(t => t.Placement == TerminalPlacement.Dock)
            .Select(t => t.Descriptor)
            .ToImmutableArray();
 
    private void Unsubscribe(Channel<TerminalUpdate> channel)
    {
        lock (_syncLock)
        {
            ImmutableInterlocked.Update(ref _outgoingChannels, static (set, c) => set.Remove(c), channel);
            channel.Writer.TryComplete();
        }
    }
 
    internal void NotifyActivated(Hex1bAspireTerminal terminal)
        => Notify(terminal, TerminalChangeType.Activated);
 
    internal void NotifyRetitled(Hex1bAspireTerminal terminal)
        => Notify(terminal, TerminalChangeType.Retitled);
 
    private void Notify(Hex1bAspireTerminal terminal, TerminalChangeType changeType)
    {
        lock (_syncLock)
        {
            if (terminal.Placement == TerminalPlacement.Dock && _terminals.ContainsKey(terminal.Id))
            {
                Publish(new TerminalChange(changeType, terminal.Descriptor));
            }
        }
    }
 
    internal async ValueTask DisposeTerminalAsync(Hex1bAspireTerminal terminal)
    {
        bool removed;
        lock (_syncLock)
        {
            removed = _terminals.TryRemove(terminal.Id, out _);
            if (removed)
            {
                _retiringTerminals.Add(terminal);
 
                if (terminal.Placement == TerminalPlacement.Dock)
                {
                    Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor));
                }
            }
        }
 
        if (removed)
        {
            _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title);
        }
 
        try
        {
            await terminal.StopAsync().ConfigureAwait(false);
        }
        finally
        {
            lock (_syncLock)
            {
                _retiringTerminals.Remove(terminal);
            }
        }
    }
 
    private void Publish(TerminalChange change)
    {
        ImmutableArray<TerminalDescriptor> snapshot = default;
        foreach (var channel in _outgoingChannels)
        {
            if (channel.Writer.TryWrite(change))
            {
                continue;
            }
 
            // All publishers, registration and completion share _syncLock. A slow gRPC writer must neither block
            // AppHost operations nor retain unlimited history. Replace its backlog with state captured under that
            // same lock, so later deltas always follow the snapshot they extend. Readers may still finish sending
            // an older dequeued update first; the replacement snapshot supersedes it.
            string? activatedTerminalId = null;
            while (channel.Reader.TryRead(out var pending))
            {
                activatedTerminalId = pending switch
                {
                    TerminalChange { ChangeType: TerminalChangeType.Activated } activation => activation.Terminal.Id,
                    TerminalSnapshot recovery => recovery.ActivatedTerminalId,
                    _ => activatedTerminalId
                };
            }
 
            if (change.ChangeType == TerminalChangeType.Activated)
            {
                activatedTerminalId = change.Terminal.Id;
            }
 
            // Share the immutable inventory when several subscribers overflow on the same publication.
            if (snapshot.IsDefault)
            {
                snapshot = GetDockSnapshot();
            }
            channel.Writer.TryWrite(new TerminalSnapshot(snapshot, activatedTerminalId));
        }
    }
 
    /// <summary>
    /// Stops and awaits teardown of every terminal this service owns.
    /// </summary>
    /// <returns>A task that completes when all terminal workloads and transports have been disposed.</returns>
    /// <remarks>
    /// Teardown starts concurrently for all terminals. Repeated calls await the same cleanup operation.
    /// </remarks>
    public ValueTask DisposeAsync()
    {
        lock (_syncLock)
        {
            if (_disposeTask is not null)
            {
                return new ValueTask(_disposeTask);
            }
 
            _disposed = 1;
            var terminals = _terminals.Values.ToArray();
            var retiringTerminals = _retiringTerminals.ToArray();
            _terminals.Clear();
            _retiringTerminals.Clear();
 
            foreach (var terminal in terminals)
            {
                if (terminal.Placement == TerminalPlacement.Dock)
                {
                    Publish(new TerminalChange(TerminalChangeType.Removed, terminal.Descriptor));
                }
            }
 
            foreach (var channel in _outgoingChannels)
            {
                channel.Writer.TryComplete();
            }
 
            _disposeTask = DisposeTerminalsAsync([.. terminals, .. retiringTerminals], ResourceTerminals);
            return new ValueTask(_disposeTask);
        }
    }
 
    private async Task DisposeTerminalsAsync(Hex1bAspireTerminal[] terminals, ResourceTerminalCatalog? resourceTerminals)
    {
        // Workload cancellation can invoke user callbacks. Do not begin teardown under the registry lock.
        await Task.Yield();
 
        try
        {
            await Task.WhenAll(terminals.Select(async terminal =>
            {
                _logger.LogDebug("Removed terminal {TerminalId} ({Title}).", terminal.Id, terminal.Title);
                await terminal.StopAsync().ConfigureAwait(false);
            })).ConfigureAwait(false);
        }
        finally
        {
            if (resourceTerminals is not null)
            {
                await resourceTerminals.DisposeAsync().ConfigureAwait(false);
            }
        }
    }
}
 
/// <summary>
/// The current set of dock terminals plus a stream of subsequent changes or recovery snapshots.
/// </summary>
/// <remarks>
/// Dispose when the subscription is no longer needed, including paths that abandon it before enumeration starts.
/// Disposal completes the stream, and enumerating <see cref="Subscription"/> to completion also releases its registration.
/// </remarks>
internal sealed record TerminalSubscription(
    ImmutableArray<TerminalDescriptor> InitialState,
    IAsyncEnumerable<TerminalUpdate> Subscription) : IDisposable
{
    /// <summary>
    /// Releases the change-stream registration held by this subscription. Safe to call more than once.
    /// </summary>
    public required Action Unsubscribe { get; init; }
 
    public void Dispose() => Unsubscribe();
}
 
/// <summary>
/// One terminal in the AppHost, as reported by <see cref="TerminalService.ListAll"/>.
/// </summary>
/// <remarks>
/// The resource-specific members are populated only for <see cref="TerminalOwner.Resource"/> terminals, whose
/// per-replica liveness is obtained by querying <see cref="ControlUdsPath"/>.
/// </remarks>
internal sealed record TerminalListing(
    string Id,
    string Title,
    TerminalOwner Owner,
    TerminalPlacement Placement,
    string? ResourceName,
    int? ReplicaIndex,
    string? ConsumerUdsPath,
    string? ControlUdsPath);