Cross-platform child-process management for .NET, with two complementary surfaces:
ProcessGroup— every child started in a group is killed atomically when the group is disposed, even if the parent process crashes. Windows: kernel Job Objects withJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. Unix (Linux / macOS / FreeBSD): POSIX process groups, with a gracefulSIGTERM-then-Killshutdown.ProcessRunner/IProcessRunner— an async-first runner for external commands. Stream stdout/stderr line-by-line viaIAsyncEnumerable<string>, capture bulk output (ProcessResult<T>with stderr and exit code), or just get the exit code. Pipe stdin from astring/ bytes /Stream/IAsyncEnumerable<string>/ file.
Every spawned process — whether started via ProcessGroup.Start or ProcessRunner —
inherits the kill-on-dispose guarantee. AOT-compatible. Zero external runtime
dependencies.
System.Diagnostics.Process leaks orphaned child processes when the parent dies, and
wiring up reliable stdout/stderr capture (without deadlocking on a full pipe buffer) is
notoriously fiddly. ProcessKit fixes both:
- No orphans. Children are bound to an OS-level group and reaped on dispose — even on a hard parent crash, the kernel tears the group down (Windows Job Object) or the runner signals the whole process group (Unix).
- No pipe deadlocks. stdout and stderr are always drained on background tasks, so a chatty child never blocks on a full OS buffer regardless of whether you read the streams.
- No stdin hangs. When you supply no input, stdin is closed at start, so a process that reads stdin sees EOF immediately instead of blocking on an inherited console handle.
- Async-first, allocation-light, and AOT-clean. All native interop is
[LibraryImport]; the library is verified end-to-end by a Native AOT smoke test in CI.
- Atomic kill-on-dispose for whole process trees (Windows Job Objects / POSIX groups).
- Streaming stdout/stderr as
IAsyncEnumerable<string>, decoded UTF-8 by default. - Bulk capture to
stringorbyte[], with exit code and captured stderr. - Stdin from
string/ bytes /Stream/IAsyncEnumerable<string>/IEnumerable<string>/ file. - Per-line push handlers (tee output to a logger while also streaming/capturing).
- Timeouts (with a distinct
WasTimedOutflag) and fullCancellationTokensupport. - Runtime diagnostics: PID, start time, duration, CPU time, peak memory, live line counters.
- Fluent error handling:
EnsureSuccess()/EnsureSuccessAsync()→ProcessExitException. - Shared or private process groups per run; structural
with-options. - Thread-safe
ProcessGroup; stateless runner with a ready-to-useProcessRunner.Defaultsingleton.
- .NET 10.0 or later
- Windows 8+ / Linux / macOS / FreeBSD
- AOT-compatible (
IsAotCompatible=true)
Available on NuGet.org.
dotnet add package ProcessKitusing ProcessKit;
// Run a command and capture everything:
var result = await ProcessRunner.Default.GetFullOutputAsync("git", ["status", "--porcelain"]);
Console.WriteLine($"exit={result.ExitCode}\n{result.StdOut}");
// Guarantee a worker tree is cleaned up when you're done:
using var group = new ProcessGroup();
group.Start(new ProcessStartInfo("myworker", ["--serve"]) { UseShellExecute = false });
// ...all children die here, atomically, even on an unhandled exception:using System.Diagnostics;
using ProcessKit;
// Children are terminated when the group is disposed — even if the parent process crashes.
using var group = new ProcessGroup();
var psi = new ProcessStartInfo("myworker", ["--arg"]) { UseShellExecute = false };
var worker = group.Start(psi);
// Kill on cancellation (the token kills the process; it does not prevent it from starting).
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var transient = group.Start(psi, cts.Token);
// Adopt an externally-started process into the group.
var external = Process.Start(psi);
group.Add(external!);
// Terminate everything now, without disposing the group.
group.TerminateAll();
// Runtime statistics (CPU time, peak memory, active count).
var stats = group.GetStats();
Console.WriteLine($"active={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}");
// Async dispose — non-blocking on Unix, where the SIGTERM/wait handshake is involved.
await using var asyncGroup = new ProcessGroup();
// ...ProcessGroup is thread-safe: Start, Add, TerminateAll, GetStats, and dispose
may be called concurrently from multiple threads.
Tune shutdown with ProcessGroupOptions (Unix only — on Windows the Job Object kills atomically):
using var group = new ProcessGroup(new ProcessGroupOptions
{
ShutdownTimeout = TimeSpan.FromSeconds(10), // grace after SIGTERM before force-kill
EscalateToKill = true, // false → leave polite survivors running
});ProcessRunner wraps ProcessGroup under the hood, so every spawned process gets the same
kill-on-dispose guarantee. The interface has a single method (Start); everything else is
built on top as extension methods. For casual use without DI, ProcessRunner.Default is a
shared, thread-safe singleton. For DI, register a runner with baseline options applied to every
call (per-call options override field-by-field):
IProcessRunner runner = new ProcessRunner(new ProcessRunOptions
{
Timeout = TimeSpan.FromMinutes(2),
OutputBuffer = new OutputBufferPolicy { MaxBufferedLines = 10_000 },
});using ProcessKit;
IProcessRunner runner = new ProcessRunner(); // or use ProcessRunner.Default
// Bulk: full stdout + stderr + exit code in one call.
var result = await runner.GetFullOutputAsync("git", ["status", "--porcelain"]);
if (result.IsSuccess)
Console.WriteLine(result.StdOut);
// Fluent error handling — throws ProcessExitException on non-zero exit.
var head = (await runner.GetFullOutputAsync("git", ["rev-parse", "HEAD"])
.EnsureSuccessAsync()).StdOut.Trim();
// Streaming: read stdout line by line as the process produces it.
await foreach (var line in runner.GetOutputAsync("docker", ["logs", "-f", "myapp"]))
Console.WriteLine(line);
// Exit-only: no output capture, just the code (streams are still drained).
var rc = await runner.GetExitCodeAsync("npm", ["install"]);
// First matching line (disposing the handle kills the process after the match).
var branch = await runner.GetFirstLineOutputAsync("git", ["branch", "--show-current"]);
// Binary output (bypasses line decoding, uses the raw stdout stream).
var bytes = await runner.GetBytesOutputAsync("git", ["show", "HEAD:logo.png"]);All of these are extension methods on IProcessRunner; each has a (ProcessStartInfo, …)
overload and a convenience (string executable, IEnumerable<string> arguments, …) overload.
| Method | Returns | Use when |
|---|---|---|
Start |
IRunningProcess |
You need the live handle (PID, counters, both streams, diagnostics). |
GetOutputAsync |
IAsyncEnumerable<string> |
Stream stdout line-by-line. |
GetFirstLineOutputAsync |
Task<string?> |
You only need the first (optionally matching) stdout line. |
GetFullOutputAsync |
Task<ProcessResult<string>> |
Capture stdout + stderr + exit code together. |
GetBytesOutputAsync |
Task<ProcessResult<byte[]>> |
stdout is binary and must not be line-decoded. |
GetExitCodeAsync |
Task<int> |
You only care about the exit code. |
GetOutput / GetFirstLineOutput |
string / string? |
Synchronous convenience wrappers. |
EnsureSuccessAsync |
Task<ProcessResult<T>> |
Await + throw on non-zero in one fluent step. |
GetFullOutputAsync captures stdout and stderr faithfully — exact line endings and any trailing
newline are preserved (decoded with the configured encoding), so the result reproduces the process's
output verbatim. GetBytesOutputAsync gives raw stdout bytes (and faithful stderr text). The
streaming line APIs (StdOut/StdErr, GetOutputAsync, and ToResultAsync) are line-oriented:
lines are terminator-free, so reconstructing exact bytes from them is not byte-faithful — use the
bulk helpers above when exact output matters (hashing, diffing, preserving formatting).
Use Start directly when you need the running handle — its PID, line counters, the
Exited cancellation token, or simultaneous stdout and stderr enumeration:
await using var p = runner.Start("ffmpeg", ["-i", "in.mp4", "out.webm"]);
// ffmpeg writes progress to stderr — consume it in real time.
_ = Task.Run(async () =>
{
await foreach (var line in p.StdErr)
progressUi.Update(line);
});
var code = await p.Completion;
Console.WriteLine(
$"pid={p.Pid} duration={p.Duration} cpu={p.CpuTime} peak={p.PeakMemoryBytes} " +
$"stdoutLines={p.StdOutLineCount} timedOut={p.WasTimedOut}");IRunningProcess exposes: StdOut / StdErr (line streams), StdOutLineCount /
StdErrLineCount (atomic, live), Pid, StartTime, Duration, CpuTime,
PeakMemoryBytes, WasTimedOut, the Exited cancellation token, and the Completion
task that resolves with the raw exit code.
From a handle you can also collapse to a result or get a timeout-aware exit code:
await using var p = runner.Start("git", ["status"]);
ProcessResult<string> result = await p.ToResultAsync(); // drain stdout+stderr+exit
int code = await p.CompletionOrThrowAsync(); // throws TimeoutException if Timeout firedAlways dispose the handle (use
await using). Disposing kills the process if it is still running, drains the pumps, and releases the underlying group when the runner owns it. Breaking out of aforeachoverStdOutearly does not kill the process — dispose to terminate.
Supply stdin in whatever form fits, via StandardInput:
var options = new ProcessRunOptions
{
StandardInput = StandardInput.FromString("first\nsecond\n"),
// or .FromBytes(ReadOnlyMemory<byte>)
// .FromStream(stream, leaveOpen: false)
// .FromLines(IAsyncEnumerable<string>) // streamed, newline-delimited
// .FromEnumerable(IEnumerable<string>) // synchronous counterpart
// .FromFile("path/to/input.txt") // eager existence check (FileNotFoundException)
Timeout = TimeSpan.FromSeconds(30), // sets WasTimedOut when it fires
StandardErrorHandler = line => logger.LogWarning("{Line}", line),
};
var result = await runner.GetFullOutputAsync("grep", ["pattern"], options);When you supply no input (the default, or StandardInput.Empty), the runner closes the
child's stdin immediately, so a process that reads stdin sees EOF at once rather than
inheriting and blocking on the parent's stdin.
For REPL / protocol processes you can keep stdin open and write to it over time. Set
KeepStandardInputOpen and drive IRunningProcess.StandardInput, then signal end-of-input:
await using var p = runner.Start("python", ["-i"], new ProcessRunOptions { KeepStandardInputOpen = true });
await p.StandardInput!.WriteLineAsync("print(1 + 1)");
// ... read p.StdOut concurrently, write more ...
await p.StandardInput.CompleteAsync(); // close stdin → child sees EOF and exits
var code = await p.Completion;Writes are serialized internally and flushed each time. Consume StdOut from a separate task while
writing (the library drains the pipes in the background, so the child won't block). Disposing the
handle without calling CompleteAsync terminates the child. Interactive stdin is only available
through Start — the bulk helpers force it off.
ProcessRunOptions is a record — derive variants with with:
var fast = new ProcessRunOptions { Timeout = TimeSpan.FromSeconds(5) };
var slow = fast with { Timeout = TimeSpan.FromMinutes(5) };| Option | Effect |
|---|---|
StandardInput |
Source of stdin data. null / Empty → stdin closed at start. |
StandardOutputHandler |
Per-line push callback for stdout (runs in parallel to streaming/capture). |
StandardErrorHandler |
Per-line push callback for stderr. |
ProcessGroup |
Join a caller-owned group (see below). null → private group, auto-disposed. |
Timeout |
Auto-kill after the duration; surfaces as WasTimedOut. |
StdOutEncoding / StdErrEncoding |
Override decoding (defaults to UTF-8). |
OutputBuffer |
Cap unconsumed stdout/stderr (OutputBufferPolicy, drop-oldest/newest). null → unbounded. |
WorkingDirectory / Environment |
Working dir / env for the Start(exe, args) convenience overloads. |
KeepStandardInputOpen |
Keep stdin open after start for interactive writing (see below). Default closed-at-start. |
PumpTeardownTimeout |
How long DisposeAsync waits for the output pumps to drain. null → 5 s. |
ProcessGroupOptions |
Shutdown options for the private group the runner creates (see below). |
The pumps always drain the OS pipe so the child never blocks — which means unconsumed stderr buffers unbounded by default (a risk on chatty processes you don't read). Cap it without ever blocking the child:
var options = new ProcessRunOptions
{
OutputBuffer = new OutputBufferPolicy { MaxBufferedLines = 1_000, Overflow = OutputOverflowMode.DropOldest },
};Line counters (StdOutLineCount / StdErrLineCount) still count every line read off the pipe, so
StdOutLineCount greater than the number of lines you received means some were dropped.
Both kill the process, but they are distinguishable:
Timeoutfiring setsWasTimedOut = trueon the handle and onProcessResult<T>.- External
CancellationTokencancellation throwsOperationCanceledExceptionfrom the awaiting call and leavesWasTimedOut = false.
var result = await runner.GetFullOutputAsync("sleep", ["30"],
new ProcessRunOptions { Timeout = TimeSpan.FromSeconds(1) });
// result.WasTimedOut == true, result.IsSuccess == falseThe runner never throws on a non-zero exit — it always returns the result. Opt into throwing
with EnsureSuccess() (sync) or EnsureSuccessAsync() (fluent on the task):
try
{
var r = await runner.GetFullOutputAsync("dotnet", ["build"]).EnsureSuccessAsync();
}
catch (ProcessExitException ex)
{
Console.Error.WriteLine($"build failed ({ex.ExitCode}): {ex.StdErr}");
}ProcessExitException carries the ExitCode and the captured StdErr. (The exception
message truncates stderr to 4 KB to avoid log-poisoning; the StdErr property keeps the
full text for programmatic inspection.)
By default each run gets a private ProcessGroup that is disposed with the handle. Pass a
shared group to bind several runs to one lifetime — the runner then does not dispose it;
you own it:
using var group = new ProcessGroup();
var options = new ProcessRunOptions { ProcessGroup = group };
await runner.GetExitCodeAsync("step-1", [], options);
await runner.GetExitCodeAsync("step-2", [], options);
// Disposing `group` (here, at end of `using`) terminates anything still alive from either step.GetStats() / CpuTime / PeakMemoryBytes are best-effort and platform-dependent:
- Windows reports kernel-tracked Job Object accounting — including the peak of total
job memory over time, and any auto-assigned descendants (e.g.
conhost). - Unix sums per-process counters from live processes (exited ones are skipped). The peak
memory is therefore an upper bound — concurrent peak may be lower if processes peaked at
different times. After a process exits,
/procmay no longer expose its counters, soCpuTime/PeakMemoryBytescan returnnull.
Each GitHub Release ships a SHA256SUMS file alongside the .nupkg / .snupkg. Download
all three into the same directory, then:
sha256sum -c SHA256SUMSExpected:
ProcessKit.<version>.nupkg: OK
ProcessKit.<version>.snupkg: OK
The package on NuGet.org carries a repository signature from nuget.org, which attributes it
to the ProcessKit account. Inspect it with
dotnet nuget verify ProcessKit.<version>.nupkg --all.
Build with dotnet build (warnings are errors) and run tests with
dotnet test tests/ProcessKit.Tests/ProcessKit.Tests.csproj. Contributors on Windows can
run the suite inside a Linux container — see docs/linux-testing.md.
Benchmarks (BenchmarkDotNet, not part of CI or the package) live in benchmarks/ProcessKit.Benchmarks:
dotnet build ProcessKit.slnx -c Release
dotnet run -c Release --project benchmarks/ProcessKit.Benchmarks --no-build -- --filter '*'See CHANGELOG.md for the version history.
This project is licensed under the MIT License.