Skip to content

Process: easy-to-use, reliable and efficient APIs for (some of the) common scenarios #123959

@adamsitnik

Description

@adamsitnik

Background and motivation

System.Diagnostics.Process has plenty of design, usability, performance and reliablity issues. The API can not be 100% fixed without breaking changes, so I would like to introduce a set of new types related to process execution.

The motivation behind this proposal is to provide a set of easy-to-use, reliable and efficient APIs for common scenarios of process execution that do not have pitfalls of the current Process API.

If you are curious, please check this gist with an example of how easy it is to get into deadlock with the current APIs when you need to capture output.

The implementation is available here and you can give it a try.

API Proposal

These APIs do all of the heavy lifting so users don't have to:

  • Kill the child process and its descendants on timeout/cancellation.
  • Ensure the child process does not outlive the parent process.
  • When reading OUT and ERR, both are drained to avoid deadlocks.
  • Ensure the write handles provided as STD OUT/ERR are opened for sync IO, but their corresponding read handles are opened for async IO (to support cancellation on every platform).
  • When reading OUT and ERR synchronously, they use multiplexing in order to avoid spawning background threads (see #81896).
  • When waiting for EOF, they also monitor process exit to detect situation where the child process has started grand child that inherited the pipe handles and EOF is not signaled until all processes exit (see #51277).
namespace System.INeedName;

public static class ChildProcess
{
    // Executes the process with STD IN/OUT/ERR redirected to current process. Waits for its completion, returns exit status.
    public static ProcessExitStatus Inherit(ProcessStartOptions options, TimeSpan? timeout = default);
    public static Task<ProcessExitStatus> InheritAsync(ProcessStartOptions options, CancellationToken cancellationToken = default);

    // Executes the process with STD IN/OUT/ERR discarded. Waits for its completion, returns exit status.
    public static ProcessExitStatus Discard(ProcessStartOptions options, TimeSpan? timeout = default);
    public static Task<ProcessExitStatus> DiscardAsync(ProcessStartOptions options, CancellationToken cancellationToken = default);

    // Executes the process with STD IN/OUT/ERR redirected to specified files. Waits for its completion, returns exit status.
    public static ProcessExitStatus RedirectToFiles(ProcessStartOptions options, string? inputFile, string? outputFile, string? errorFile, TimeSpan? timeout = default);
    public static Task<ProcessExitStatus> RedirectToFilesAsync(ProcessStartOptions options, string? inputFile, string? outputFile, string? errorFile, CancellationToken cancellationToken = default);

    // Creates an instance of ProcessOutputLines to stream the output of the process.
    public static ProcessOutputLines StreamOutputLines(ProcessStartOptions options, TimeSpan? timeout = null, Encoding? encoding = null);

    // Executes the process and returns the standard output and error as strings.
    public static ProcessOutput CaptureOutput(ProcessStartOptions options, Encoding? encoding = null, SafeFileHandle? input = null, TimeSpan? timeout = null);
    public static Task<ProcessOutput> CaptureOutputAsync(ProcessStartOptions options, Encoding? encoding = null, SafeFileHandle? input = null, CancellationToken cancellationToken = default);

    // Executes the process and returns the combined output (stdout + stderr) as bytes.
    public static CombinedOutput CaptureCombined(ProcessStartOptions options, SafeFileHandle? input = null, TimeSpan? timeout = null);
    public static Task<CombinedOutput> CaptureCombinedAsync(ProcessStartOptions options, SafeFileHandle? input = null, CancellationToken cancellationToken = default);

    // Starts the process with STD IN/OUT/ERR redirected to specified handles. 
    // Does not wait for its completion, returns process id and cleans up the resources.
    public static int FireAndForget(ProcessStartOptions options, SafeFileHandle? input = null, SafeFileHandle? output = null, SafeFileHandle? error = null)
}

public readonly struct ProcessOutput
{
    public ProcessExitStatus ExitStatus { get; }
    public string StandardOutput { get; }
    public string StandardError { get; }
    public int ProcessId { get; }

    public ProcessOutput(ProcessExitStatus exitStatus, string standardOutput, string standardError, int processId);
}

public readonly struct ProcessOutputLine : IEquatable<ProcessOutputLine>
{
    public string Content { get; }
    public bool StandardError { get; }

    public ProcessOutputLine(string content, bool standardError);
}

public sealed class ProcessOutputLines : IAsyncEnumerable<ProcessOutputLine>, IEnumerable<ProcessOutputLine>
{
    public int ProcessId { get; } // Available after enumeration starts
    public ProcessExitStatus ExitStatus { get; } // Available after enumeration completes

    internal ProcessOutputLines(ProcessStartOptions options, TimeSpan? timeout, Encoding? encoding);

    public async IAsyncEnumerator<ProcessOutputLine> GetAsyncEnumerator(CancellationToken cancellationToken = default);
    public IEnumerator<ProcessOutputLine> GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator();
}

API Usage

This example runs dotnet restore, captures its output and throws an exception with standard error content when exit code is non-zero:

ProcessStartOptions info = new("dotnet")
{
    Arguments = { "restore" },
};

ProcessOutput processOutput = ChildProcess.CaptureOutput(info);
if (processOutput.ExitStatus.ExitCode != 0)
{
    throw new Exception($"dotnet restore failed: '{processOutput.StandardError}'");
}

This example streams output lines from dotnet build process as they are produced:

ProcessStartOptions info = new("dotnet")
{
    Arguments = { "build" },
};

await foreach (ProcessOutputLine line in ChildProcess.StreamOutputLines(info))
{
    if (line.StandardError)
    {
        Console.Error.WriteLine($"ERR: {line.Content}");
    }
    else
    {
        Console.WriteLine($"OUT: {line.Content}");
    }
}

Alternative Designs

The names could be longer to better reflect the purpose of the methods:

  • Inherit -> RunWithInheritedIO
  • Discard -> RunAndRedirectToNull
  • RedirectToFiles -> RunAndRedirectToFiles
  • StreamOutputLines -> RunAndStreamOutputAndErrorLines
  • CaptureOutput -> RunAndCaptureOutputAndError
  • CaptureCombined -> RunAndCaptureOutputAndErrorCombined

Note: Process is by default inherting the IO of the parent process. It's not always desired, as it sometimes leads to grand child processes holding pipe opened (see #51277). Some languages like golang, actually redirect to null by default. I don't believe that there is a single best default behavior, so I opted for explicit naming and forcing the users to choose.

Risks

It may be hard to discover that the class returned by StreamOutputLines implements both sync and async enumerables.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions