File: Terminals\TerminalServiceTests.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.Collections.Immutable;
using System.Globalization;
using System.Reflection;
using System.Threading.Channels;
using Aspire.Hosting.Tests.Dcp;
using Aspire.Hosting.Tests.Utils;
using Aspire.Hosting.Utils;
using Hex1b;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
 
#pragma warning disable ASPIRETERMINAL001 // Test consumer of the experimental AppHost terminal API.
 
namespace Aspire.Hosting.Tests.Terminals;
 
/// <summary>
/// Guards <see cref="TerminalService"/>'s registry and dock change fan-out. Most tests leave terminals lazy,
/// so creation, lookup, removal, and the dock subscription can be exercised without a PTY.
/// </summary>
[Trait("Partition", "2")]
public class TerminalServiceTests
{
    [Theory]
    [InlineData(typeof(TerminalService))]
    [InlineData(typeof(AspireTerminal))]
    [InlineData(typeof(AspireTerminalKey))]
    [InlineData(typeof(TerminalLaunchOptions))]
    [InlineData(typeof(TerminalOwner))]
    [InlineData(typeof(TerminalPlacement))]
    public void PublicTerminalTypes_UseApplicationModelNamespace(Type type)
    {
        Assert.Equal(typeof(ResourceNotificationService).Namespace, type.Namespace);
    }
 
    [Fact]
    public void HostingAssembly_HasNoTerminalsNamespace()
    {
        Assert.All(typeof(TerminalService).Assembly.GetTypes(),
            type => Assert.NotEqual("Aspire.Hosting.Terminals", type.Namespace));
    }
 
    [Theory]
    [InlineData(1)]
    [InlineData(8)]
    [InlineData(128)]
    public async Task SubscribeDockTerminals_UsesConfiguredCapacityFromAppHost(int capacity)
    {
        using var builder = TestDistributedApplicationBuilder.Create();
        builder.Configuration[KnownConfigNames.TerminalWatchBufferCapacity] = capacity.ToString(CultureInfo.InvariantCulture);
        await using var app = builder.Build();
        var service = app.Services.GetRequiredService<TerminalService>();
        var terminal = CreateDockTerminal(service, "Terminal");
        using var subscription = service.SubscribeDockTerminals();
        var channel = Assert.Single(GetOutgoingChannels(service));
        terminal.Show();
        for (var i = 1; i < capacity; i++)
        {
            terminal.Retitle($"Revision {i}");
        }
        Assert.Equal(capacity, channel.Reader.Count);
 
        terminal.Retitle("Recovered");
        Assert.Equal(1, channel.Reader.Count);
        await using var updates = subscription.Subscription.GetAsyncEnumerator();
        Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout());
        var snapshot = Assert.IsType<TerminalSnapshot>(updates.Current);
        Assert.Equal(terminal.Id, snapshot.ActivatedTerminalId);
        Assert.Equal(new TerminalDescriptor(terminal.Id, "Recovered"), Assert.Single(snapshot.Terminals));
    }
 
    [Theory]
    [InlineData("0")]
    [InlineData("-1")]
    [InlineData("invalid")]
    [InlineData("2147483648")]
    [InlineData("")]
    public void Constructor_InvalidWatchBufferCapacity_Throws(string capacity)
    {
        using var configuration = new ConfigurationManager();
        configuration[KnownConfigNames.TerminalWatchBufferCapacity] = capacity;
 
        Assert.Throws<InvalidOperationException>(() => TestTerminalService.Create(configuration));
    }
 
    [Fact]
    public void CreateTerminal_NullOptions_Throws()
    {
        var service = TestTerminalService.Create();
 
        Assert.Throws<ArgumentNullException>(() => service.CreateTerminal(null!));
    }
 
    [Theory]
    [InlineData(null, false)]
    [InlineData(null, true)]
    [InlineData("", false)]
    [InlineData("", true)]
    [InlineData(" ", false)]
    [InlineData(" ", true)]
    [InlineData("\t\r\n", false)]
    [InlineData("\t\r\n", true)]
    [InlineData("\u00a0", false)]
    [InlineData("\u00a0", true)]
    public async Task CreateTerminal_InvalidTitle_ThrowsBeforeRegistration(string? title, bool useBuilder)
    {
        await using var service = TestTerminalService.Create();
 
        void Create() => CreateTerminal(service, TerminalPlacement.Dock, useBuilder, title!);
 
        if (title is null)
        {
            Assert.Throws<ArgumentNullException>(nameof(title), Create);
        }
        else
        {
            Assert.Throws<ArgumentException>(nameof(title), Create);
        }
 
        Assert.Empty(service.ListAll());
        using var subscription = service.SubscribeDockTerminals();
        Assert.Empty(subscription.InitialState);
    }
 
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData(" ")]
    [InlineData("\t\r\n")]
    [InlineData("\u00a0")]
    public async Task Retitle_InvalidTitle_LeavesTitleUnchanged(string? title)
    {
        await using var service = TestTerminalService.Create();
        var terminal = CreateDockTerminal(service, "Shell");
        using var subscription = service.SubscribeDockTerminals();
        var channel = Assert.Single(GetOutgoingChannels(service));
 
        if (title is null)
        {
            Assert.Throws<ArgumentNullException>(nameof(title), () => terminal.Retitle(title!));
        }
        else
        {
            Assert.Throws<ArgumentException>(nameof(title), () => terminal.Retitle(title));
        }
 
        Assert.Equal("Shell", terminal.Handle.Title);
        Assert.Equal("Shell", Assert.Single(service.ListAll()).Title);
        Assert.False(channel.Reader.TryRead(out _));
    }
 
    [Theory]
    [InlineData(TerminalPlacement.ResourceView, false)]
    [InlineData(TerminalPlacement.ResourceView, true)]
    [InlineData((TerminalPlacement)(-1), false)]
    [InlineData((TerminalPlacement)(-1), true)]
    [InlineData((TerminalPlacement)4, false)]
    [InlineData((TerminalPlacement)4, true)]
    public async Task CreateTerminal_UnsupportedPlacement_ThrowsBeforeRegistration(TerminalPlacement placement, bool useBuilder)
    {
        await using var service = TestTerminalService.Create();
 
        var ex = Assert.Throws<ArgumentOutOfRangeException>(nameof(placement), () => CreateTerminal(service, placement, useBuilder, "Shell"));
 
        Assert.Equal(placement, ex.ActualValue);
        Assert.Empty(service.ListAll());
    }
 
    [Theory]
    [InlineData(TerminalPlacement.Dock, false)]
    [InlineData(TerminalPlacement.Dock, true)]
    [InlineData(TerminalPlacement.Dialog, false)]
    [InlineData(TerminalPlacement.Dialog, true)]
    [InlineData(TerminalPlacement.None, false)]
    [InlineData(TerminalPlacement.None, true)]
    public async Task CreateTerminal_SupportedPlacement_RegistersTerminal(TerminalPlacement placement, bool useBuilder)
    {
        await using var service = TestTerminalService.Create();
        await using var terminal = CreateTerminal(service, placement, useBuilder, "Shell");
 
        Assert.Equal("Shell", terminal.Title);
        Assert.Equal(TerminalOwner.AppHost, terminal.Owner);
        Assert.Equal(placement, terminal.Placement);
        Assert.True(service.TryGetTerminal(terminal.Id, out var registered));
        Assert.Same(terminal, registered);
        var listing = Assert.Single(service.ListAll());
        Assert.Equal(terminal.Id, listing.Id);
        Assert.Equal(placement, listing.Placement);
 
        using var subscription = service.SubscribeDockTerminals();
        if (placement == TerminalPlacement.Dock)
        {
            Assert.Equal(terminal.Id, Assert.Single(subscription.InitialState).Id);
        }
        else
        {
            Assert.Empty(subscription.InitialState);
        }
    }
 
    [Fact]
    public void CreateTerminal_RegistersTerminalUnderANonGuessableId()
    {
        var service = TestTerminalService.Create();
 
        var terminal = CreateInteractionTerminal(service, "Shell");
 
        Assert.Equal("Shell", terminal.Title);
        Assert.Equal(TerminalPlacement.Dialog, terminal.Placement);
 
        // Ids appear in websocket query strings, so they must not be a sequence number a caller could walk.
        Assert.Equal(32, terminal.Id.Length);
        Assert.True(Guid.TryParseExact(terminal.Id, "N", out _));
 
        Assert.True(service.TryGetTerminal(terminal.Id, out var found));
        Assert.Same(terminal, found);
    }
 
    [Fact]
    public void CreateTerminal_TwoTerminals_GetDistinctIds()
    {
        var service = TestTerminalService.Create();
 
        var first = CreateInteractionTerminal(service, "First");
        var second = CreateInteractionTerminal(service, "Second");
 
        Assert.NotEqual(first.Id, second.Id);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task CreateTerminal_SnapshotsLaunchOptionsBeforeLazyStartup(bool replaceArguments)
    {
        Assert.SkipUnless(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(), "The workload uses a POSIX shell.");
 
        var home = Environment.GetEnvironmentVariable("HOME");
        Assert.NotNull(home);
        var firstDirectory = Path.TrimEndingDirectorySeparator(AppContext.BaseDirectory);
        var secondDirectory = Directory.GetParent(firstDirectory)!.FullName;
 
        // Pass paths and values as positional arguments rather than interpolating shell syntax. Compare
        // directory identity to allow macOS symlinks, and keep reading so the screen survives the assertions.
        const string script = """
            set -eu
            printf '%s\n' "$1"
            printf 'environment:[%s][%s][%s]\n' "$ASPIRE_TERMINAL_SNAPSHOT_SETTING" "${ASPIRE_TERMINAL_SNAPSHOT_REMOVED-}" "${ASPIRE_TERMINAL_SNAPSHOT_ADDED-}"
            test "$HOME" = "$2"
            printf 'inherited-environment\n'
            test . -ef "$3"
            printf 'working-directory\n'
            printf 'ready\n'
            read -r input
            """;
        List<string> arguments = ["-c", script, "terminal-snapshot", "first argument with spaces", home, firstDirectory];
        var options = new TerminalLaunchOptions
        {
            Title = "First",
            Placement = TerminalPlacement.None,
            Executable = "/bin/sh",
            Arguments = arguments,
            WorkingDirectory = firstDirectory,
            EnvironmentVariables =
            {
                ["ASPIRE_TERMINAL_SNAPSHOT_SETTING"] = "first value",
                ["ASPIRE_TERMINAL_SNAPSHOT_REMOVED"] = "preserved",
                ["ASPIRE_TERMINAL_SNAPSHOT_ADDED"] = string.Empty
            }
        };
 
        await using var service = TestTerminalService.Create();
        await using var first = service.CreateTerminal(options);
        Assert.Empty(first.GetScreenText());
 
        options.Title = "Second";
        options.WorkingDirectory = secondDirectory;
        if (replaceArguments)
        {
            options.Arguments = [.. arguments];
        }
        options.Arguments[3] = "second argument with spaces";
        options.Arguments[5] = secondDirectory;
        options.EnvironmentVariables.Clear();
        options.EnvironmentVariables["ASPIRE_TERMINAL_SNAPSHOT_SETTING"] = "second value";
        options.EnvironmentVariables["ASPIRE_TERMINAL_SNAPSHOT_ADDED"] = "added";
        await using var second = service.CreateTerminal(options);
        Assert.Empty(second.GetScreenText());
 
        options.Executable = "must-not-be-started";
        options.WorkingDirectory = Path.Combine(secondDirectory, "must-not-be-used");
        options.Arguments.Clear();
        arguments.Clear();
        options.EnvironmentVariables.Clear();
        options.EnvironmentVariables["ASPIRE_TERMINAL_SNAPSHOT_SETTING"] = "must-not-be-used";
 
        first.Start();
        second.Start();
        await Task.WhenAll(first.WaitForTextAsync("ready"), second.WaitForTextAsync("ready")).DefaultTimeout();
 
        Assert.Equal("First", first.Title);
        Assert.Equal("Second", second.Title);
        Assert.Equal(
            ["first argument with spaces", "environment:[first value][preserved][]", "inherited-environment", "working-directory", "ready"],
            first.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
        Assert.Equal(
            ["second argument with spaces", "environment:[second value][][added]", "inherited-environment", "working-directory", "ready"],
            second.GetScreenText().Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
    }
 
    [Fact]
    public void TryGetTerminal_UnknownId_ReturnsFalse()
    {
        var service = TestTerminalService.Create();
 
        Assert.False(service.TryGetTerminal("does-not-exist", out var terminal));
        Assert.Null(terminal);
    }
 
    [Fact]
    public async Task Start_IsIdempotentAndThrowsOnceStopped()
    {
        var service = TestTerminalService.Create();
        var terminal = CreateInteractionTerminal(service, "Shell");
 
        // Callers decide when the workload spawns, so starting has to tolerate being called more than once --
        // a caller that starts explicitly and then attaches a viewer goes through this twice.
        terminal.Start();
        terminal.Start();
 
        await terminal.DisposeAsync().DefaultTimeout();
 
        Assert.Throws<InvalidOperationException>(terminal.Start);
    }
 
    [Fact]
    public async Task DisposeAsync_RemovesTerminalFromRegistry()
    {
        var service = TestTerminalService.Create();
        var terminal = CreateInteractionTerminal(service, "Shell");
 
        await terminal.DisposeAsync().DefaultTimeout();
 
        Assert.False(service.TryGetTerminal(terminal.Id, out _));
    }
 
    [Fact]
    public async Task AttachAsync_UnknownTerminal_Throws()
    {
        var service = TestTerminalService.Create();
        using var stream = new MemoryStream();
 
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.AttachAsync("does-not-exist", stream, _ => Task.CompletedTask, CancellationToken.None)).DefaultTimeout();
    }
 
    [Fact]
    public void SubscribeDockTerminals_SnapshotExcludesInteractionTerminals()
    {
        var service = TestTerminalService.Create();
        var dock = CreateDockTerminal(service, "Dock");
        CreateInteractionTerminal(service, "Dialog");
 
        using var subscription = service.SubscribeDockTerminals();
 
        // Dialog terminals are surfaced by their interaction rather than the dock.
        var descriptor = Assert.Single(subscription.InitialState);
        Assert.Equal(dock.Id, descriptor.Id);
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_PublishesAddedDockTerminal()
    {
        var service = TestTerminalService.Create();
        using var subscription = service.SubscribeDockTerminals();
 
        Assert.Empty(subscription.InitialState);
 
        var dock = CreateDockTerminal(service, "Dock");
 
        await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None);
        Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout());
 
        Assert.Equal(new TerminalChange(TerminalChangeType.Added, new(dock.Id, "Dock")), changes.Current);
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_DoesNotPublishInteractionTerminal()
    {
        var service = TestTerminalService.Create();
        using var subscription = service.SubscribeDockTerminals();
 
        var dialog = Assert.IsType<Hex1bAspireTerminal>(CreateInteractionTerminal(service, "Dialog").Backend);
        dialog.Retitle("Updated dialog");
        dialog.Show();
        var dock = CreateDockTerminal(service, "Dock");
 
        // The interaction terminal was created first, so if it were published at all it would arrive first.
        await using var changes = subscription.Subscription.GetAsyncEnumerator(CancellationToken.None);
        Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout());
 
        Assert.Equal(new TerminalChange(TerminalChangeType.Added, new(dock.Id, "Dock")), changes.Current);
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_OverflowReplacesBacklogWithCurrentSnapshot()
    {
        await using var service = TestTerminalService.Create();
        var first = CreateDockTerminal(service, "First");
        var removed = CreateDockTerminal(service, "Removed");
        CreateInteractionTerminal(service, "Dialog");
        using var subscription = service.SubscribeDockTerminals();
        var channel = Assert.Single(GetOutgoingChannels(service));
 
        first.Retitle("Updated");
        await removed.DisposeAsync();
        var added = CreateDockTerminal(service, "Added");
        for (var i = 3; i < TerminalService.DefaultDockUpdateBufferCapacity; i++)
        {
            first.Retitle($"Revision {i}");
        }
        Assert.Equal(TerminalService.DefaultDockUpdateBufferCapacity, channel.Reader.Count);
 
        first.Retitle("Latest");
        Assert.Equal(1, channel.Reader.Count);
        await using var updates = subscription.Subscription.GetAsyncEnumerator();
        Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout());
        var snapshot = Assert.IsType<TerminalSnapshot>(updates.Current);
        Assert.Null(snapshot.ActivatedTerminalId);
        Assert.Equal(
            new[] { new TerminalDescriptor(first.Id, "Latest"), new TerminalDescriptor(added.Id, "Added") }.OrderBy(t => t.Id),
            snapshot.Terminals.OrderBy(t => t.Id));
 
        added.Retitle("After recovery");
        Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.Equal(new TerminalChange(TerminalChangeType.Retitled, new(added.Id, "After recovery")), updates.Current);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task SubscribeDockTerminals_RepeatedOverflowPreservesLatestPendingActivation(bool activateAgain)
    {
        await using var service = TestTerminalService.Create();
        var first = CreateDockTerminal(service, "First");
        var second = CreateDockTerminal(service, "Second");
        using var subscription = service.SubscribeDockTerminals();
        var channel = Assert.Single(GetOutgoingChannels(service));
        first.Show();
        second.Show();
 
        for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity * 4; i++)
        {
            first.Retitle($"Revision {i}");
            if (activateAgain && i == TerminalService.DefaultDockUpdateBufferCapacity + 3)
            {
                first.Show();
            }
            Assert.InRange(channel.Reader.Count, 1, TerminalService.DefaultDockUpdateBufferCapacity);
        }
 
        await using var updates = subscription.Subscription.GetAsyncEnumerator();
        Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout());
        var snapshot = Assert.IsType<TerminalSnapshot>(updates.Current);
        Assert.Equal(activateAgain ? first.Id : second.Id, snapshot.ActivatedTerminalId);
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_ActivationThatOverflowsIsIncludedInSnapshot()
    {
        await using var service = TestTerminalService.Create();
        var terminal = CreateDockTerminal(service, "Terminal");
        using var subscription = service.SubscribeDockTerminals();
        for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity; i++)
        {
            terminal.Retitle($"Revision {i}");
        }
 
        terminal.Show();
        await using var updates = subscription.Subscription.GetAsyncEnumerator();
        Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.Equal(terminal.Id, Assert.IsType<TerminalSnapshot>(updates.Current).ActivatedTerminalId);
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task SubscribeDockTerminals_OverflowRetainsRevealIntentWhenActivatedTerminalWasRemoved(bool keepAnotherTerminal)
    {
        await using var service = TestTerminalService.Create();
        var terminal = CreateDockTerminal(service, "Activated");
        var remaining = keepAnotherTerminal ? CreateDockTerminal(service, "Remaining") : null;
        using var subscription = service.SubscribeDockTerminals();
        terminal.Show();
        for (var i = 1; i < TerminalService.DefaultDockUpdateBufferCapacity; i++)
        {
            terminal.Retitle($"Revision {i}");
        }
 
        await terminal.DisposeAsync();
        await using var updates = subscription.Subscription.GetAsyncEnumerator();
        Assert.True(await updates.MoveNextAsync().AsTask().DefaultTimeout());
        var snapshot = Assert.IsType<TerminalSnapshot>(updates.Current);
        Assert.Equal(terminal.Id, snapshot.ActivatedTerminalId);
        Assert.Equal(remaining is null ? [] : new[] { new TerminalDescriptor(remaining.Id, "Remaining") }, snapshot.Terminals);
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_SlowSubscriberDoesNotDisruptFastSubscriber()
    {
        await using var service = TestTerminalService.Create();
        var terminal = CreateDockTerminal(service, "Terminal");
        using var slow = service.SubscribeDockTerminals();
        using var fast = service.SubscribeDockTerminals();
        await using var fastUpdates = fast.Subscription.GetAsyncEnumerator();
 
        for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity * 4; i++)
        {
            var title = $"Revision {i}";
            terminal.Retitle(title);
            Assert.True(await fastUpdates.MoveNextAsync().AsTask().DefaultTimeout());
            Assert.Equal(new TerminalChange(TerminalChangeType.Retitled, new(terminal.Id, title)), fastUpdates.Current);
            Assert.All(GetOutgoingChannels(service),
                channel => Assert.InRange(channel.Reader.Count, 0, TerminalService.DefaultDockUpdateBufferCapacity));
        }
 
        await using var slowUpdates = slow.Subscription.GetAsyncEnumerator();
        Assert.True(await slowUpdates.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.IsType<TerminalSnapshot>(slowUpdates.Current);
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_CancellationAfterOverflowReleasesRegistration()
    {
        await using var service = TestTerminalService.Create();
        var terminal = CreateDockTerminal(service, "Terminal");
        using var subscription = service.SubscribeDockTerminals();
        for (var i = 0; i <= TerminalService.DefaultDockUpdateBufferCapacity; i++)
        {
            terminal.Show();
        }
 
        using var cts = new CancellationTokenSource();
        await using var updates = subscription.Subscription.GetAsyncEnumerator(cts.Token);
        cts.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => updates.MoveNextAsync().AsTask()).DefaultTimeout();
        Assert.Empty(GetOutgoingChannels(service));
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_DisposalCompletesPendingRead()
    {
        await using var service = TestTerminalService.Create();
        using var subscription = service.SubscribeDockTerminals();
        await using var updates = subscription.Subscription.GetAsyncEnumerator();
        var next = updates.MoveNextAsync().AsTask();
        subscription.Dispose();
 
        Assert.False(await next.DefaultTimeout());
        Assert.Empty(GetOutgoingChannels(service));
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_ShutdownWithBacklogStaysBoundedAndClearsInventory()
    {
        await using var service = TestTerminalService.Create();
        for (var i = 0; i < TerminalService.DefaultDockUpdateBufferCapacity + 5; i++)
        {
            CreateDockTerminal(service, $"Terminal {i}");
        }
        using var subscription = service.SubscribeDockTerminals();
        var channel = Assert.Single(GetOutgoingChannels(service));
        var inventory = subscription.InitialState.ToDictionary(t => t.Id);
 
        await service.DisposeAsync();
        Assert.InRange(channel.Reader.Count, 1, TerminalService.DefaultDockUpdateBufferCapacity);
        var recovered = false;
        await foreach (var update in subscription.Subscription)
        {
            if (update is TerminalSnapshot snapshot)
            {
                recovered = true;
                inventory = snapshot.Terminals.ToDictionary(t => t.Id);
            }
            else
            {
                var change = Assert.IsType<TerminalChange>(update);
                Assert.Equal(TerminalChangeType.Removed, change.ChangeType);
                inventory.Remove(change.Terminal.Id);
            }
        }
        Assert.True(recovered);
        Assert.Empty(inventory);
        Assert.Empty(GetOutgoingChannels(service));
    }
 
    [Fact]
    public void SubscribeDockTerminals_DisposedWithoutEnumerating_ReleasesItsChannelRegistration()
    {
        var service = TestTerminalService.Create();
 
        // The subscription registers its channel eagerly, but StreamChanges is an async iterator whose
        // finally only runs once someone calls MoveNextAsync. A caller that faults before it starts enumerating --
        // a viewer that disconnects while the snapshot is being written, for example -- would otherwise leave a
        // channel and its buffer registered for the lifetime of the AppHost.
        var subscription = service.SubscribeDockTerminals();
        Assert.Single(GetOutgoingChannels(service));
 
        subscription.Dispose();
        Assert.Empty(GetOutgoingChannels(service));
 
        // Removal is idempotent, so the iterator's finally and an explicit Dispose can both run.
        subscription.Dispose();
        Assert.Empty(GetOutgoingChannels(service));
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_DisposedWithoutEnumerating_StopsReceivingChanges()
    {
        var service = TestTerminalService.Create();
 
        var abandoned = service.SubscribeDockTerminals();
        abandoned.Dispose();
 
        for (var i = 0; i < 5; i++)
        {
            CreateDockTerminal(service, $"Dock {i}");
        }
 
        // Nothing was written to the released channel, so the fan-out no longer holds those changes anywhere.
        Assert.Empty(GetOutgoingChannels(service));
 
        // A subscription taken afterwards still works, and sees the dock terminals in its snapshot rather than
        // replaying them as changes.
        using var live = service.SubscribeDockTerminals();
        Assert.Equal(5, live.InitialState.Length);
 
        var afterwards = CreateDockTerminal(service, "Later");
 
        await using var changes = live.Subscription.GetAsyncEnumerator(CancellationToken.None);
        Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout());
        Assert.Equal(new TerminalChange(TerminalChangeType.Added, new(afterwards.Id, "Later")), changes.Current);
    }
 
    [Fact]
    public async Task DisposeAsync_TearsDownRegisteredTerminals()
    {
        var service = TestTerminalService.Create();
        var terminal = CreateInteractionTerminal(service, "Shell");
 
        await service.DisposeAsync().DefaultTimeout();
 
        Assert.False(service.TryGetTerminal(terminal.Id, out _));
    }
 
    [Fact]
    public async Task DisposeAsync_WaitsForAllWorkloadsAndRepeatedCalls()
    {
        await using var service = TestTerminalService.Create();
        GatedTerminalWorkloadAdapter[] workloads = [new(), new()];
        var terminals = workloads.Select((workload, index) =>
            service.CreateTerminal($"Terminal {index}", TerminalPlacement.Dock,
                Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24)).ToArray();
        foreach (var terminal in terminals)
        {
            terminal.Start();
        }
 
        Task disposal = Task.CompletedTask;
        try
        {
            await Task.WhenAll(workloads.Select(workload => workload.ReadStarted)).DefaultTimeout();
            disposal = service.DisposeAsync().AsTask();
            await Task.WhenAll(workloads.Select(workload => workload.DisposeStarted)).DefaultTimeout();
 
            Assert.False(disposal.IsCompleted);
            Assert.Same(disposal, service.DisposeAsync().AsTask());
            Assert.Empty(service.ListAll());
 
            var terminalDisposal = terminals[0].DisposeAsync().AsTask();
            Assert.False(terminalDisposal.IsCompleted);
            workloads[0].ReleaseDispose();
            await terminalDisposal.DefaultTimeout();
            Assert.False(disposal.IsCompleted);
 
            workloads[1].ReleaseDispose();
            await disposal.DefaultTimeout();
            Assert.All(workloads, workload => Assert.True(workload.IsDisposed));
        }
        finally
        {
            foreach (var workload in workloads)
            {
                workload.ReleaseDispose();
            }
 
            await disposal.DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task DisposeAsync_ObservesWorkloadDisposalFailure()
    {
        var service = TestTerminalService.Create();
        var expected = new IOException("Workload disposal failed.");
        var workload = new GatedTerminalWorkloadAdapter { DisposalException = expected };
        var terminal = service.CreateTerminal("Failure", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
        terminal.Start();
        var disposal = service.DisposeAsync().AsTask();
        try
        {
            await workload.DisposeStarted.DefaultTimeout();
            Assert.False(disposal.IsCompleted);
        }
        finally
        {
            workload.ReleaseDispose();
        }
 
        Assert.Same(expected, await Assert.ThrowsAsync<IOException>(() => disposal).DefaultTimeout());
        Assert.Same(disposal, service.DisposeAsync().AsTask());
        Assert.Same(expected, await Assert.ThrowsAsync<IOException>(() => terminal.DisposeAsync().AsTask()).DefaultTimeout());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task DisposeAsync_JoinsRetiringTerminalAndObservesItsFailure(bool fail)
    {
        var service = TestTerminalService.Create();
        var expected = fail ? new IOException("Retiring workload disposal failed.") : null;
        var workload = new GatedTerminalWorkloadAdapter { DisposalException = expected };
        var terminal = service.CreateTerminal("Retiring", TerminalPlacement.Dock,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
        using var subscription = service.SubscribeDockTerminals();
        await using var changes = subscription.Subscription.GetAsyncEnumerator();
        Task close = Task.CompletedTask;
        Exception? closeFailure;
        Exception? shutdownFailure;
        try
        {
            terminal.Start();
            await workload.ReadStarted.DefaultTimeout();
            close = terminal.DisposeAsync().AsTask();
            await workload.DisposeStarted.DefaultTimeout();
 
            Assert.False(close.IsCompleted);
            Assert.False(service.TryGetTerminal(terminal.Id, out _));
            Assert.Empty(service.ListAll());
            Assert.True(await changes.MoveNextAsync().AsTask().DefaultTimeout());
            Assert.Equal(new TerminalChange(TerminalChangeType.Removed, new(terminal.Id, terminal.Title)), changes.Current);
 
            var shutdown = service.DisposeAsync().AsTask();
            Assert.False(shutdown.IsCompleted);
            Assert.Same(shutdown, service.DisposeAsync().AsTask());
            // Shutdown completes the stream without publishing a second removal for the retiring terminal.
            Assert.False(await changes.MoveNextAsync().AsTask().DefaultTimeout());
        }
        finally
        {
            workload.ReleaseDispose();
            closeFailure = await Record.ExceptionAsync(() => close).DefaultTimeout();
            shutdownFailure = await Record.ExceptionAsync(() => service.DisposeAsync().AsTask()).DefaultTimeout();
        }
 
        Assert.Same(expected, closeFailure);
        Assert.Same(expected, shutdownFailure);
        Assert.Equal(!fail, workload.IsDisposed);
        Assert.Same(expected, await Record.ExceptionAsync(() => terminal.DisposeAsync().AsTask()).DefaultTimeout());
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task AppHostStop_StopsTerminalsBeforeDisposalAndCancellationOnlyBoundsWait(bool cancelWait)
    {
        using var builder = TestDistributedApplicationBuilder.Create(options =>
        {
            options.DisableDashboard = true;
            options.TrustDeveloperCertificate = false;
        });
        // Keep the production terminal lifecycle registration, without starting DCP or unrelated hosted services.
        foreach (var registration in builder.Services.Where(descriptor =>
            descriptor.ServiceType == typeof(IHostedService) &&
            descriptor.ImplementationType != typeof(TerminalServiceHost)).ToArray())
        {
            builder.Services.Remove(registration);
        }
        Assert.Single(builder.Services, descriptor => descriptor.ServiceType == typeof(IHostedService));
        await using var app = builder.Build();
        await app.StartAsync().DefaultTimeout();
        var service = app.Services.GetRequiredService<TerminalService>();
        var workload = new GatedTerminalWorkloadAdapter();
        var terminal = service.CreateTerminal("Host shutdown", TerminalPlacement.None,
            Hex1bTerminal.CreateBuilder().WithWorkload(workload), 80, 24);
        using var stopCts = new CancellationTokenSource();
        Task stop = Task.CompletedTask;
        Task disposal = Task.CompletedTask;
        try
        {
            terminal.Start();
            await workload.ReadStarted.DefaultTimeout();
            stop = app.StopAsync(stopCts.Token);
            await workload.DisposeStarted.DefaultTimeout();
 
            Assert.False(stop.IsCompleted);
            Assert.False(workload.IsDisposed);
            Assert.Empty(service.ListAll());
            Assert.Throws<ObjectDisposedException>(() => CreateDockTerminal(service, "Too late"));
 
            if (cancelWait)
            {
                await stopCts.CancelAsync();
                await Assert.ThrowsAnyAsync<OperationCanceledException>(() => stop).DefaultTimeout();
                disposal = app.DisposeAsync().AsTask();
                Assert.False(disposal.IsCompleted);
            }
 
            workload.ReleaseDispose();
            if (!cancelWait)
            {
                await stop.DefaultTimeout();
            }
            await disposal.DefaultTimeout();
            Assert.True(workload.IsDisposed);
        }
        finally
        {
            workload.ReleaseDispose();
            try
            {
                await stop.DefaultTimeout();
            }
            catch (OperationCanceledException) when (stopCts.IsCancellationRequested)
            {
            }
            await disposal.DefaultTimeout();
            await terminal.DisposeAsync().DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task CreateTerminal_AfterDispose_Throws()
    {
        var service = TestTerminalService.Create();
        await service.DisposeAsync().DefaultTimeout();
 
        Assert.Throws<ObjectDisposedException>(() => CreateInteractionTerminal(service, "Shell"));
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_DuringCreation_DoesNotReplaySnapshotAsAdded()
    {
        var logger = new GatedLogger<TerminalService>("Created Dock terminal");
        await using var service = new TerminalService(logger, new ConfigurationBuilder().Build());
        var create = Task.Run(() => CreateDockTerminal(service, "Dock"));
        try
        {
            // The log is a deterministic interleaving point. Registry mutation and publication must
            // already agree before any other code, including a logger, can subscribe.
            await logger.Blocked.DefaultTimeout();
            using var subscription = service.SubscribeDockTerminals();
            var descriptor = Assert.Single(subscription.InitialState);
            logger.Release();
            Assert.Equal(descriptor.Id, (await create.DefaultTimeout()).Id);
 
            await service.DisposeAsync();
            var changes = new List<TerminalUpdate>();
            await foreach (var change in subscription.Subscription)
            {
                changes.Add(change);
            }
 
            var removed = Assert.IsType<TerminalChange>(Assert.Single(changes));
            Assert.Equal(TerminalChangeType.Removed, removed.ChangeType);
            Assert.Equal(descriptor.Id, removed.Terminal.Id);
        }
        finally
        {
            logger.Release();
            await create.DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_DuringRemoval_DoesNotReceiveRemovalForAnAbsentSnapshotEntry()
    {
        var logger = new GatedLogger<TerminalService>("Removed terminal");
        await using var service = new TerminalService(logger, new ConfigurationBuilder().Build());
        var terminal = CreateDockTerminal(service, "Dock");
        var remove = Task.Run(async () => await terminal.DisposeAsync());
        try
        {
            await logger.Blocked.DefaultTimeout();
            using var subscription = service.SubscribeDockTerminals();
            Assert.Empty(subscription.InitialState);
            logger.Release();
            await remove.DefaultTimeout();
 
            await service.DisposeAsync();
            await using var changes = subscription.Subscription.GetAsyncEnumerator();
            Assert.False(await changes.MoveNextAsync().AsTask().DefaultTimeout());
        }
        finally
        {
            logger.Release();
            await remove.DefaultTimeout();
        }
    }
 
    [Fact]
    public async Task CreateTerminal_ConcurrentWithShutdown_DoesNotLeaveARegisteredTerminal()
    {
        for (var i = 0; i < 100; i++)
        {
            await using var service = TestTerminalService.Create();
            var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
            var create = Task.Run(async () =>
            {
                await start.Task;
                try
                {
                    return CreateDockTerminal(service, "Dock");
                }
                catch (ObjectDisposedException)
                {
                    return null;
                }
            });
            var shutdown = Task.Run(async () =>
            {
                await start.Task;
                await service.DisposeAsync();
            });
            start.SetResult();
            await Task.WhenAll(create, shutdown).DefaultTimeout();
 
            try
            {
                Assert.Empty(service.ListAll());
                Assert.Throws<ObjectDisposedException>(() => CreateDockTerminal(service, "Late"));
            }
            finally
            {
                if (await create is { } terminal)
                {
                    await terminal.DisposeAsync().DefaultTimeout();
                }
            }
        }
    }
 
    [Fact]
    public async Task SubscribeDockTerminals_AfterShutdown_ReturnsCompletedEmptySubscription()
    {
        await using var service = TestTerminalService.Create();
        CreateDockTerminal(service, "Dock");
        await service.DisposeAsync();
 
        using var subscription = service.SubscribeDockTerminals();
        Assert.Empty(subscription.InitialState);
        Assert.Empty(GetOutgoingChannels(service));
        await using var changes = subscription.Subscription.GetAsyncEnumerator();
        Assert.False(await changes.MoveNextAsync().AsTask().DefaultTimeout());
    }
 
    [Fact]
    public void ListAll_IncludesTerminalsRegardlessOfPlacement()
    {
        // A terminal driven only through automation is never displayed, so a listing keyed off the dock would
        // miss it entirely. `aspire terminal ps` is meant to answer "what exists", not "what is on screen".
        var service = TestTerminalService.Create();
        var dock = CreateDockTerminal(service, "Dock");
        var dialog = CreateInteractionTerminal(service, "Dialog");
        var hidden = service.CreateTerminal(new TerminalLaunchOptions
        {
            Title = "Automation",
            Executable = "bash",
            Placement = TerminalPlacement.None
        });
 
        var listings = service.ListAll();
 
        Assert.Equal(
            new[] { dock.Id, dialog.Id, hidden.Id }.OrderBy(id => id, StringComparer.Ordinal),
            listings.Select(l => l.Id).OrderBy(id => id, StringComparer.Ordinal));
        Assert.All(listings, l => Assert.Equal(TerminalOwner.AppHost, l.Owner));
        Assert.All(listings, l => Assert.Null(l.ResourceName));
    }
 
    [Fact]
    public void ListAll_CarriesPlacementAndTitle()
    {
        var service = TestTerminalService.Create();
        CreateDockTerminal(service, "Build output");
 
        var listing = Assert.Single(service.ListAll());
 
        Assert.Equal("Build output", listing.Title);
        Assert.Equal(TerminalPlacement.Dock, listing.Placement);
    }
 
    [Fact]
    public async Task ListAll_DropsRemovedTerminals()
    {
        var service = TestTerminalService.Create();
        var terminal = CreateDockTerminal(service, "Dock");
 
        await terminal.DisposeAsync().DefaultTimeout();
 
        Assert.Empty(service.ListAll());
    }
 
    [Fact]
    public void ListAll_WithoutAResourceCatalogReturnsOnlyAppHostTerminals()
    {
        // ResourceTerminals is left null when the AppHost has no model yet, which must degrade to "no resource
        // terminals" rather than faulting the listing.
        var service = TestTerminalService.Create();
        CreateDockTerminal(service, "Dock");
 
        Assert.Null(service.ResourceTerminals);
        Assert.Single(service.ListAll());
    }
 
    private static AspireTerminal CreateTerminal(TerminalService service, TerminalPlacement placement, bool useBuilder, string title)
        => useBuilder
            ? service.CreateTerminal(title, placement, Hex1bTerminal.CreateBuilder().WithPtyProcess("bash"), 80, 24)
            : service.CreateTerminal(new TerminalLaunchOptions
            {
                Title = title,
                Executable = "bash",
                Placement = placement
            });
 
    private static AspireTerminal CreateInteractionTerminal(TerminalService service, string title)
        => service.CreateTerminal(new TerminalLaunchOptions
        {
            Title = title,
            Executable = "bash",
            Placement = TerminalPlacement.Dialog
        });
 
    private static Hex1bAspireTerminal CreateDockTerminal(TerminalService service, string title)
        => Assert.IsType<Hex1bAspireTerminal>(service.CreateTerminal(new TerminalLaunchOptions
        {
            Title = title,
            Executable = "bash",
            Placement = TerminalPlacement.Dock
        }).Backend);
 
    /// <summary>
    /// Reads the private channel set the dock fan-out writes to.
    /// </summary>
    /// <remarks>
    /// Registration is deliberately invisible from the public surface: a leaked channel is silent, and the only
    /// observable symptom is retained buffers as abandoned subscriptions accumulate. Asserting on the set directly is
    /// what makes the leak regression detectable at all -- a test that only checks a later subscription still
    /// receives changes passes whether or not the abandoned channel was released.
    /// </remarks>
    private static ImmutableHashSet<Channel<TerminalUpdate>> GetOutgoingChannels(TerminalService service)
    {
        var field = typeof(TerminalService).GetField("_outgoingChannels", BindingFlags.Instance | BindingFlags.NonPublic);
        Assert.NotNull(field);
 
        return (ImmutableHashSet<Channel<TerminalUpdate>>)field.GetValue(service)!;
    }
}