File: Terminals\Hex1bAspireTerminalTests.cs
Web Access
Project: src\tests\Aspire.Hosting.Tests\Aspire.Hosting.Tests.csproj (Aspire.Hosting.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using System.Globalization;
using System.IO.Pipelines;
using System.Text;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Hex1b;
using Hex1b.Automation;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
 
#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API.
#pragma warning disable ASPIREFILESYSTEM001 // Use the hosting temporary directory abstraction.
 
namespace Aspire.Hosting.Tests.Terminals;
 
[Trait("Partition", "2")]
public class Hex1bAspireTerminalTests
{
    [Theory]
    [InlineData("key", true)]
    [InlineData("key", false)]
    [InlineData("modified-key", true)]
    [InlineData("modified-key", false)]
    [InlineData("alt-letter", true)]
    [InlineData("alt-letter", false)]
    [InlineData("text", true)]
    [InlineData("text", false)]
    public async Task SendInput_CallerCancellationPreservesTokenAndTerminal(string inputKind, bool preCanceled)
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        var input = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var inputReader = input.Reader.AsStream();
        await using var inputWriter = input.Writer.AsStream();
        using var gated = new GatedTerminalWriteStream(inputWriter);
        await using var terminal = service.CreateTerminal("Cancellation", TerminalPlacement.None,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, gated)), 80, 24);
        using var cts = new CancellationTokenSource();
        if (preCanceled)
        {
            cts.Cancel();
        }
 
        var blockingInput = Task.CompletedTask;
        try
        {
            if (!preCanceled)
            {
                // Hold Hex1b's input lock so cancellation happens while the next operation waits to write,
                // not inside StreamWorkloadAdapter, which suppresses cancellation from its own stream.
                blockingInput = terminal.SendKeyAsync(AspireTerminalKey.Alt(AspireTerminalKey.E));
                await gated.WriteStarted.DefaultTimeout();
            }
 
            var operation = inputKind switch
            {
                "key" => terminal.SendKeyAsync(AspireTerminalKey.Enter, cts.Token),
                "modified-key" => terminal.SendKeyAsync(AspireTerminalKey.Ctrl(AspireTerminalKey.R), cts.Token),
                "alt-letter" => terminal.SendKeyAsync(AspireTerminalKey.Alt(AspireTerminalKey.E), cts.Token),
                "text" => terminal.SendTextAsync("canceled", cts.Token),
                _ => throw new InvalidOperationException($"Unknown input kind '{inputKind}'.")
            };
            if (!preCanceled)
            {
                Assert.False(operation.IsCompleted);
                await cts.CancelAsync();
            }
 
            var exception = await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation).DefaultTimeout();
            Assert.Equal(cts.Token, exception.CancellationToken);
            Assert.True(operation.IsCanceled);
        }
        finally
        {
            gated.ReleaseWrite();
            await blockingInput.DefaultTimeout();
        }
 
        await terminal.SendTextAsync("after").DefaultTimeout();
        var expected = Encoding.UTF8.GetBytes(preCanceled ? "after" : "\x1b" + "eafter");
        var bytes = new byte[expected.Length];
        await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout();
        Assert.Equal(expected, bytes);
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(false, true)]
    [InlineData(true, false)]
    [InlineData(true, true)]
    public async Task SendKey_UnrelatedFailuresAreNotConvertedToCallerCancellation(bool cancelCaller, bool unrelatedCancellation)
    {
        await using var service = TestTerminalService.Create();
        using var cts = new CancellationTokenSource();
        Exception expected = unrelatedCancellation
            ? new OperationCanceledException(new CancellationToken(canceled: true))
            : new IOException("Input failed.");
        var workload = new GatedTerminalWorkloadAdapter
        {
            OnWriteInput = (_, _) =>
            {
                if (cancelCaller)
                {
                    cts.Cancel();
                }
                return ValueTask.FromException(expected);
            }
        };
        workload.ReleaseDispose();
        await using var terminal = service.CreateTerminal("Failed input", TerminalPlacement.None,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
 
        var exception = await Assert.ThrowsAsync<Hex1bAutomationException>(
            () => terminal.SendKeyAsync(AspireTerminalKey.Enter, cts.Token)).DefaultTimeout();
 
        Assert.Same(expected, exception.InnerException);
    }
 
    [Theory]
    [InlineData(80, 24)]
    [InlineData(82, 28)]
    [InlineData(160, 48)]
    [InlineData(40, 12)]
    public async Task CreateTerminal_InitialScreenUsesRequestedDimensions(int columns, int rows)
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var terminal = service.CreateTerminal("Initial grid", TerminalPlacement.None,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), columns, rows);
        terminal.Start();
        await outputWriter.WriteAsync("ready"u8.ToArray());
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
 
        var lines = terminal.GetScreenText().Split('\n');
        Assert.Equal(rows, lines.Length);
        Assert.All(lines, line => Assert.Equal(columns, line.Length));
    }
 
    [Theory]
    [InlineData(null, null)]
    [InlineData(80, 24)]
    [InlineData(82, 28)]
    [InlineData(160, 48)]
    [InlineData(40, 12)]
    public async Task CreateTerminal_HeadlessProcessRetainsInitialDimensions(int? columns, int? rows)
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses POSIX stty.");
 
        var options = new TerminalLaunchOptions
        {
            Title = "Headless dimensions",
            Executable = "/bin/sh",
            Arguments = ["-c", ReportDimensionsScript],
            Placement = TerminalPlacement.None
        };
        if (columns is { } width)
        {
            options.Columns = width;
        }
        if (rows is { } height)
        {
            options.Rows = height;
        }
 
        var expectedColumns = options.Columns;
        var expectedRows = options.Rows;
        await using var service = TestTerminalService.Create();
        await using var terminal = service.CreateTerminal(options);
        // Creation captures the initial dimensions; later edits to the options must not alter startup.
        options.Columns = 1;
        options.Rows = 1;
        terminal.Start();
        await terminal.WaitForTextAsync("initial-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "initial", expectedColumns, expectedRows);
 
        await terminal.SendTextAsync("automation\r").DefaultTimeout();
        await terminal.WaitForTextAsync("automation-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "automation", expectedColumns, expectedRows);
    }
 
    [Theory]
    [InlineData(TerminalPlacement.Dock)]
    [InlineData(TerminalPlacement.Dialog)]
    public async Task CreateTerminal_ViewerCanResizeInitiallySizedProcess(TerminalPlacement placement)
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses POSIX stty.");
 
        await using var service = TestTerminalService.Create();
        await using var terminal = service.CreateTerminal(new TerminalLaunchOptions
        {
            Title = "Viewer dimensions",
            Executable = "/bin/sh",
            Arguments = ["-c", ReportDimensionsScript],
            Placement = placement,
            Columns = 82,
            Rows = 28
        });
        terminal.Start();
        await terminal.WaitForTextAsync("initial-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "initial", 82, 28);
 
        await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        await terminal.SendTextAsync("attached\r").DefaultTimeout();
        await terminal.WaitForTextAsync("attached-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "attached", 82, 28);
 
        await viewer.ResizeAsync(100, 30);
        await terminal.SendTextAsync("resized\r").DefaultTimeout();
        await terminal.WaitForTextAsync("resized-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "resized", 100, 30);
 
        await viewer.ResizeAsync(60, 18);
        await terminal.SendTextAsync("narrowed\r").DefaultTimeout();
        await terminal.WaitForTextAsync("narrowed-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "narrowed", 60, 18);
 
        await viewer.DisconnectPeerAsync().DefaultTimeout();
        await terminal.SendTextAsync("detached\r").DefaultTimeout();
        await terminal.WaitForTextAsync("detached-ready").DefaultTimeout();
        AssertReportedDimensions(terminal, "detached", 60, 18);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task CreateTerminal_UsesProcessOptions(bool overrideEnvironment)
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses a POSIX shell.");
 
        var home = Environment.GetEnvironmentVariable("HOME");
        var path = Environment.GetEnvironmentVariable("PATH");
        Assert.NotNull(home);
        Assert.NotNull(path);
        using var configuration = new ConfigurationManager();
        var fileSystem = new FileSystemService(configuration);
        using var directory = fileSystem.TempDirectory.CreateTempSubdirectory("terminal-options-");
        await File.WriteAllTextAsync(Path.Combine(directory.Path, "working-directory-marker"), string.Empty);
 
        // Positional arguments preserve spaces without shell interpolation. Keep the process reading after
        // "ready" so its screen remains available until the test disposes the terminal.
        const string script = """
            set -eu
            printf '%s\n' "$1"
            test "$HOME" = "$2"
            printf 'inherited-home\n'
            test "$PATH" = "$3"
            test "${ASPIRE_TERMINAL_TEST_SETTING-}" = "$4"
            printf 'environment-ok\n'
            test -f working-directory-marker
            printf 'working-directory-ok\n'
            printf 'ready\n'
            read -r input
            """;
        var options = new TerminalLaunchOptions
        {
            Title = "Launch options",
            Placement = TerminalPlacement.None,
            Executable = "/bin/sh",
            Arguments = ["-c", script, "terminal-options", "argument with spaces", home,
                overrideEnvironment ? "/usr/bin:/bin" : path, overrideEnvironment ? "value with spaces" : string.Empty],
            WorkingDirectory = directory.Path
        };
        if (overrideEnvironment)
        {
            options.EnvironmentVariables["PATH"] = "/usr/bin:/bin";
            options.EnvironmentVariables["ASPIRE_TERMINAL_TEST_SETTING"] = "value with spaces";
        }
 
        await using var service = TestTerminalService.Create();
        await using var terminal = service.CreateTerminal(options);
        terminal.Start();
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
 
        Assert.Equal(
            ["argument with spaces", "inherited-home", "environment-ok", "working-directory-ok", "ready"],
            terminal.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task DisposeAsync_TerminatesPtyProcessIgnoringHangupAndTermination(bool disposeService)
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses POSIX signals.");
 
        await using var service = TestTerminalService.Create();
        await using var terminal = service.CreateTerminal(new TerminalLaunchOptions
        {
            Title = "Signal-resistant process",
            Placement = TerminalPlacement.None,
            Executable = "/bin/sh",
            // Ignored signals survive exec. The fixed sleep is a backstop if the test host is killed.
            Arguments = ["-c", "trap '' HUP TERM; printf 'pid:%s\\nprocess-ready\\n' \"$$\"; exec sleep 300"]
        });
        terminal.Start();
        await terminal.WaitForTextAsync("process-ready").DefaultTimeout();
 
        // The workload emits "pid:12345\r\nprocess-ready\r\n"; terminal rows can have trailing spaces.
        var pidLine = Assert.Single(
            terminal.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
            line => line.StartsWith("pid:", StringComparison.Ordinal));
        var pid = int.Parse(pidLine["pid:".Length..], CultureInfo.InvariantCulture);
        using var process = Process.GetProcessById(pid);
        try
        {
            Assert.False(process.HasExited);
            var disposal = disposeService ? service.DisposeAsync().AsTask() : terminal.DisposeAsync().AsTask();
            await disposal.DefaultTimeout();
 
            Assert.True(process.HasExited);
            Assert.False(service.TryGetTerminal(terminal.Id, out _));
        }
        finally
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
                await process.WaitForExitAsync().DefaultTimeout();
            }
        }
    }
 
    [Theory]
    [InlineData(TerminalPlacement.Dock)]
    [InlineData(TerminalPlacement.Dialog)]
    public async Task Resize_ReflowsMainScreenAndRetainsHistory(TerminalPlacement placement)
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var terminal = service.CreateTerminal("Reflow", placement,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24);
        await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        var lines = Enumerable.Range(0, 7).Select(i => $"{i}:" + new string('x', 63) + "-END").ToArray();
        var expected = string.Join('\n', lines.Select(line => line.PadRight(80)).Append("ready"));
        await outputWriter.WriteAsync(Encoding.UTF8.GetBytes(string.Join("\r\n", lines) + "\r\nready"));
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
        Assert.Equal(expected, terminal.GetScreenText().TrimEnd());
 
        // Narrowing pushes wrapped rows into history. Widening must restore them without new output.
        await viewer.ResizeAsync(20, 4);
        await viewer.ResizeAsync(80, 24);
        Assert.Equal(expected, terminal.GetScreenText().TrimEnd());
    }
 
    [Fact]
    public async Task Resize_CropsAlternateScreenAndReflowsSavedMainScreen()
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var terminal = service.CreateTerminal("Alternate", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24);
        await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        const string main = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-END";
        await outputWriter.WriteAsync(Encoding.UTF8.GetBytes(main + "\r\nready"));
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
        // DECSET 1049 enters the alternate screen; CUP positions a fixed-layout row.
        await outputWriter.WriteAsync("\u001b[?1049h\u001b[HALTERNATE-ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\nalt-ready"u8.ToArray());
        await terminal.WaitForTextAsync("alt-ready").DefaultTimeout();
 
        await viewer.ResizeAsync(20, 24);
        Assert.Equal("ALTERNATE-ABCDEFGHIJ\nalt-ready", terminal.GetScreenText().TrimEnd());
        await outputWriter.WriteAsync("\u001b[?1049l"u8.ToArray());
        await terminal.WaitForTextAsync("ABCDEFGHIJKLMNOPQRST").DefaultTimeout();
        Assert.Equal(string.Join('\n', main.Chunk(20).Select(chunk => new string(chunk).PadRight(20)).Append("ready")),
            terminal.GetScreenText().TrimEnd());
        await viewer.ResizeAsync(80, 24);
        Assert.Equal(main.PadRight(80) + "\nready", terminal.GetScreenText().TrimEnd());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WorkloadExit_EndsAutomationAndKeepsTabUntilDisposed(bool attachViewer)
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        var workload = new StreamWorkloadAdapter(outputReader, Stream.Null);
        await using var terminal = service.CreateTerminal("Ended", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
        await using var viewer = attachViewer ? await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id) : null;
        terminal.Start();
        await outputWriter.WriteAsync("ready\r\n"u8.ToArray());
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
 
        // Raw stream workloads report disconnection explicitly. Observe output first; Hex1b's completion is
        // not an output-drain barrier, and this test makes no claim about preserving the final screen.
        workload.SignalDisconnected();
        await Assert.IsType<Hex1bAspireTerminal>(terminal.Backend).WorkloadEnded.DefaultTimeout();
 
        Assert.True(service.TryGetTerminal(terminal.Id, out var registered));
        Assert.Same(terminal, registered);
        using var subscription = service.SubscribeDockTerminals();
        Assert.Equal(terminal.Id, Assert.Single(subscription.InitialState).Id);
        Assert.Throws<InvalidOperationException>(terminal.Start);
        Assert.Throws<InvalidOperationException>(terminal.GetScreenText);
        await Assert.ThrowsAsync<InvalidOperationException>(() => terminal.SendTextAsync("input"));
        await Assert.ThrowsAsync<InvalidOperationException>(() => terminal.SendKeyAsync(AspireTerminalKey.Enter));
        await Assert.ThrowsAsync<InvalidOperationException>(() => terminal.WaitForTextAsync("never"));
 
        // Reopening an ended tab must report completion without queuing a client for Hex1b's disposed server.
        // In particular, it must not need a ClientHello or restart the workload.
        using var reconnected = new MemoryStream();
        var endedNotifications = 0;
        await service.AttachAsync(terminal.Id, reconnected, _ =>
        {
            endedNotifications++;
            return Task.CompletedTask;
        }, CancellationToken.None).DefaultTimeout();
        Assert.Equal(1, endedNotifications);
        Assert.Equal(0, reconnected.Length);
 
        await terminal.DisposeAsync().AsTask().DefaultTimeout();
        Assert.False(service.TryGetTerminal(terminal.Id, out _));
        using var afterClose = service.SubscribeDockTerminals();
        Assert.Empty(afterClose.InitialState);
    }
 
    [Fact]
    public async Task AttachAsync_MultipleViewersCanDisconnectAndReconnectWithoutStoppingTheWorkload()
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        var input = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var inputReader = input.Reader.AsStream();
        await using var inputWriter = input.Writer.AsStream();
        var workload = new StreamWorkloadAdapter(outputReader, inputWriter);
        await using var terminal = service.CreateTerminal("Shared", TerminalPlacement.Dialog,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
 
        // Attach starts the previously idle workload. The other viewer must survive the first peer's EOF,
        // and a later viewer must receive the same terminal's existing screen rather than a fresh process.
        await using var first = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        await using var second = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        await outputWriter.WriteAsync("before-disconnect\r\n"u8.ToArray());
        await Task.WhenAll(
            first.WaitForTextAsync("before-disconnect"),
            second.WaitForTextAsync("before-disconnect")).DefaultTimeout();
 
        await first.DisconnectPeerAsync().DefaultTimeout();
        Assert.True(service.TryGetTerminal(terminal.Id, out var registered));
        Assert.Same(terminal, registered);
 
        await second.SendTextAsync("viewer-input").DefaultTimeout();
        var bytes = new byte["viewer-input"u8.Length];
        await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout();
        Assert.Equal("viewer-input"u8.ToArray(), bytes);
 
        await terminal.SendTextAsync("automation-input").DefaultTimeout();
        bytes = new byte["automation-input"u8.Length];
        await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout();
        Assert.Equal("automation-input"u8.ToArray(), bytes);
 
        await terminal.SendKeyAsync(AspireTerminalKey.Enter).DefaultTimeout();
        bytes = new byte[1];
        await inputReader.ReadExactlyAsync(bytes).AsTask().DefaultTimeout();
        Assert.Equal("\r"u8.ToArray(), bytes);
 
        await outputWriter.WriteAsync("after-disconnect\r\n"u8.ToArray());
        await Task.WhenAll(
            second.WaitForTextAsync("after-disconnect"),
            terminal.WaitForTextAsync("after-disconnect")).DefaultTimeout();
        Assert.Contains("after-disconnect", terminal.GetScreenText());
 
        await using var reconnected = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        await reconnected.WaitForTextAsync("after-disconnect").DefaultTimeout();
    }
 
    [Fact]
    public async Task AttachAsync_CancellationDuringHandshakeWaitsForTheOutstandingWrite()
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        var workload = new StreamWorkloadAdapter(outputReader, Stream.Null);
        await using var terminal = service.CreateTerminal("Handshake", TerminalPlacement.Dialog,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
 
        var (serverStream, clientStream) = TestDuplexStream.CreatePair();
        using var serverOwner = serverStream;
        using var clientOwner = clientStream;
        using var gated = new GatedTerminalWriteStream(serverStream);
        using var attachmentCts = new CancellationTokenSource();
        using var clientCts = new CancellationTokenSource();
        await using var client = Hex1bTerminal.CreateBuilder().WithHeadless().WithHmp1Stream(clientStream).Build();
        var attachment = service.AttachAsync(terminal.Id, gated, _ => Task.CompletedTask, attachmentCts.Token);
        var run = client.RunAsync(clientCts.Token);
 
        try
        {
            await gated.WriteStarted.DefaultTimeout();
            await attachmentCts.CancelAsync();
            await gated.WriteCancelled.DefaultTimeout();
            Assert.False(attachment.IsCompleted);
        }
        finally
        {
            gated.ReleaseWrite();
            await attachmentCts.CancelAsync();
            await attachment.DefaultTimeout();
            await clientCts.CancelAsync();
            try
            {
                await run.DefaultTimeout();
            }
            catch (OperationCanceledException) when (clientCts.IsCancellationRequested)
            {
            }
 
            serverStream.Dispose();
        }
 
        // The cancelled viewer did not cancel the AppHost terminal or poison subsequent attachments.
        await using var replacement = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
        await outputWriter.WriteAsync("replacement-ready\r\n"u8.ToArray());
        await replacement.WaitForTextAsync("replacement-ready").DefaultTimeout();
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AttachAsync_UnfinishedHandshakeDoesNotBlockOtherViewers(bool closePeer)
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var terminal = service.CreateTerminal("Concurrent handshakes", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24);
        var (serverStream, clientStream) = TestDuplexStream.CreatePair();
        using var serverOwner = serverStream;
        using var clientOwner = clientStream;
        using var cts = new CancellationTokenSource();
        var attachment = service.AttachAsync(terminal.Id, serverStream, _ => Task.CompletedTask, cts.Token);
 
        try
        {
            // This peer never sends ClientHello. Another viewer must still complete its handshake.
            await using var viewer = await TestAppHostTerminalViewer.ConnectAsync(service, terminal.Id);
            await outputWriter.WriteAsync("connected-ready\r\n"u8.ToArray());
            await viewer.WaitForTextAsync("connected-ready").DefaultTimeout();
            Assert.False(attachment.IsCompleted);
 
            if (closePeer)
            {
                clientStream.Dispose();
            }
            else
            {
                await cts.CancelAsync();
            }
            await attachment.DefaultTimeout();
            Assert.False(serverStream.Disposed);
 
            await outputWriter.WriteAsync("still-connected\r\n"u8.ToArray());
            await viewer.WaitForTextAsync("still-connected").DefaultTimeout();
        }
        finally
        {
            await cts.CancelAsync();
            await attachment.DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task DisposeAsync_CancelsUnfinishedHandshakeWithoutDisposingCallerTransport()
    {
        await using var service = TestTerminalService.Create();
        var output = new Pipe();
        await using var outputReader = output.Reader.AsStream();
        await using var outputWriter = output.Writer.AsStream();
        await using var terminal = service.CreateTerminal("Pending handshake", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(new StreamWorkloadAdapter(outputReader, Stream.Null)), 80, 24);
        terminal.Start();
        await outputWriter.WriteAsync("ready\r\n"u8.ToArray());
        await terminal.WaitForTextAsync("ready").DefaultTimeout();
 
        var (serverStream, clientStream) = TestDuplexStream.CreatePair();
        using var serverOwner = serverStream;
        using var clientOwner = clientStream;
        var attachment = service.AttachAsync(terminal.Id, serverStream, _ => Task.CompletedTask, CancellationToken.None);
        Assert.False(attachment.IsCompleted);
 
        await terminal.DisposeAsync().AsTask().DefaultTimeout();
        await attachment.DefaultTimeout();
        Assert.False(serverStream.Disposed);
    }
 
    private static void AssertReportedDimensions(AspireTerminal terminal, string marker, int columns, int rows)
    {
        var line = Assert.Single(
            terminal.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries),
            line => line.StartsWith(marker + ":", StringComparison.Ordinal));
        Assert.Equal($"{marker}:{rows} {columns}", line);
    }
 
    // stty reports the actual PTY as "<rows> <columns>". Marker-prefixed output such as
    // "initial:28 82" distinguishes measurements from input echoed by the shell.
    private const string ReportDimensionsScript = """
        set -eu
        printf 'initial:'
        stty size
        printf 'initial-ready\n'
        while read -r marker; do
            printf '%s:' "$marker"
            stty size
            printf '%s-ready\n' "$marker"
        done
        """;
}