File: Processes\IsolatedProcessTests.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.Collections.Concurrent;
using Aspire.Cli.Processes;
 
namespace Aspire.Cli.Tests.Processes;
 
public class IsolatedProcessTests
{
    [Fact]
    public async Task Start_EchoesLine_InvokesOutputCallbackAndCompletesStandardOutputClosed()
    {
        var stdout = new ConcurrentQueue<string>();
        var stderr = new ConcurrentQueue<string>();
 
        var (fileName, arguments) = GetEchoCommand("hello-from-launcher");
 
        var startInfo = new IsolatedProcessStartInfo
        {
            FileName = fileName,
            WorkingDirectory = Environment.CurrentDirectory,
        };
        foreach (var arg in arguments)
        {
            startInfo.ArgumentList.Add(arg);
        }
 
        await using var child = new IsolatedProcess(startInfo);
        child.OutputDataReceived += (_, line) => stdout.Enqueue(line);
        child.ErrorDataReceived += (_, line) => stderr.Enqueue(line);
        await child.StartAsync(CancellationToken.None);
        child.BeginOutputReadLine();
        child.BeginErrorReadLine();
 
        // Both pumps complete on pipe EOF — child exits within tens of milliseconds, but
        // the OS pipe close + StreamReader drain can take a bit longer under load.
        await Task.WhenAll(child.StandardOutputClosed, child.StandardErrorClosed).WaitAsync(TimeSpan.FromSeconds(10));
        await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
 
        Assert.Contains("hello-from-launcher", stdout);
        Assert.Equal(0, child.ExitCode);
    }
 
    [Fact]
    public async Task Start_ExposesFileNameAndArgumentsOnReturnedChild()
    {
        var (fileName, arguments) = GetEchoCommand("metadata-check");
 
        var startInfo = new IsolatedProcessStartInfo
        {
            FileName = fileName,
            WorkingDirectory = Environment.CurrentDirectory,
        };
        foreach (var arg in arguments)
        {
            startInfo.ArgumentList.Add(arg);
        }
 
        await using var child = new IsolatedProcess(startInfo);
        await child.StartAsync(CancellationToken.None);
        child.BeginOutputReadLine();
        child.BeginErrorReadLine();
 
        // Carried explicitly because Process.GetProcessById returns a Process whose
        // StartInfo is empty — telemetry callers depend on these fields.
        Assert.Equal(fileName, child.FileName);
        Assert.Equal(arguments, child.Arguments);
 
        await Task.WhenAll(child.StandardOutputClosed, child.StandardErrorClosed).WaitAsync(TimeSpan.FromSeconds(10));
        await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
    }
 
    [Fact]
    public async Task Start_CallbackThrows_PumpDrainsToEndAndFaultsStandardOutputClosed()
    {
        // Emit two lines; callback throws on the FIRST and records every line it sees so we
        // can verify the pump kept draining (i.e. did not abandon the pipe after the throw).
        var seenLines = new ConcurrentQueue<string>();
        var (fileName, arguments) = GetTwoLineCommand("line-one", "line-two");
 
        var startInfo = new IsolatedProcessStartInfo
        {
            FileName = fileName,
            WorkingDirectory = Environment.CurrentDirectory,
        };
        foreach (var arg in arguments)
        {
            startInfo.ArgumentList.Add(arg);
        }
 
        await using var child = new IsolatedProcess(startInfo);
        child.OutputDataReceived += (_, line) =>
        {
            seenLines.Enqueue(line);
            if (line.Contains("line-one"))
            {
                throw new InvalidOperationException("intentional callback failure");
            }
        };
        await child.StartAsync(CancellationToken.None);
        child.BeginOutputReadLine();
        child.BeginErrorReadLine();
 
        // StandardOutputClosed should fault with the recorded exception, but only AFTER
        // draining every line. The first OperationCanceledException-style early-exit was the bug.
        var fault = await Assert.ThrowsAsync<InvalidOperationException>(
            async () => await child.StandardOutputClosed.WaitAsync(TimeSpan.FromSeconds(10)));
        Assert.Equal("intentional callback failure", fault.Message);
 
        await child.StandardErrorClosed.WaitAsync(TimeSpan.FromSeconds(10));
        await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
 
        Assert.Contains(seenLines, line => line.Contains("line-one"));
        Assert.Contains(seenLines, line => line.Contains("line-two"));
    }
 
    [Fact]
    public async Task DisposeAsync_DoesNotWaitForOutputCallbackToComplete()
    {
        var callbackEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var (fileName, arguments) = GetEchoCommand("blocked-callback");
 
        var startInfo = new IsolatedProcessStartInfo
        {
            FileName = fileName,
            WorkingDirectory = Environment.CurrentDirectory,
        };
        foreach (var arg in arguments)
        {
            startInfo.ArgumentList.Add(arg);
        }
 
        var child = new IsolatedProcess(startInfo);
        child.OutputDataReceived += (_, _) =>
        {
            callbackEntered.TrySetResult();
            releaseCallback.Task.GetAwaiter().GetResult();
        };
 
        try
        {
            await child.StartAsync(CancellationToken.None);
            child.BeginOutputReadLine();
            child.BeginErrorReadLine();
            await callbackEntered.Task.WaitAsync(TimeSpan.FromSeconds(10));
            await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
 
            Assert.False(child.StandardOutputClosed.IsCompleted);
            await child.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1));
        }
        finally
        {
            releaseCallback.TrySetResult();
            await child.StandardOutputClosed.WaitAsync(TimeSpan.FromSeconds(10));
            await child.DisposeAsync();
        }
    }
 
    [Fact]
    public async Task StartAsync_AfterDispose_ThrowsObjectDisposedException()
    {
        var (fileName, arguments) = GetEchoCommand("should-not-start");
 
        var startInfo = new IsolatedProcessStartInfo
        {
            FileName = fileName,
            WorkingDirectory = Environment.CurrentDirectory,
        };
        foreach (var arg in arguments)
        {
            startInfo.ArgumentList.Add(arg);
        }
 
        await using var child = new IsolatedProcess(startInfo);
        await child.DisposeAsync();
 
        await Assert.ThrowsAsync<ObjectDisposedException>(() => child.StartAsync(CancellationToken.None));
    }
 
    private static (string FileName, IReadOnlyList<string> Arguments) GetEchoCommand(string text)
    {
        if (OperatingSystem.IsWindows())
        {
            // cmd /c echo <text> — cmd ships with every Windows install.
            return ("cmd.exe", new[] { "/c", "echo", text });
        }
 
        return ("/bin/sh", new[] { "-c", $"echo {text}" });
    }
 
    private static (string FileName, IReadOnlyList<string> Arguments) GetTwoLineCommand(string line1, string line2)
    {
        if (OperatingSystem.IsWindows())
        {
            return ("cmd.exe", new[] { "/c", $"echo {line1}&echo {line2}" });
        }
 
        return ("/bin/sh", new[] { "-c", $"echo {line1}; echo {line2}" });
    }
}