// 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.Reflow;
using Microsoft.Extensions.Logging;
#pragma warning disable ASPIRETERMINAL001 // Internal consumer of the experimental AppHost terminal API.
namespace Aspire.Hosting.ApplicationModel;
/// <summary>
/// The Hex1b-backed implementation of <see cref="AspireTerminal"/>.
/// </summary>
/// <remarks>
/// Each viewer is attached to the same HMP1 presentation adapter, allowing several dashboard views to
/// share one terminal. The AppHost owns the workload, so terminal state survives a viewer disconnecting.
/// </remarks>
internal sealed class Hex1bAspireTerminal : ITerminalBackend
{
private readonly HashSet<Task> _clientTasks = [];
// Cancellation requests a stop; workload completion updates viewers; session completion reports that
// teardown has finished. Keeping these separate lets viewers display an ended state without allowing
// the gRPC handler to dispose a transport that Hex1b is still accessing.
private readonly CancellationTokenSource _workloadCts = new();
private readonly TaskCompletionSource _workloadEnded = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _sessionEnded = new(TaskCreationOptions.RunContinuationsAsynchronously);
// Aspire.Hosting targets net8.0, which predates System.Threading.Lock, so this is a plain monitor gate.
private readonly object _gate = new();
private readonly TerminalService _owner;
private readonly Hex1bTerminalBuilder _builder;
private readonly int _columns;
private readonly int _rows;
private readonly ILogger _logger;
private Hmp1PresentationAdapter? _presentation;
private Hex1bTerminal? _terminal;
private Hex1bTerminalAutomator? _automator;
private Task? _runTask;
private Task? _stopTask;
private bool _stopped;
public Hex1bAspireTerminal(TerminalService owner, string id, string title, TerminalPlacement placement, Hex1bTerminalBuilder builder, int columns, int rows, ILogger logger)
{
_owner = owner;
_builder = builder;
_columns = columns;
_rows = rows;
_logger = logger;
Id = id;
Title = title;
Placement = placement;
Handle = new(this);
}
// Creation and lookup must return the same handle because interaction validation checks instance identity.
public AspireTerminal Handle { get; }
public string Id { get; }
public string Title { get; private set; }
public TerminalOwner Owner => TerminalOwner.AppHost;
public TerminalPlacement Placement { get; }
public TerminalDescriptor Descriptor => new(Id, Title);
internal Task WorkloadEnded => _workloadEnded.Task;
public void Start() => EnsureStarted();
public void Show()
{
if (Placement != TerminalPlacement.Dock)
{
// A terminal in a dialog is revealed by that dialog, and one with no placement is not displayed
// at all, so in neither case is there a dock tab to switch to.
return;
}
_owner.NotifyActivated(this);
}
public void Retitle(string title)
{
ArgumentException.ThrowIfNullOrWhiteSpace(title);
lock (_gate)
{
if (string.Equals(Title, title, StringComparison.Ordinal))
{
return;
}
Title = title;
}
_owner.NotifyRetitled(this);
}
/// <summary>
/// Attaches a viewer, starting the workload if this is the first thing to need it.
/// </summary>
/// <returns>
/// A task that completes once this viewer disconnects or the terminal ends, and all operations on the
/// caller's transport have finished. Callers keep their transport open until it completes.
/// </returns>
public async Task AttachAsync(Stream clientStream, Func<CancellationToken, Task> onEnded, CancellationToken cancellationToken)
{
// Hex1b owns and disposes the wrapper, never the gRPC stream. Closing it cancels only this viewer's
// I/O and waits for outstanding accesses, even when Hex1b's other pump is still winding down.
var attachment = new TerminalClientStream(clientStream);
await using var _ = attachment.ConfigureAwait(false);
using var clientCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var registration = cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), cancelled);
var lifetimeEnded = Task.WhenAny(_workloadEnded.Task, attachment.Released, cancelled.Task);
var clientTask = Task.CompletedTask;
lock (_gate)
{
if (!_workloadEnded.Task.IsCompleted)
{
EnsureStarted();
clientTask = RunClientAsync(_presentation!, attachment, lifetimeEnded, clientCts.Token, _workloadCts.Token);
_clientTasks.Add(clientTask);
}
}
try
{
await lifetimeEnded.ConfigureAwait(false);
if (_workloadEnded.Task.IsCompleted)
{
cancellationToken.ThrowIfCancellationRequested();
await onEnded(cancellationToken).ConfigureAwait(false);
}
await Task.WhenAny(_sessionEnded.Task, attachment.Released, cancelled.Task).ConfigureAwait(false);
}
finally
{
try
{
await clientCts.CancelAsync().ConfigureAwait(false);
await clientTask.ConfigureAwait(false);
}
finally
{
lock (_gate)
{
_clientTasks.Remove(clientTask);
}
}
}
}
private async Task RunClientAsync(
Hmp1PresentationAdapter presentation,
TerminalClientStream attachment,
Task lifetimeEnded,
CancellationToken clientCancellationToken,
CancellationToken workloadCancellationToken)
{
// Keep handshake I/O off the thread holding _gate. Task registration and shutdown's snapshot
// share that lock, so a stalled viewer neither blocks other viewers nor escapes cleanup.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(clientCancellationToken, workloadCancellationToken);
await Task.Yield();
try
{
var client = await presentation.AddClient(attachment, cts.Token).ConfigureAwait(false);
await using var _ = client.ConfigureAwait(false);
await lifetimeEnded.ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException or InvalidOperationException)
{
_logger.LogDebug(ex, "Terminal {TerminalId} viewer connection ended.", Id);
}
finally
{
// AddClient can fail before Hex1b owns the stream (for example before ClientHello). Always
// release our wrapper, but leave the underlying gRPC transport with its caller.
await attachment.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Starts the workload if it is not already running.
/// </summary>
/// <remarks>
/// Callers start a terminal explicitly through <see cref="Start"/>. This remains the backstop for the two
/// paths that cannot function without a running workload — a viewer attaching and an automation call — so
/// that forgetting to start is a late start rather than a hard failure.
/// </remarks>
private Hex1bTerminal EnsureStarted()
{
lock (_gate)
{
if (_stopped || _workloadEnded.Task.IsCompleted)
{
throw new InvalidOperationException($"Terminal '{Id}' has already stopped.");
}
if (_terminal is not null)
{
return _terminal;
}
// WithHmp1Server creates an 80x24 adapter regardless of WithDimensions. Supply the adapter
// directly so the PTY starts at the requested size, before any viewer can resize it.
// Revisit the manual client wiring when https://github.com/mitchdenny/hex1b/issues/548 is fixed.
_presentation = new Hmp1PresentationAdapter(_columns, _rows);
_terminal = _builder
.WithDimensions(_columns, _rows)
.WithPresentation(_presentation)
.WithReflow(GhosttyReflowStrategy.Instance)
.WithScrollback(10000)
.Build();
_automator = new Hex1bTerminalAutomator(_terminal, TerminalAutomation.DefaultTimeout);
_logger.LogDebug("Starting terminal {TerminalId} ({Title}).", Id, Title);
_runTask = RunTerminalAsync(_terminal);
return _terminal;
}
}
private async Task RunTerminalAsync(Hex1bTerminal terminal)
{
// Yield before touching the terminal so RunAsync never executes inline under _gate.
await Task.Yield();
try
{
await terminal.RunAsync(_workloadCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected when the terminal is disposed while the workload is still running. Logged so the lifecycle
// reads end-to-end alongside the "Starting terminal" entry above -- otherwise a cancelled workload is
// indistinguishable from one that is still running.
_logger.LogDebug("Terminal {TerminalId} ({Title}) workload was cancelled because the terminal is being disposed.", Id, Title);
}
catch (Exception ex)
{
_logger.LogError(ex, "Terminal {TerminalId} ({Title}) failed unexpectedly and its session has ended.", Id, Title);
}
finally
{
Task[] clients;
lock (_gate)
{
// Hex1b cannot serve completion to later HMP clients. Keep Aspire's registry entry (and dock tab),
// but report completion ourselves rather than attaching to the disposed terminal.
// Replace the separate notification when native ended-session support is available:
// https://github.com/mitchdenny/hex1b/issues/483.
_workloadEnded.TrySetResult();
clients = [.. _clientTasks];
}
// Complete _sessionEnded only after disposal. Unlike _workloadEnded's UI notification, this signal
// releases attached clients; Hex1b may still write to their transports during teardown.
try
{
try
{
// Natural process exit must also cancel handshakes that have not sent ClientHello;
// those streams are not yet owned by the presentation adapter.
await _workloadCts.CancelAsync().ConfigureAwait(false);
await Task.WhenAll(clients).ConfigureAwait(false);
}
finally
{
await terminal.DisposeAsync().ConfigureAwait(false);
}
_sessionEnded.TrySetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Disposing terminal {TerminalId} ({Title}) failed.", Id, Title);
_sessionEnded.TrySetException(ex);
}
}
}
public async Task SendTextAsync(string text, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(text);
EnsureStarted();
await TerminalAutomation.SendTextAsync(_automator!, text, cancellationToken).ConfigureAwait(false);
}
public async Task SendKeyAsync(AspireTerminalKey key, CancellationToken cancellationToken = default)
{
var terminal = EnsureStarted();
await TerminalAutomation.SendKeyAsync(terminal, _automator!, key, cancellationToken).ConfigureAwait(false);
}
public async Task WaitForTextAsync(string text, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(text);
EnsureStarted();
await TerminalAutomation.WaitForTextAsync(_automator!, Id, text, timeout, cancellationToken).ConfigureAwait(false);
}
public string GetScreenText()
{
lock (_gate)
{
if (_stopped || _workloadEnded.Task.IsCompleted)
{
throw new InvalidOperationException($"Terminal '{Id}' has already stopped.");
}
return TerminalAutomation.GetScreenText(_automator);
}
}
/// <summary>
/// Stops the workload without notifying the owning service. Used when the service is tearing everything down.
/// </summary>
public Task StopAsync()
{
lock (_gate)
{
if (_stopTask is not null)
{
return _stopTask;
}
_stopped = true;
if (_runTask is null)
{
// Registered but never started, so there is nothing to wind down.
_workloadCts.Dispose();
_workloadEnded.TrySetResult();
_sessionEnded.TrySetResult();
return _stopTask = _sessionEnded.Task;
}
return _stopTask = StopCoreAsync();
}
}
private async Task StopCoreAsync()
{
// Cancellation callbacks must not run under _gate or prevent other terminals from beginning shutdown.
await Task.Yield();
try
{
// Cancellation interrupts Hex1b's process-exit wait. Its PTY disposal forcibly terminates any
// remaining child, so join that disposal rather than relying on the AppHost process exiting.
await Task.WhenAll(_workloadCts.CancelAsync(), _sessionEnded.Task).ConfigureAwait(false);
}
finally
{
_workloadCts.Dispose();
}
}
public ValueTask DisposeAsync() => _owner.DisposeTerminalAsync(this);
}