Running External Processes with Process
Objective
Calling git, ffmpeg, docker or a shell script from C# looks like one line, Process.Start, and goes wrong in four predictable ways: arguments that a string concatenation mangled or let an attacker rewrite, a child that blocks forever because nobody read its stderr, a timeout that kills the program but leaves its children running, and an exit code that nobody interpreted. This concept covers Process and ProcessStartInfo as you use them in .NET 8 and 10 (the pattern that works everywhere), the deadlock and its fix, timeout with Kill(entireProcessTree: true), exit codes and signals, and the shortcuts .NET 11 adds (Process.Run, RunAndCaptureText, ReadAllLines, ProcessExitStatus), including a behavior of their timeout that surprised me. Console exit codes of your own program are in the console apps concept.
Use Cases
- Running
git,ffmpegor a linter from a build tool or a worker and returning what it printed. - Calling a legacy command-line program where the parameters come from user input.
- Running a script with a deadline in a CI step and cleaning up everything it started.
- Telling "the tool failed" from "the tool was killed" in a retry policy.
Deep Dive
Start without a shell, one argument per item
Every number and message below was run on .NET 11.0.0 (SDK 11.0.100), macOS, with sh as the child.
csharpvar psi = new ProcessStartInfo("printf") { RedirectStandardOutput = true };
psi.ArgumentList.Add("[%s]\n");
psi.ArgumentList.Add("hello world");
psi.ArgumentList.Add("a \"quoted\" $HOME");
using var p = Process.Start(psi)!;
Console.WriteLine(p.StandardOutput.ReadToEnd());
// [hello world]
// [a "quoted" $HOME]ArgumentList hands each item to the child as one argument. The same call with
the string property, Arguments = "[%s]\\n hello world", printed [hello] and
[world]: the string is split on spaces, and you are the one who has to quote
and escape. UseShellExecute is false by default in .NET, so no shell is
involved and $HOME or ; rm -rf stay plain characters. Never build an
argument string from user input. Setting both properties fails at start with
InvalidOperationException: Only one of Arguments or ArgumentList may be used.
A program that does not exist throws at Start, not later:
Win32Exception: An error occurred trying to start process 'definitely-not-a-program' with working directory '...'. No such file or directory.
Catch it where you start the process, it is the "tool not installed" case.
WorkingDirectory and psi.Environment["FOO"] = "bar" set the child's folder
and variables (the child printed bar and /private/tmp).
Redirecting output, and the deadlock
RedirectStandardOutput and RedirectStandardError give you the pipes as
StreamReaders. A pipe has a fixed buffer. When the child fills it and you are
not reading that pipe, the child blocks on write, and if you are blocked
waiting for the other pipe you wait forever. The classic code does exactly that:
csharp// child writes 200 KB to stderr, then "done" to stdout
var o = p.StandardOutput.ReadToEnd(); // waits for stdout to close...
var e = p.StandardError.ReadToEnd(); // ...but the child is stuck writing stderrI ran it with a 3 second guard: it had not finished, HasExited was False.
Nothing throws, the program just stops. The fix is to drain both pipes at the
same time and only then wait:
csharpvar o = p.StandardOutput.ReadToEndAsync();
var e = p.StandardError.ReadToEndAsync();
await Task.WhenAll(o, e);
await p.WaitForExitAsync();
// out=done err=200000 exit=0Start both reads before awaiting either. Redirecting only one stream, or none,
is not a problem: with none, the child writes straight to your console.
Reading a stream you did not redirect throws
InvalidOperationException: StandardOut has not been redirected or the process hasn't started yet.
The older alternative is BeginOutputReadLine with OutputDataReceived. It
works, with one trap: WaitForExit() with no argument also waits for the event
streams to reach end of file. With a shell that exited at once but left a
sleep 2 holding the pipe, WaitForExit() returned after 2012 ms, while
WaitForExit(5000) returned after 3 ms with HasExited true. That is a common
reason for a "stuck" build step: a grandchild still owns the pipe.
For input, set RedirectStandardInput, write, and close the writer. cat
echoed hello only after StandardInput.Close(), because closing is what
tells the child there is no more input.
Timeout and killing the tree
Process has no timeout of its own. Cancel the wait and kill:
csharpusing var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try { await p.WaitForExitAsync(cts.Token); }
catch (OperationCanceledException)
{
p.Kill(entireProcessTree: true);
await p.WaitForExitAsync();
}Kill() kills one process. With sh -c "sleep 310 & echo $!; wait" as the
child, after Kill(false) the parent was dead and the sleep grandchild was
still alive; after Kill(true) both were gone. A surviving grandchild also
keeps your stdout pipe open, so a reader awaiting end of file hangs after the
"successful" kill. Use Kill(true) by default, then await the exit before you
read ExitCode: reading it earlier throws
InvalidOperationException: Process must exit before requested information can be determined.
Exit codes and signals
A normal exit gives the program's own code (exit 3 gave ExitCode=3). On
Unix a process killed by a signal has no exit code, and .NET reports
128 + signal: a child that sent itself SIGTERM gave 143, and one killed by
Kill() gave 137 (SIGKILL is 9). So code > 128 is "killed by a signal" in
practice, with the caveat that a program may also exit with 130 on its own. On
Windows there are no signals and the code is whatever the program returned.
On .NET 11 you can send a signal and read the signal back: Process.Signal(PosixSignal.SIGTERM) returned True and WaitForExitStatusAsync() gave a ProcessExitStatus with ExitCode=143 and Signal=SIGTERM. A polite SIGTERM first, then a kill after a grace period, is the graceful way to stop a server child.
What .NET 11 adds
.NET 11 puts the whole pattern above behind static methods on Process
(documented in What's new in .NET 11 libraries). On the GA runtime I called each
one:
csharpvar r = Process.RunAndCaptureText("sh", ["-c", "echo out; echo err >&2; exit 3"]);
// r.StandardOutput = "out", r.StandardError = "err", r.ExitStatus.ExitCode = 3, r.ProcessId > 0
var st = Process.Run("sleep", ["30"], silent: false, timeout: TimeSpan.FromMilliseconds(300));
// st.ExitCode = 137, st.Signal = SIGKILL, st.Canceled = TrueRun,RunAsync: start, wait, return aProcessExitStatus(ExitCode,Signal,Canceled).silent: truediscards the child's output (nothing was printed),falselets it through to your console.RunAndCaptureText,RunAndCaptureTextAsync: the redirect and read pattern from this concept, with the two streams kept apart. 2 MB on each stream finished without deadlock.ReadAllLines,ReadAllText,ReadAllBytes(and async versions) on a startedProcessyou already own.ReadAllLinesyieldsProcessOutputLinewithContentand aStandardErrorflag. The order is not the order the child wrote: for three interleaved lines on each stream I got the stderr lines first, then the stdout lines, so use the flag, not the position.StartAndForgetreturns the process id and does not track the child.ProcessStartInfoalso gainedStartSuspended(documented for Windows and macOS),KillOnParentExit(Linux, Android and Windows, the compiler warned me on macOS),CreateNewProcessGroup(Windows) andInheritedHandles. I did not run these.
The timeout is where the shortcuts differ from each other and from the manual
pattern. Run with a timeout and RunAsync with a canceled token do not
throw: they return Canceled=True, ExitCode=137, Signal=SIGKILL.
RunAndCaptureText with a timeout throws TimeoutException: The operation has timed out., and
RunAndCaptureTextAsync with a canceled token throws OperationCanceledException.
And in both Run and RunAndCaptureText the grandchild from the earlier
experiment was still alive after the timeout. So the shortcut kills the
process it started, not the tree. If your child starts children, keep the
manual Kill(entireProcessTree: true) version, or put them in a script that
cleans up. I observed this on macOS only; check the docs for your platform.
Trade-offs
- The shortcut is shorter, the manual version is the one you can adjust.
RunAndCaptureTextremoves the deadlock class of bugs. It does not stream output while the child runs, does not feed input and does not kill the tree.csharpvar r = Process.RunAndCaptureText("git", ["rev-parse", "HEAD"], TimeSpan.FromSeconds(10)); - Capturing text keeps everything in memory. A child that prints gigabytes
fills your heap. For big output, read line by line (
ReadAllLines) or copy the stream to a file. ArgumentListis safe from the shell, not from the program. A value starting with-is still an option togitortar. Put--before user values when the program supports it, and validate the value.- Exit code 0 is a convention, not a law.
grepreturns 1 for "no match",diffreturns 1 for "different". Map codes per tool instead of treating every non-zero as an error. 128 + nis a Unix reading. Portable code checksExitStatus.Signalon .NET 11 or the platform before it assumes a signal.- Do not wrap this in a thread per call. The async reads and
WaitForExitAsyncdo not block a thread while the child works.