File: Commands\TerminalTapePlayCommand.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.Globalization;
using System.Net.Sockets;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Hex1b;
using Hex1b.Automation;
using Hex1b.Reflow;
using Hex1b.Tokens;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Plays a tape against an existing resource terminal without owning its process or dimensions.
/// </summary>
internal sealed class TerminalTapePlayCommand : BaseCommand
{
    // CancellationTokenSource's underlying timer accepts at most uint.MaxValue - 1 milliseconds.
    private const int MaximumTimeoutSeconds = (int)((uint.MaxValue - 1) / 1000);
    internal override HelpGroup HelpGroup => HelpGroup.Monitoring;
 
    private readonly AppHostConnectionResolver _connectionResolver;
    private readonly TerminalResourceResolver _terminalResolver;
    private readonly ILogger<TerminalTapePlayCommand> _logger;
    private readonly TimeProvider _timeProvider;
    private readonly Argument<string> _resourceArgument = new("resource")
    {
        Description = TerminalCommandStrings.ResourceArgumentDescription
    };
    private readonly Option<string> _tapeFileOption = new("--tape-file")
    {
        Description = TerminalCommandStrings.TapeFileDescription,
        Required = true
    };
    private readonly Option<int?> _replicaOption = new("--replica", "-r")
    {
        Description = TerminalCommandStrings.ReplicaOptionDescription
    };
    private readonly OptionWithLegacy<FileInfo?> _appHostOption =
        new("--apphost", "--project", SharedCommandStrings.AppHostOptionDescription);
    private readonly Option<int> _timeoutOption = new("--timeout")
    {
        Description = TerminalCommandStrings.TapeTimeoutDescription,
        DefaultValueFactory = _ => 120
    };
 
    public TerminalTapePlayCommand(
        AppHostConnectionResolver connectionResolver,
        TerminalResourceResolver terminalResolver,
        ILogger<TerminalTapePlayCommand> logger,
        TimeProvider timeProvider,
        CommonCommandServices services) : base("play", TerminalCommandStrings.TapePlayDescription, services)
    {
        _connectionResolver = connectionResolver;
        _terminalResolver = terminalResolver;
        _logger = logger;
        _timeProvider = timeProvider;
        Arguments.Add(_resourceArgument);
        Options.Add(_tapeFileOption);
        Options.Add(_replicaOption);
        Options.Add(_appHostOption);
        Options.Add(_timeoutOption);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        using var activity = Telemetry.StartDiagnosticActivity("terminal tape play");
        // The final screen is the command's stdout payload; discovery, warnings and failures belong on stderr.
        InteractionService.Console = ConsoleOutput.Error;
        var resourceName = parseResult.GetValue(_resourceArgument)!;
        var tapePath = parseResult.GetValue(_tapeFileOption)!;
        var timeoutSeconds = parseResult.GetValue(_timeoutOption);
        if (string.IsNullOrWhiteSpace(resourceName))
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, TerminalCommandStrings.ResourceRequired);
        }
        if (timeoutSeconds is <= 0 or > MaximumTimeoutSeconds)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand,
                string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeTimeoutInvalid, MaximumTimeoutSeconds));
        }
 
        FileInfo file;
        TapeDocument tape;
        try
        {
            file = new FileInfo(Path.GetFullPath(tapePath, ExecutionContext.WorkingDirectory.FullName));
            tape = await new TapeParser().ParseAsync(file, cancellationToken).ConfigureAwait(false);
        }
        catch (TapeParseException ex)
        {
            DisplayDiagnostics(ex.Diagnostics);
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand,
                string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeFileReadFailed, tapePath, ex.Message));
        }
 
        var connectionResult = await _connectionResolver.ResolveConnectionAsync(
            parseResult.GetValue(_appHostOption),
            SharedCommandStrings.ScanningForRunningAppHosts,
            string.Format(CultureInfo.CurrentCulture, SharedCommandStrings.SelectAppHost, TerminalCommandStrings.TapeSelectAppHostAction),
            SharedCommandStrings.AppHostNotRunning,
            cancellationToken).ConfigureAwait(false);
        if (!connectionResult.Success)
        {
            return CommandResult.FromExitCode(AppHostConnectionResultHandler.DisplayFailureAsError(
                connectionResult, InteractionService, CliExitCodes.FailedToFindProject));
        }
        if (!connectionResult.Connection.SupportsTerminalsV1)
        {
            return CommandResult.Failure(CliExitCodes.AppHostIncompatible, TerminalCommandStrings.TerminalIncompatible);
        }
 
        var (canonicalName, replica) = await _terminalResolver.ResolveAsync(
            connectionResult.Connection, resourceName, parseResult.GetValue(_replicaOption), cancellationToken).ConfigureAwait(false);
        if (replica is null)
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
 
        using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds), _timeProvider);
        using var playback = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
        var disconnected = 0;
        try
        {
            // A false IsAlive only means no producer is currently attached, not permanent exit.
            // DCP can attach after startup or a recycle, even when ExitCode describes a previous cycle.
            // Refresh the selected replica's endpoint without reprompting or resetting the playback budget.
            while (!replica.IsAlive)
            {
                await Task.Delay(TimeSpan.FromMilliseconds(100), _timeProvider, playback.Token).ConfigureAwait(false);
                var info = await connectionResult.Connection.GetTerminalInfoAsync(canonicalName, playback.Token).ConfigureAwait(false);
                if (info.IsAvailable && info.Replicas is { } replicas &&
                    Array.Find(replicas, r => r.ReplicaIndex == replica.ReplicaIndex) is { } refreshedReplica)
                {
                    replica = refreshedReplica;
                }
            }
            playback.Token.ThrowIfCancellationRequested();
 
            await using var adapter = new Hmp1WorkloadAdapter(new Hmp1ClientOptions
            {
                StreamFactory = async ct => await Hmp1Transports.ConnectUnixSocket(replica.ConsumerUdsPath, ct).ConfigureAwait(false),
                DefaultRole = Hmp1Role.Secondary,
                DisplayName = $"aspire-tape:{Environment.ProcessId}",
                OnDisconnected = _ =>
                {
                    Interlocked.Exchange(ref disconnected, 1);
                    // HMP can ignore writes after disconnection. Cancel the player instead of reporting a
                    // successful tape whose input never reached the resource.
                    playback.Cancel();
                    return Task.CompletedTask;
                }
            });
            await adapter.ConnectAsync(playback.Token).ConfigureAwait(false);
 
            var initialScreen = new InitialScreenFilter();
            // A preconnected workload starts the mirror's pumps during Build(). No local process is created,
            // and no scrollback is enabled: VHS Wait+Screen must inspect this mirror's visible screen.
            await using var terminal = Hex1bTerminal.CreateBuilder()
                .WithHeadless()
                .WithReflow(GhosttyReflowStrategy.Instance)
                .WithWorkload(adapter)
                .WithDimensions(adapter.RemoteWidth, adapter.RemoteHeight)
                .AddPresentationFilter(initialScreen)
                .Build();
            await initialScreen.Ready.WaitAsync(playback.Token).ConfigureAwait(false);
 
            var player = new TapePlayer();
            var options = new TapePlaybackOptions
            {
                WorkingDirectory = file.DirectoryName
            };
            var validation = await player.ValidateAsync(tape, terminal, options, playback.Token).ConfigureAwait(false);
            DisplayDiagnostics(validation.Diagnostics);
            if (!validation.CanExecute)
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand);
            }
 
            using var result = await player.PlayAsync(tape, terminal, options, playback.Token).ConfigureAwait(false);
            playback.Token.ThrowIfCancellationRequested();
            DisplayDiagnostics(result.Diagnostics.Except(validation.Diagnostics));
            InteractionService.DisplayRawText(result.FinalSnapshot.GetScreenText(), ConsoleOutput.Standard);
            return CommandResult.Success();
        }
        catch (TapeValidationException ex)
        {
            DisplayDiagnostics(ex.Diagnostics);
            return CommandResult.Failure(CliExitCodes.InvalidCommand);
        }
        catch (TapePlaybackException ex)
        {
            InteractionService.DisplayRawText(ex.TerminalText, ConsoleOutput.Standard);
            // The native diagnostic already contains the failing source span and command context.
            return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, ex.Message);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (OperationCanceledException) when (deadline.IsCancellationRequested)
        {
            return CommandResult.Failure(CliExitCodes.WaitTimeout,
                string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapeTimeout, timeoutSeconds));
        }
        catch (OperationCanceledException) when (Volatile.Read(ref disconnected) != 0)
        {
            return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand, TerminalCommandStrings.TapeConnectionClosed);
        }
        catch (Exception ex) when (ex is IOException or SocketException or TimeoutException)
        {
            _logger.LogDebug(ex, "Terminal tape connection failed for {ResourceName}, replica {ReplicaIndex}.", canonicalName, replica.ReplicaIndex);
            return CommandResult.Failure(CliExitCodes.FailedToExecuteResourceCommand,
                string.Format(CultureInfo.CurrentCulture, TerminalCommandStrings.TapePlaybackFailed, ex.Message));
        }
    }
 
    private void DisplayDiagnostics(IEnumerable<TapeDiagnostic> diagnostics)
    {
        foreach (var diagnostic in diagnostics)
        {
            var span = diagnostic.Span;
            InteractionService.DisplayRawText(
                FormattableString.Invariant($"{span.SourceName}:{span.Line}:{span.Column}: {diagnostic.Severity} {diagnostic.Code}: {diagnostic.Message}"),
                ConsoleOutput.Error);
        }
    }
 
    private sealed class InitialScreenFilter : IHex1bTerminalPresentationFilter
    {
        private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
        public Task Ready => _ready.Task;
 
        public ValueTask<IReadOnlyList<AnsiToken>> OnOutputAsync(
            IReadOnlyList<AppliedToken> appliedTokens, TimeSpan elapsed, CancellationToken cancellationToken = default)
        {
            // This pin exposes no public initial-replay barrier. A fresh headless HMP mirror first calls its
            // presentation filters after committing the authoritative screen, even when it is empty.
            // https://github.com/mitchdenny/hex1b/blob/496ccf508470eed8744dbe46675e3d26928e8c91/src/Hex1b/Hmp1/Hex1bTerminal.Hmp1Replay.cs#L117-L149
            _ready.TrySetResult();
            return ValueTask.FromResult<IReadOnlyList<AnsiToken>>(appliedTokens.Select(t => t.Token).ToArray());
        }
 
        public ValueTask OnSessionStartAsync(int width, int height, DateTimeOffset timestamp, CancellationToken cancellationToken = default)
            => ValueTask.CompletedTask;
        public ValueTask OnInputAsync(IReadOnlyList<AnsiToken> tokens, TimeSpan elapsed, CancellationToken cancellationToken = default)
            => ValueTask.CompletedTask;
        public ValueTask OnResizeAsync(int width, int height, TimeSpan elapsed, CancellationToken cancellationToken = default)
            => ValueTask.CompletedTask;
        public ValueTask OnSessionEndAsync(TimeSpan elapsed, CancellationToken cancellationToken = default)
            => ValueTask.CompletedTask;
    }
}