Repository navigation
Description
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
dotnet-policy-service commentedon Mar 20, 2026
Tagging subscribers to this area: @dotnet/area-system-diagnostics-process
See info in area-owners.md if you want to be subscribed.
jkotas commentedon Mar 20, 2026
What are the handles that this is talking about? Should it be on
ProcessStartOptionsinstead?adamsitnik commentedon Mar 20, 2026
jkotas commentedon Mar 20, 2026
Are we going to get a deterministic interleaving of stdout and stderr? This sounds like a race-prone API.
jkotas commentedon Mar 20, 2026
How does this interact with InheritedHandles property? Does setting InheritedHandles to an empty collection have the same effect as setting InheritHandles to false?
adamsitnik commentedon Mar 20, 2026
Did you mean the
ReadAllLines*APIs? If so:jkotas commentedon Mar 20, 2026
Yes.
adamsitnik commentedon Mar 20, 2026
InheritHandlesis 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:
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.
jkotas commentedon Mar 20, 2026
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.
jkotas commentedon Mar 20, 2026
A single property would make more sense to me given that these two properties interact like this.
32 remaining items
alastairlundy commentedon Mar 24, 2026
It may be helpful to retain
ProcessIdsuch as for logging or otherwise keeping track of a process' lifecycle.jkotas commentedon Mar 24, 2026
I think
Runis fine. We are not going to changeStarttoStartWithConsoleeither.+1
adamsitnik commentedon Mar 24, 2026
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 think the best names are already taken (
WaitForExit*) and havingWaitandGetmethods on the same time could be confusing as it's not obvious thatGetExitCodemethods would wait for the process to finish first.Since
ProcessexposesSafeProcessHandle, the users can doprocess.SafeProcessHandle.NewMethodfor now. I am not against introducing these methods, I just don't have a proposal that I would 100% support as of now.@alastairlundy is right:
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.
Perhaps I could re-use the names from my original proposal (#123959):
RunWithInheritedIO,RunAndRedirectToNullandRunAndCaptureTextOutput. It would force the users to think and make the decision whether they want to inherit the standard handles or not.Update: but having
RunacceptProcessStartInfomakes it possible for people to provide their own handles, so I am going to stick with Run for now.tmds commentedon Mar 24, 2026
I don't think you spend hours of debugging. It manifest as a method/property that blocks. Most often, the behavior is reproducible.
You prefer the
WaitForExitnames, but those are taken.I think the
GetXxxnames are a good option. I think it's expected thatGetExitCodeAsynccompletes when the exit code is available.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.
It might be interesting to limit the
Runmethods initially to those that you consider common and defer users toStartfor more advanced/less common ones.As a user, the common method I personally want is
RunAndCaptureTextAsync. That's it*.*: may be also
RunAndCaptureLinesAsyncbartonjs commentedon Mar 24, 2026
Video
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); } }MattParkerDev commentedon Apr 29, 2026
@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? :)
tmds commentedon Apr 29, 2026
That is already possible (and the API for it remains the same): call
ClearonProcessStartInfo.Environment.adamsitnik commentedon Apr 30, 2026
All APIs approved in this issue got implemented, closing. Blog post coming soon ;)