File: Projects\RunningInstanceManagerTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.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.Cli.Backchannel;
using Aspire.Cli.Projects;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Tests.TestServices;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StreamJsonRpc;
 
namespace Aspire.Cli.Tests.Projects;
 
public class RunningInstanceManagerTests
{
    [Fact]
    public async Task StopRunningInstanceAsync_DeletesSocketFile_WhenStopSucceeds()
    {
        // Regression test for https://github.com/microsoft/aspire/issues/17587.
        //
        // After a successful stop, the CLI must delete the auxiliary backchannel socket file. If the
        // file is left behind, a later command (e.g. 'aspire add' or 'aspire stop') rediscovers it via
        // FindMatchingNonOrphanedSockets and tries to connect to a now-defunct process, failing with
        // "Unable to stop one or more running Aspire AppHost instances". This is most visible on Windows
        // where the dead AppHost's PID can be reused (so the orphan-pruning heuristic believes the
        // process is still alive), which is why a unit test is the deterministic, cross-platform guard.
 
        // The AppHost process reported by the fake backchannel must be one MonitorProcessesForTerminationAsync
        // observes as already terminated so StopRunningInstanceAsync reaches the socket-cleanup branch. A fabricated,
        // guaranteed-nonexistent PID makes Process.GetProcessById throw ArgumentException, which the monitor treats as
        // "terminated". We deliberately do NOT start a real process and reuse its exited PID: the OS can recycle that
        // exact PID for an unrelated live process before the monitor checks, which would make the monitor loop for the
        // full timeout, return stopped == false, and flake the cleanup assertion.
        const int exitedProcessId = int.MaxValue - 9;
 
        var socketDirectory = Directory.CreateTempSubdirectory("aspire-rim-");
        try
        {
            // Keep the socket path short: a UnixDomainSocketEndPoint is limited to ~104 bytes on macOS.
            var socketPath = Path.Combine(socketDirectory.FullName, "a.sock");
            using var server = TestAuxiliaryBackchannelUdsServer.Start(socketPath, exitedProcessId);
 
            // Binding the server creates the socket file on disk.
            Assert.True(File.Exists(socketPath));
 
            var manager = new RunningInstanceManager(NullLogger.Instance, new TestInteractionService(), TimeProvider.System, new ProfilingTelemetry(new ConfigurationBuilder().Build()));
 
            var stopped = await manager.StopRunningInstanceAsync(socketPath, CancellationToken.None).DefaultTimeout();
 
            Assert.True(stopped);
            Assert.True(server.Target.StopRequested);
            // The fix: the socket file must be removed once the instance has been stopped.
            Assert.False(File.Exists(socketPath));
        }
        finally
        {
            socketDirectory.Delete(recursive: true);
        }
    }
 
    private sealed class TestAuxiliaryBackchannelUdsServer : IDisposable
    {
        private readonly Socket _listener;
        private readonly CancellationTokenSource _cts = new();
        private readonly List<IDisposable> _disposables = [];
 
        private TestAuxiliaryBackchannelUdsServer(string socketPath, int appHostProcessId)
        {
            Target = new StoppableAppHostRpcTarget(appHostProcessId);
            _listener = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
            _listener.Bind(new UnixDomainSocketEndPoint(socketPath));
            _listener.Listen(1);
        }
 
        public StoppableAppHostRpcTarget Target { get; }
 
        public static TestAuxiliaryBackchannelUdsServer Start(string socketPath, int appHostProcessId)
        {
            var server = new TestAuxiliaryBackchannelUdsServer(socketPath, appHostProcessId);
            _ = server.AcceptConnectionAsync();
            return server;
        }
 
        private async Task AcceptConnectionAsync()
        {
            try
            {
                var serverSocket = await _listener.AcceptAsync(_cts.Token).ConfigureAwait(false);
                var serverStream = new NetworkStream(serverSocket, ownsSocket: true);
                var messageHandler = new HeaderDelimitedMessageHandler(serverStream, serverStream, BackchannelJsonSerializerContext.CreateRpcMessageFormatter());
                var rpc = new JsonRpc(messageHandler, Target);
                rpc.StartListening();
                _disposables.Add(rpc);
                _disposables.Add(messageHandler);
                _disposables.Add(serverStream);
            }
            catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException)
            {
                // Expected when the server is disposed before/while a connection is accepted.
            }
        }
 
        public void Dispose()
        {
            _cts.Cancel();
            foreach (var disposable in _disposables)
            {
                disposable.Dispose();
            }
 
            _listener.Dispose();
            _cts.Dispose();
        }
    }
 
    private sealed class StoppableAppHostRpcTarget
    {
        private readonly int _processId;
        private readonly string[] _capabilities =
        [
            AuxiliaryBackchannelCapabilities.V1,
            AuxiliaryBackchannelCapabilities.V2
        ];
 
        public StoppableAppHostRpcTarget(int processId)
        {
            _processId = processId;
        }
 
        public bool StopRequested { get; private set; }
 
        public Task<AppHostInformation> GetAppHostInformationAsync(CancellationToken cancellationToken = default)
        {
            _ = cancellationToken;
 
            return Task.FromResult(new AppHostInformation
            {
                AppHostPath = "/path/to/AppHost.csproj",
                ProcessId = _processId
            });
        }
 
        public Task<GetCapabilitiesResponse> GetCapabilitiesAsync(GetCapabilitiesRequest? request = null, CancellationToken cancellationToken = default)
        {
            _ = request;
            _ = cancellationToken;
 
            return Task.FromResult(new GetCapabilitiesResponse
            {
                Capabilities = _capabilities
            });
        }
 
        public Task StopAppHostAsync(CancellationToken cancellationToken = default)
        {
            _ = cancellationToken;
            StopRequested = true;
            return Task.CompletedTask;
        }
    }
}