File: TerminalResourceBuilderExtensions.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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Lifecycle;
using Aspire.Shared.TerminalHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting;
 
/// <summary>
/// Provides extension methods for configuring interactive terminal support on resources.
/// </summary>
public static class TerminalResourceBuilderExtensions
{
    private const string TerminalExperimentalDiagnosticId = "ASPIRETERMINAL001";
 
    /// <summary>
    /// Configures a resource to expose an interactive terminal session.
    /// </summary>
    /// <typeparam name="T">The type of the resource.</typeparam>
    /// <param name="builder">The resource builder.</param>
    /// <param name="configure">An optional callback to configure the terminal options.</param>
    /// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for chaining additional configuration.</returns>
    /// <remarks>
    /// <para>
    /// When a resource is configured with <c>.WithTerminal()</c>, DCP allocates a pseudo-terminal
    /// (PTY) per replica and a hidden terminal host process bridges the PTY traffic over Hex1b's
    /// HMP v1 protocol. The terminal session can be accessed from the Aspire Dashboard's terminal
    /// page or via the <c>aspire terminal</c> CLI command.
    /// </para>
    /// <para>
    /// One terminal host process is spawned per parent replica (e.g. <c>WithReplicas(3).WithTerminal()</c>
    /// → 3 terminal host processes named <c>{parent}-terminalhost-0</c> .. <c>{parent}-terminalhost-2</c>).
    /// The order of <c>WithReplicas(...)</c> and <c>WithTerminal()</c> does not matter: the per-replica
    /// hosts are materialized during <see cref="BeforeStartEvent"/> after the model is fully built,
    /// so the final replica count is always honoured.
    /// </para>
    /// </remarks>
    /// <example>
    /// Add terminal support to an executable resource:
    /// <code>
    /// var agent = builder.AddExecutable("agent", "my-agent", ".")
    ///     .WithTerminal();
    /// </code>
    /// </example>
    /// <example>
    /// Add terminal support with custom dimensions to a multi-replica resource. The order of
    /// <c>WithReplicas</c> and <c>WithTerminal</c> does not matter:
    /// <code>
    /// var agent = builder.AddExecutable("agent", "my-agent", ".")
    ///     .WithReplicas(3)
    ///     .WithTerminal(options =>
    ///     {
    ///         options.Columns = 200;
    ///         options.Rows = 50;
    ///     });
    /// </code>
    /// </example>
    [Experimental(TerminalExperimentalDiagnosticId, UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
    [AspireExportIgnore(Reason = "Polyglot AppHosts use the parameterless withTerminal dispatcher export.")]
    public static IResourceBuilder<T> WithTerminal<T>(this IResourceBuilder<T> builder, Action<TerminalOptions>? configure = null)
        where T : IResource
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        if (builder.Resource.Annotations.OfType<TerminalAnnotation>().Any())
        {
            throw new InvalidOperationException(
                $"Resource '{builder.Resource.Name}' already has a terminal configured. Call WithTerminal() only once per resource.");
        }
 
        var options = new TerminalOptions();
        configure?.Invoke(options);
 
        // Annotation is added eagerly so consumers (DCP creators, dashboard data, backchannel)
        // can detect "this resource has a terminal" the moment WithTerminal() returns. The
        // per-replica hosts inside it are populated later, during BeforeStartEvent — the model
        // (including any subsequent WithReplicas calls) is fully built by then, so the final
        // replica count is always honoured even if WithTerminal() ran before WithReplicas().
        var annotation = new TerminalAnnotation(options);
        builder.WithAnnotation(annotation);
 
        // DCP cannot currently run a process under the debugger and a PTY at the same time.
        // Prefer a working terminal over IDE execution until both can be combined:
        // https://github.com/microsoft/dcp/issues/189
        builder.WithAnnotation(new ForceProcessExecutionAnnotation());
 
        var parent = builder.Resource;
        var appBuilder = builder.ApplicationBuilder;
        appBuilder.Services.TryAddSingleton<TerminalHostOrphanCleanupService>();
        appBuilder.Services.TryAddEventingSubscriber<TerminalHostOrphanCleanupEventingSubscriber>();
 
        // Subscribe directly on the IDistributedApplicationEventing rather than registering an
        // IDistributedApplicationEventingSubscriber: subscriptions registered during the builder
        // phase fire in registration order ahead of DI-registered subscribers (which only attach
        // their callbacks during DistributedApplication.RunApplicationAsync). That ordering is
        // important — TerminalHostEventingSubscriber resolves each host's binary path by
        // iterating model.Resources.OfType<TerminalHostResource>(), so the hosts MUST already
        // be in the model by the time it runs.
        appBuilder.Eventing.Subscribe<BeforeStartEvent>((@event, cancellationToken) =>
            MaterializeTerminalHostsAsync(@event, parent, annotation, options, cancellationToken));
 
        return builder;
    }
 
    /// <summary>
    /// Polyglot dispatcher for <see cref="WithTerminal{T}(IResourceBuilder{T}, Action{TerminalOptions}?)"/>.
    /// Exposed to non-C# AppHosts via ATS as <c>withTerminal</c> — they cannot pass a
    /// C# <see cref="Action{T}"/>, so this overload simply applies the defaults from
    /// <see cref="TerminalOptions"/> (120×30). Polyglot AppHosts that need to customise
    /// the terminal dimensions can wait for a future overload that accepts a DTO.
    /// </summary>
    /// <ats-summary>Adds an interactive terminal session to a resource using the default terminal options.</ats-summary>
    [AspireExport("withTerminal")]
    internal static IResourceBuilder<T> WithTerminalForPolyglot<T>(this IResourceBuilder<T> builder)
        where T : IResource
#pragma warning disable ASPIRETERMINAL001 // Internal dispatcher into the experimental API.
        => builder.WithTerminal();
#pragma warning restore ASPIRETERMINAL001
 
#pragma warning disable ASPIRETERMINAL001 // Internal implementation of the experimental terminal configuration API.
 
    /// <summary>
    /// Reads the parent's final <see cref="ReplicaAnnotation"/> and creates one
    /// <see cref="TerminalHostResource"/> per replica. Idempotent — re-firing
    /// <see cref="BeforeStartEvent"/> (e.g. from a test) is a no-op once the
    /// <paramref name="annotation"/> is initialized.
    /// </summary>
    private static async Task MaterializeTerminalHostsAsync(
        BeforeStartEvent @event,
        IResource parent,
        TerminalAnnotation annotation,
        TerminalOptions options,
        CancellationToken cancellationToken)
    {
        if (annotation.IsInitialized)
        {
            return;
        }
 
        var executionContext = @event.Services.GetRequiredService<DistributedApplicationExecutionContext>();
        if (!executionContext.IsRunMode)
        {
            return;
        }
 
        // ReplicaAnnotation may have been added before OR after WithTerminal — that's
        // exactly why this code runs at BeforeStartEvent time. The model is locked down
        // by now, so LastOrDefault() reflects the final WithReplicas(N) call.
        var replicaCount = parent.Annotations.OfType<ReplicaAnnotation>().LastOrDefault()?.Replicas ?? 1;
        if (replicaCount < 1)
        {
            replicaCount = 1;
        }
 
        // All per-replica terminal-host files live flat under ~/.aspire/trmnl/, with
        // a random per-run replica id. This:
        //  - matches the repo's convention for per-user runtime state (cf. ~/.aspire/cli/bch,
        //    ~/.aspire/dev-certs, ~/.aspire/deployments)
        //  - avoids dropping UDS sockets in the global /tmp on Linux where different distros
        //    treat /tmp permissions differently
        //  - keeps absolute paths short enough to fit sun_path (104 bytes on macOS)
        //  - lets external tools enumerate by listing {trmnlDir}/{id}.metadata.json
        //  - prevents an old child or AppHost cleanup from touching a replacement run's sockets.
        var configuration = @event.Services.GetRequiredService<IConfiguration>();
        var appHostPath = configuration["AppHost:FilePath"] ?? configuration["AppHost:Path"];
        if (string.IsNullOrEmpty(appHostPath))
        {
            throw new InvalidOperationException(
                "Cannot materialize terminal hosts: AppHost:FilePath / AppHost:Path is not set in configuration.");
        }
 
        var trmnlDirectory = configuration[TerminalHostPaths.DirectoryOverrideConfigName];
        if (string.IsNullOrEmpty(trmnlDirectory))
        {
            var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
            trmnlDirectory = TerminalHostPaths.GetTrmnlDirectory(homeDirectory);
        }
 
        // 0700 on Unix so other local users cannot enumerate which terminals exist on
        // this machine. On Windows the user-profile ACLs (per-user by default) make this
        // a no-op; CreateDirectory is idempotent.
        Directory.CreateDirectory(trmnlDirectory);
        if (!OperatingSystem.IsWindows())
        {
            try
            {
                File.SetUnixFileMode(
                    trmnlDirectory,
                    UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
            }
            catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
            {
                // Best-effort: directory may already have stricter perms or be on a filesystem
                // that does not support chmod (e.g. some FAT-formatted home dirs). Per-socket
                // 0600 in TerminalHostControlListener still protects each endpoint.
            }
        }
 
        var terminalHosts = new TerminalHostResource[replicaCount];
        var replicaIds = new string[replicaCount];
        var metadataLogger = @event.Services.GetService<ILoggerFactory>()?.CreateLogger("Aspire.Hosting.WithTerminal");
        var appHostPid = Environment.ProcessId;
        var appHostProcessIdentity = ProcessStartTimeHelper.GetCurrentProcessStartTimeUnixMilliseconds();
        var appHostProcessScopeId = TerminalHostOrphanCleanupService.GetCurrentProcessScopeId();
        var appHostBootId = TerminalHostOrphanCleanupService.GetCurrentBootId();
        var createdAtUtc = DateTime.UtcNow;
 
        for (var i = 0; i < replicaCount; i++)
        {
            var replicaId = TerminalHostPaths.CreateReplicaId();
            var layout = CreateTerminalHostLayout(trmnlDirectory, replicaId, i);
            var terminalHostName = $"{parent.Name}-terminalhost-{i.ToString(CultureInfo.InvariantCulture)}";
            var terminalHost = new TerminalHostResource(terminalHostName, parent, layout);
 
            ConfigureTerminalHostAnnotations(
                terminalHost,
                options,
                appHostPid,
                appHostProcessIdentity);
 
            // Wire OTLP env vars onto each terminal host so it can ship logs/traces/metrics to
            // the dashboard. Without this, terminal host failures (DCP never dials, control
            // socket bind fails, replica recycles in a loop) are only visible by attaching a
            // debugger — there is no other log sink: the host explicitly does not write to
            // stderr because DCP captures stderr into the consumer's resource log stream
            // and any host-generated bytes would corrupt that view.
            //
            // AddOtlpEnvironment also injects OTEL_RESOURCE_ATTRIBUTES=service.instance.id=...,
            // which combined with DCP's OTEL_SERVICE_NAME annotation gives each replica's
            // host process a unique identity in the dashboard. We do not pin a protocol here
            // (gRPC vs HTTP/protobuf); the env-callback picks the dashboard's preferred
            // protocol and the host's composite `UseOtlpExporter()` honours
            // OTEL_EXPORTER_OTLP_PROTOCOL — same path every ServiceDefaults-wired Aspire
            // project takes.
            OtlpConfigurationExtensions.AddOtlpEnvironment(terminalHost, configuration, @event.Services.GetRequiredService<IHostEnvironment>());
 
            // Propagate ASPIRE_TERMINAL_HOST_LOG_LEVEL from the AppHost process so playground/dev
            // can dial up host verbosity (e.g. "Debug") from launchSettings.json without code
            // changes. The terminal host itself reads this env var and applies it to
            // ILoggingBuilder.SetMinimumLevel; only relevant when OTLP is wired so the resulting
            // log records reach the dashboard.
            var hostLogLevel = Environment.GetEnvironmentVariable("ASPIRE_TERMINAL_HOST_LOG_LEVEL");
            if (!string.IsNullOrWhiteSpace(hostLogLevel))
            {
                terminalHost.Annotations.Add(new EnvironmentCallbackAnnotation(ctx =>
                {
                    ctx.EnvironmentVariables["ASPIRE_TERMINAL_HOST_LOG_LEVEL"] = hostLogLevel;
                }));
            }
 
            @event.Model.Resources.Add(terminalHost);
 
            // Write the sidecar BEFORE the host process starts so external discovery tools
            // (CLI `aspire terminal ps`, dashboard) can see the terminal as soon as DCP
            // begins spawning hosts — without waiting for the host to come up and bind its
            // control socket. The host process never reads its own sidecar; the AppHost is
            // the sole writer.
            await WriteMetadataSidecarAsync(
                layout.MetadataPath,
                new TerminalHostMetadata
                {
                    ReplicaId = replicaId,
                    ResourceName = parent.Name,
                    ReplicaIndex = i,
                    AppHostPath = appHostPath,
                    AppHostPid = appHostPid,
                    AppHostProcessIdentity = appHostProcessIdentity,
                    AppHostProcessScopeId = appHostProcessScopeId,
                    AppHostBootId = appHostBootId,
                    CreatedAtUtc = createdAtUtc,
                    Columns = options.Columns,
                    Rows = options.Rows,
                    ControlSocketPath = layout.ControlUdsPath,
                    ConsumerSocketPath = layout.ConsumerUdsPath,
                },
                metadataLogger,
                cancellationToken).ConfigureAwait(false);
 
            terminalHosts[i] = terminalHost;
            replicaIds[i] = replicaId;
        }
 
        // The terminal-host children unlink their sockets on graceful shutdown, while this
        // app-scoped backstop removes metadata and exact-path artifacts left by abrupt exits.
        // The singleton applies one bounded ApplicationStopped wait across every terminal resource.
        @event.Services.GetRequiredService<TerminalHostOrphanCleanupService>()
            .RegisterReplicaArtifacts(trmnlDirectory, replicaIds);
 
        // The target waits until each host has started so its viewer-facing UDS listener
        // is bound before any consumer (Dashboard or CLI) tries to connect. A follow-up
        // pass will switch this to WaitUntilHealthy once each host implements a real
        // health probe.
        if (parent is IResourceWithWaitSupport)
        {
            for (var i = 0; i < terminalHosts.Length; i++)
            {
                parent.Annotations.Add(new WaitAnnotation(terminalHosts[i], WaitType.WaitUntilStarted));
            }
        }
 
        annotation.Initialize(terminalHosts);
    }
 
    private static async Task WriteMetadataSidecarAsync(
        string metadataPath,
        TerminalHostMetadata metadata,
        ILogger? logger,
        CancellationToken cancellationToken)
    {
        var temporaryMetadataPath = TerminalHostPaths.GetMetadataTemporaryPath(metadataPath);
        try
        {
            // Indented for human inspection: the file is small (<1 KiB) and is expected to
            // be `cat`-ed by users debugging terminal-host issues. Performance is irrelevant.
            var json = JsonSerializer.Serialize(metadata, s_metadataSerializerOptions);
 
            // Write and chmod a sibling temporary file before atomically replacing the sidecar.
            // A crash during serialization can then leave only an undiscoverable .tmp file, never
            // a truncated metadata document that permanently blocks orphan recovery.
            using (var fs = new FileStream(
                temporaryMetadataPath,
                FileMode.Create,
                FileAccess.Write,
                FileShare.None,
                bufferSize: 4096,
                useAsync: true))
            {
                if (!OperatingSystem.IsWindows())
                {
                    try
                    {
                        File.SetUnixFileMode(temporaryMetadataPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
                    }
                    catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
                    {
                        // Filesystem may not support chmod (e.g. FAT). The parent dir is 0700
                        // so the file is still unreachable by other users.
                        logger?.LogDebug(ex, "Failed to chmod terminal host metadata file '{Path}'.", temporaryMetadataPath);
                    }
                }
 
                var bytes = System.Text.Encoding.UTF8.GetBytes(json);
                await fs.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
            }
 
            File.Move(temporaryMetadataPath, metadataPath, overwrite: true);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            // Per-run paths cannot collide with another AppHost. A missing sidecar only degrades
            // external discovery and crash recovery for this terminal.
            logger?.LogDebug(ex, "Failed to write terminal host metadata sidecar '{Path}'.", metadataPath);
        }
        finally
        {
            try
            {
                File.Delete(temporaryMetadataPath);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger?.LogDebug(ex, "Failed to delete temporary terminal host metadata file '{Path}'.", temporaryMetadataPath);
            }
        }
    }
 
    private static readonly JsonSerializerOptions s_metadataSerializerOptions = new()
    {
        WriteIndented = true,
    };
 
    private static void ConfigureTerminalHostAnnotations(
        TerminalHostResource host,
        TerminalOptions options,
        int appHostPid,
        long appHostProcessIdentity)
    {
        // Equivalent to the previous WithInitialState(...).ExcludeFromManifest().WithArgs(...) chain
        // but we can't go through IResourceBuilder<T> here — we're running mid-event without an
        // IDistributedApplicationBuilder reference, and creating one against the already-built
        // application is not supported. Adding the annotations directly produces an identical
        // resource state (each helper above is just sugar over Annotations.Add).
        host.Annotations.Add(new ResourceSnapshotAnnotation(new CustomResourceSnapshot
        {
            ResourceType = "TerminalHost",
            State = KnownResourceStates.NotStarted,
            Properties = [],
            // Hidden by default — terminal hosts are an implementation detail of
            // WithTerminal(). Users opt in to seeing them via
            // TerminalOptions.ShowTerminalHost = true when diagnosing host startup /
            // recycle / DCP-connectivity problems.
            IsHidden = !options.ShowTerminalHost,
        }));
 
        host.Annotations.Add(ManifestPublishingCallbackAnnotation.Ignore);
 
        host.Annotations.Add(new EnvironmentCallbackAnnotation(context =>
        {
            context.EnvironmentVariables[KnownConfigNames.TerminalHostParentProcessId] =
                appHostPid.ToString(CultureInfo.InvariantCulture);
            context.EnvironmentVariables[KnownConfigNames.TerminalHostParentProcessStartedStable] =
                appHostProcessIdentity.ToString(CultureInfo.InvariantCulture);
        }));
 
        host.Annotations.Add(new CommandLineArgsCallbackAnnotation(context =>
        {
            context.Args.Add("--producer-uds");
            context.Args.Add(host.Layout.ProducerUdsPath);
 
            context.Args.Add("--consumer-uds");
            context.Args.Add(host.Layout.ConsumerUdsPath);
 
            context.Args.Add("--control-uds");
            context.Args.Add(host.Layout.ControlUdsPath);
 
            context.Args.Add("--columns");
            context.Args.Add(options.Columns.ToString(CultureInfo.InvariantCulture));
 
            context.Args.Add("--rows");
            context.Args.Add(options.Rows.ToString(CultureInfo.InvariantCulture));
 
            return Task.CompletedTask;
        }));
    }
 
#pragma warning restore ASPIRETERMINAL001
 
    /// <summary>
    /// Builds the per-replica UDS triple + metadata path for a single terminal host. All
    /// four files live flat under the configured terminal artifact directory and share the same
    /// <paramref name="replicaId"/> filename prefix.
    /// </summary>
    private static TerminalHostLayout CreateTerminalHostLayout(string trmnlDirectory, string replicaId, int replicaIndex)
    {
        ArgumentException.ThrowIfNullOrEmpty(trmnlDirectory);
        ArgumentException.ThrowIfNullOrEmpty(replicaId);
        ArgumentOutOfRangeException.ThrowIfNegative(replicaIndex);
 
        var producerPath = TerminalHostPaths.GetSocketPath(trmnlDirectory, replicaId, TerminalHostPaths.ProducerSockPurpose);
        var consumerPath = TerminalHostPaths.GetSocketPath(trmnlDirectory, replicaId, TerminalHostPaths.ConsumerSockPurpose);
        var controlPath = TerminalHostPaths.GetSocketPath(trmnlDirectory, replicaId, TerminalHostPaths.ControlSockPurpose);
        var metadataPath = TerminalHostPaths.GetMetadataPath(trmnlDirectory, replicaId);
 
        return new TerminalHostLayout(replicaId, replicaIndex, producerPath, consumerPath, controlPath, metadataPath);
    }
}