| 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(); } }