File: Lifecycle\TerminalHostEventingSubscriber.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 Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Eventing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Aspire.Hosting.Lifecycle;
 
/// <summary>
/// Resolves the path to the <c>aspire.terminalhost</c> binary on each
/// <see cref="TerminalHostResource"/> before DCP launches it. The hosts themselves are
/// created earlier in the same <see cref="BeforeStartEvent"/> by the subscription
/// installed by <see cref="TerminalResourceBuilderExtensions.WithTerminal{T}(IResourceBuilder{T}, Action{TerminalOptions}?)"/>;
/// they each carry a placeholder <see cref="TerminalHostResource.UnresolvedCommand"/> because
/// <see cref="DcpOptions"/> is not yet configured at <c>WithTerminal()</c> time. This
/// subscriber finalises the executable command before the event completes and DCP picks
/// the resources up.
/// </summary>
/// <remarks>
/// <para>
/// Ordering note: builder-phase event subscriptions (registered by <c>WithTerminal()</c>)
/// fire ahead of DI-registered <see cref="IDistributedApplicationEventingSubscriber"/>
/// instances, so by the time this subscriber runs the per-replica hosts are already in
/// <c>@event.Model.Resources</c>.
/// </para>
/// <para>
/// Each parent replica gets its own <see cref="TerminalHostResource"/>, so this iterates
/// over all of them and resolves each independently. They all point at the same binary
/// (just with different per-replica UDS args).
/// </para>
/// </remarks>
internal sealed class TerminalHostEventingSubscriber(
    IOptions<DcpOptions> dcpOptions,
    ILogger<TerminalHostEventingSubscriber> logger) : IDistributedApplicationEventingSubscriber
{
    private readonly IOptions<DcpOptions> _dcpOptions = dcpOptions ?? throw new ArgumentNullException(nameof(dcpOptions));
    private readonly ILogger<TerminalHostEventingSubscriber> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
 
    /// <inheritdoc />
    public Task SubscribeAsync(IDistributedApplicationEventing eventing, DistributedApplicationExecutionContext executionContext, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(eventing);
        eventing.Subscribe<BeforeStartEvent>(ResolveTerminalHostsAsync);
        return Task.CompletedTask;
    }
 
    private Task ResolveTerminalHostsAsync(BeforeStartEvent @event, CancellationToken cancellationToken)
    {
        var terminalHostPath = _dcpOptions.Value.TerminalHostPath;
        var invocationArgs = ParseInvocationArgs(_dcpOptions.Value.TerminalHostInvocationArgs);
 
        // No need to validate ParentReplicaIndex against the parent's ReplicaAnnotation:
        // WithTerminal() now defers host creation to BeforeStartEvent and reads the final
        // replica count at that point, so by construction the count of hosts always equals
        // the parent's declared replica count.
        foreach (var host in @event.Model.Resources.OfType<TerminalHostResource>())
        {
            if (host.Annotations.OfType<ExecutableAnnotation>().LastOrDefault() is not { } annotation)
            {
                continue;
            }
 
            if (annotation.Command != TerminalHostResource.UnresolvedCommand)
            {
                continue;
            }
 
            if (string.IsNullOrEmpty(terminalHostPath))
            {
                _logger.LogWarning(
                    "Terminal host binary path is not configured. The terminal for resource '{TargetName}' (replica {ReplicaIndex}) will not be available. Set ASPIRE_TERMINAL_HOST_PATH or ensure the Aspire SDK provides the 'aspireterminalhostpath' assembly metadata.",
                    host.Parent.Name, host.ParentReplicaIndex);
                continue;
            }
 
            if (!File.Exists(terminalHostPath))
            {
                _logger.LogWarning(
                    "Terminal host binary not found at '{TerminalHostPath}'. The terminal for resource '{TargetName}' (replica {ReplicaIndex}) will not be available.",
                    terminalHostPath,
                    host.Parent.Name, host.ParentReplicaIndex);
                continue;
            }
 
            annotation.Command = terminalHostPath;
 
            if (invocationArgs.Length > 0)
            {
                // Prepend the invocation args (e.g. "terminalhost") so the multi-mode
                // aspire-managed.exe dispatches to TerminalHostApp.RunAsync. Mirrors how
                // the Dashboard wires "dashboard" via DashboardEventHandlers.
                host.Annotations.Add(new CommandLineArgsCallbackAnnotation(args =>
                {
                    for (var i = 0; i < invocationArgs.Length; i++)
                    {
                        args.Insert(i, invocationArgs[i]);
                    }
                }));
            }
 
            _logger.LogDebug(
                "Resolved terminal host '{HostName}' for target '{TargetName}' replica {ReplicaIndex} to '{TerminalHostPath}' (invocation args: '{InvocationArgs}').",
                host.Name,
                host.Parent.Name,
                host.ParentReplicaIndex,
                terminalHostPath,
                _dcpOptions.Value.TerminalHostInvocationArgs ?? string.Empty);
        }
 
        return Task.CompletedTask;
    }
 
    /// <summary>
    /// Tokenises a raw command-line string from configuration into argv.
    /// Supports POSIX-ish single (<c>'...'</c>) and double (<c>"..."</c>) quoted
    /// tokens so callers can pass arguments that contain spaces or paths with
    /// embedded whitespace, e.g.
    /// <c>ASPIRE_TERMINAL_HOST_INVOCATION_ARGS="--profile 'My Profile' --foo bar"</c>.
    /// Inside double quotes, a backslash escapes <c>"</c> and <c>\</c>; inside
    /// single quotes everything is literal (no escape processing). Outside
    /// quotes, whitespace separates tokens.
    /// </summary>
    /// <remarks>
    /// We don't reach for <c>System.CommandLine.CommandLineStringSplitter</c>
    /// because it is not exposed publicly in System.CommandLine 2.0.x; this
    /// reimplements the common subset we need.
    /// </remarks>
    internal static string[] ParseInvocationArgs(string? raw)
    {
        if (string.IsNullOrWhiteSpace(raw))
        {
            return [];
        }
 
        var result = new List<string>();
        var current = new System.Text.StringBuilder();
        var inDouble = false;
        var inSingle = false;
        var hasContent = false;
 
        for (var i = 0; i < raw.Length; i++)
        {
            var c = raw[i];
 
            if (inDouble)
            {
                if (c == '\\' && i + 1 < raw.Length && (raw[i + 1] == '"' || raw[i + 1] == '\\'))
                {
                    current.Append(raw[++i]);
                    hasContent = true;
                }
                else if (c == '"')
                {
                    inDouble = false;
                }
                else
                {
                    current.Append(c);
                    hasContent = true;
                }
                continue;
            }
 
            if (inSingle)
            {
                if (c == '\'')
                {
                    inSingle = false;
                }
                else
                {
                    current.Append(c);
                    hasContent = true;
                }
                continue;
            }
 
            switch (c)
            {
                case '"':
                    inDouble = true;
                    hasContent = true;
                    break;
                case '\'':
                    inSingle = true;
                    hasContent = true;
                    break;
                case ' ':
                case '\t':
                    if (hasContent)
                    {
                        result.Add(current.ToString());
                        current.Clear();
                        hasContent = false;
                    }
                    break;
                default:
                    current.Append(c);
                    hasContent = true;
                    break;
            }
        }
 
        if (inDouble || inSingle)
        {
            throw new InvalidOperationException(
                $"ASPIRE_TERMINAL_HOST_INVOCATION_ARGS has unterminated {(inDouble ? "double" : "single")} quote.");
        }
 
        if (hasContent)
        {
            result.Add(current.ToString());
        }
 
        return [.. result];
    }
}