
ProcessKit — documentation
ProcessKit is a child-process toolkit for .NET in two layers:
┌─────────────────────────────────────────────────────────────────┐
│ Runner layer (async, Task) │
│ Command · RunningProcess · Pipeline · Supervisor · CliClient │
│ capture / streaming / interactive stdin / readiness probes │
│ testing seam: IProcessRunner → ScriptedRunner / RecordReplay… │
├─────────────────────────────────────────────────────────────────┤
│ Group layer (kill-on-dispose containment) │
│ ProcessGroup: spawn / signal / suspend / members / stats / │
│ limits / shutdown │
├─────────────────────────────────────────────────────────────────┤
│ OS mechanisms │
│ Windows Job Object · Linux cgroup v2 · POSIX process group │
└─────────────────────────────────────────────────────────────────┘
Every Command run gets containment for free: the one-shot verbs spawn into a fresh private
group that dies with the run, so an early return or an unhandled exception never leaks a process
tree. The layers are also usable independently — a raw ProcessGroup can contain children you
spawn yourself, and the runner's test doubles never touch the OS at all.
Written in F#, built for both F# and C#. ProcessKit is implemented in F#, but it is designed for first-class, idiomatic use from both F# and C# — every public API is meant to be called naturally from either language, and every example in these guides is shown in both.
The orphan process problem
Process.Start() gives you a handle to the direct child, and a bare Process.Kill() ends that
child — not a durable containment boundary for its descendants. If a build tool starts compiler
workers, or a shell wrapper such as cmd /c <command> starts the real payload, those grandchildren
can outlive a timeout, exception, or early return. They become orphans that keep holding ports,
temporary files, handles, and other resources after the code that started them has moved on.
using var process = Process.Start(new ProcessStartInfo("cmd", "/c build.cmd")
{
UseShellExecute = false,
})!;
await Task.Delay(TimeSpan.FromSeconds(5));
process.Kill(); // stops cmd.exe; a compiler worker started by build.cmd can keep running
Cleaning that up correctly means tracking every descendant and handling races while the tree is
still spawning. ProcessKit makes that ownership explicit: a ProcessGroup contains the whole tree,
and disposing it reaps the group. The one-shot Command verbs create and dispose a private group
for each run, so the same guarantee applies without extra plumbing.
OS-level containment mechanisms
ProcessKit uses the operating system's own kernel containment mechanism rather than app-level bookkeeping or best-effort signals to individual PIDs:
- Windows: a Job Object.
- Linux: cgroup v2 when resource limits are requested and available; otherwise a POSIX process group.
- macOS / BSD: a POSIX process group.
ProcessGroup.Mechanism reports which primitive was selected, so code can verify its containment
environment instead of assuming one. See Process groups and
Platform support for the mechanism details and platform caveats.
How it compares
| Capability | System.Diagnostics.Process | CliWrap | Medallion.Shell | SimpleExec | ProcessKit |
|---|---|---|---|---|---|
| Whole-tree kill-on-dispose containment | — | partial | — | — | ✓ |
| Honest results (non-zero exit is not an exception by default) | partial | partial | partial | — | ✓ |
| Typed, pattern-matchable errors | — | — | — | — | ✓ |
| Line streaming + readiness probes | partial | partial | partial | — | ✓ |
| Shell-free pipelines | — | ✓ | ✓ | — | ✓ |
| Supervision (restart, backoff, jitter) | — | — | — | — | ✓ |
Mockable test seam (IProcessRunner) | — | — | — | — | ✓ |
| Built-in observability (logging, tracing, metrics) | — | — | — | — | ✓ |
See the Comparison and migration guide for details and migration recipes.
Guides
New here? Start with the Cookbook — short task-to-snippet recipes for
everything the library does — then read Running commands end to end (it's the
vocabulary every other guide builds on). Reach for the rest as the need arises, and keep
Platform support handy before you ship: it collects every per-OS caveat in
one place. Deploying to Docker/Kubernetes? Running in containers collects the
container-specific consequences of that fine print — mechanism selection, PID 1, graceful
shutdown, and minimal images.
The repository also includes runnable sample projects under ../samples/ for F#
and C# users who want compiled examples instead of Markdown snippets.
| Guide | Covers |
|---|---|
| Comparison and migration guide | How ProcessKit compares to System.Diagnostics.Process, CliWrap, Medallion.Shell, and SimpleExec, plus "was → now" migration snippets |
| Cookbook | "I want to …" → working snippet, for every capability; each recipe links to its deep guide |
| Running commands | The Command builder end to end: args, env, stdin sources, encodings, buffer policies, line handlers, timeouts, retry — and every consuming verb (RunAsync, OutputStringAsync, ProbeAsync, …) with its error semantics |
| Process groups | Kill-on-dispose containment: creating groups, spawning, teardown verbs, whole-tree signals, suspend/resume, member listing, resource limits, stats sampling |
| Streaming & interactive I/O | StartAsync() and the live RunningProcess: line streaming, interactive stdin, readiness probes (WaitForLineAsync / WaitForPortAsync / WaitForAsync), racing children with WaitAnyAsync, per-run profiling |
| Pipelines | a → b → c without a shell: wiring, pipefail attribution, UncheckedInPipe stages for the … → head pattern, timeouts, stdin/stdout at the ends |
| Timeouts, retries & cancellation | How a deadline is captured vs when it errors, retry policies and their classifier, and cancellation: per-command tokens and client-level defaults via CliClient.WithDefaults |
| Supervision | Keeping a child alive: restart policies, backoff & jitter math, the failure-storm guard, stop conditions, outcomes, supervising inside a shared group |
| Testing your code | The IProcessRunner seam — bulk and streaming: ScriptedRunner (incl. scripted StartAsync() with canned lines), record/replay cassettes, and building hermetically-testable CLI wrappers with CliClient |
| Observability | Logging, tracing & metrics: the ILogger lifecycle events (EventIds + per-run correlation), the ProcessKit ActivitySource span, and the ProcessKit Meter instruments — all secret-safe and OpenTelemetry-ready |
| Dependency injection | The ProcessKit.Extensions.DependencyInjection and ProcessKit.Extensions.Hosting packages: AddProcessKit (options / IConfiguration defaults), keyed per-tool CliClients, shared ProcessGroups, and supervised hosted processes |
| Platform support | The containment mechanisms, every per-capability support matrix in one place, and the platform caveats worth knowing before you ship |
| Running in containers | Which Mechanism you actually get inside Docker/Kubernetes, running as PID 1 (signals, reparenting, zombies), graceful shutdown on orchestrator SIGTERM, minimal/musl/shell-less images, and container-level limits vs ProcessGroupOptions limits |
The 60-second tour
F#
task {
// One-shot: capture everything. A non-zero exit is data, not an Error.
match! (Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ]).OutputStringAsync() with
| Ok head -> printfn $"HEAD = {head.Stdout.Trim()}"
| Error err -> eprintfn $"{err.Message}"
// Success-checking: a non-zero exit / timeout / signal-kill becomes a typed error.
match! (Command.create "dotnet" |> Command.arg "--version").RunAsync() with
| Ok version -> printfn $"{version}"
| Error err -> eprintfn $"{err.Message}"
// Stdin + timeout (streaming, pipelines, supervision … see the guides).
let sort =
Command.create "sort"
|> Command.stdin (Stdin.FromString "b\na\n")
|> Command.timeout (TimeSpan.FromSeconds 5.0)
let! _ = sort.RunAsync()
// Containment: anything spawned through a group dies with it.
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _server = group.StartAsync(Command.create "dev-server")
() // disposing the group reaps the server — and everything it spawned
| Error err -> eprintfn $"{err.Message}"
}
C#
// One-shot: capture everything. A non-zero exit is data, not an Error.
Console.WriteLine(await new Command("git").Args(["rev-parse", "HEAD"]).OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var head } => $"HEAD = {head.Stdout.Trim()}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
// Success-checking: a non-zero exit / timeout / signal-kill becomes a typed error.
Console.WriteLine(await new Command("dotnet").Arg("--version").RunAsync() switch
{
{ IsOk: true, ResultValue: var version } => version,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
// Stdin + timeout (streaming, pipelines, supervision … see the guides).
await new Command("sort")
.Stdin(Stdin.FromString("b\na\n"))
.Timeout(TimeSpan.FromSeconds(5))
.RunAsync();
// Containment: anything spawned through a group dies with it.
using var group = ProcessGroup.Create().GetValueOrThrow();
await group.StartAsync(new Command("dev-server"));
// disposing the group reaps the server — and everything it spawned
API reference
The XML documentation shipped in the package powers IntelliSense / quick-info in your IDE for every public type and member; these guides are the narrative layer on top — they explain how the pieces compose, with the platform fine print collected in Platform support. The same XML docs also power a browsable, generated API reference — published alongside these guides on the same site — reach for it when you want a member-by-member lookup instead of a task-oriented guide.