Skip to content

New Process APIs #125838

Closed
Closed

Description

@adamsitnik

Background and motivation

System.Diagnostics.Process has plenty of design, usability, performance and reliablity issues. 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 process output.

I would like to introduce a set of new APIs to address the most painful problems. The key goals are:

  • Being able to avoid deadlocks when reading process output and error, by providing APIs that do all the heavy lifting:
    • draining both stdout and stderr at a same time,
    • using multiplexing to use a single thread for synchronous overloads,
    • waiting for process exit or EOF,
    • draining available output and error pipe buffers when process exited, but EOF was not reported (because some granchild process keeps the pipe opened).
  • Being able to natively redirect process standard handles to any file (NUL, pipe, regular file, terminal):
    • improved performance,
    • fewer reasons to use custom P/Invoke code to launch processes,
    • ability to implement process piping.
  • Being able to limit process inheritance to a specific set of handles instead of inheriting all handles in the process to avoid accidental handle leaks (and very hard to diagnose bugs).
  • Ensuring that the child process does not outlive the parent process to avoid resource leaks ("file in use" etc).
  • Exposing process exit status to be able to distinguish between normal exit, kill and signal termination.

API Proposal

I would like to introduce changes to the approved new Process APIs.

The full list of approved APIs is available here.

namespace System.Diagnostics
{
-    public sealed class ProcessStartOptions
-    {
-        public string FileName { get; }
-        public IList<string> Arguments { get; set; }
-        public IDictionary<string, string?> Environment { get; }
-        public string? WorkingDirectory { get; set; }
-
-        public IList<SafeHandle> InheritedHandles { get; set; }
-        public bool KillOnParentExit { get; set; }
-        public bool CreateNewProcessGroup { get; set; }
-
-        public ProcessStartOptions(string fileName);
-    }

    public sealed ProcessStartInfo
    {
+       public SafeFileHandle? StandardInputHandle { get; set; }
+       public SafeFileHandle? StandardOutputHandle { get; set; }
+       public SafeFileHandle? StandardErrorHandle { get; set; }

        // approved in 2020, but not implemented yet https://github.com/dotnet/runtime/issues/13943#issuecomment-663652191
        public bool InheritHandles { get; set; }

+       public IList<SafeHandle> InheritedHandles { get; set; } // name approved for ProcessStartOptions
+       public bool LeaveHandlesOpen { get; set; } = false; // applies to Standard*Handles and InheritedHandles 

+       public bool KillOnParentExit { get; set; } // name approved for ProcessStartOptions
+       public TimeSpan? PostExitDrainTimeout { get; set; } // when null, default is used (exact value may change)
+       public bool StartDetached { get; set; }
    }
}

namespace Microsoft.Win32.SafeHandles
{
    public partial class SafeProcessHandle
    {
        public int ProcessId { get; }

        public static SafeProcessHandle Open(int processId);

-       public static SafeProcessHandle Start(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error);
-       public static SafeProcessHandle StartSuspended(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error);
+       public static SafeProcessHandle Start(ProcessStartInfo startInfo);

-       public bool Kill();
+       public void Kill();
-       public bool KillProcessGroup();
-       public void Resume();

-       public void Signal(PosixSignal signal);
+       public bool Signal(PosixSignal signal);
-       public void SignalProcessGroup(PosixSignal signal);

        public ProcessExitStatus WaitForExit();
        public bool TryWaitForExit(TimeSpan timeout, [NotNullWhen(true)] out ProcessExitStatus? exitStatus);
        public ProcessExitStatus WaitForExitOrKillOnTimeout(TimeSpan timeout);

        public Task<ProcessExitStatus> WaitForExitAsync(CancellationToken cancellationToken = default);
        public Task<ProcessExitStatus> WaitForExitOrKillOnCancellationAsync(CancellationToken cancellationToken);
    }

    public partial class SafeFileHandle
    {
+       public bool IsInheritable();
    }
}

And introduce brand new APIs:

namespace System.Diagnostics
{
    public readonly struct ProcessOutputLine // struct on purpose (LOTs of allocated instances)
    {
        public string Content { get; } // it's not nullable, on EOF enumeration just ends
        public bool StandardError { get; }
 
        public ProcessOutputLine(string content, bool standardError);
    }

    public sealed class ProcessTextOutput
    {
        public ProcessExitStatus ExitStatus { get; }
        public string StandardOutput { get; }
        public string StandardError { get; }
        public int ProcessId { get; }

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

    public class Process : System.ComponentModel.Component, System.IDisposable
    {
        // All Read* methods wait for either EOF or process exit (then .
        public IEnumerable<ProcessOutputLine> ReadAllLines(TimeSpan? timeout = default);
        public IAsyncEnumerable<ProcessOutputLine> ReadAllLinesAsync(CancellationToken cancellationToken = default);

        public (string standardOutput, string standardError) ReadAllText(TimeSpan? timeout = default);
        public Task<(string standardOutput, string standardError)> ReadAllTextAsync(CancellationToken cancellationToken = default);
        public (byte[] standardOutput, byte[] standardError) ReadAllBytes(TimeSpan? timeout = default);
        public Task<(byte[] standardOutput, byte[] standardError)> ReadAllBytesAsync(CancellationToken cancellationToken = default);

        // The Process and its entire tree are killed when the timeout expires.
        public static ProcessTextOutput CaptureTextOutput(ProcessStartInfo startInfo, TimeSpan? timeout = default);
        public static ProcessTextOutput CaptureTextOutput(string fileName, IList<string>? arguments = null, TimeSpan? timeout = default);
        public static Task<ProcessTextOutput> CaptureTextOutputAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default);
        public static Task<ProcessTextOutput> CaptureTextOutputAsync(string fileName, IList<string>? arguments = null, CancellationToken cancellationToken = default);
        public static ProcessExitStatus Run(ProcessStartInfo startInfo, TimeSpan? timeout = default);
        public static ProcessExitStatus Run(string fileName, IList<string>? arguments = null, TimeSpan? timeout = default);
        public static Task<ProcessExitStatus> RunAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default);
        public static Task<ProcessExitStatus> RunAsync(string fileName, IList<string>? arguments = null, CancellationToken cancellationToken = default);

        // Starts the process, does not wait for its completion, returns process id and cleans up the resources.
        public static int StartAndForget(ProcessStartInfo startInfo);
    }
}

Alternative design

    public sealed ProcessStartInfo
    {
+       public SafeFileHandle? StandardInputHandle { get; set; }
+       public SafeFileHandle? StandardOutputHandle { get; set; }
+       public SafeFileHandle? StandardErrorHandle { get; set; }

        // approved in 2020, but not implemented yet https://github.com/dotnet/runtime/issues/13943#issuecomment-663652191
-       public bool InheritHandles { get; set; }

+       public IList<SafeHandle>? AdditionalInheritedHandles { get; set; } = null; // null means inherit everything
+       public bool LeaveHandlesOpen { get; set; } = false; // applies to Standard*Handles and InheritedHandles 
    }

API Usage

Streaming process output lines

This example demonstrates how to read process output line by line, synchronously by using a single thread and draining both stdout and stderr without risking a deadlock.

ProcesStartInfo startInfo = new("ping", "localhost -n 5")
{
    RedirectStandardOutput = true,
    RedirectStandardError = true
};

using Process process = Process.Start(startInfo);
foreach (ProcessOutputLine line in process.ReadAllLines())
{
    if (line.StandardError)
    {
        Console.ForegroundColor = ConsoleColor.Red;
    }

    Console.WriteLine(line.Content);
    Console.ResetColor();
}

Process piping

This example demonstrates piping output from one process to another using anonymous pipes, providing empty input, redirecting to file and discarding error

SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe);

// Start producer with output redirected to the write end of the pipe, no input and discarding error.
ProcessStartInfo producer = new("cmd", ["/c", "echo hello world & echo test line & echo another test" ])
{
    StandardInputHandle = File.OpeNullHandle(),
    StandardOutputHandle = writePipe,
    StandardErrorHandle = File.OpeNullHandle()
}

// Start consumer with input from the read end of the pipe, writing output to file and discarding error.
ProcessStartInfo consumer = new("findstr", "test")
{
    StandardInputHandle = readPipe,
    StandardOutputHandle = File.OpenHandle("output.txt", FileMode.Create, FileAccess.Write, FileShare.ReadWrite),
    StandardErrorHandle = File.OpeNullHandle()
};

using Process producerProcess = Process.Start(producer);
using Process consumerProcess = Process.Start(consumer);

await producerProcess.WaitForExitAsync();
await consumerProcess.WaitForExitAsync();

Preventing resource leaks

Following example demonstrates how to start a background process that automatically terminates when the parent process exits to avoid resource leaks. It also releases all the resources associated with the child process (e.g. Process, SafeProcessHandle etc).

SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle readPipe, out SafeFileHandle writePipe, asyncReads: true, asyncWrites: true);

ProcessStartInfo startInfo = new("myBackgroundProcess.exe")
{
    KillOnParentExit = true,
    InheritedHandles = [writePipe]
};

_ = Process.StartAndForget(startInfo);
// readPipe can be used for IPC

Activity

  1. added this to the 11.0.0 milestone on Mar 20, 2026
  2. self-assigned this
    on Mar 20, 2026
  3. dotnet-policy-service commented on Mar 20, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-diagnostics-process
    See info in area-owners.md if you want to be subscribed.

  4. jkotas commented on Mar 20, 2026

    @jkotas
    Member

    // leaveOpen controls whether Start method closes all the handles

    What are the handles that this is talking about? Should it be on ProcessStartOptions instead?

  5. adamsitnik commented on Mar 20, 2026

    @adamsitnik
    MemberAuthor

    // leaveOpen controls whether Start method closes all the handles

    What are the handles that this is talking about? Should it be on ProcessStartOptions instead?

    public sealed ProcessStartInfo
    {
        public IList<SafeHandle> InheritedHandles { get; set; }
    
        public SafeFileHandle? StandardInput { get; set; }
        public SafeFileHandle? StandardOutput { get; set; }
        public SafeFileHandle? StandardError { get; set; }
    }
  6. jkotas commented on Mar 20, 2026

    @jkotas
    Member

    public bool StandardError { get; }

    Are we going to get a deterministic interleaving of stdout and stderr? This sounds like a race-prone API.

  7. jkotas commented on Mar 20, 2026

    @jkotas
    Member
       // approved in 2020, but not implemented yet https://github.com/dotnet/runtime/issues/13943#issuecomment-663652191
       public bool InheritHandles { get; set; }
    

    How does this interact with InheritedHandles property? Does setting InheritedHandles to an empty collection have the same effect as setting InheritHandles to false?

  8. adamsitnik commented on Mar 20, 2026

    @adamsitnik
    MemberAuthor

    Are we going to get a deterministic interleaving of stdout and stderr? This sounds like a race-prone API.

    Did you mean the ReadAllLines* APIs? If so:

    • to avoid deadlocks, we need to drain both at the same time
    • as soon as polling std out/err tells us there is data available, we drain all available data from given pipe
    • we can't guarantee the order OS decides to notify us about which pipe has data first
  9. jkotas commented on Mar 20, 2026

    @jkotas
    Member

    Did you mean the ReadAllLines* APIs?

    Yes.

  10. adamsitnik commented on Mar 20, 2026

    @adamsitnik
    MemberAuthor

    How does this interact with InheritedHandles property? Does setting InheritedHandles to an empty collection have the same effect as setting InheritHandles to false?

    InheritHandles is true by default, as on every platform processes just inherit all inheritable handles.
    Setting it to false means only standard handles are inherited.
    If users needs any extra handle to be inherited, they need to add them to InheritedHandles.

    So InheritedHandles with any value means InheritHandles=true, but an empty InheritedHandles does not mean InheritHandles=false.

    Unless you want to make it a nullable API and in this way have a single property to control it:

    public IList<SafeHandle>? InheritedHandles { get; set; } = null

    Then null (default) means inherit all inheritable handles, empty means inherit only standard and non-empty means standard and what is on the allow list.

  11. jkotas commented on Mar 20, 2026

    @jkotas
    Member

    leaveOpen

    I think this should be on ProcessStartOptions next to the handles that it controls. It is nice to have property. It just saves people from having to close the handles manually.

  12. jkotas commented on Mar 20, 2026

    @jkotas
    Member

    InheritedHandles with any value means InheritHandles=true, but an empty InheritedHandles does not mean InheritHandles=false.

    A single property would make more sense to me given that these two properties interact like this.

  13. 32 remaining items

  14. alastairlundy commented on Mar 24, 2026

    @alastairlundy

    Since the process has already terminated, I think we should leave out the ProcessId.

    It may be helpful to retain ProcessId such as for logging or otherwise keeping track of a process' lifecycle.

  15. jkotas commented on Mar 24, 2026

    @jkotas
    Member

    Suggestion: RunOnConsole to make more explicit this uses stdin/out/err.

    I think Run is fine. We are not going to change Start to StartWithConsole either.

    I think it would be nice if all method that encompass the whole process duration start with Run.
    RunRedirected / RunAndCaptureText / RunAndCaptureAsString.

    +1

  16. adamsitnik commented on Mar 24, 2026

    @adamsitnik
    MemberAuthor

    That said, as mentioned earlier, I would prefer for the default to be an infinite wait because this feature is for working around an issue in the child app and I think we should default to assume that child processes do not have this issue.

    The problem is that once you hit this issue you need to spend hours debugging to get to the source of the problem. I believe it's better to simply ensure our users won't ever run into this issue.

    I prefer the proposal to include async methods that enables waiting for the exit code/status in a non-blocking way which also do not wait for the EOF. These are the semantics of the methods on the SafeProcessHandle and we should have such semantics available on Process too.

    I think GetExitCodeAsync and GetExitStatusAsync would be good names for this.

    I think the best names are already taken (WaitForExit*) and having Wait and Get methods on the same time could be confusing as it's not obvious that GetExitCode methods would wait for the process to finish first.

    Since Process exposes SafeProcessHandle, the users can do process.SafeProcessHandle.NewMethod for now. I am not against introducing these methods, I just don't have a proposal that I would 100% support as of now.

    Since the process has already terminated, I think we should leave out the ProcessId.

    @alastairlundy is right:

    It may be helpful to retain ProcessId such as for logging or otherwise keeping track of a process' lifecycle.

    My favorite scenario: I run machine-wide profiling, I start multiple processes and I need to reliably recognize them in the trace file. With PID is very simple.

    Suggestion: RunOnConsole to make more explicit this uses stdin/out/err.
    I think Run is fine. We are not going to change Start to StartWithConsole either.

    Perhaps I could re-use the names from my original proposal (#123959):

    RunWithInheritedIO, RunAndRedirectToNull and RunAndCaptureTextOutput. It would force the users to think and make the decision whether they want to inherit the standard handles or not.

    Update: but having Run accept ProcessStartInfo makes it possible for people to provide their own handles, so I am going to stick with Run for now.

  17. tmds commented on Mar 24, 2026

    @tmds
    Member

    The problem is that once you hit this issue you need to spend hours debugging to get to the source of the problem. I believe it's better to simply ensure our users won't ever run into this issue.

    I don't think you spend hours of debugging. It manifest as a method/property that blocks. Most often, the behavior is reproducible.

    I think the best names are already taken (WaitForExit*) and having Wait and Get methods on the same time could be confusing as it's not obvious that GetExitCode methods would wait for the process to finish first.

    You prefer the WaitForExit names, but those are taken.

    I think the GetXxx names are a good option. I think it's expected that GetExitCodeAsync completes when the exit code is available.

    I am not against introducing these methods, I just don't have a proposal that I would 100% support as of now.

    I think it's desired to have these semantics over what is now available directly on Process to avoid pitfalls.

    It would be unfortunate if we're not adding them because of not being able to name them.

    but having Run accept ProcessStartInfo makes it possible for people to provide their own handles, so I am going to stick with Run for now.

    It might be interesting to limit the Run methods initially to those that you consider common and defer users to Start for more advanced/less common ones.

    As a user, the common method I personally want is RunAndCaptureTextAsync. That's it*.

    *: may be also RunAndCaptureLinesAsync

  18. bartonjs commented on Mar 24, 2026

    @bartonjs
    Member

    Video

    • Rather than a manual boolean and a collection property for handle inheritance, we went with just a nullable collection.
      • But omitted the prefix "Additional"
    • We removed ProcessStartInfo.LeaveHandlesOpen
    • We believe KillOnParentExit should be annotated to indicate it only works on Windows and Linux
    • Let's leave PostExitDrainTimeout out for now, it needs more data.
    • Let's leave SafeFileHandle.IsInheritable out for now, it needs more data.
    • There was debate for the ReadAllText and ReadAllBytes returning value-tuples, but we left it doing so (but with correctly cased names)
    • Renamed CaptureTextOutput to RunAndCaptureText
    • Added the fileName/arguments overload to StartAndForget

    Previously approved (but never shipped) API being removed:

    namespace System.Diagnostics
    {
    -    public sealed partial class ProcessStartOptions
    -    {
    -        public string FileName { get; }
    -        public IList<string> Arguments { get; set; }
    -        public IDictionary<string, string?> Environment { get; }
    -        public string? WorkingDirectory { get; set; }
    -
    -        public IList<SafeHandle> InheritedHandles { get; set; }
    -        public bool KillOnParentExit { get; set; }
    -        public bool CreateNewProcessGroup { get; set; }
    -
    -        public ProcessStartOptions(string fileName);
    -    }
    
        public sealed partial class ProcessStartInfo
        {
    -       public bool InheritHandles { get; set; }
        }
    }
    
    namespace Microsoft.Win32.SafeHandles
    {
        public partial class SafeProcessHandle
        {
    -       public static SafeProcessHandle Start(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error);
    -       public static SafeProcessHandle StartSuspended(ProcessStartOptions options, SafeFileHandle? input, SafeFileHandle? output, SafeFileHandle? error);
    
    -       public bool Kill();
    -       public bool KillProcessGroup();
    -       public void Resume();
    
    -       public void Signal(PosixSignal signal);
    -       public void SignalProcessGroup(PosixSignal signal);
        }
    }
    namespace System.Diagnostics
    {
        public sealed partial class ProcessStartInfo
        {
            public SafeFileHandle? StandardInputHandle { get; set; }
            public SafeFileHandle? StandardOutputHandle { get; set; }
            public SafeFileHandle? StandardErrorHandle { get; set; }
    
            public IList<SafeHandle>? InheritedHandles { get; set; }
    
            [SupportedOS("windows")]
            [SupportedOS("linux")]
            public bool KillOnParentExit { get; set; }
    
            public bool StartDetached { get; set; }
        }
    }
    
    namespace Microsoft.Win32.SafeHandles
    {
        public partial class SafeProcessHandle
        {
            public static SafeProcessHandle Start(ProcessStartInfo startInfo);
    
            public void Kill();
    
            public bool Signal(PosixSignal signal);
        }
    }
    
    namespace System.Diagnostics
    {
        public readonly struct ProcessOutputLine
        {
            public string Content { get; }
            public bool StandardError { get; }
     
            public ProcessOutputLine(string content, bool standardError);
        }
    
        public sealed class ProcessTextOutput
        {
            public ProcessExitStatus ExitStatus { get; }
            public string StandardOutput { get; }
            public string StandardError { get; }
            public int ProcessId { get; }
    
            public ProcessTextOutput(ProcessExitStatus exitStatus, string standardOutput, string standardError, int processId);
        }
    
        public class Process : System.ComponentModel.Component, System.IDisposable
        {
            public IEnumerable<ProcessOutputLine> ReadAllLines(TimeSpan? timeout = default);
            public IAsyncEnumerable<ProcessOutputLine> ReadAllLinesAsync(CancellationToken cancellationToken = default);
    
            public (string StandardOutput, string StandardError) ReadAllText(TimeSpan? timeout = default);
            public Task<(string StandardOutput, string StandardError)> ReadAllTextAsync(CancellationToken cancellationToken = default);
            public (byte[] StandardOutput, byte[] StandardError) ReadAllBytes(TimeSpan? timeout = default);
            public Task<(byte[] StandardOutput, byte[] StandardError)> ReadAllBytesAsync(CancellationToken cancellationToken = default);
    
            public static ProcessTextOutput RunAndCaptureText(ProcessStartInfo startInfo, TimeSpan? timeout = default);
            public static ProcessTextOutput RunAndCaptureText(string fileName, IList<string>? arguments = null, TimeSpan? timeout = default);
            public static Task<ProcessTextOutput> RunAndCaptureTextAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default);
            public static Task<ProcessTextOutput> RunAndCaptureTextAsync(string fileName, IList<string>? arguments = null, CancellationToken cancellationToken = default);
            public static ProcessExitStatus Run(ProcessStartInfo startInfo, TimeSpan? timeout = default);
            public static ProcessExitStatus Run(string fileName, IList<string>? arguments = null, TimeSpan? timeout = default);
            public static Task<ProcessExitStatus> RunAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default);
            public static Task<ProcessExitStatus> RunAsync(string fileName, IList<string>? arguments = null, CancellationToken cancellationToken = default);
    
            public static int StartAndForget(ProcessStartInfo startInfo);
            public static int StartAndForget(string fileName, IList<string>? arguments = null);
        }
    }
  19. added
    api-approvedAPI was approved in API review, it can be implemented
    and removed
    blockingMarks issues that we want to fast track in order to unblock other important work
    api-ready-for-reviewAPI is ready for review, it is NOT ready for implementation
    on Mar 24, 2026
  20. MattParkerDev commented on Apr 29, 2026

    @MattParkerDev

    @adamsitnik is there/will there be a way to start a process with a clean environment block, rather than inheriting the env vars from the current process? :)

  21. tmds commented on Apr 29, 2026

    @tmds
    Member

    That is already possible (and the API for it remains the same): call Clear on ProcessStartInfo.Environment.

  22. adamsitnik commented on Apr 30, 2026

    @adamsitnik
    MemberAuthor

    All APIs approved in this issue got implemented, closing. Blog post coming soon ;)

  23. locked and limited conversation to collaborators on May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions