File: DotNet\ProcessExecutionTests.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.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using Aspire.Cli.DotNet;
using Aspire.Cli.Processes;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
using Microsoft.Extensions.Logging.Abstractions;
using static Aspire.Cli.Tests.TestServices.ProcessTestHelpers;
 
namespace Aspire.Cli.Tests.DotNet;
 
public sealed class ProcessExecutionTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public async Task StartAsync_AfterDispose_ThrowsObjectDisposedException()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot);
 
        var execution = CreateExecution(
            scriptFile,
            new ProcessInvocationOptions());
 
        await execution.DisposeAsync();
 
        await Assert.ThrowsAsync<ObjectDisposedException>(() => execution.StartAsync(CancellationToken.None));
    }
 
    [Fact]
    public async Task WaitForExitAsync_AllowsForwardersToDrainBeforeClosingStreams()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var outputFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "output.json"));
        await File.WriteAllTextAsync(outputFile.FullName, CreateJsonPayload(lineCount: 400));
 
        var scriptFile = await CreateOutputScriptAsync(workspace.WorkspaceRoot, outputFile);
 
        var stdoutBuilder = new StringBuilder();
        var stderrBuilder = new StringBuilder();
        var firstLineSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var releaseTask = Task.Run(async () =>
        {
            await firstLineSeen.Task.WaitAsync(TimeSpan.FromSeconds(10));
 
            await Task.Delay(TimeSpan.FromMilliseconds(500));
            releaseCallback.SetResult();
        });
 
        var isFirstLine = true;
        await using var execution = CreateExecution(
            scriptFile,
            new ProcessInvocationOptions
            {
                StandardOutputCallback = line =>
                {
                    if (isFirstLine)
                    {
                        isFirstLine = false;
                        firstLineSeen.TrySetResult();
 
                        if (!releaseCallback.Task.Wait(TimeSpan.FromSeconds(20)))
                        {
                            throw new TimeoutException("Timed out waiting to release the blocked stdout callback.");
                        }
                    }
 
                    stdoutBuilder.AppendLine(line);
                },
                StandardErrorCallback = line => stderrBuilder.AppendLine(line)
            });
 
        Assert.True(await execution.StartAsync(CancellationToken.None));
 
        var exitCode = await execution.WaitForExitAsync(CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        await releaseTask.WaitAsync(TimeSpan.FromSeconds(1));
 
        Assert.Equal(0, exitCode);
        Assert.True(string.IsNullOrWhiteSpace(stderrBuilder.ToString()));
 
        using var jsonDocument = JsonDocument.Parse(stdoutBuilder.ToString());
        var values = jsonDocument.RootElement.GetProperty("values");
        Assert.Equal(400, values.GetArrayLength());
        Assert.Equal("value-399", values[399].GetString());
    }
 
    [Fact]
    public async Task WaitForExitAsync_AllowsBufferedTailOutputAfterLongIdlePeriod()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var outputFile = new FileInfo(Path.Combine(workspace.WorkspaceRoot.FullName, "output.json"));
        await File.WriteAllTextAsync(outputFile.FullName, CreateJsonPayload(lineCount: 400));
 
        var scriptFile = await CreateDelayedOutputScriptAsync(workspace.WorkspaceRoot, outputFile);
 
        var stdoutBuilder = new StringBuilder();
        var stderrBuilder = new StringBuilder();
        var firstLineSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var releaseCallback = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
        var releaseTask = Task.Run(async () =>
        {
            await firstLineSeen.Task.WaitAsync(TimeSpan.FromSeconds(10));
 
            await Task.Delay(TimeSpan.FromMilliseconds(6500));
            releaseCallback.SetResult();
        });
 
        await using var execution = CreateExecution(
            scriptFile,
            new ProcessInvocationOptions
            {
                StandardOutputCallback = line =>
                {
                    stdoutBuilder.AppendLine(line);
 
                    if (line == "ready")
                    {
                        firstLineSeen.TrySetResult();
 
                        if (!releaseCallback.Task.Wait(TimeSpan.FromSeconds(20)))
                        {
                            throw new TimeoutException("Timed out waiting to release the blocked stdout callback.");
                        }
                    }
                },
                StandardErrorCallback = line => stderrBuilder.AppendLine(line)
            });
 
        Assert.True(await execution.StartAsync(CancellationToken.None));
 
        var exitCode = await execution.WaitForExitAsync(CancellationToken.None).DefaultTimeout(TestConstants.LongTimeoutTimeSpan);
        await releaseTask.WaitAsync(TimeSpan.FromSeconds(1));
 
        Assert.Equal(0, exitCode);
        Assert.True(string.IsNullOrWhiteSpace(stderrBuilder.ToString()));
 
        var stdout = stdoutBuilder.ToString();
        var jsonStart = stdout.IndexOf('{', StringComparison.Ordinal);
        Assert.True(jsonStart >= 0, stdout);
 
        using var jsonDocument = JsonDocument.Parse(stdout[jsonStart..]);
        var values = jsonDocument.RootElement.GetProperty("values");
        Assert.Equal(400, values.GetArrayLength());
        Assert.Equal("value-399", values[399].GetString());
    }
 
    [Fact]
    public async Task WaitForExitAsync_KillsProcessWhenCanceled()
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
 
        var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot);
 
        await using var execution = CreateExecution(
            scriptFile,
            new ProcessInvocationOptions());
 
        Assert.True(await execution.StartAsync(CancellationToken.None));
 
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();
 
        await Assert.ThrowsAsync<OperationCanceledException>(() => execution.WaitForExitAsync(cts.Token));
 
        Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to exit after cancellation.");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WaitForExitAsync_WithGracefulServices_InvokesSignalerAndThrowsOnCancellation(bool isolateConsole)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot);
        using var shutdownService = new TestGracefulShutdownWindow();
        // Model the run path: graceful shutdown is enabled (positive budget) so the coordinator runs
        // the ladder. Escalation in this test is driven explicitly (signaler kill), not by the budget.
        var signaler = new RecordingGracefulSignaler(onSignal: pid =>
        {
            TryKillProcess(pid);
            return Task.FromResult(true);
        });
 
        await using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService);
 
        Assert.True(await execution.StartAsync(CancellationToken.None));
 
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();
 
        await Assert.ThrowsAsync<OperationCanceledException>(() => execution.WaitForExitAsync(cts.Token));
 
        Assert.Single(signaler.Pids);
        Assert.False(shutdownService.GracefulShutdownToken.IsCancellationRequested);
        Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to exit after graceful signal.");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WaitForExitAsync_WithGracefulServices_ProcessIgnoresSignal_ExpireEscalatesToKill(bool isolateConsole)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot);
        using var shutdownService = new TestGracefulShutdownWindow();
        // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder.
        // Escalation is driven by the explicit Expire() below, not by the budget elapsing.
        var signaled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var signaler = new RecordingGracefulSignaler(onSignal: _ =>
        {
            signaled.TrySetResult();
            return Task.FromResult(true);
        });
 
        await using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService);
 
        Assert.True(await execution.StartAsync(CancellationToken.None));
 
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();
 
        var waitTask = Assert.ThrowsAsync<OperationCanceledException>(() => execution.WaitForExitAsync(cts.Token));
        await signaled.Task.WaitAsync(TimeSpan.FromSeconds(10));
 
        shutdownService.Expire();
 
        await waitTask.WaitAsync(TimeSpan.FromSeconds(30));
 
        Assert.Single(signaler.Pids);
        Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to be killed after graceful expiration.");
    }
 
    [Theory]
    [InlineData(false)]
    [InlineData(true)]
    public async Task WaitForExitAsync_WithGracefulServices_SignalerThrows_StillEscalatesToKill(bool isolateConsole)
    {
        using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
        var scriptFile = await CreateLongRunningScriptAsync(workspace.WorkspaceRoot);
        using var shutdownService = new TestGracefulShutdownWindow();
        // Model the run path: graceful shutdown is enabled so the coordinator runs the ladder.
        var signaler = new RecordingGracefulSignaler(onSignal: _ =>
            throw new InvalidOperationException("simulated DCP failure"));
 
        await using var execution = CreateExecution(scriptFile, isolateConsole, signaler, shutdownService);
 
        Assert.True(await execution.StartAsync(CancellationToken.None));
 
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();
        shutdownService.Expire();
 
        await Assert.ThrowsAsync<OperationCanceledException>(() => execution.WaitForExitAsync(cts.Token));
 
        Assert.Single(signaler.Pids);
        Assert.True(WaitForProcessExit(execution.ProcessId, TimeSpan.FromSeconds(10)), $"Expected process {execution.ProcessId} to be killed after signaler failure.");
    }
 
    private static string CreateJsonPayload(int lineCount)
    {
        var builder = new StringBuilder();
        builder.AppendLine("{");
        builder.AppendLine("  \"values\": [");
 
        for (var i = 0; i < lineCount; i++)
        {
            var suffix = i == lineCount - 1 ? string.Empty : ",";
            builder.AppendLine($"    \"value-{i}\"{suffix}");
        }
 
        builder.AppendLine("  ]");
        builder.AppendLine("}");
        return builder.ToString();
    }
 
    private static async Task<FileInfo> CreateOutputScriptAsync(DirectoryInfo workspaceRoot, FileInfo outputFile)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-output.cmd"));
            var content =
                "@echo off" + Environment.NewLine +
                $"type \"{outputFile.FullName}\"" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
            return scriptFile;
        }
        else
        {
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-output.sh"));
            var content =
                "#!/usr/bin/env bash" + Environment.NewLine +
                $"cat \"{outputFile.FullName}\"" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
 
            File.SetUnixFileMode(
                scriptFile.FullName,
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
                UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
                UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
 
            return scriptFile;
        }
    }
 
    private static async Task<FileInfo> CreateDelayedOutputScriptAsync(DirectoryInfo workspaceRoot, FileInfo outputFile)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-delayed-output.cmd"));
            var content =
                "@echo off" + Environment.NewLine +
                "echo ready" + Environment.NewLine +
                // Use ping instead of powershell to avoid variable PowerShell
                // cold-start overhead on loaded CI agents (can add 10-20s).
                "ping -n 7 127.0.0.1 > nul" + Environment.NewLine +
                $"type \"{outputFile.FullName}\"" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
            return scriptFile;
        }
        else
        {
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "emit-delayed-output.sh"));
            var content =
                "#!/usr/bin/env bash" + Environment.NewLine +
                "echo ready" + Environment.NewLine +
                "sleep 6" + Environment.NewLine +
                $"cat \"{outputFile.FullName}\"" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
 
            File.SetUnixFileMode(
                scriptFile.FullName,
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
                UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
                UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
 
            return scriptFile;
        }
    }
 
    private static async Task<FileInfo> CreateLongRunningScriptAsync(DirectoryInfo workspaceRoot)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "long-running.cmd"));
            var content =
                "@echo off" + Environment.NewLine +
                "powershell -NoProfile -Command \"Start-Sleep -Seconds 60\"" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
            return scriptFile;
        }
        else
        {
            var scriptFile = new FileInfo(Path.Combine(workspaceRoot.FullName, "long-running.sh"));
            var content =
                "#!/usr/bin/env bash" + Environment.NewLine +
                "sleep 60" + Environment.NewLine;
            await File.WriteAllTextAsync(scriptFile.FullName, content);
 
            File.SetUnixFileMode(
                scriptFile.FullName,
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
                UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
                UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
 
            return scriptFile;
        }
    }
 
    private static ProcessStartInfo CreateStartInfo(FileInfo scriptFile)
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            return new ProcessStartInfo("cmd.exe")
            {
                UseShellExecute = false,
                CreateNoWindow = true,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                RedirectStandardInput = true,
                WorkingDirectory = scriptFile.Directory!.FullName,
                ArgumentList = { "/d", "/c", scriptFile.FullName }
            };
        }
 
        return new ProcessStartInfo("/bin/bash")
        {
            UseShellExecute = false,
            CreateNoWindow = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            RedirectStandardInput = true,
            WorkingDirectory = scriptFile.Directory!.FullName,
            ArgumentList = { scriptFile.FullName }
        };
    }
 
    private static IProcessExecution CreateExecution(
        FileInfo scriptFile,
        ProcessInvocationOptions options)
    {
        var factory = new ProcessExecutionFactory(new TestEnvironment(), NullLogger<ProcessExecutionFactory>.Instance);
        var startInfo = CreateStartInfo(scriptFile);
 
        return factory.CreateExecution(
            startInfo.FileName,
            startInfo.ArgumentList.ToArray(),
            env: null,
            new DirectoryInfo(startInfo.WorkingDirectory),
            options);
    }
 
    private static IProcessExecution CreateExecution(
        FileInfo scriptFile,
        bool isolateConsole,
        IProcessTreeGracefulShutdownSignaler signaler,
        IGracefulShutdownWindow shutdownService)
    {
        // The Windows kill-on-close job is now resolved on-demand inside the factory via
        // WindowsConsoleProcessJob.Shared, so the test no longer creates or disposes one.
        return CreateExecution(
            scriptFile,
            new ProcessInvocationOptions
            {
                IsolateConsole = isolateConsole,
                GracefulShutdownSignaler = signaler,
                ShutdownService = shutdownService
            });
    }
 
}