File: Processes\ParentProcessLivenessMonitorTests.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 Microsoft.Extensions.Time.Testing;
 
namespace Aspire.Cli.Tests.Processes;
 
public class ParentProcessLivenessMonitorTests
{
    private static readonly TimeSpan s_observationTimeout = TimeSpan.FromSeconds(15);
 
    [Fact]
    public async Task ParentAlreadyExited_InvokesCallbackWithoutWaitingForFirstTick()
    {
        // Immediate-first-check: when the parent is already gone at Start time, the callback must fire on
        // the pre-tick probe rather than after a full poll interval. A FakeTimeProvider that is never
        // advanced proves it: with the immediate probe the callback still fires; if detection depended on
        // the timer, WaitForNextTickAsync would never complete and this would time out.
        using var parent = StartLongRunningProcess();
        var parentStartedUnix = GetProcessStartTimeUnixMilliseconds(parent);
        var parentPid = parent.Id;
 
        parent.Kill(entireProcessTree: true);
        parent.WaitForExit();
 
        var timeProvider = new FakeTimeProvider();
        var exitedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        await using var monitor = ParentProcessLivenessMonitor.Start(
            parentPid,
            parentStartedUnix,
            _ =>
            {
                exitedTcs.TrySetResult();
                return Task.CompletedTask;
            },
            timeProvider);
 
        // Deliberately never advance the fake clock. The callback must still fire from the initial probe.
        await exitedTcs.Task.WaitAsync(s_observationTimeout);
    }
 
    [Fact]
    public async Task ParentExit_InvokesCallback()
    {
        using var parent = StartLongRunningProcess();
        var parentStartedUnix = GetProcessStartTimeUnixMilliseconds(parent);
 
        var exitedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        await using var monitor = ParentProcessLivenessMonitor.Start(
            parent.Id,
            parentStartedUnix,
            _ =>
            {
                exitedTcs.TrySetResult();
                return Task.CompletedTask;
            });
 
        parent.Kill(entireProcessTree: true);
        parent.WaitForExit();
 
        await exitedTcs.Task.WaitAsync(s_observationTimeout);
    }
 
    [Fact]
    public async Task ParentAlive_DoesNotInvokeCallback()
    {
        // The current process stands in for a parent that stays alive.
        var parentStartedUnix = ProcessStartTimeHelper.GetCurrentProcessStartTimeUnixMilliseconds();
 
        var invoked = false;
        await using (var monitor = ParentProcessLivenessMonitor.Start(
            Environment.ProcessId,
            parentStartedUnix,
            _ =>
            {
                invoked = true;
                return Task.CompletedTask;
            }))
        {
            // Give the monitor several poll intervals to (incorrectly) fire.
            await Task.Delay(TimeSpan.FromSeconds(3));
            Assert.False(invoked);
        }
 
        Assert.False(invoked);
    }
 
    [Fact]
    public async Task Dispose_DisarmsBeforeParentExit()
    {
        using var parent = StartLongRunningProcess();
        var parentStartedUnix = GetProcessStartTimeUnixMilliseconds(parent);
 
        var invoked = false;
        var monitor = ParentProcessLivenessMonitor.Start(
            parent.Id,
            parentStartedUnix,
            _ =>
            {
                invoked = true;
                return Task.CompletedTask;
            });
 
        // Disarm first, then kill the parent. A disposed monitor must not invoke the callback.
        await monitor.DisposeAsync();
 
        parent.Kill(entireProcessTree: true);
        parent.WaitForExit();
 
        await Task.Delay(TimeSpan.FromSeconds(3));
        Assert.False(invoked);
    }
 
    [Fact]
    public async Task ParentExit_CallbackFailureDoesNotFaultDispose()
    {
        using var parent = StartLongRunningProcess();
        var parentStartedUnix = GetProcessStartTimeUnixMilliseconds(parent);
 
        var callbackInvokedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var monitor = ParentProcessLivenessMonitor.Start(
            parent.Id,
            parentStartedUnix,
            _ =>
            {
                callbackInvokedTcs.TrySetResult();
                throw new InvalidOperationException("Callback failure should not fault monitor disposal.");
            });
 
        try
        {
            parent.Kill(entireProcessTree: true);
            parent.WaitForExit();
 
            await callbackInvokedTcs.Task.WaitAsync(s_observationTimeout);
 
            var exception = await Record.ExceptionAsync(async () => await monitor.DisposeAsync());
 
            Assert.Null(exception);
        }
        finally
        {
            if (!parent.HasExited)
            {
                parent.Kill(entireProcessTree: true);
                parent.WaitForExit();
            }
 
            await monitor.DisposeAsync();
        }
    }
 
    private static Process StartLongRunningProcess() => TestProcesses.StartLongRunning();
 
    private static long GetProcessStartTimeUnixMilliseconds(Process process)
    {
        var startTime = ProcessStartTimeHelper.TryGetProcessStartTimeUnixMilliseconds(process.Id);
        Assert.NotNull(startTime);
        return startTime.Value;
    }
}