File: Backchannel\OrphanedAppHostCollectorTests.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.Diagnostics;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Tests.TestServices;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Tests.Backchannel;
 
public class OrphanedAppHostCollectorTests
{
    [Fact]
    public void IsOrphaned_NoAppHostInfo_ReturnsFalse()
    {
        var connection = new TestAppHostAuxiliaryBackchannel { AppHostInfo = null };
 
        Assert.False(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_NoCliProcessId_ReturnsFalse()
    {
        // Without a known launching CLI we cannot attribute ownership, so never collect.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = null,
            },
        };
 
        Assert.False(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_StableStartTimeMatchesLiveCli_ReturnsFalse()
    {
        // Current AppHosts report CliStableStartedAt (the launcher CLI's /proc-based identity). The
        // current process stands in for a launching CLI that is still alive, so its stable start time
        // matches exactly and the AppHost is not orphaned.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = Environment.ProcessId,
                CliStableStartedAt = GetCurrentProcessStableStartTime(),
            },
        };
 
        Assert.False(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_StableStartTimeMismatchOnLiveCli_ReturnsTrue()
    {
        // The PID is alive but its stable /proc start time does not match — a recycled PID. The stable
        // identity is immune to clock drift, so this mismatch is trustworthy evidence the original
        // launcher is gone and the AppHost is orphaned.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = Environment.ProcessId,
                CliStableStartedAt = DateTimeOffset.FromUnixTimeMilliseconds(1),
            },
        };
 
        Assert.True(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_StablePreferredOverMismatchedLegacy_ReturnsFalse()
    {
        // Both identities are present. The stable value matches the live CLI while the legacy value does
        // not (as can happen after a clock step). The collector must trust the drift-immune stable value
        // and leave the live AppHost alone.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = Environment.ProcessId,
                CliStableStartedAt = GetCurrentProcessStableStartTime(),
                CliStartedAt = DateTimeOffset.FromUnixTimeSeconds(1),
            },
        };
 
        Assert.False(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_LegacyLiveCliProcess_ReturnsFalse()
    {
        // Older AppHost: only the legacy CliStartedAt is reported. The current process stands in for a
        // launching CLI that is still alive, so the AppHost is not orphaned.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = Environment.ProcessId,
                CliStartedAt = GetCurrentProcessRuntimeStartTime(),
            },
        };
 
        Assert.False(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_LegacyMismatchedStartTimeOnLiveCli_ReturnsFalse()
    {
        // Older AppHost: only the drift-prone legacy CliStartedAt is available and it does NOT match the
        // live CLI PID (as happens after a wall-clock step, not just PID reuse). Collecting is
        // destructive, so an ambiguous legacy mismatch on a still-live PID must NOT tear down the app.
        // The collector only acts on the legacy path when the CLI PID is entirely gone.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = Environment.ProcessId,
                CliStartedAt = DateTimeOffset.FromUnixTimeSeconds(1),
            },
        };
 
        Assert.False(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_DeadCliProcess_ReturnsTrue()
    {
        // A dead launcher: start the shared bounded, self-terminating helper (so an aborted test host
        // can't leak it), capture its start time, then kill it. The collector must then see the
        // recorded PID + start time as no-longer-running and report the AppHost orphaned.
        using var cliProcess = TestProcesses.StartLongRunning();
 
        var cliStartedAt = GetProcessStartTime(cliProcess);
        cliProcess.Kill(entireProcessTree: true);
        cliProcess.WaitForExit();
 
        // The launching CLI PID is entirely gone, so the AppHost is orphaned regardless of which
        // identity domain was reported.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = cliProcess.Id,
                CliStableStartedAt = cliStartedAt,
            },
        };
 
        Assert.True(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public void IsOrphaned_LegacyDeadCliProcess_ReturnsTrue()
    {
        var psi = OperatingSystem.IsWindows()
            ? new ProcessStartInfo("ping", "-t localhost") { CreateNoWindow = true }
            : new ProcessStartInfo("tail", "-f /dev/null") { CreateNoWindow = true };
        using var cliProcess = Process.Start(psi);
        Assert.NotNull(cliProcess);
 
        var cliStartedAt = GetProcessStartTime(cliProcess);
        cliProcess.Kill(entireProcessTree: true);
        cliProcess.WaitForExit();
 
        // Older AppHost reporting only the legacy identity: the PID is gone, so it is orphaned.
        var connection = new TestAppHostAuxiliaryBackchannel
        {
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = 4242,
                CliProcessId = cliProcess.Id,
                CliStartedAt = cliStartedAt,
            },
        };
 
        Assert.True(OrphanedAppHostCollector.IsOrphaned(connection));
    }
 
    [Fact]
    public async Task CollectAsync_ScansThenStopsOnlyOrphans()
    {
        var tempDir = Directory.CreateTempSubdirectory("aspire-orphan-collect-tests");
        try
        {
            var monitor = new TestAuxiliaryBackchannelMonitor();
            var stopper = new TestAppHostStopper();
 
            var orphanSocket = CreateSocketFile(tempDir, "orphan.sock");
            var liveSocket = CreateSocketFile(tempDir, "live.sock");
            monitor.AddConnection("orphan-hash", orphanSocket, CreateConnection(orphanSocket, appHostPid: 4242, orphaned: true));
            monitor.AddConnection("live-hash", liveSocket, CreateConnection(liveSocket, appHostPid: 4343, orphaned: false));
 
            var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
            var collected = await collector.CollectAsync(CancellationToken.None);
 
            Assert.Equal(1, collected);
            // The collector must scan for fresh state before deciding what to collect.
            Assert.Equal(1, monitor.ScanCallCount);
            // Only the orphaned AppHost is stopped; the live one is left running.
            var stopped = Assert.Single(stopper.StopRequests);
            Assert.Equal(4242, stopped?.ProcessId);
        }
        finally
        {
            Directory.Delete(tempDir.FullName, recursive: true);
        }
    }
 
    [Fact]
    public async Task CollectAsync_SuccessfulStop_DeletesSocketAndCounts()
    {
        var tempDir = Directory.CreateTempSubdirectory("aspire-orphan-collect-tests");
        try
        {
            var monitor = new TestAuxiliaryBackchannelMonitor();
            var stopper = new TestAppHostStopper { DefaultResult = true };
 
            var socketPath = CreateSocketFile(tempDir, "orphan.sock");
            monitor.AddConnection("orphan-hash", socketPath, CreateConnection(socketPath, appHostPid: 4242, orphaned: true));
 
            var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
            var collected = await collector.CollectAsync(CancellationToken.None);
 
            Assert.Equal(1, collected);
            // A confirmed stop must remove the now-dead AppHost's stale socket.
            Assert.False(File.Exists(socketPath));
        }
        finally
        {
            Directory.Delete(tempDir.FullName, recursive: true);
        }
    }
 
    [Fact]
    public async Task CollectAsync_FailedStop_DoesNotDeleteSocketOrCount()
    {
        var tempDir = Directory.CreateTempSubdirectory("aspire-orphan-collect-tests");
        try
        {
            var monitor = new TestAuxiliaryBackchannelMonitor();
            var stopper = new TestAppHostStopper { DefaultResult = false };
 
            var socketPath = CreateSocketFile(tempDir, "orphan.sock");
            monitor.AddConnection("orphan-hash", socketPath, CreateConnection(socketPath, appHostPid: 4242, orphaned: true));
 
            var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
            var collected = await collector.CollectAsync(CancellationToken.None);
 
            Assert.Equal(0, collected);
            // The process is not confirmed gone, so the socket must be left for a later cleanup pass.
            Assert.True(File.Exists(socketPath));
        }
        finally
        {
            Directory.Delete(tempDir.FullName, recursive: true);
        }
    }
 
    [Fact]
    public async Task CollectAsync_WhenStopThrows_SwallowsAndContinues()
    {
        var tempDir = Directory.CreateTempSubdirectory("aspire-orphan-collect-tests");
        try
        {
            var monitor = new TestAuxiliaryBackchannelMonitor();
 
            var throwingSocket = CreateSocketFile(tempDir, "throwing.sock");
            var okSocket = CreateSocketFile(tempDir, "ok.sock");
            monitor.AddConnection("throwing-hash", throwingSocket, CreateConnection(throwingSocket, appHostPid: 1, orphaned: true));
            monitor.AddConnection("ok-hash", okSocket, CreateConnection(okSocket, appHostPid: 2, orphaned: true));
 
            var stopper = new TestAppHostStopper
            {
                ThrowSelector = info => info?.ProcessId == 1 ? new InvalidOperationException("boom") : null,
            };
 
            var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
            var collected = await collector.CollectAsync(CancellationToken.None);
 
            // Collection is best effort: one orphan throwing must not abort the rest.
            Assert.Equal(1, collected);
            Assert.True(File.Exists(throwingSocket));
            Assert.False(File.Exists(okSocket));
        }
        finally
        {
            Directory.Delete(tempDir.FullName, recursive: true);
        }
    }
 
    [Fact]
    public async Task CollectAsync_NoOrphans_ReturnsZeroWithoutStopping()
    {
        var monitor = new TestAuxiliaryBackchannelMonitor();
        var stopper = new TestAppHostStopper();
 
        const string socketPath = "/tmp/aspire-orphan-tests-live.sock";
        monitor.AddConnection("live-hash", socketPath, CreateConnection(socketPath, appHostPid: 4343, orphaned: false));
 
        var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
        var collected = await collector.CollectAsync(CancellationToken.None);
 
        Assert.Equal(0, collected);
        Assert.Empty(stopper.StopRequests);
        Assert.Equal(1, monitor.ScanCallCount);
    }
 
    [Fact]
    public async Task CollectAsync_WhenScanThrows_ReturnsZero()
    {
        // Collection is best effort: a non-cancellation scan failure must not surface to the caller
        // (e.g. `aspire ps`), even when an orphan is present that would otherwise have been collected.
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            ScanAsyncCallback = _ => throw new InvalidOperationException("scan boom")
        };
        var stopper = new TestAppHostStopper();
 
        const string socketPath = "/tmp/aspire-orphan-tests-scan-throw.sock";
        monitor.AddConnection("orphan-hash", socketPath, CreateConnection(socketPath, appHostPid: 4242, orphaned: true));
 
        var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
        var collected = await collector.CollectAsync(CancellationToken.None);
 
        Assert.Equal(0, collected);
        Assert.Empty(stopper.StopRequests);
    }
 
    [Fact]
    public async Task CollectAsync_WhenScanCancelled_Throws()
    {
        // Cancellation is the one failure that must NOT be swallowed, so callers can abort promptly.
        var monitor = new TestAuxiliaryBackchannelMonitor
        {
            ScanAsyncCallback = _ => throw new OperationCanceledException()
        };
        var stopper = new TestAppHostStopper();
 
        var collector = new OrphanedAppHostCollector(monitor, stopper, NullLogger<OrphanedAppHostCollector>.Instance);
 
        await Assert.ThrowsAsync<OperationCanceledException>(() => collector.CollectAsync(CancellationToken.None));
        Assert.Empty(stopper.StopRequests);
    }
 
    private static string CreateSocketFile(DirectoryInfo directory, string name)
    {
        var path = Path.Combine(directory.FullName, name);
        File.WriteAllText(path, string.Empty);
        return path;
    }
 
    private static TestAppHostAuxiliaryBackchannel CreateConnection(string socketPath, int appHostPid, bool orphaned)
    {
        return new TestAppHostAuxiliaryBackchannel
        {
            SocketPath = socketPath,
            AppHostInfo = new AppHostInformation
            {
                AppHostPath = "/tmp/AppHost.csproj",
                ProcessId = appHostPid,
                CliProcessId = Environment.ProcessId,
                // Drive the decision through the stable /proc identity the collector prefers.
                // Orphaned: a mismatched stable start time is trustworthy evidence of a recycled CLI PID.
                // Live: the current process's real stable start time matches, so the launching CLI is alive.
                CliStableStartedAt = orphaned
                    ? DateTimeOffset.FromUnixTimeMilliseconds(1)
                    : GetCurrentProcessStableStartTime(),
            },
        };
    }
 
    private static DateTimeOffset GetCurrentProcessRuntimeStartTime()
        => DateTimeOffset.FromUnixTimeSeconds(ProcessStartTimeHelper.GetCurrentProcessRuntimeStartTimeUnixSeconds());
 
    private static DateTimeOffset GetCurrentProcessStableStartTime()
        => DateTimeOffset.FromUnixTimeMilliseconds(ProcessStartTimeHelper.GetCurrentProcessStartTimeUnixMilliseconds());
 
    private static DateTimeOffset GetProcessStartTime(Process process)
    {
        var startTime = ProcessStartTimeHelper.TryGetProcessStartTime(process.Id);
        Assert.NotNull(startTime);
        return startTime.Value;
    }
}