TL;DR: In .NET 11, Process.RunAndCaptureText("git", ["status", "--porcelain"]) starts the process, reads stdout and stderr at the same time, waits for exit, and hands you both strings plus the exit code. .NET 11 is at Release Candidate 2 right now: go-live licensed, but not final yet.
The ten lines everyone copies
You want to run git, or ffmpeg, or some CLI your team wrote in 2019. You want its output and its exit code. So you write the ten lines everyone writes:
var psi = new ProcessStartInfo("some-tool", "build")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var p = Process.Start(psi)!;
string output = p.StandardOutput.ReadToEnd();
string errors = p.StandardError.ReadToEnd();
p.WaitForExit();
It works on your machine. Then one day the tool prints a lot of warnings, and your app just stops. No exception, no log line, no CPU.
Here is why. Redirected output goes through pipes with small buffers. Your code reads stdout to the end before it touches stderr. The child fills the stderr pipe and waits for someone to drain it. You are waiting for stdout to finish, the child is waiting for you, and nobody is coming.
I reproduced it with a child that writes 1 MB to stderr and a short line to stdout. The StandardOutput.ReadToEnd() call had still not returned after 5 seconds, so the demo killed the child. Swapping the two reads does not help either: then a chatty stdout blocks it instead.
The real fix used to be OutputDataReceived, ErrorDataReceived, BeginOutputReadLine, and a StringBuilder per stream. Nobody remembers that code. Everybody pastes it.
Meet RunAndCaptureText
.NET 11 adds a set of one-shot helpers to Process. The one you want most of the time:
using System.Diagnostics;
ProcessTextOutput result = Process.RunAndCaptureText("git", ["--version"]);
Console.WriteLine(result.StandardOutput.TrimEnd()); // git version 2.51.2.windows.1
Console.WriteLine(result.ExitStatus.ExitCode); // 0
ProcessTextOutput carries StandardOutput, StandardError, ExitStatus, and ProcessId. The arguments are a list, so nothing needs quoting. I passed ["echo", "my file.txt", "say \"hi\"", ""] to a child that echoes each argument after echo in brackets, and it printed [my file.txt], [say "hi"], and an empty [].
The same 1 MB stderr child that hung the classic code finished in one call:
stdout='built OK', stderr chars=1000000, exit=3, canceled=False
According to the .NET team's write-up, the sync version drains both pipes at once by multiplexing on one thread, and the async version uses async I/O, so neither burns a thread per stream.
A realistic one: a tiny git wrapper
Most apps that shell out want the same thing: give me stdout, and throw with stderr if it failed.
using System.Diagnostics;
string repo = args[0];
string[] changed = Git(repo, "status", "--porcelain")
.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine($"{changed.Length} changed file(s):");
foreach (var line in changed) Console.WriteLine($" {line}");
try
{
Git(repo, "checkout", "no-such-branch");
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
static string Git(string repo, params string[] args)
{
ProcessTextOutput result = Process.RunAndCaptureText(
"git", ["-C", repo, .. args], timeout: TimeSpan.FromSeconds(30));
if (result.ExitStatus.ExitCode != 0)
throw new InvalidOperationException(
$"git {string.Join(' ', args)} failed ({result.ExitStatus.ExitCode}): {result.StandardError.Trim()}");
return result.StandardOutput;
}
Run against a fresh repo with two untracked files:
2 changed file(s):
?? "my file.txt"
?? notes.md
git checkout no-such-branch failed (1): error: pathspec 'no-such-branch' did not match any file(s) known to git
The whole process story is one call and one if. The timeout is built in, too, which is more than most hand-rolled versions ever got.
The rest of the family
-
Process.Run(fileName, args, silent, timeout)runs a process and returns only aProcessExitStatus. Withsilent: true, its stdin, stdout, and stderr go to the null device. -
Process.RunAndCaptureTextAsync(...)is the async twin, and takes aCancellationToken. -
process.ReadAllText()andReadAllBytes()drain both streams of a process you started yourself. -
process.ReadAllLines()andReadAllLinesAsync()yieldProcessOutputLinevalues withContentand aStandardErrorflag, so you know which stream each line came from:
var psi = new ProcessStartInfo("git", ["--version"])
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using var g = Process.Start(psi)!;
foreach (ProcessOutputLine line in g.ReadAllLines())
Console.WriteLine($"{(line.StandardError ? "err" : "out")}: {line.Content}");
g.WaitForExit();
// out: git version 2.51.2.windows.1
Can I use it?
- These APIs are new in .NET 11 and live in
System.Diagnostics. The .NET team's post says they arrived in Preview 4. - .NET 11 RC2 shipped with a go-live support license, so production use is allowed. It is still a release candidate, and the API docs still carry the prerelease warning.
- On the .NET 10 SDK, the git wrapper fails with
CS0117: 'Process' does not contain a definition for 'RunAndCaptureText'. - The docs mark them unsupported on iOS and tvOS.
I ran every snippet here as a file-based app with dotnet run file.cs on SDK 11.0.100-rc.1, on Windows.
Gotchas
1. A timeout while reading throws. RunAndCaptureText with a 2-second timeout, on a child that sleeps for 10, threw TimeoutException after 2.0 seconds. The docs say it kills the process first, and in my run no child was left behind. You only get a result with ExitStatus.Canceled == true when the streams already closed but the process still did not exit in time.
2. Process.Run reports a timeout instead. Process.Run on the same sleeping child, with silent: true and a 1-second timeout, returned Canceled=True and ExitCode=-1. Same idea, different shape, so check Canceled before you trust the exit code.
3. Async cancellation throws, too. RunAndCaptureTextAsync with a token cancelled after 2 seconds threw OperationCanceledException after 2.0 seconds. Catch it where you catch your other cancellations.
4. A ProcessStartInfo must redirect both streams. The ProcessStartInfo overload of RunAndCaptureText throws InvalidOperationException unless RedirectStandardOutput and RedirectStandardError are both true, and UseShellExecute is false. Process.Run wants the opposite: no redirection at all.
5. ReadAllText ends at end-of-stream, not at exit. A child can close its output and keep running. If you use ReadAllText or ReadAllLines on your own Process, still call WaitForExit() before you read the exit code.
6. A non-zero exit code is not an exception. git status outside a repository came back from Process.Run with exit code 128 and no exception. The helpers report what happened; deciding it was a failure is still your job, which is fair, since grep exits with 1 just for finding nothing.
Wrap-up
Shelling out used to mean ten lines of boilerplate and one deadlock waiting for a noisy day. Now it is one call with a timeout. Go find that ProcessHelper.cs everybody copied from Stack Overflow and see how much of it survives .NET 11.
Sources
- What's new in .NET libraries for .NET 11: Process API expansion (Microsoft Learn)
- Process.RunAndCaptureText method (Microsoft Learn)
- Process.Run method (Microsoft Learn)
- ProcessExitStatus class (Microsoft Learn)
- Process API improvements in .NET 11 (.NET Blog)
- Announcing .NET 11 Release Candidate 2 (.NET Blog)
This article was written with the help of AI and checked against the sources above.
Top comments (0)