File: tests\Shared\Processes\ChildProcessTests.cs
Web Access
Project: src\tests\Aspire.Hosting.RemoteHost.Tests\Aspire.Hosting.RemoteHost.Tests.csproj (Aspire.Hosting.RemoteHost.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.Collections.Concurrent;
using System.Diagnostics;
using System.IO.Pipes;
using Aspire.TestUtilities;
using Microsoft.Extensions.Time.Testing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
using Xunit;
 
namespace Aspire.Shared.Tests;
 
[RequiresTools(["dotnet"])]
[Collection(ProcessTestCollection.Name)]
public class ChildProcessTests(ProcessTestFixture fixture)
{
    [Fact]
    public async Task StartAsync_CancellationAndDisposalDoNotLaunch()
    {
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("output", "0"), new ChildProcessOptions());
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() => process.StartAsync(cancellation.Token));
        Assert.Throws<InvalidOperationException>(() => process.ProcessId);
        await process.DisposeAsync();
        await Assert.ThrowsAsync<ObjectDisposedException>(() => process.StartAsync(TestContext.Current.CancellationToken));
        Assert.Throws<InvalidOperationException>(() => process.ProcessId);
    }
 
    [Fact]
    public async Task WaitForExitAsync_ThrowingCallbackStillDrainsBothStreams()
    {
        var stdout = new ConcurrentQueue<string>();
        var stderr = new ConcurrentQueue<string>();
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("output", "23"),
            new ChildProcessOptions
            {
                TimeProvider = new FakeTimeProvider(),
                StandardOutputCallback = line =>
                {
                    stdout.Enqueue(line);
                    if (line == "stdout:0")
                    {
                        throw new InvalidOperationException("Test callback failure.");
                    }
                },
                StandardErrorCallback = stderr.Enqueue
            });
        await process.StartAsync(TestContext.Current.CancellationToken);
 
        var exitCode = await process.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.Equal(23, exitCode);
        Assert.Equal([$"runtime:{ProcessTestFixture.RuntimeMajor}", "stdin:0", .. Enumerable.Range(0, 256).Select(i => $"stdout:{i}")], stdout.ToArray());
        Assert.Equal(Enumerable.Range(0, 256).Select(i => $"stderr:{i}"), stderr);
    }
 
    [Fact]
    public async Task WaitForExitAsync_DrainsBufferedTailAfterLongIdlePeriod()
    {
        var clock = new FakeTimeProvider();
        var stdout = new ConcurrentQueue<string>();
        var stderr = new ConcurrentQueue<string>();
        var firstLine = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseConsumer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("output", "0"),
            new ChildProcessOptions
            {
                TimeProvider = clock,
                StandardOutputCallback = line =>
                {
                    if (line.StartsWith("runtime:", StringComparison.Ordinal))
                    {
                        firstLine.TrySetResult();
                        releaseConsumer.Task.WaitAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
                    }
                    stdout.Enqueue(line);
                },
                StandardErrorCallback = stderr.Enqueue
            });
        await process.StartAsync(TestContext.Current.CancellationToken);
        try
        {
            await firstLine.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
            await process.WaitForRootExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30));
            // Exceed the five-second idle window before observing exit. Exit must reset that
            // budget, and completing the reader must wake it without advancing the clock again.
            clock.Advance(TimeSpan.FromSeconds(10));
            var exit = process.WaitForExitAsync(TestContext.Current.CancellationToken);
 
            Assert.False(exit.IsCompleted);
            releaseConsumer.TrySetResult();
            Assert.Equal(0, await exit.WaitAsync(TimeSpan.FromSeconds(30)));
            Assert.Equal([$"runtime:{ProcessTestFixture.RuntimeMajor}", "stdin:0", .. Enumerable.Range(0, 256).Select(i => $"stdout:{i}")], stdout.ToArray());
            Assert.Equal(Enumerable.Range(0, 256).Select(i => $"stderr:{i}"), stderr);
        }
        finally
        {
            releaseConsumer.TrySetResult();
        }
    }
 
    [Fact]
    public async Task StartAsync_RejectsSecondLaunch()
    {
        using var readiness = new ProcessTestReadiness();
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("wait", readiness.Name), new ChildProcessOptions());
        await process.StartAsync(TestContext.Current.CancellationToken);
 
        await Assert.ThrowsAsync<InvalidOperationException>(() => process.StartAsync(TestContext.Current.CancellationToken));
    }
 
    [Fact]
    public async Task WaitForExitAsync_CancellationTerminatesStartedProcess()
    {
        using var readiness = new ProcessTestReadiness();
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("wait", readiness.Name), new ChildProcessOptions());
        await process.StartAsync(TestContext.Current.CancellationToken);
        var identity = await readiness.ReadRuntimeAsync();
        using var cancellation = new CancellationTokenSource();
        cancellation.Cancel();
 
        await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
            process.WaitForExitAsync(cancellation.Token).WaitAsync(TimeSpan.FromSeconds(30)));
 
        await ProcessTestFixture.AssertExitedAsync(identity);
    }
 
    [Fact]
    public async Task DisposeAsync_TerminatesStartedProcessWithoutAnExitWait()
    {
        using var readiness = new ProcessTestReadiness();
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("wait", readiness.Name), new ChildProcessOptions());
        await process.StartAsync(TestContext.Current.CancellationToken);
        var identity = await readiness.ReadRuntimeAsync();
        using var observedProcess = Process.GetProcessById(identity.ProcessId);
 
        await process.DisposeAsync();
 
        await observedProcess.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30));
        Assert.True(observedProcess.HasExited);
        await process.DisposeAsync();
    }
 
    [Theory]
    [InlineData(0)]
    [InlineData(23)]
    public async Task OwnedTree_CommandExitPreservesStatusAndReapsOrphanedWorkers(int exitCode)
    {
        using var readiness = new ProcessTestReadiness();
        await using var process = ProcessTestFixture.CreateProcess(
            fixture.CreateStartInfo("tree-exit", readiness.Name, exitCode.ToString(System.Globalization.CultureInfo.InvariantCulture)),
            new ChildProcessOptions
            {
                Lifetime = ChildProcessLifetime.OwnedTree,
                CreateSupervisorStartInfo = fixture.CreateSupervisorStartInfo
            });
        ProcessTestIdentity[] identities = [];
        try
        {
            await process.StartAsync(TestContext.Current.CancellationToken);
            identities = await readiness.ReadTreeAsync();
 
            Assert.Equal(exitCode, await process.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30)));
            await process.DisposeAsync();
            await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync));
        }
        finally
        {
            foreach (var identity in identities)
            {
                ProcessTestFixture.KillIfRunning(identity);
            }
        }
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task OwnedTree_ShutdownReapsWorkersWhenRootExitsBeforeThem(bool dispose)
    {
        using var readiness = new ProcessTestReadiness();
        ProcessTestIdentity[] identities = [];
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("tree", readiness.Name),
            new ChildProcessOptions
            {
                Lifetime = ChildProcessLifetime.OwnedTree,
                CreateSupervisorStartInfo = fixture.CreateSupervisorStartInfo,
                BeginGracefulShutdown = () => CancellationToken.None,
                RequestGracefulShutdownAsync = async (pid, token) =>
                {
                    Assert.NotEqual(pid, identities[0].ProcessId);
                    using var root = Process.GetProcessById(identities[0].ProcessId);
                    root.Kill(entireProcessTree: false);
                    await root.WaitForExitAsync(token);
                }
            });
        try
        {
            await process.StartAsync(TestContext.Current.CancellationToken);
            identities = await readiness.ReadTreeAsync();
            using var cancellation = new CancellationTokenSource();
            cancellation.Cancel();
 
            if (dispose)
            {
                await process.DisposeAsync();
            }
            else
            {
                await Assert.ThrowsAnyAsync<OperationCanceledException>(() => process.WaitForExitAsync(cancellation.Token));
            }
            await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync));
        }
        finally
        {
            foreach (var identity in identities)
            {
                ProcessTestFixture.KillIfRunning(identity);
            }
        }
    }
 
    [Fact]
    public async Task OwnedTree_ForwardsGracefulSignalAndReapsWorkersAfterRuntimeExit()
    {
        Assert.SkipUnless(!OperatingSystem.IsWindows(), "POSIX signals require Unix.");
        using var readiness = new ProcessTestReadiness();
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("tree-graceful", readiness.Name),
            new ChildProcessOptions
            {
                Lifetime = ChildProcessLifetime.OwnedTree,
                CreateSupervisorStartInfo = fixture.CreateSupervisorStartInfo
            });
        ProcessTestIdentity[] identities = [];
        try
        {
            await process.StartAsync(TestContext.Current.CancellationToken);
            identities = await readiness.ReadTreeAsync();
 
            ProcessTestFixture.RequestGracefulShutdown(process.ProcessId);
 
            Assert.Equal(23, await process.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30)));
            await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync));
        }
        finally
        {
            foreach (var identity in identities)
            {
                ProcessTestFixture.KillIfRunning(identity);
            }
        }
    }
 
    [Theory]
    [InlineData(false, false, false)]
    [InlineData(false, true, false)]
    [InlineData(true, false, false)]
    [InlineData(true, true, false)]
    [InlineData(false, false, true)]
    [InlineData(false, true, true)]
    [InlineData(true, false, true)]
    [InlineData(true, true, true)]
    public async Task Shutdown_UsesSharedGracefulBudgetAndEscalates(bool dispose, bool signalFailure, bool ownedTree)
    {
        using var readiness = new ProcessTestReadiness();
        using var gracefulBudget = new CancellationTokenSource();
        using var cancellation = new CancellationTokenSource();
        var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var sink = new TestSink();
        var signalCount = 0;
        var budgetCount = 0;
        var signaledPid = 0;
        var signalToken = CancellationToken.None;
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo(ownedTree ? "tree" : "wait", readiness.Name),
            new TestLogger("shared shutdown", sink, enabled: true),
            new ChildProcessOptions
            {
                Lifetime = ownedTree ? ChildProcessLifetime.OwnedTree : ChildProcessLifetime.CallerManaged,
                CreateSupervisorStartInfo = fixture.CreateSupervisorStartInfo,
                BeginGracefulShutdown = () =>
                {
                    Interlocked.Increment(ref budgetCount);
                    return gracefulBudget.Token;
                },
                RequestGracefulShutdownAsync = (pid, token) =>
                {
                    Interlocked.Increment(ref signalCount);
                    signaledPid = pid;
                    signalToken = token;
                    signaled.TrySetResult();
                    return signalFailure
                        ? Task.FromException(new InvalidOperationException("test signal failure"))
                        : Task.CompletedTask;
                }
            });
        await process.StartAsync(TestContext.Current.CancellationToken);
        ProcessTestIdentity[] identities = ownedTree ? await readiness.ReadTreeAsync() : [await readiness.ReadRuntimeAsync()];
        cancellation.Cancel();
        var shutdown = dispose
            ? process.DisposeAsync().AsTask()
            : Assert.ThrowsAnyAsync<OperationCanceledException>(() => process.WaitForExitAsync(cancellation.Token));
        try
        {
            await signaled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
            Assert.False(shutdown.IsCompleted);
            Assert.Equal(1, budgetCount);
            Assert.Equal(1, signalCount);
            Assert.Equal(process.ProcessId, signaledPid);
            Assert.Equal(gracefulBudget.Token, signalToken);
        }
        finally
        {
            gracefulBudget.Cancel();
        }
        await shutdown.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
        await Task.WhenAll(identities.Select(ProcessTestFixture.AssertExitedAsync));
        if (signalFailure)
        {
            var entry = Assert.Single(sink.Writes, entry => entry.Exception?.Message == "test signal failure");
            Assert.Equal(LogLevel.Warning, entry.LogLevel);
        }
    }
 
    [Theory]
    [InlineData(false, false)]
    [InlineData(true, false)]
    [InlineData(false, true)]
    [InlineData(true, true)]
    public async Task Windows_GracefulTimeoutPreservesAppHostCleanupWorkerButReapsOwnedTrees(bool dispose, bool ownedTree)
    {
        Assert.SkipUnless(OperatingSystem.IsWindows(), "DCP-safe AppHost escalation is Windows-specific.");
        using var readiness = new ProcessTestReadiness();
        using var gracefulBudget = new CancellationTokenSource();
        using var cancellation = new CancellationTokenSource();
        using var cleanup = new NamedPipeServerStream($"{readiness.Name}-cleanup", PipeDirection.Out, 1,
            PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
        var cleanupPath = Path.Combine(fixture.CreateDirectory().FullName, "cleanup-completed");
        var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("tree-cleanup", readiness.Name, cleanupPath),
            new ChildProcessOptions
            {
                Lifetime = ownedTree ? ChildProcessLifetime.OwnedTree : ChildProcessLifetime.AppHost,
                KillEntireProcessTreeOnCancel = false,
                CreateSupervisorStartInfo = fixture.CreateSupervisorStartInfo,
                BeginGracefulShutdown = () => gracefulBudget.Token,
                RequestGracefulShutdownAsync = (_, _) =>
                {
                    signaled.TrySetResult();
                    return Task.CompletedTask;
                }
            });
        ProcessTestIdentity[] identities = [];
        try
        {
            await process.StartAsync(TestContext.Current.CancellationToken);
            identities = await readiness.ReadTreeAsync();
            cancellation.Cancel();
            var shutdown = dispose
                ? process.DisposeAsync().AsTask()
                : Assert.ThrowsAnyAsync<OperationCanceledException>(() => process.WaitForExitAsync(cancellation.Token));
            await signaled.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
            Assert.False(shutdown.IsCompleted);
            gracefulBudget.Cancel();
 
            await shutdown.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
            await ProcessTestFixture.AssertExitedAsync(identities[0]);
            if (ownedTree)
            {
                await ProcessTestFixture.AssertExitedAsync(identities[1]);
            }
            else
            {
                using var worker = Process.GetProcessById(identities[1].ProcessId);
                Assert.False(worker.HasExited);
                await cleanup.WaitForConnectionAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30));
                using var writer = new StreamWriter(cleanup, leaveOpen: true) { AutoFlush = true };
                await writer.WriteLineAsync("cleanup");
                await worker.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30));
                Assert.Equal(0, worker.ExitCode);
                Assert.Equal("completed", await File.ReadAllTextAsync(cleanupPath, TestContext.Current.CancellationToken));
            }
        }
        finally
        {
            gracefulBudget.Cancel();
            await process.DisposeAsync();
            foreach (var identity in identities)
            {
                ProcessTestFixture.KillIfRunning(identity);
            }
        }
    }
 
    [Fact]
    public async Task WaitForExitAsync_PreservesCancellationAndShutdownFailure()
    {
        using var readiness = new ProcessTestReadiness();
        using var cancellation = new CancellationTokenSource();
        var shutdownFailure = new InvalidOperationException("test graceful window failure");
        var sink = new TestSink();
        var budgetCount = 0;
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("wait", readiness.Name),
            new TestLogger("failed shutdown", sink, enabled: true),
            new ChildProcessOptions
            {
                BeginGracefulShutdown = () => Interlocked.Increment(ref budgetCount) == 1
                    ? throw shutdownFailure
                    : null,
                RequestGracefulShutdownAsync = (_, _) => Task.CompletedTask
            });
        await process.StartAsync(TestContext.Current.CancellationToken);
        var identity = await readiness.ReadRuntimeAsync();
        cancellation.Cancel();
 
        var exception = await Assert.ThrowsAsync<AggregateException>(() => process.WaitForExitAsync(cancellation.Token));
 
        Assert.Collection(exception.InnerExceptions,
            failure => Assert.Equal(cancellation.Token, Assert.IsAssignableFrom<OperationCanceledException>(failure).CancellationToken),
            failure => Assert.Same(shutdownFailure, failure));
        var entry = Assert.Single(sink.Writes, entry => ReferenceEquals(entry.Exception, shutdownFailure));
        Assert.Equal(LogLevel.Error, entry.LogLevel);
        await process.DisposeAsync();
        await ProcessTestFixture.AssertExitedAsync(identity);
    }
 
#if NET11_0_OR_GREATER
    [Fact]
    public async Task StartAsync_DetachedExecutionUsesNullStandardHandles()
    {
        var startInfo = fixture.CreateStartInfo("output", "0");
        startInfo.RedirectStandardOutput = false;
        startInfo.RedirectStandardError = false;
        var forwardedLines = new ConcurrentQueue<string>();
        await using var process = ProcessTestFixture.CreateProcess(startInfo, new ChildProcessOptions
        {
            Detached = true,
            StandardOutputCallback = forwardedLines.Enqueue,
            StandardErrorCallback = forwardedLines.Enqueue
        });
 
        await process.StartAsync(TestContext.Current.CancellationToken);
        var exitCode = await process.WaitForExitAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.Equal(0, exitCode);
        Assert.Empty(forwardedLines);
    }
#else
    [Fact]
    public async Task StartAsync_DetachedExecutionIsExplicitlyUnsupported()
    {
        await using var process = ProcessTestFixture.CreateProcess(fixture.CreateStartInfo("output", "0"),
            new ChildProcessOptions { Detached = true });
 
        var exception = await Assert.ThrowsAsync<NotSupportedException>(() => process.StartAsync(TestContext.Current.CancellationToken));
 
        Assert.Equal("Detached process execution requires .NET 11.", exception.Message);
        Assert.Throws<InvalidOperationException>(() => process.ProcessId);
    }
#endif
}