File: ApplicationModel\TerminalAutomation.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 Hex1b;
using Hex1b.Automation;
using Hex1b.Input;
 
#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.ApplicationModel;
 
/// <summary>
/// The shared implementation of <see cref="AspireTerminal"/>'s automation members.
/// </summary>
/// <remarks>
/// Every terminal Aspire exposes is ultimately a <see cref="Hex1bTerminal"/>, whether its workload runs in the
/// AppHost or in a resource's terminal host that this process is merely connected to as a peer. Only the way
/// that terminal is obtained differs, so the automation semantics — cancellation layering, exception
/// translation, snapshot disposal — live here once rather than in each implementation.
/// </remarks>
internal static class TerminalAutomation
{
    /// <summary>
    /// How long the wait helpers poll for before giving up when the caller does not specify a timeout.
    /// </summary>
    public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
 
    public static Task SendTextAsync(Hex1bTerminalAutomator automator, string text, CancellationToken cancellationToken)
        => ObserveInputAsync(automator.TypeAsync(text, cancellationToken), cancellationToken);
 
    public static Task SendKeyAsync(
        Hex1bTerminal terminal,
        Hex1bTerminalAutomator automator,
        AspireTerminalKey key,
        CancellationToken cancellationToken)
    {
        if (key.Key is >= Hex1bKey.A and <= Hex1bKey.Z &&
            (key.Modifiers & (Hex1bModifiers.Alt | Hex1bModifiers.Control)) == Hex1bModifiers.Alt)
        {
            // Hex1b's automator drops the printable text for Alt+letter, so its encoder sends nothing.
            // Send the legacy ESC+letter sequence (for example, ESC e for Alt+E) as one input write.
            // Remove this workaround when https://github.com/mitchdenny/hex1b/issues/550 is fixed.
            var firstLetter = (key.Modifiers & Hex1bModifiers.Shift) != 0 ? 'A' : 'a';
            var letter = (byte)(firstLetter + (key.Key - Hex1bKey.A));
            return terminal.SendInputAsync([0x1b, letter], cancellationToken);
        }
 
        return ObserveInputAsync(automator.KeyAsync(key.Key, key.Modifiers, cancellationToken), cancellationToken);
    }
 
    private static async Task ObserveInputAsync(Task operation, CancellationToken cancellationToken)
    {
        try
        {
            await operation.ConfigureAwait(false);
        }
        catch (Hex1bAutomationException ex) when (cancellationToken.IsCancellationRequested &&
            ex.InnerException is OperationCanceledException canceled && canceled.CancellationToken == cancellationToken)
        {
            // Hex1b wraps canceled input steps as automation failures. Preserve normal cancellation semantics,
            // but do not hide unrelated failures just because the caller canceled at the same time.
            throw new OperationCanceledException("Terminal input was canceled.", ex, cancellationToken);
        }
    }
 
    public static async Task WaitForTextAsync(
        Hex1bTerminalAutomator automator,
        string terminalId,
        string text,
        TimeSpan? timeout,
        CancellationToken cancellationToken)
    {
        // Hex1b's wait takes a timeout but no token, so the caller's cancellation is layered on here. The
        // underlying wait keeps running until its timeout elapses; that is acceptable because it is a passive
        // screen poll with no side effects.
        var wait = automator.WaitUntilTextAsync(text, timeout ?? DefaultTimeout);
        var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled);
 
        var completed = await Task.WhenAny(wait, cancelled.Task).ConfigureAwait(false);
        if (completed != wait)
        {
            // The wait is abandoned rather than awaited, so nothing would observe the WaitUntilTimeoutException it
            // raises when its own timeout later elapses. An unobserved faulted task surfaces on
            // TaskScheduler.UnobservedTaskException, which is a process-wide event an AppHost may treat as fatal.
            _ = wait.ContinueWith(
                static t => _ = t.Exception,
                CancellationToken.None,
                TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default);
 
            cancellationToken.ThrowIfCancellationRequested();
        }
 
        try
        {
            await wait.ConfigureAwait(false);
        }
        catch (Exception ex) when (FindWaitTimeout(ex) is { } timedOut)
        {
            // Translate so callers never have to reference Hex1b to handle a timeout.
            throw new TimeoutException($"Terminal '{terminalId}' did not display the expected text within the timeout.", timedOut);
        }
    }
 
    /// <summary>
    /// Finds the wait timeout inside an automation failure, or <see langword="null"/> when the failure was
    /// caused by something else.
    /// </summary>
    /// <remarks>
    /// The automator reports a failed step by wrapping the step's own exception in a
    /// <see cref="Hex1bAutomationException"/> carrying the step history, so a timeout does not arrive as a bare
    /// <see cref="WaitUntilTimeoutException"/>. The chain is walked rather than unwrapped one level because the
    /// nesting depth is an implementation detail of the automator. Only a timeout is translated: any other
    /// automation failure is a real fault and keeps its original type.
    /// </remarks>
    private static WaitUntilTimeoutException? FindWaitTimeout(Exception exception)
    {
        for (var current = exception; current is not null; current = current.InnerException)
        {
            if (current is WaitUntilTimeoutException timedOut)
            {
                return timedOut;
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Reads the current screen, treating a terminal that has no automator yet as an empty screen.
    /// </summary>
    /// <remarks>
    /// A terminal that has never been attached to or driven has no screen yet. Reporting empty is friendlier
    /// than starting the workload, or dialling a socket, as a side effect of a read.
    /// </remarks>
    public static string GetScreenText(Hex1bTerminalAutomator? automator)
    {
        if (automator is null)
        {
            return string.Empty;
        }
 
        // The snapshot holds pooled buffers, so it must be released rather than left to finalization.
        using var snapshot = automator.CreateSnapshot();
        return snapshot.GetScreenText();
    }
}