ProcessKit

CI NuGet License: MIT .NET

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

CapabilitySystem.Diagnostics.ProcessCliWrapMedallion.ShellSimpleExecProcessKit
Whole-tree kill-on-dispose containmentpartial
Honest results (non-zero exit is not an exception by default)partialpartialpartial
Typed, pattern-matchable errors
Line streaming + readiness probespartialpartialpartial
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.

GuideCovers
Comparison and migration guideHow 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 commandsThe 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 groupsKill-on-dispose containment: creating groups, spawning, teardown verbs, whole-tree signals, suspend/resume, member listing, resource limits, stats sampling
Streaming & interactive I/OStartAsync() and the live RunningProcess: line streaming, interactive stdin, readiness probes (WaitForLineAsync / WaitForPortAsync / WaitForAsync), racing children with WaitAnyAsync, per-run profiling
Pipelinesa → b → c without a shell: wiring, pipefail attribution, UncheckedInPipe stages for the … → head pattern, timeouts, stdin/stdout at the ends
Timeouts, retries & cancellationHow 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
SupervisionKeeping a child alive: restart policies, backoff & jitter math, the failure-storm guard, stop conditions, outcomes, supervising inside a shared group
Testing your codeThe IProcessRunner seam — bulk and streaming: ScriptedRunner (incl. scripted StartAsync() with canned lines), record/replay cassettes, and building hermetically-testable CLI wrappers with CliClient
ObservabilityLogging, 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 injectionThe ProcessKit.Extensions.DependencyInjection and ProcessKit.Extensions.Hosting packages: AddProcessKit (options / IConfiguration defaults), keyed per-tool CliClients, shared ProcessGroups, and supervised hosted processes
Platform supportThe containment mechanisms, every per-capability support matrix in one place, and the platform caveats worth knowing before you ship
Running in containersWhich 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.