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.Threading.Channels;
using Aspire.Components.Common.Tests;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Cli;
using Aspire.Hosting.Utils;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Time.Testing;
using Xunit;
using Xunit.Abstractions;
 
namespace Aspire.Cli.Tests;
 
public class CliOrphanDetectorTests(ITestOutputHelper testOutputHelper)
{
    [Fact]
    public async Task CliOrphanDetectorCompletesWhenNoPidEnvironmentVariablePresent()
    {
        var configuration = new ConfigurationBuilder().Build();
 
        var lifetime = new HostLifetimeStub(() => {});
        var detector = new CliOrphanDetector(configuration, lifetime, TimeProvider.System);
 
        // 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).WaitAsync(TimeSpan.FromSeconds(5));
    }
 
    [Fact]
    public async Task CliOrphanDetectorCallsStopIfEnvironmentVariablePresentAndProcessNotRunning()
    {
        var configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?> { { "ASPIRE_CLI_PID", "1111" } })    
            .Build();
 
        var stopSignalChannel = Channel.CreateUnbounded<bool>();
        var lifetime = new HostLifetimeStub(() => stopSignalChannel.Writer.TryWrite(true));
 
        var detector = new CliOrphanDetector(configuration, lifetime, TimeProvider.System);
        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).WaitAsync(TimeSpan.FromSeconds(5));
        Assert.True(await stopSignalChannel.Reader.WaitToReadAsync());
    }
 
    [Fact]
    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 stopSignalChannel = Channel.CreateUnbounded<bool>();
        var processRunningChannel = Channel.CreateUnbounded<int>();
 
        var lifetime = new HostLifetimeStub(() => stopSignalChannel.Writer.TryWrite(true));
        var detector = new CliOrphanDetector(configuration, lifetime, fakeTimeProvider);
        
        var processRunningCallCounter = 0;
        detector.IsProcessRunning = pid => {
            Assert.True(processRunningChannel.Writer.TryWrite(++processRunningCallCounter));
            return processRunningCallCounter < 5;
        };
 
        // The detector should complete after about 5 seconds
 
        await detector.StartAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
 
        Assert.True(await processRunningChannel.Reader.WaitToReadAsync());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.True(await processRunningChannel.Reader.WaitToReadAsync());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.True(await processRunningChannel.Reader.WaitToReadAsync());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.True(await processRunningChannel.Reader.WaitToReadAsync());
        fakeTimeProvider.Advance(TimeSpan.FromSeconds(1));
 
        Assert.True(await processRunningChannel.Reader.WaitToReadAsync());
        Assert.Equal(5, processRunningCallCounter);
 
        Assert.True(await stopSignalChannel.Reader.WaitToReadAsync());
    }
 
    [Fact]
    [ActiveIssue("https://github.com/dotnet/aspire/issues/7920", typeof(PlatformDetection), nameof(PlatformDetection.IsRunningOnGithubActions), nameof(PlatformDetection.IsWindows))]
    public async Task AppHostExitsWhenCliProcessPidDies()
    {
        using var fakeCliProcess = RemoteExecutor.Invoke(
            static () => Thread.Sleep(Timeout.Infinite),
            new RemoteInvokeOptions { CheckExitCode = false }
            );
            
        using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(testOutputHelper);
        builder.Configuration["ASPIRE_CLI_PID"] = fakeCliProcess.Process.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.WaitAsync(TimeSpan.FromSeconds(10));
        fakeCliProcess.Process.Kill();
 
        await pendingRun.WaitAsync(TimeSpan.FromSeconds(10));
    }
}
 
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();
}