# How to Capture Process Output and Exit Codes in .NET 11

> Capture stdout, stderr, and the exit code of a local tool with Process.RunAndCaptureTextAsync in .NET 11. See what the caller gets when the tool warns, fails, or times out.

Published: 2026-11-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/dotnet-11-process-run-and-capture

[**.NET 11**](https://milanjovanovic.tech/blog/whats-new-dotnet-11) adds `Process.RunAndCaptureTextAsync` to start a local executable, collect its standard output and standard error, and wait for its exit code in one call.
Use it for commands with a bounded text response.
A child can write a warning to standard error and still exit successfully.

## Why Read Both Output Streams at Once?

An export job calls a CSV checker before uploading a file.
A file with the header `id,total` and no data rows passes the check with a warning. A file with the wrong header should stop the upload.
Both cases write to standard error, so the parent needs the exit code to tell them apart.

A common implementation redirects both output streams, reads standard output to the end, then reads standard error.
If the child fills its standard-error pipe, it can block before it closes standard output.
The parent keeps waiting for that first read to finish.
The new method [**drains both pipes**](https://devblogs.microsoft.com/dotnet/process-api-improvements-in-dotnet-11/) while the child runs.

## Call the CSV Checker

Create a console project for the export job.
The executable will launch a second copy of itself with `--check`, so you can run the example on Windows, Linux, or macOS without a separate CSV tool.

```bash
dotnet new console -n ExportCheck -f net11.0
cd ExportCheck
```

Save the following as `Program.cs`.
When you launch the checker with `dotnet run`, `Environment.ProcessPath` points to the project's generated executable.
If you launch a DLL via `dotnet ExportCheck.dll` instead, the process path points to the `dotnet` host and you would have to pass the DLL path as its first argument.

```csharp
using System.ComponentModel;
using System.Diagnostics;

if (args is ["--check", var file])
{
    using var reader = File.OpenText(file);
    if (reader.ReadLine() != "id,total")
    {
        Console.Error.WriteLine("Expected CSV header: id,total");
        return 2;
    }

    Console.WriteLine("Header OK");
    if (reader.ReadLine() is null)
        Console.Error.WriteLine("Warning: export contains no rows.");

    return 0;
}

if (args is not [var path])
{
    Console.Error.WriteLine("Usage: ExportCheck <csv-file>");
    return 1;
}

using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
    var result = await Process.RunAndCaptureTextAsync(
        Environment.ProcessPath!, ["--check", path], deadline.Token);

    Console.Write(result.StandardOutput);
    Console.Error.Write(result.StandardError);
    if (result.ExitStatus.Canceled)
    {
        Console.Error.WriteLine("Check timed out.");
        return 1;
    }

    Console.WriteLine($"Child exit code: {result.ExitStatus.ExitCode}");
    return result.ExitStatus.ExitCode;
}
catch (OperationCanceledException) when (deadline.IsCancellationRequested)
{
    Console.Error.WriteLine("Check timed out.");
    return 1;
}
catch (Win32Exception ex)
{
    Console.Error.WriteLine($"Could not start the checker: {ex.Message}");
    return 1;
}
```

This overload redirects both streams for you.
It passes `path` as one argument, even when the filename contains spaces.
If you use the [**overload that takes a `ProcessStartInfo`**](https://github.com/dotnet/runtime/blob/v11.0.0/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Scenarios.cs) to set a working directory or environment variable, set `UseShellExecute = false`, `RedirectStandardOutput = true`, and `RedirectStandardError = true`.

## Which Result Should Fail the Job?

In a Unix shell, start with a file containing only the expected header.
Save stdout and stderr separately.

```bash
printf 'id,total\n' > 'order export.csv'
dotnet run -- 'order export.csv' > success.out 2> success.err
cat success.out
cat success.err
```

The parent writes `Header OK` and `Child exit code: 0` to standard output.
The diagnostic `Warning: export contains no rows.` goes to standard error.
A parent that fails every time `StandardError` contains text would reject this file even though its header passed.

Now change the first column name.

```bash
printf 'order_id,total\n' > bad.csv
dotnet run -- bad.csv > failure.out 2> failure.err
echo "$?"
cat failure.err
```

The shell prints `2`, the code the child assigned to a bad header.
The diagnostic now reads `Expected CSV header: id,total`.
A different executable may assign a different meaning to the same number.
For example, a tool can use a nonzero exit code to report that it found differences.

## What If the Deadline Expires?

If the five-second deadline expires while the parent is reading the pipes, `RunAndCaptureTextAsync` attempts to kill the child and throws `OperationCanceledException`.
A child can also close both output streams and continue doing work.
If cancellation arrives while the helper is waiting for that child to exit, it can [**return a result with `ExitStatus.Canceled` set**](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.runandcapturetextasync?view=net-11.0).

The parent checks both outcomes so it can print `Check timed out.` and exit with code 1 in either case.
If the checker runs in a [**hosted background service**](https://milanjovanovic.tech/blog/running-background-tasks-in-asp-net-core), link the host's stopping token to the command deadline so shutting down the worker also cancels its wait.

## When Should You Stream the Output?

Both captured strings remain in memory until your code releases the result.
The five-second deadline puts no limit on how many bytes the checker can write before it expires.
For a build that prints progress for several minutes, start a `Process` with both streams redirected and enumerate [**`ReadAllLinesAsync`**](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#process-api-expansion).
Each `ProcessOutputLine` has a `Content` string and a `StandardError` flag, so you can forward each line as it arrives.
After the streams end, wait for the exit status as well.

## Summary

For the CSV checker, keep the warning in the job log and use the child's exit code to decide whether to upload the file.
If a replacement checker prints a diagnostic for every row, read its output with `ReadAllLinesAsync` so a large import doesn't leave you holding the entire log in memory.

## Frequently asked questions

### How do you capture both stdout and stderr from a process in .NET 11?

Pass the executable name and a sequence of arguments to Process.RunAndCaptureTextAsync. The returned ProcessTextOutput exposes StandardOutput, StandardError, and ExitStatus after the child finishes.

### Does RunAndCaptureTextAsync throw for a nonzero exit code?

A normal exit with a nonzero code produces a result. Inspect ExitStatus.ExitCode and apply the meaning defined by the child program. A startup failure, such as an executable that cannot be found, can throw before you get a result.

### Can RunAndCaptureTextAsync stream live build output?

The one-shot method returns the complete output after the process finishes. Start a Process with both streams redirected and consume ReadAllLinesAsync for progress lines as they arrive.
