File: Hosting\CliOrphanDetectorTests.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 System.Threading.Channels;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Cli;
using Aspire.Hosting.Utils;
using Aspire.TestUtilities;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests;
 
public class CliOrphanDetectorTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public async Task CliOrphanDetectorCompletesWhenNoPidEnvironmentVariablePresent()
    {
        var configuration = new ConfigurationBuilder().Build();
 
        var lifetime = new HostLifetimeStub(() => { });
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime);
 
        // The detector should complete almost immediately because there is no
        // environment variable present that indicates that it is hitched to
        // .NET Aspire lifetime.
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
    }
 
    [Fact]
    public async Task CliOrphanDetectorCallsStopIfEnvironmentVariablePresentAndProcessNotRunning()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { { "ASPIRE_CLI_PID", "1111" } })
            .Build();
 
        var stopSignalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var lifetime = new HostLifetimeStub(() => stopSignalTcs.TrySetResult());
 
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime);
        detector.IsProcessRunning = _ => false;
 
        // The detector should complete almost immediately because there is no
        // environment variable present that indicates that it is hitched to
        // .NET Aspire lifetime.
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
        await stopSignalTcs.Task.DefaultTimeout();
    }
 
    [Fact]
    public async Task CliOrphanDetectorUsesTimestampDetectionWhenStartTimeProvided()
    {
        var expectedStartTime = DateTime.Now.AddMinutes(-5);
        var expectedStartTimeUnixSeconds = ((DateTimeOffset)expectedStartTime).ToUnixTimeSeconds();
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "ASPIRE_CLI_PID", "1111" },
                { "ASPIRE_CLI_STARTED", expectedStartTimeUnixSeconds.ToString() }
            })
            .Build();
 
        var stopSignalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var lifetime = new HostLifetimeStub(() => stopSignalTcs.TrySetResult());
 
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime);
        detector.IsProcessRunningWithStartTime = (pid, startTime) => false;
 
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
        await stopSignalTcs.Task.DefaultTimeout();
    }
 
    [Fact]
    public async Task CliOrphanDetectorFallsBackToPidOnlyWhenStartTimeInvalid()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "ASPIRE_CLI_PID", "1111" },
                { "ASPIRE_CLI_STARTED", "invalid_start_time" }
            })
            .Build();
 
        var stopSignalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var lifetime = new HostLifetimeStub(() => stopSignalTcs.TrySetResult());
 
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime);
        detector.IsProcessRunning = _ => false;
 
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
        await stopSignalTcs.Task.DefaultTimeout();
    }
 
    [Fact]
    [QuarantinedTest("https://github.com/dotnet/aspire/issues/12710")]
    public async Task CliOrphanDetectorContinuesRunningWhenProcessAliveWithCorrectStartTime()
    {
        var expectedStartTime = DateTime.Now.AddMinutes(-5);
        var expectedStartTimeUnix = ((DateTimeOffset)expectedStartTime).ToUnixTimeSeconds();
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "ASPIRE_CLI_PID", "1111" },
                { "ASPIRE_CLI_STARTED", expectedStartTimeUnix.ToString() }
            })
            .Build();
        var fakeTimeProvider = new FakeTimeProvider(DateTimeOffset.Now);
 
        var stopSignalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var processRunningChannel = Channel.CreateUnbounded<int>();
 
        var lifetime = new HostLifetimeStub(() => stopSignalTcs.TrySetResult());
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime, fakeTimeProvider);
 
        var processRunningCallCounter = 0;
        detector.IsProcessRunningWithStartTime = (pid, startTime) =>
        {
            Assert.True(processRunningChannel.Writer.TryWrite(++processRunningCallCounter));
            return processRunningCallCounter < 3; // Process dies after 3 checks
        };
 
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
 
        // Verify process is checked first time
        Assert.Equal(1, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        // Second check
        Assert.Equal(2, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        // Third check (process dies)
        Assert.Equal(3, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
 
        // Should have exited.
        await detector.ExecuteTask!.DefaultTimeout();
        await stopSignalTcs.Task.DefaultTimeout();
    }
 
    [Fact]
    public async Task CliOrphanDetectorStopsWhenProcessHasDifferentStartTime()
    {
        var expectedStartTime = DateTime.Now.AddMinutes(-5);
        var expectedStartTimeUnixString = ((DateTimeOffset)expectedStartTime).ToUnixTimeSeconds().ToString();
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                { "ASPIRE_CLI_PID", "1111" },
                { "ASPIRE_CLI_STARTED", expectedStartTimeUnixString }
            })
            .Build();
 
        var stopSignalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var lifetime = new HostLifetimeStub(() => stopSignalTcs.TrySetResult());
 
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime);
 
        // Simulate process with different start time (PID reuse scenario)
        detector.IsProcessRunningWithStartTime = (pid, startTime) =>
        {
            // Process exists but has different start time - indicates PID reuse
            return false;
        };
 
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
        await stopSignalTcs.Task.DefaultTimeout();
    }
 
    [Fact]
    [QuarantinedTest("https://github.com/dotnet/aspire/issues/12710")]
    public async Task CliOrphanDetectorAfterTheProcessWasRunningForAWhileThenStops()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { { "ASPIRE_CLI_PID", "1111" } })
            .Build();
        var fakeTimeProvider = new FakeTimeProvider(DateTimeOffset.Now);
 
        var stopSignalTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var processRunningChannel = Channel.CreateUnbounded<int>();
 
        var lifetime = new HostLifetimeStub(() => stopSignalTcs.TrySetResult());
        var loggerFactory = CreateLoggerFactory(testOutputHelper);
        var testLogger = loggerFactory.CreateLogger<CliOrphanDetectorTests>();
        var detector = CreateCliOrphanDetector(loggerFactory, configuration, lifetime, fakeTimeProvider);
 
        var processRunningCallCounter = 0;
        detector.IsProcessRunning = pid =>
        {
            Assert.True(processRunningChannel.Writer.TryWrite(++processRunningCallCounter));
 
            var isProcessRunning = processRunningCallCounter < 5;
            testLogger.LogDebug($"IsProcessRunning called. Running count: {processRunningCallCounter}. IsProcessRunning: {isProcessRunning}");
            return isProcessRunning;
        };
 
        // The detector should complete after about 5 seconds
 
        await detector.StartAsync(CancellationToken.None).DefaultTimeout();
 
        Assert.Equal(1, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.Equal(2, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.Equal(3, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.Equal(4, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.Equal(5, await processRunningChannel.Reader.ReadAsync().DefaultTimeout());
 
        // Should have exited.
        await detector.ExecuteTask!.DefaultTimeout();
        await stopSignalTcs.Task.DefaultTimeout();
    }
 
    [Fact]
    public async Task AppHostExitsWhenCliProcessPidDies()
    {
        // Start a long-running process that will stay alive until killed
        // These are system utilities on their respective platforms and don't require any additional dependencies.
        var psi = OperatingSystem.IsWindows()
            ? new ProcessStartInfo("ping", "-t localhost") { CreateNoWindow = true }
            : new ProcessStartInfo("tail", "-f /dev/null");
 
        psi.RedirectStandardOutput = true;
        psi.RedirectStandardError = true;
 
        using var fakeCliProcess = Process.Start(psi);
        Assert.NotNull(fakeCliProcess);
 
        using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
        builder.Configuration["ASPIRE_CLI_PID"] = fakeCliProcess.Id.ToString();
 
        var resourcesCreatedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        builder.Eventing.Subscribe<AfterResourcesCreatedEvent>((e, ct) =>
        {
            resourcesCreatedTcs.SetResult();
            return Task.CompletedTask;
        });
 
        using var app = builder.Build();
        var pendingRun = app.RunAsync();
 
        // Wait until the apphost is spun up and then kill off the stub
        // process so everything is torn down.
        await resourcesCreatedTcs.Task.DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        fakeCliProcess.Kill();
 
        await pendingRun.DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
    }
 
    private static CliOrphanDetector CreateCliOrphanDetector(
        ILoggerFactory loggerFactory,
        IConfiguration configuration,
        IHostApplicationLifetime lifetime,
        TimeProvider? timeProvider = null)
    {
        timeProvider ??= new FakeTimeProvider(new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero));
        var logger = loggerFactory.CreateLogger<CliOrphanDetector>();
        return new CliOrphanDetector(configuration, lifetime, timeProvider, logger);
    }
 
    private static ILoggerFactory CreateLoggerFactory(ITestOutputHelper testOutputHelper)
    {
        return LoggerFactory.Create(builder =>
        {
            builder.SetMinimumLevel(LogLevel.Debug);
            builder.AddXunit(testOutputHelper);
        });
    }
}
 
file sealed class HostLifetimeStub(Action stopImplementation) : IHostApplicationLifetime
{
    public CancellationToken ApplicationStarted => throw new NotImplementedException();
 
    public CancellationToken ApplicationStopped => throw new NotImplementedException();
 
    public CancellationToken ApplicationStopping => throw new NotImplementedException();
 
    public void StopApplication() => stopImplementation();
}