// 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)!;
}
}