File: tools\Aspire.TestTools\ProcessRunner.cs
Web Access
Project: src\tools\AnalyzeCiFailure\AnalyzeCiFailure.csproj (AnalyzeCiFailure)
// 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;
 
namespace Aspire.TestTools;
 
public static class ProcessRunner
{
    public static async Task<ProcessResult> RunAsync(
        string executable,
        IReadOnlyList<string> arguments,
        string workingDirectory,
        IReadOnlyDictionary<string, string>? environment = null,
        string? outputFile = null)
    {
        var fileName = executable;
        if (!Path.IsPathRooted(executable) && environment is not null && environment.TryGetValue("PATH", out var searchPath))
        {
            // ProcessStartInfo.Environment does not control executable lookup on Unix.
            // Honor the caller's PATH without changing process-global state.
            // Windows native lookup appends .exe to extensionless executable names.
            var searchName = OperatingSystem.IsWindows() && !Path.HasExtension(executable) ? executable + ".exe" : executable;
            fileName = searchPath.Split(Path.PathSeparator).Select(path => Path.Combine(path, searchName))
                .FirstOrDefault(File.Exists) ?? throw new System.ComponentModel.Win32Exception($"Executable not found: {executable}");
        }
        using var process = new Process
        {
            StartInfo = new ProcessStartInfo(fileName)
            {
                WorkingDirectory = workingDirectory,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                UseShellExecute = false,
            },
        };
        foreach (var argument in arguments)
        {
            process.StartInfo.ArgumentList.Add(argument);
        }
        foreach (var (name, value) in environment ?? new Dictionary<string, string>())
        {
            process.StartInfo.Environment[name] = value;
        }
 
        // Open the destination before launching so an invalid output path cannot
        // leave a child waiting on an unread stdout pipe.
        using var destination = outputFile is null ? null : File.Create(outputFile);
        process.Start();
        // Both pipes must drain concurrently. ZIP bytes must never pass through
        // a text decoder; text callers receive stdout and stderr separately.
        var stderr = process.StandardError.ReadToEndAsync();
        if (destination is not null)
        {
            await process.StandardOutput.BaseStream.CopyToAsync(destination).ConfigureAwait(continueOnCapturedContext: false);
            await process.WaitForExitAsync().ConfigureAwait(continueOnCapturedContext: false);
 
            return new ProcessResult(process.ExitCode, "", await stderr.ConfigureAwait(continueOnCapturedContext: false));
        }
        var stdout = process.StandardOutput.ReadToEndAsync();
        await process.WaitForExitAsync().ConfigureAwait(continueOnCapturedContext: false);
 
        return new ProcessResult(process.ExitCode, await stdout.ConfigureAwait(continueOnCapturedContext: false), await stderr.ConfigureAwait(continueOnCapturedContext: false));
    }
}
 
public sealed record ProcessResult(int ExitCode, string Output, string Error);