File: Backchannel\GetTerminalInfoAsyncTests.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.Net.Sockets;
using Aspire.Hosting.Backchannel;
using Aspire.Hosting.Diagnostics;
using Aspire.Shared.TerminalHost;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StreamJsonRpc;
 
namespace Aspire.Hosting.Tests.Backchannel;
 
[Trait("Partition", "4")]
public class GetTerminalInfoAsyncTests : IAsyncDisposable
{
    private readonly List<IAsyncDisposable> _toDispose = [];
    private readonly List<string> _tempDirs = [];
 
    [Fact]
    public async Task ReturnsUnavailable_WhenResourceDoesNotExist()
    {
        var (model, _) = BuildModel(replicaCount: 1, controlListeners: null);
 
        var target = CreateTarget(model);
 
        var result = await target.GetTerminalInfoAsync(
            new GetTerminalInfoRequest { ResourceName = "nope" }).DefaultTimeout();
 
        Assert.False(result.IsAvailable);
        Assert.Null(result.Replicas);
    }
 
    [Fact]
    public async Task ReturnsUnavailable_WhenResourceHasNoTerminalAnnotation()
    {
        var model = new DistributedApplicationModel(new ResourceCollection
        {
            new CustomResource("plain"),
        });
 
        var target = CreateTarget(model);
 
        var result = await target.GetTerminalInfoAsync(
            new GetTerminalInfoRequest { ResourceName = "plain" }).DefaultTimeout();
 
        Assert.False(result.IsAvailable);
        Assert.Null(result.Replicas);
    }
 
    [Fact]
    public async Task ReturnsAvailableWithDegradedReplicas_WhenAllControlSocketsAreUnreachable()
    {
        // Build a layout pointing at control sockets nobody is listening on. The new
        // fan-out model treats each per-replica host independently — every host's
        // control RPC will time out, but the call still returns IsAvailable=true with
        // one degraded TerminalReplicaInfo per replica (IsAlive=false, AppHost-known
        // ConsumerUdsPath populated). This mirrors the user-visible behavior in
        // `aspire terminal ps` where the row still appears for each replica even
        // when its host hasn't started yet.
        var (model, hosts) = BuildModel(replicaCount: 2, controlListeners: null);
 
        var target = CreateTarget(model);
 
        var result = await target.GetTerminalInfoAsync(
            new GetTerminalInfoRequest { ResourceName = "myapp" }).DefaultTimeout(TimeSpan.FromSeconds(15));
 
        Assert.True(result.IsAvailable);
        Assert.NotNull(result.Replicas);
        Assert.Equal(2, result.Replicas!.Length);
        for (var i = 0; i < 2; i++)
        {
            Assert.Equal(i, result.Replicas[i].ReplicaIndex);
            Assert.False(result.Replicas[i].IsAlive);
            Assert.Equal(hosts[i].Layout.ConsumerUdsPath, result.Replicas[i].ConsumerUdsPath);
        }
    }
 
    [Fact]
    public async Task ReturnsPerReplicaInfo_WhenAllHostsReachable()
    {
        // Each per-replica terminal host serves a single session. The AppHost's
        // GetTerminalInfo fans out across them in parallel and assembles the
        // per-replica response array. ReplicaIndex on the wire comes from the
        // AppHost's layout (TerminalHostLayout.ParentReplicaIndex), not from the
        // host's reply, so a misbehaving host can never confuse the AppHost's view.
        var fakeHost0 = await StartFakeControlHostAsync(new TerminalHostSessionInfo
        {
            ProducerUdsPath = "host-claim-p0",
            ConsumerUdsPath = "host-claim-r0",
            IsAlive = true,
            ProducerConnected = true,
            RestartCount = 3,
            // Populated so the mapping in QueryReplicaAsync (Current{Columns,Rows}
            // / AttachedPeerCount / Peers -> ConvertPeers) is actually exercised.
            // A silent drop of any of these mappings — including swapping the
            // Cols/Rows pair or breaking ConvertPeers' null/empty handling —
            // should fail this test.
            CurrentColumns = 120,
            CurrentRows = 42,
            AttachedPeerCount = 2,
            Peers = new[]
            {
                new TerminalHostPeerInfo { PeerId = "peer-a", DisplayName = "aspire-cli:1234" },
                // Second peer omits DisplayName so the nullable-pass-through in
                // ConvertPeers gets covered.
                new TerminalHostPeerInfo { PeerId = "peer-b", DisplayName = null },
            },
        }).DefaultTimeout();
 
        var fakeHost1 = await StartFakeControlHostAsync(new TerminalHostSessionInfo
        {
            ProducerUdsPath = "host-claim-p1",
            ConsumerUdsPath = "host-claim-r1",
            IsAlive = false,
            ExitCode = 7,
        }).DefaultTimeout();
 
        var (model, hosts) = BuildModel(
            replicaCount: 2,
            controlListeners: [fakeHost0, fakeHost1]);
 
        var target = CreateTarget(model);
 
        var result = await target.GetTerminalInfoAsync(
            new GetTerminalInfoRequest { ResourceName = "myapp" }).DefaultTimeout(TimeSpan.FromSeconds(10));
 
        Assert.True(result.IsAvailable);
        Assert.Equal(132, result.Columns);
        Assert.Equal(40, result.Rows);
        Assert.Null(result.SocketPath);
 
        Assert.NotNull(result.Replicas);
        Assert.Equal(2, result.Replicas!.Length);
 
        Assert.Equal(0, result.Replicas[0].ReplicaIndex);
        Assert.Equal("replica 0", result.Replicas[0].Label);
        // AppHost is the source of truth for the consumer UDS path even though the host
        // echoed back its own claim — verify we trust the layout.
        Assert.Equal(hosts[0].Layout.ConsumerUdsPath, result.Replicas[0].ConsumerUdsPath);
        Assert.True(result.Replicas[0].IsAlive);
        Assert.Null(result.Replicas[0].ExitCode);
        Assert.True(result.Replicas[0].ProducerConnected);
        Assert.Equal(3, result.Replicas[0].RestartCount);
        // The Cols/Rows pair is easy to swap by accident; pin both ends of the round-trip.
        Assert.Equal(120, result.Replicas[0].CurrentColumns);
        Assert.Equal(42, result.Replicas[0].CurrentRows);
        Assert.Equal(2, result.Replicas[0].AttachedPeerCount);
        Assert.NotNull(result.Replicas[0].Peers);
        Assert.Equal(2, result.Replicas[0].Peers!.Length);
        Assert.Equal("peer-a", result.Replicas[0].Peers![0].PeerId);
        Assert.Equal("aspire-cli:1234", result.Replicas[0].Peers![0].DisplayName);
        Assert.Equal("peer-b", result.Replicas[0].Peers![1].PeerId);
        Assert.Null(result.Replicas[0].Peers![1].DisplayName);
 
        Assert.Equal(1, result.Replicas[1].ReplicaIndex);
        Assert.Equal("replica 1", result.Replicas[1].Label);
        Assert.Equal(hosts[1].Layout.ConsumerUdsPath, result.Replicas[1].ConsumerUdsPath);
        Assert.False(result.Replicas[1].IsAlive);
        Assert.Equal(7, result.Replicas[1].ExitCode);
    }
 
    [Fact]
    public async Task DegradedReplicaReportedWhenSomeHostsUnreachable()
    {
        // Mixed scenario: replica 0's host is reachable, replica 1's is not.
        // Both show up in the result; replica 1 has IsAlive=false but the
        // AppHost-known consumer path is still reported so the row stays visible.
        var fakeHost0 = await StartFakeControlHostAsync(new TerminalHostSessionInfo
        {
            ProducerUdsPath = "host-claim-p0",
            ConsumerUdsPath = "host-claim-r0",
            IsAlive = true,
            ProducerConnected = true,
        }).DefaultTimeout();
 
        var (model, hosts) = BuildModel(
            replicaCount: 2,
            controlListeners: [fakeHost0, null]);
 
        var target = CreateTarget(model);
 
        var result = await target.GetTerminalInfoAsync(
            new GetTerminalInfoRequest { ResourceName = "myapp" }).DefaultTimeout(TimeSpan.FromSeconds(15));
 
        Assert.True(result.IsAvailable);
        Assert.NotNull(result.Replicas);
        Assert.Equal(2, result.Replicas!.Length);
        Assert.True(result.Replicas[0].IsAlive);
        Assert.False(result.Replicas[1].IsAlive);
        Assert.Equal(hosts[0].Layout.ConsumerUdsPath, result.Replicas[0].ConsumerUdsPath);
        Assert.Equal(hosts[1].Layout.ConsumerUdsPath, result.Replicas[1].ConsumerUdsPath);
    }
 
    [Fact]
    public async Task ListTerminalsAsync_AllUnreachable_KeepsDegradedReplicaShape()
    {
        // Regression: ListTerminalsAsync used to drop the per-replica array when no
        // host responded ("Replicas = anyHostReachable ? replicas : null"), which
        // made `aspire terminal ps --format json` blanker in the failure case than
        // in the success case — exactly when users need replica indexes / consumer
        // UDS paths to diagnose attach. The fix: always emit the degraded shape so
        // the JSON consumer sees "host unreachable but here are the slots" rather
        // than "host unreachable, you get nothing".
        var (model, hosts) = BuildModel(replicaCount: 2, controlListeners: null);
 
        var target = CreateTarget(model);
 
        var result = await target.ListTerminalsAsync(
            new ListTerminalsRequest()).DefaultTimeout(TimeSpan.FromSeconds(15));
 
        var summary = Assert.Single(result.Terminals);
        Assert.False(summary.IsHostReachable);
        Assert.NotNull(summary.Replicas);
        Assert.Equal(2, summary.Replicas!.Length);
        for (var i = 0; i < 2; i++)
        {
            Assert.Equal(i, summary.Replicas[i].ReplicaIndex);
            Assert.False(summary.Replicas[i].IsAlive);
            Assert.Equal(hosts[i].Layout.ConsumerUdsPath, summary.Replicas[i].ConsumerUdsPath);
        }
    }
 
    [Fact]
    public async Task ListTerminalsAsync_MixedReachability_ReturnsAllReplicas()
    {
        // One host up, one host down. The summary's IsHostReachable flag aggregates
        // to true (anyHostReachable), but both replicas must surface so callers can
        // tell which slot is the unhealthy one.
        var fakeHost0 = await StartFakeControlHostAsync(new TerminalHostSessionInfo
        {
            ProducerUdsPath = "host-claim-p0",
            ConsumerUdsPath = "host-claim-r0",
            IsAlive = true,
            ProducerConnected = true,
        }).DefaultTimeout();
 
        var (model, hosts) = BuildModel(
            replicaCount: 2,
            controlListeners: [fakeHost0, null]);
 
        var target = CreateTarget(model);
 
        var result = await target.ListTerminalsAsync(
            new ListTerminalsRequest()).DefaultTimeout(TimeSpan.FromSeconds(15));
 
        var summary = Assert.Single(result.Terminals);
        Assert.True(summary.IsHostReachable);
        Assert.NotNull(summary.Replicas);
        Assert.Equal(2, summary.Replicas!.Length);
        Assert.True(summary.Replicas[0].IsAlive);
        Assert.False(summary.Replicas[1].IsAlive);
        Assert.Equal(hosts[0].Layout.ConsumerUdsPath, summary.Replicas[0].ConsumerUdsPath);
        Assert.Equal(hosts[1].Layout.ConsumerUdsPath, summary.Replicas[1].ConsumerUdsPath);
    }
 
    [Fact]
    public async Task GetCapabilities_AdvertisesTerminalsV1()
    {
        var model = new DistributedApplicationModel(new ResourceCollection());
        var target = CreateTarget(model);
 
        var result = await target.GetCapabilitiesAsync().DefaultTimeout();
 
        Assert.Contains(AuxiliaryBackchannelCapabilities.V1, result.Capabilities);
        Assert.Contains(AuxiliaryBackchannelCapabilities.V2, result.Capabilities);
        Assert.Contains(AuxiliaryBackchannelCapabilities.Terminals_V1, result.Capabilities);
    }
 
    /// <summary>
    /// Builds a target resource with a <see cref="TerminalAnnotation"/> wired to one
    /// per-replica <see cref="TerminalHostResource"/> per replica. <paramref name="controlListeners"/>
    /// is matched by index against the hosts: a non-null entry points the corresponding layout
    /// at that fake host's listening UDS, a null entry leaves the layout pointing at a path
    /// nobody is listening on (so the per-host RPC degrades gracefully).
    /// </summary>
    private (DistributedApplicationModel Model, IReadOnlyList<TerminalHostResource> Hosts) BuildModel(
        int replicaCount,
        IReadOnlyList<FakeControlHost?>? controlListeners)
    {
        var baseDir = CreateShortTempDir();
 
        var target = new CustomResource("myapp");
        var hosts = new TerminalHostResource[replicaCount];
        for (var i = 0; i < replicaCount; i++)
        {
            // The test only needs four distinct, writable paths per replica; the production
            // ~/.aspire/trmnl/<id>.* layout is unnecessary here. We synthesise a stable
            // pseudo-id so paths look similar to production logs.
            var pseudoId = $"test{i.ToString(System.Globalization.CultureInfo.InvariantCulture).PadLeft(7, '0')}";
 
            var producer = Path.Combine(baseDir, $"{pseudoId}.dcp.sock");
            var consumer = Path.Combine(baseDir, $"{pseudoId}.host.sock");
            // If a fake host is supplied at this index, use its real listening path so
            // the AppHost-side fan-out actually reaches it. Otherwise leave it pointing
            // at a path that does not exist.
            var control = controlListeners is not null && i < controlListeners.Count && controlListeners[i] is { } listener
                ? listener.SocketPath
                : Path.Combine(baseDir, $"{pseudoId}.ctrl.sock");
            var metadata = Path.Combine(baseDir, $"{pseudoId}.metadata.json");
 
            var layout = new TerminalHostLayout(
                replicaId: pseudoId,
                parentReplicaIndex: i,
                producerUdsPath: producer,
                consumerUdsPath: consumer,
                controlUdsPath: control,
                metadataPath: metadata);
 
            hosts[i] = new TerminalHostResource($"myapp-terminalhost-{i}", target, layout);
        }
 
        var annotation = new TerminalAnnotation(new TerminalOptions { Columns = 132, Rows = 40 });
        annotation.Initialize(hosts);
        target.Annotations.Add(annotation);
 
        var resources = new ResourceCollection { target };
        foreach (var h in hosts)
        {
            resources.Add(h);
        }
 
        return (new DistributedApplicationModel(resources), hosts);
    }
 
    private static AuxiliaryBackchannelRpcTarget CreateTarget(DistributedApplicationModel model)
    {
        var services = new ServiceCollection();
        services.AddSingleton(model);
        var sp = services.BuildServiceProvider();
        var configuration = new ConfigurationBuilder().Build();
        var profilingTelemetry = new ProfilingTelemetry(configuration);
        return new AuxiliaryBackchannelRpcTarget(NullLogger<AuxiliaryBackchannelRpcTarget>.Instance, configuration, profilingTelemetry, sp);
    }
 
    private async Task<FakeControlHost> StartFakeControlHostAsync(TerminalHostSessionInfo session)
    {
        var dir = CreateShortTempDir();
        var socketPath = Path.Combine(dir, "ctrl.sock");
        var host = new FakeControlHost(socketPath, session);
        await host.StartAsync().ConfigureAwait(false);
        _toDispose.Add(host);
        return host;
    }
 
    private string CreateShortTempDir()
    {
        // Windows has a 108-byte limit on AF_UNIX paths (and 104 on macOS), and the default
        // %TEMP% can be deep. Allocate a short subdirectory we control.
        var dir = Directory.CreateTempSubdirectory("at-").FullName;
        _tempDirs.Add(dir);
        return dir;
    }
 
    public async ValueTask DisposeAsync()
    {
        foreach (var d in _toDispose)
        {
            try { await d.DisposeAsync().ConfigureAwait(false); }
            catch { }
        }
        foreach (var dir in _tempDirs)
        {
            try { Directory.Delete(dir, recursive: true); }
            catch { }
        }
    }
 
    /// <summary>
    /// Stand-in for an <c>aspire.terminalhost</c> process: binds the control UDS and
    /// exposes a single <see cref="TerminalHostControlProtocol.GetSessionMethod"/> that
    /// returns the canned <see cref="TerminalHostSessionInfo"/>.
    /// </summary>
    private sealed class FakeControlHost(string socketPath, TerminalHostSessionInfo session) : IAsyncDisposable
    {
        private Socket? _listenSocket;
        private CancellationTokenSource? _cts;
        private Task? _acceptLoop;
        private readonly List<JsonRpc> _rpcs = [];
 
        public string SocketPath { get; } = socketPath;
 
        public Task StartAsync()
        {
            var dir = Path.GetDirectoryName(SocketPath);
            if (!string.IsNullOrEmpty(dir))
            {
                Directory.CreateDirectory(dir);
            }
 
            var sock = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            sock.Bind(new UnixDomainSocketEndPoint(SocketPath));
            sock.Listen(8);
            _listenSocket = sock;
 
            _cts = new CancellationTokenSource();
            _acceptLoop = Task.Run(() => AcceptLoopAsync(_cts.Token));
            return Task.CompletedTask;
        }
 
        private async Task AcceptLoopAsync(CancellationToken ct)
        {
            while (!ct.IsCancellationRequested)
            {
                Socket client;
                try
                {
                    client = await _listenSocket!.AcceptAsync(ct).ConfigureAwait(false);
                }
                catch
                {
                    return;
                }
 
                _ = Task.Run(async () =>
                {
                    var stream = new NetworkStream(client, ownsSocket: true);
                    var formatter = new SystemTextJsonFormatter();
                    var handler = new HeaderDelimitedMessageHandler(stream, stream, formatter);
                    var rpc = new JsonRpc(handler);
 
                    rpc.AddLocalRpcMethod(
                        TerminalHostControlProtocol.GetSessionMethod,
                        new Func<TerminalHostSessionInfo>(() => session));
 
                    lock (_rpcs)
                    {
                        _rpcs.Add(rpc);
                    }
 
                    rpc.StartListening();
                    try { await rpc.Completion.ConfigureAwait(false); }
                    catch { }
                }, ct);
            }
        }
 
        public async ValueTask DisposeAsync()
        {
            try { _cts?.Cancel(); } catch { }
            try { _listenSocket?.Dispose(); } catch { }
            if (_acceptLoop is not null)
            {
                try { await _acceptLoop.ConfigureAwait(false); } catch { }
            }
            lock (_rpcs)
            {
                foreach (var rpc in _rpcs)
                {
                    try { rpc.Dispose(); } catch { }
                }
                _rpcs.Clear();
            }
            try { File.Delete(SocketPath); } catch { }
            _cts?.Dispose();
        }
    }
 
    private sealed class CustomResource(string name) : Resource(name);
}