Library / SDK
Cysharp/ProcessX avatar
Cysharp/ProcessX

ProcessX: calling external processes with async streams in C#

Simplify call an external process with the async streams in C# 8.0.

549 stars33 forksC#MIT

At a glance

What is it?
ProcessX replaces the ceremony around System.Diagnostics.Process with an IAsyncEnumerable you can await foreach over, so a child process's output arrives line by line and its stderr becomes a typed exception rather than a string to check later. It also ships a zx mode that lets you write shell-style scripts in C#.
Who is it for?
Adopt ProcessX when you shell out from C# often enough that the boilerplate around Process has become noise, and you want streaming output with cancellation and timeouts built in. Do not adopt it if you need only a one-shot exit code, since the plain Process API is fine there.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 17 days ago.
What is it written in?
Mainly C#, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem: Process is a class you have to babysit

ProcessX simplifies calling an external process with the async streams in C# 8.0, without complex Process code. You can receive standard output results with await foreach, and it is completely asynchronous and realtime.

The library is installed from NuGet and supports .NET Standard 2.0:

csharp
using Cysharp.Diagnostics; // using namespace

// async iterate.
await foreach (string item in ProcessX.StartAsync("dotnet --info"))
{
    Console.WriteLine(item);
}

// receive string result from stdout.
var version = await ProcessX.StartAsync("dotnet --version").FirstAsync();

// receive buffered result(similar as WaitForExit).
string[] result = await ProcessX.StartAsync("dotnet --info").ToTask();

// like the shell exec, write all data to console.
await ProcessX.StartAsync("dotnet --info").WriteLineAllAsync();

// consume all result and wait complete asynchronously(useful to use no result process).
await ProcessX.StartAsync("cmd /c mkdir foo").WaitAsync();

The shape of that API is the design. Instead of Start, then BeginOutputReadLine and an event handler, then WaitForExit, you get an IAsyncEnumerable of strings and consume it with await foreach. Instead of redirecting output to a StringBuilder and parsing it, you get FirstAsync for the first line, ToTask for the whole thing buffered, WriteLineAllAsync to echo it live, and WaitAsync for a process whose output you do not care about.

One detail is worth noticing in the command strings. They are written as a single string, dotnet --info, rather than as a file name and an argument array. The README calls that out as a feature, describing it as simple, only write single string command like the shell script, which is the opposite of the usual .NET guidance about argument quoting and injection. The string is parsed and quoted for you, which is convenient, and it is also a different security posture from passing arguments separately.

The main API is only Cysharp.Diagnostics.ProcessX.StartAsync, and it throws ProcessErrorException when an error is detected.

Error handling as an exception, not a return code you forget to check

The default behaviour is the part that changes how you write the calling code.

By default, when stderr is used, the library buffers the error messages and throws ProcessErrorException with those messages after the process exits. The exception carries the exit code as an int property and the error output as a string array property:

csharp
// when ExitCode is not 0 or StandardError is exists, throws ProcessErrorException
try
{
    await foreach (var item in ProcessX.StartAsync("dotnet --foo --bar")) { }
}
catch (ProcessErrorException ex)
{
    // int .ExitCode
    // string[] .ErrorOutput
    Console.WriteLine(ex.ToString());
}

So a failed process becomes a catchable exception rather than a return code buried in a variable, and the stderr text comes with it. The trade is that any process which writes to stderr for informational reasons will now throw, which is why the next section exists.

The exit code set is configurable. By default a non-zero exit code throws, and you can change the acceptable exit codes globally with the ProcessX.AcceptableExitCodes property, which defaults to an array containing only zero.

There is also a way to opt out of the throwing behaviour entirely, which is what you want when stderr is being used as progress output or diagnostics rather than as an error signal. The GetDualAsyncEnumerable method gives you the raw Process alongside two separate streams, and the stdout iterator is the one that throws on a non-zero exit code:

csharp
// first argument is Process, if you want to know ProcessID, use StandardInput, use it.
var (_, stdOut, stdError) = ProcessX.GetDualAsyncEnumerable("dotnet --foo --bar");

Because the raw Process is returned, you can reach the ProcessID, StandardInput and the rest of the Process API, which means the escape hatch is not limited to the streams.

For binary output there is a separate entry point, StartReadBinaryAsync, which returns a byte array instead of strings.

Cancellation and timeouts, and the detail that a killed process leaves behind

Cancellation uses the standard async streams mechanism, WithCancellation on the IAsyncEnumerable, which means no special API:

csharp
// when cancel has been called and process still exists, call process kill before exit.
await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cancellationToken))
{
    Console.WriteLine(item);
}

The comment in that snippet states the behaviour that matters. When cancellation has been called and the process still exists, the process is killed before exit. Without that, a cancelled await foreach would leave an orphaned child process running, which is the failure mode people hit when they hand-roll this pattern.

Timeouts are the same mechanism with a CancellationTokenSource, which is the standard .NET answer:

csharp
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)))
{
    await foreach (var item in ProcessX.StartAsync("dotnet --info").WithCancellation(cts.Token))
    {
        Console.WriteLine(item);
    }
}

There is no separate timeout parameter, and that is a deliberate design choice. A timeout is a cancellation with a deadline, and expressing it as one means there is a single path where the kill behaviour is implemented rather than two.

The zx mode has its own variant of the same idea, with a run method that comes in versions including withTimeout and withCancellation, alongside run2 and runl, where runl returns a string array for a list of commands.

The library is MIT licensed, the last push was on 2026-10-06, and the releases are 1.5.6 on 2025-01-15, 1.5.5 on 2022-10-18 and 1.5.4 on 2022-01-11. That spacing is worth noting before you adopt it: the version line moves slowly, and the project is in maintenance rather than rapid development.

The zx mode: shell scripts written in C#

The second half of the project is zx mode, named after the google/zx tool, and it lets you write shell script style code in C#.

csharp
// ProcessX and C# 9.0 Top level statement; like google/zx.

using Zx;
using static Zx.Env;

// `await string` execute process like shell
await "cat package.json | grep name";

// receive result msg of stdout
var branch = await "git branch --show-current";
await $"dep deploy --branch={branch}";

// parallel request (similar as Task.WhenAll)
await new[]
{
    "echo 1",
    "echo 2",
    "echo 3",
};

// you can also use cd(chdir)
await "cd ../../";

// run with $"" automatically escaped and quoted
var dir = "foo/foo bar";
await run($"mkdir {dir}"); // mkdir "/foo/foo bar"

The interpolation form is the interesting part. A plain awaited string is passed to the shell as written, so it can contain pipes and redirects, while the interpolated form is escaped and quoted, and the comment shows the difference: a directory name with a space is quoted automatically rather than breaking the command. That is the same distinction between a raw shell string and an argument, handled at the point of use.

The rest of the zx surface is helper functions rather than process control. There is a log helper for output and colorisation, with a color helper that scopes a block so everything printed inside it is coloured, a fetchText helper for web requests, and a question helper that reads a line from standard input. The README lists the reasons to write shell scripts this way in C#: static typing, async and await, a code formatter, cleaner syntax through C# itself, and a powerful editor environment in Visual Studio, Code or Rider.

The environment is configurable, and the configuration properties are the interesting part. There is a verbose flag that writes all stdout and stderr to the console, on by default, and a useShell flag, also on by default, which controls whether a process is invoked through a shell. The shell itself defaults to cmd /c on Windows and a bash invocation on Linux, and can be overridden, with the example setting it to a /bin/sh -c style string.

There is also a terminateToken, a CancellationToken triggered by SIGTERM, which is Ctrl+C, so a script can react to the user interrupting it.

The repository layout and the Reference and Competitor sections

The repository is small, and the top level listing says what kind of project this is: a.github directory, an.editorconfig, a.gitignore, a Directory.Build.props, an Icon.png, the LICENSE, the ProcessX.sln, the README, and three directories called sandbox, src and tests.

The sandbox directory is worth a second look. For a library whose job is running arbitrary external processes, a sandbox of example scripts is a natural way to try things out, and having it in the repository rather than only in the README means the examples are compiled and tested rather than being documentation that rots.

The README itself has a generated table of contents, with a comment marking it as maintained by the doctoc tool, which means the section list is regenerated rather than hand-maintained. The sections it lists are Getting Started, Cancellation, Raw Process/StdError Stream, Read Binary Data, Change acceptable exit codes, Zx, Reference, Competitor and License.

The Competitor section is unusual and worth looking for in the repository, because a library that names its alternatives and explains the differences is making a much stronger case than one that does not. The reference section, similarly, suggests a fuller API reference than what the README excerpts show.

The build is a Visual Studio solution with shared MSBuild properties in Directory.Build.props, which is the standard way to apply version numbers, assembly information and analyser settings across the projects in one solution. The tests are in a separate directory, and the source in src.

The library sits under the Cysharp organisation, so it comes out of an organisation that ships multiple.NET libraries rather than a one-off.

Editorial conclusion

Adopt ProcessX when you shell out from C# often enough that the boilerplate around Process has become noise, and you want streaming output with cancellation and timeouts built in. Do not adopt it if you need only a one-shot exit code, since the plain Process API is fine there. Before you rely on the error behaviour, read the stderr section, because the default is to buffer stderr and throw ProcessErrorException after exit, and note that the package is on a slow release line with the newest version at 1.5.6 from 2025-01-15.

Frequently asked questions

What does the ProcessX library do?

It simplifies calling an external process in C# 8.0 with async streams, so you receive standard output with await foreach instead of complex Process code. The main API is Cysharp.Diagnostics.ProcessX.StartAsync, and it throws ProcessErrorException when an error is detected.

How do I cancel a ProcessX process or apply a timeout?

Use WithCancellation on the IAsyncEnumerable, passing either a CancellationToken or the token of a CancellationTokenSource created with a delay for a timeout. When cancellation is called and the process still exists, the process is killed before exit.

How does ProcessX handle stderr output?

By default stderr is buffered and a ProcessErrorException is thrown after the process exits, carrying the exit code and the error output. To stream stderr or to avoid throwing when a process uses stderr for progress, use GetDualAsyncEnumerable, which also returns the raw Process so you can reach ProcessID and StandardInput.

What is zx mode in ProcessX?

A mode named after google/zx that lets you write shell script style code in C#. You can await a string to run a command, interpolate into it for automatic escaping and quoting, run lists of commands in parallel, and use helpers for logging, colour, web requests and reading standard input.

How do I read binary output from a process with ProcessX?

Use StartReadBinaryAsync, which returns a byte array rather than strings. You can also change which exit codes are acceptable globally with the ProcessX.AcceptableExitCodes property, which defaults to an array containing only zero.

Official sources

  1. Cysharp/ProcessX on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/cysharp-processx.svg)](https://hysenlabs.com/projects/cysharp-processx)

Was this useful?