
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.
Comparison and migration guide
How ProcessKit compares to System.Diagnostics.Process and three widely-used
third-party process-running libraries for .NET — CliWrap,
Medallion.Shell, and
SimpleExec — plus short "was → now" recipes for the
most common migration patterns.
This is not a "everyone else is bad" pitch: all three libraries are solid, widely used, and each has real strengths ProcessKit doesn't try to match (see each section below). The comparison below is scoped to the axes where ProcessKit makes a deliberate, load-bearing choice; if a library isn't mentioned on an axis, assume it does not attempt that particular guarantee, not that it is somehow deficient in general. All descriptions reflect the current, public API surface of the alternative libraries at the time of writing (2026) — if a library ships a newer capability after this guide was written, treat this page as possibly stale on that one row rather than authoritative.
At a glance
System.Diagnostics.Process | CliWrap | Medallion.Shell | SimpleExec | ProcessKit | |
|---|---|---|---|---|---|
| Whole-tree kill-on-dispose containment | — | partial¹ | — | — | ✓ |
| Honest results (non-zero exit ≠ exception) | partial² | partial³ | partial⁴ | — | ✓ |
| Typed, pattern-matchable error | — | — | — | — | ✓ |
| Line streaming | partial⁵ | ✓ | ✓ | — | ✓ |
| Readiness probes (wait for line / port / condition) | — | — | — | — | ✓ |
Shell-free pipelines (a → b → c) | — | ✓ | ✓ | — | ✓ |
| Supervision (restart on crash, backoff) | — | — | — | — | ✓ |
Mockable runner seam (IProcessRunner-style) | — | — | — | — | ✓ |
| Secret-safe built-in observability (logging/tracing/metrics) | — | — | — | — | ✓ |
¹ CliWrap kills the whole process tree it started on cancellation (cross-platform, since it
added tree-aware forceful/graceful cancellation), but it has no persistent, disposable container
you can hold across several commands — each Cli.Wrap(...) run is a standalone execution, not a
member of a shared kill-on-dispose group.
² Process.ExitCode is available without throwing, but Process throws on failure to start
(Win32Exception) and offers no structured distinction between "exited non-zero," "timed out,"
and "killed by signal" — the caller reconstructs that from ExitCode/HasExited by hand.
³ CommandResult/BufferedCommandResult carry ExitCode without throwing, but CliWrap validates
the exit code by default and throws CommandExecutionException unless the caller opts out with
WithValidation(CommandResultValidation.None).
⁴ Command.Result exposes ExitCode/Success without throwing by default, which is honest; the
gap on this row is the typed error below it, not this one.
⁵ Process.OutputDataReceived/ErrorDataReceived deliver lines via events the caller wires up
manually (BeginOutputReadLine(), subscribe, unsubscribe, watch for null-line-means-EOF); there is
no async-enumerable line stream and no built-in readiness helper.
System.Diagnostics.Process
The BCL type every .NET process library — including ProcessKit — is ultimately built on. Its
strengths: zero extra dependency, full control over every ProcessStartInfo knob, and it is the
lowest common denominator every other library on this page eventually needs to escape hatch to for
something exotic.
Where ProcessKit differs: Process only ever tracks the one process it started directly. Anything
that process spawns — a build tool's compiler workers, the real payload behind cmd /c/sh -c,
a test's helper server — is invisible to Process and outlives a Process.Kill(), a timeout, or a
crashed test runner as an orphan. Getting Process to contain a whole tree requires hand-rolling a
Windows Job Object or a Linux cgroup/process-group yourself; ProcessKit does that unconditionally,
underneath every verb, as the default rather than an advanced technique.
CliWrap
A mature, widely-adopted, dependency-free library with a clean fluent builder
(Cli.Wrap("git").WithArguments(...)), first-class shell-free piping via the | operator, and a
PipeSource/PipeTarget abstraction that lets stdin/stdout attach to a Stream, a file, a
StringBuilder, or another command with the same syntax — genuinely nice API design, and a good
default choice for scripting scenarios that don't need tree containment or supervision.
Where ProcessKit differs: CliWrap's honest-by-default posture stops at this run's exit code —
by default a non-zero exit throws CommandExecutionException, and turning that off
(CommandResultValidation.None) still leaves the caller pattern-matching loosely-typed exceptions
for the timeout/cancellation/not-found cases rather than one closed, structured error type. There is
also no persistent container object: CliWrap's tree-aware cancellation kills what that invocation
spawned, but there is nothing to use/dispose across several related commands the way
ProcessGroup does, no readiness-probe helpers, no supervision, and no injectable runner interface
for hermetic unit tests (you mock PipeSource/PipeTarget streams instead of the process runner
itself).
Medallion.Shell
A small, pragmatic cross-platform wrapper with the same appealing | pipe operator as CliWrap, a
synchronous-and-async API (useful in non-async call sites), and a straightforward
Command.Run(...) entry point that reads naturally for simple "run this, get the result" call
sites.
Where ProcessKit differs: Medallion.Shell does not attempt whole-tree containment (only the
directly-spawned process is tracked/killed), does not have a typed error result — Command.Result
gives you ExitCode and Success, but timeouts, signals, and spawn failures aren't unified into
one pattern-matchable type — and, like CliWrap, has no readiness-probe helpers, no supervision, and
no formal mockable seam (tests either run the real process or wrap Command themselves).
SimpleExec
The simplest of the three: a couple of static methods (Command.Run, Command.ReadAsync) designed
for build scripts and CLI tooling glue (it shows up a lot in Cake/Nuke/custom build scripts). Its
minimalism is the point — no pipeline DSL, no event streams, just "run this and get stdout, or throw."
It also echoes the command line and output to the console by default, which is genuinely convenient
in CI logs for a build script.
Where ProcessKit differs: SimpleExec is exception-first (a non-zero exit throws
SimpleExec.ExitCodeException unless the caller passes noThrow: true — there's no separate
honest-result verb to opt into instead), has no line-by-line streaming API, no shell-free pipeline
concept, no whole-tree containment, and no runner seam to substitute in tests. Its console-echo
default is also worth flagging from a secrets standpoint: it prints the full command line (including
arguments) to the console by default, which is the opposite of ProcessKit's "argv and environment
values are never logged automatically" stance — fine for a build script's own trusted commands, but
worth turning off (echoCommand: false) before echoing anything derived from external input.
Migration recipes
Every snippet below assumes open ProcessKit (F#) / using ProcessKit; (C#), matching the rest of
the docs. See Running commands, Streaming & interactive I/O, and
Pipelines for the full picture of each verb used here.
Process.Start + manual stream reading → a verb
Before (F#, raw System.Diagnostics.Process)
task {
let psi =
ProcessStartInfo("git", "rev-parse HEAD", RedirectStandardOutput = true, UseShellExecute = false)
use proc = Process.Start psi
let! stdout = proc.StandardOutput.ReadToEndAsync()
do! proc.WaitForExitAsync()
if proc.ExitCode <> 0 then
eprintfn $"git failed with {proc.ExitCode}"
else
printfn $"HEAD is {stdout.Trim()}"
}
After (F#, ProcessKit)
task {
match! (Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ]).OutputStringAsync() with
| Ok result -> printfn $"HEAD is {result.Stdout.Trim()}"
| Error err -> eprintfn $"{err.Message}"
}
Before (C#, raw System.Diagnostics.Process)
var psi = new ProcessStartInfo("git", "rev-parse HEAD")
{
RedirectStandardOutput = true,
UseShellExecute = false,
};
using var process = Process.Start(psi)!;
var stdout = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
Console.WriteLine(process.ExitCode != 0
? $"git failed with {process.ExitCode}"
: $"HEAD is {stdout.Trim()}");
After (C#, ProcessKit)
Console.WriteLine(await new Command("git").Args(["rev-parse", "HEAD"]).OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => $"HEAD is {result.Stdout.Trim()}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
OutputStringAsync() captures stdout/stderr and reports the exit code as data — no manual
WaitForExitAsync + ReadToEndAsync ordering to get right, and the whole tree it spawns is
contained and reaped even if the task is abandoned mid-run.
A CliWrap pipeline → Pipeline
Before (C#, CliWrap)
var result = await (
Cli.Wrap("git").WithArguments(["log", "--format=%an"])
| Cli.Wrap("sort")
| Cli.Wrap("uniq").WithArguments("-c")
).ExecuteBufferedAsync();
Console.WriteLine(result.StandardOutput);
After (C#, ProcessKit)
var pipeline = new Command("git").Args(["log", "--format=%an"])
.Pipe(new Command("sort"))
.Pipe(new Command("uniq").Arg("-c"));
Console.WriteLine(await pipeline.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var output } => output.Stdout,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Before (F#, CliWrap) — CliWrap's | is a plain operator overload on .NET types, so it works
unchanged from F#:
task {
let cmd =
Cli.Wrap("git").WithArguments [ "log"; "--format=%an" ]
|> fun git -> git | Cli.Wrap "sort" | Cli.Wrap("uniq").WithArguments "-c"
let! result = cmd.ExecuteBufferedAsync()
printfn $"{result.StandardOutput}"
}
After (F#, ProcessKit)
task {
let pipeline =
(Command.create "git" |> Command.args [ "log"; "--format=%an" ])
.Pipe(Command.create "sort")
.Pipe(Command.create "uniq" |> Command.arg "-c")
match! pipeline.OutputStringAsync() with
| Ok out -> printfn $"{out.Stdout}"
| Error err -> eprintfn $"{err.Message}"
}
Pipeline reports pipefail semantics out of the box: the exit code, stderr, and the reported
failing program come from the first stage that didn't exit cleanly, not just the last one — the
same information CliWrap's ExecuteBufferedAsync() does not surface per-stage without extra
plumbing. See Pipelines for Command.uncheckedInPipe (the producer | head -1
case) and chain timeouts.
Event-based output subscription → line streaming
Before (C#, CliWrap's event stream)
await foreach (var cmdEvent in Cli.Wrap("dotnet").WithArguments(["build", "-c", "Release"]).ListenAsync())
{
switch (cmdEvent)
{
case StandardOutputCommandEvent stdOut:
Console.WriteLine(stdOut.Text);
break;
case StandardErrorCommandEvent stdErr:
Console.Error.WriteLine(stdErr.Text);
break;
case ExitedCommandEvent exited:
Console.WriteLine($"exited {exited.ExitCode}");
break;
}
}
After (C#, ProcessKit)
await using var proc = (await new Command("dotnet").Args(["build", "-c", "Release"]).StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine(line);
var finished = (await proc.FinishAsync()).GetValueOrThrow();
Console.WriteLine($"exited {finished.Outcome}");
Before (F#, Process.OutputDataReceived)
let psi = ProcessStartInfo("dotnet", "build -c Release", RedirectStandardOutput = true, UseShellExecute = false)
use proc = new Process(StartInfo = psi)
proc.OutputDataReceived.Add(fun args -> if args.Data <> null then printfn $"{args.Data}")
proc.Start() |> ignore
proc.BeginOutputReadLine()
proc.WaitForExit()
After (F#, ProcessKit)
task {
match! (Command.create "dotnet" |> Command.args [ "build"; "-c"; "Release" ]).StartAsync() with
| Ok proc ->
use _ = proc
let e = proc.StdoutLinesAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"{e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
match! proc.FinishAsync() with
| Ok finished -> printfn $"exited {finished.Outcome}"
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
For a callback-style tee instead of consuming the async stream directly (logging/progress bars
alongside capture), see Command.onStdoutLine / .OnStdoutLine(...) in
Running commands → Line handlers and tees — the closest
equivalent to CliWrap's event-stream callback style, without giving up buffered capture.
See also
- README → Why ProcessKit? — the elevator pitch and the single-column differentiator table.
- Running commands, Streaming & interactive I/O, Pipelines, Supervision, Testing your code — the full guide set each recipe above links into.
ProcessKit cookbook
Task-oriented, idiomatic examples for every part of the public API. The run and capture
verbs return Task<Result<_, ProcessError>>, so the F# samples below run inside a task { }
block and use match! (a few RunningProcess members — WaitAsync, ProfileAsync — return their
value directly). Where a snippet writes let! r = cmd.Verb() for brevity, r is the
Result<_, ProcessError> you then match. From C# the same surface is await-able fluent
methods.
- Running a command
- Capturing output
- Error handling
- Exit codes and probing
- Accepting non-zero exits
- Parsing output
- Standard input
- Pipelines
- Streaming and interactive I/O
- Readiness probes
- Timeouts, cancellation, retry
- Process groups and tree control
- Resource limits
- Stats and profiling
- Supervision
- CliClient
- Top-level Exec helpers
- Preflight: is a program installed?
- Logging, tracing & metrics
- Dependency injection
- Testing without subprocesses
Running a command
Build a Command (an immutable value), then call a verb. RunAsync requires a zero (or
accepted) exit and returns stdout with trailing whitespace trimmed.
F#
task {
let cmd = Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ]
match! cmd.RunAsync() with
| Ok sha -> printfn $"HEAD is {sha}"
| Error err -> eprintfn $"git failed: {err.Message}"
}
C#
var cmd = new Command("git").Args(["rev-parse", "HEAD"]);
Console.WriteLine(await cmd.RunAsync() switch
{
{ IsOk: true, ResultValue: var sha } => $"HEAD is {sha}",
{ IsOk: false, ErrorValue: var err } => $"git failed: {err.Message}",
});
The builder is fluent and immutable — each method returns a new Command:
F#
let cmd =
Command.create "dotnet"
|> Command.args [ "build"; "-c"; "Release" ]
|> Command.currentDir "/repo"
|> Command.env "DOTNET_NOLOGO" "1"
C#
var cmd = new Command("dotnet")
.Args(["build", "-c", "Release"])
.CurrentDir("/repo")
.Env("DOTNET_NOLOGO", "1");
The same in method style (identical from C#):
F#
let cmd = (Command "dotnet").Args([ "build"; "-c"; "Release" ]).CurrentDir("/repo")
C#
var cmd = new Command("dotnet").Args(["build", "-c", "Release"]).CurrentDir("/repo");
Use RunUnitAsync when you only care that it succeeded:
F#
match! (Command.create "mkdir" |> Command.arg "out").RunUnitAsync() with
| Ok () -> ()
| Error err -> eprintfn $"{err.Message}"
C#
if (await new Command("mkdir").Arg("out").RunUnitAsync() is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
Capturing output
OutputStringAsync / OutputBytesAsync return a ProcessResult<_> — a non-zero exit is data here,
not an error. Inspect Stdout, Stderr, Code, IsSuccess, Duration, Outcome.
F#
match! (Command.create "ls" |> Command.arg "-la").OutputStringAsync() with
| Ok result ->
printfn $"exit={result.Code} success={result.IsSuccess} in {result.Duration}"
printfn $"{result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
C#
switch (await new Command("ls").Arg("-la").OutputStringAsync())
{
case { IsOk: true, ResultValue: var result }:
Console.WriteLine($"exit={result.Code} success={result.IsSuccess} in {result.Duration}");
Console.WriteLine(result.Stdout);
break;
case { IsOk: false, ErrorValue: var err }:
Console.Error.WriteLine(err.Message);
break;
}
OutputBytesAsync is the binary companion (ProcessResult<byte[]>), for non-text output.
Error handling
ProcessError is a discriminated union — pattern-match it, or use .Message for a short
description. The capture verbs only error on failure to run (spawn / not-found / I/O /
timeout / cancellation), never on a non-zero exit.
F#
match! (Command.create "definitely-not-a-program").OutputStringAsync() with
| Ok result -> printfn $"{result.Stdout}"
| Error(ProcessError.NotFound(program, _)) -> eprintfn $"not installed: {program}"
| Error(ProcessError.Timeout(program, timeout, _, _)) -> eprintfn $"{program} timed out after {timeout}"
| Error err -> eprintfn $"{err.Message}"
C#
Console.WriteLine(await new Command("definitely-not-a-program").OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => result.Stdout,
{ IsOk: false, ErrorValue: ProcessError.NotFound n } => $"not installed: {n.Program}",
{ IsOk: false, ErrorValue: ProcessError.Timeout t } => $"{t.Program} timed out after {t.Timeout}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Classifiers help with retry/diagnostic logic:
F#
match! cmd.RunAsync() with
| Ok _ -> ()
| Error err when ProcessError.isNotFound err -> installThenRetry ()
| Error err when ProcessError.isTransient err -> scheduleRetry () // spawn / I/O blips
| Error err -> fail err
C#
switch (await cmd.RunAsync())
{
case { IsOk: true }:
break;
case { IsOk: false, ErrorValue: { IsNotFound: true } }:
installThenRetry();
break;
case { IsOk: false, ErrorValue: { IsTransient: true } }:
scheduleRetry(); // spawn / I/O blips
break;
case { IsOk: false, ErrorValue: var err }:
fail(err);
break;
}
The success-requiring verbs (RunAsync / RunUnitAsync) additionally turn a non-zero exit into
ProcessError.Exit(program, code, stdout, stderr).
Exit codes and probing
F#
let! code = (Command.create "grep" |> Command.args [ "pattern"; "file" ]).ExitCodeAsync() // Ok 0 / Ok 1 / ...
let! found = (Command.create "which" |> Command.arg "git").ProbeAsync() // Ok true if exit 0
C#
var code = await new Command("grep").Args(["pattern", "file"]).ExitCodeAsync(); // Ok 0 / Ok 1 / ...
var found = await new Command("which").Arg("git").ProbeAsync(); // Ok true if exit 0
ProbeAsync is true when the command runs and exits zero — handy for feature detection.
Accepting non-zero exits
Some tools use non-zero exits as information (e.g. grep returns 1 for "no match"). Tell
ProcessKit which codes count as success:
F#
let grep =
Command.create "grep"
|> Command.args [ "ERROR"; "app.log" ]
|> Command.okCodes [ 0; 1 ] // 1 ("no match") is not a failure
match! grep.RunAsync() with
| Ok output -> printfn $"matches:\n{output}"
| Error err -> eprintfn $"{err.Message}" // a real failure (e.g. exit 2)
C#
var grep = new Command("grep")
.Args(["ERROR", "app.log"])
.OkCodes([0, 1]); // 1 ("no match") is not a failure
Console.WriteLine(await grep.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => $"matches:\n{output}",
{ IsOk: false, ErrorValue: var err } => err.Message, // a real failure (e.g. exit 2)
});
OkCodes sets which exit codes ProcessResult.IsSuccess, RunAsync/RunUnitAsync, and supervisor crash
detection accept — the codes replace the default {0} (include 0 to keep it, as [ 0; 1 ] does
above).
Parsing output
ParseAsync maps stdout through a function (requires success); TryParseAsync uses the standard
.NET try-parse shape, so C# can pass int.TryParse (and friends) with an explicit type argument
(TryParseAsync<int>(int.TryParse) — needed because the BCL parsers are overloaded) and a false
return becomes ProcessError.Parse — F# reaches for the Result-returning Runner.tryParse instead;
OutputJsonAsync<'T> deserializes stdout as JSON via System.Text.Json (same explicit-type-argument
need, since there is no parser argument to infer 'T from — OutputJsonAsync<int>()), takes an
optional JsonSerializerOptions overload, and turns invalid JSON into ProcessError.Parse just like
a rejecting parser; FirstLineAsync returns the first stdout line matching a predicate.
F#
let! version = (Command.create "node" |> Command.arg "--version").ParseAsync(fun s -> s.TrimStart('v'))
let! widget = (Command.create "widget-cli" |> Command.arg "get").OutputJsonAsync<Widget>()
let! port = (Command.create "myserver").FirstLineAsync(fun line -> line.StartsWith "Listening on ")
C#
var version = await new Command("node").Arg("--version").ParseAsync(s => s.TrimStart('v'));
var count = await new Command("git").Args(["rev-list", "--count", "HEAD"]).TryParseAsync<int>(int.TryParse);
var widget = await new Command("widget-cli").Arg("get").OutputJsonAsync<Widget>();
var port = await new Command("myserver").FirstLineAsync(line => line.StartsWith("Listening on "));
A plain F# record deserializes through STJ's constructor-based deserialization by default, matching
JSON keys to the record's field names case-sensitively; mark the record [<CLIMutable>] for the
classic default-constructor-plus-settable-properties shape, or pass options with
PropertyNameCaseInsensitive = true for case-insensitive matching.
Standard input
Feed input with a Stdin source:
F#
let cmd =
Command.create "grep"
|> Command.arg "needle"
|> Command.stdin (Stdin.FromString "haystack\nneedle\nmore")
C#
var cmd = new Command("grep")
.Arg("needle")
.Stdin(Stdin.FromString("haystack\nneedle\nmore"));
Sources: Stdin.FromString, FromBytes, FromFile path, FromStream stream,
FromLines seq, FromAsyncLines asyncSeq, and Stdin.Empty. For interactive writing, see
streaming.
Pipelines
Pipe wires each stage's stdout into the next stage's stdin — no shell — and runs the whole
chain in one kill-on-dispose group. The exit status follows shell pipefail.
F#
let pipeline =
(Command.create "cat" |> Command.arg "access.log")
.Pipe(Command.create "grep" |> Command.arg "ERROR")
.Pipe(Command.create "wc" |> Command.arg "-l")
match! pipeline.RunAsync() with
| Ok count -> printfn $"{count} error lines"
| Error err -> eprintfn $"{err.Message}"
C#
var pipeline = new Command("cat").Arg("access.log")
.Pipe(new Command("grep").Arg("ERROR"))
.Pipe(new Command("wc").Arg("-l"));
Console.WriteLine(await pipeline.RunAsync() switch
{
{ IsOk: true, ResultValue: var count } => $"{count} error lines",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
A pipeline supports the same verbs as a command (RunAsync/OutputStringAsync/ExitCodeAsync/…) plus
Timeout / CancelOn. Let a stage fail without failing the pipeline with
Command.uncheckedInPipe. The pipe-style module mirror is Pipeline.create / Pipeline.pipe.
Streaming and interactive I/O
StartAsync() returns a live RunningProcess. Stream stdout line by line as an
IAsyncEnumerable. (use ensures the tree is killed on scope exit.)
F#
task {
match! (Command.create "dotnet" |> Command.arg "watch").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
let lines = proc.StdoutLinesAsync()
let e = lines.GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"> {e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
}
C#
await using var proc = (await new Command("dotnet").Arg("watch").StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine($"> {line}");
From C# this is simply await foreach (var line in proc.StdoutLinesAsync()) { ... }.
OutputEventsAsync() interleaves stdout and stderr as OutputEvent values (IsStdout/IsStderr,
.Text). Write to a running process's stdin via TakeStdin():
F#
match proc.TakeStdin() with
| Some stdin ->
do! stdin.WriteLineAsync "command one"
do! stdin.FlushAsync()
do! stdin.FinishAsync() // close stdin (EOF)
| None -> ()
C#
if (proc.TakeStdin() is { Value: var stdin }) // Some(stdin); None is null and won't match
{
await stdin.WriteLineAsync("command one");
await stdin.FlushAsync();
await stdin.FinishAsync(); // close stdin (EOF)
}
Race or await several started processes with RunningProcess.WaitAny / WaitAllAsync.
Readiness probes
Wait for a started process to become ready before proceeding:
F#
match! (Command.create "myserver").StartAsync() with
| Ok proc ->
use _ = proc
// Wait up to 10s for a log line, a TCP port, or a custom predicate.
match! proc.WaitForLineAsync((fun l -> l.Contains "ready"), TimeSpan.FromSeconds 10.0) with
| Ok _ -> printfn "server is up"
| Error err -> eprintfn $"never became ready: {err.Message}" // ProcessError.NotReady on timeout
| Error err -> eprintfn $"{err.Message}"
C#
await using var proc = (await new Command("myserver").StartAsync()).GetValueOrThrow();
// Wait up to 10s for a log line, a TCP port, or a custom predicate.
Console.WriteLine(await proc.WaitForLineAsync(l => l.Contains("ready"), TimeSpan.FromSeconds(10)) switch
{
{ IsOk: true } => "server is up",
{ IsOk: false, ErrorValue: var err } => $"never became ready: {err.Message}", // ProcessError.NotReady on timeout
});
Also WaitForPortAsync(endpoint, timeout) and WaitForAsync(predicateReturningTask, timeout).
Timeouts, cancellation, retry
F#
let cmd =
Command.create "slow-job"
|> Command.timeout (TimeSpan.FromSeconds 30.0) // kill at the deadline -> Outcome.TimedOut
|> Command.retry 3 (TimeSpan.FromMilliseconds 200.0) (fun err -> ProcessError.isTransient err)
C#
var cmd = new Command("slow-job")
.Timeout(TimeSpan.FromSeconds(30)) // kill at the deadline -> Outcome.TimedOut
.Retry(3, TimeSpan.FromMilliseconds(200), err => err.IsTransient);
TimeoutGrace sends SIGTERM, waits a grace window, then SIGKILL (atomic on Windows). Tie a
run to a CancellationToken with CancelOn, or pass a token to any verb's optional
token parameter (cmd.RunAsync(ct)). A cancelled run is always an Error (ProcessError.Cancelled).
Process groups and tree control
A ProcessGroup is a kill-on-dispose container for a whole process tree (Windows Job
Object / Linux cgroup v2 / POSIX process group). It is itself an IProcessRunner.
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"{err.Message}"
| Ok group ->
use group = group // disposes (and reaps the whole tree) on scope exit
match! group.StartAsync(Command.create "build-everything") with
| Ok _proc ->
group.Signal Signal.Term |> ignore // signal the whole tree
group.Suspend() |> ignore // freeze it
group.Resume() |> ignore // thaw it
match group.Members() with
| Ok pids -> printfn $"{pids.Count} processes in the tree"
| Error _ -> ()
do! group.ShutdownAsync(TimeSpan.FromSeconds 5.0) // graceful: SIGTERM -> grace -> SIGKILL
| Error err -> eprintfn $"{err.Message}"
}
C#
using var group = ProcessGroup.Create().GetValueOrThrow(); // disposes (and reaps the whole tree) on scope exit
await group.StartAsync(new Command("build-everything"));
group.Signal(Signal.Term); // signal the whole tree
group.Suspend(); // freeze it
group.Resume(); // thaw it
if (group.Members() is { IsOk: true, ResultValue: var pids })
Console.WriteLine($"{pids.Count} processes in the tree");
await group.ShutdownAsync(TimeSpan.FromSeconds(5)); // graceful: SIGTERM -> grace -> SIGKILL
Portable Signal values: Term, Kill, Int, Hup, Quit, Usr1, Usr2,
Signal.Other n. On Windows only Kill is delivered. Share one container across a fleet by
passing the group as the IProcessRunner (e.g. to a Supervisor).
Resource limits
Cap the whole tree's memory, process count, or CPU. Enforced by a Windows Job Object or a
Linux cgroup v2; where no limit-capable container exists, creation fails fast with
ProcessError.ResourceLimit rather than running unbounded.
F#
let options =
ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // 512 MiB
.WithMaxProcesses(64)
.WithCpuQuota(1.5) // 1.5 cores
match ProcessGroup.Create options with
| Ok group ->
use group = group // ... run within the limited group
()
| Error err -> eprintfn $"limits unavailable: {err.Message}"
C#
var options = new ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // 512 MiB
.WithMaxProcesses(64)
.WithCpuQuota(1.5); // 1.5 cores
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine($"limits unavailable: {err.Message}");
return;
}
using var group = created.GetValueOrThrow(); // ... run within the limited group
Stats and profiling
F#
match group.Stats() with
| Ok stats ->
printfn $"active={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}"
| Error _ -> ()
// A periodic series (IAsyncEnumerable) for live dashboards:
let series = group.SampleStatsAsync(TimeSpan.FromSeconds 1.0)
C#
if (group.Stats() is { IsOk: true, ResultValue: var stats })
Console.WriteLine($"active={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}");
// A periodic series (IAsyncEnumerable) for live dashboards:
var series = group.SampleStatsAsync(TimeSpan.FromSeconds(1));
Per-run profiling captures exit code, duration, CPU, and peak memory:
F#
match! (Command.create "heavy-job").StartAsync() with
| Ok proc ->
use _ = proc
let! profile = proc.ProfileAsync()
printfn $"exit={profile.ExitCode} cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} samples={profile.Samples}"
| Error _ -> ()
C#
await using var proc = (await new Command("heavy-job").StartAsync()).GetValueOrThrow();
var profile = await proc.ProfileAsync();
Console.WriteLine($"exit={profile.ExitCode} cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} samples={profile.Samples}");
Supervision
Keep a command alive with policy-driven restarts, exponential backoff + jitter, and a failure-storm guard.
F#
let outcome =
(Supervisor.create (Command.create "worker"))
.Restart(RestartPolicy.OnCrash)
.Backoff(TimeSpan.FromSeconds 1.0, 2.0) // base delay, multiplier
.MaxBackoff(TimeSpan.FromMinutes 1.0)
.Jitter(true)
.MaxRestarts(20)
.StormPause(TimeSpan.FromMinutes 5.0) // pause after a burst of failures
.RunAsync()
match! outcome with
| Ok result -> printfn $"stopped: {result.Stopped} after {result.Restarts} restarts"
| Error err -> eprintfn $"{err.Message}"
C#
var outcome = new Supervisor(new Command("worker"))
.Restart(RestartPolicy.OnCrash)
.Backoff(TimeSpan.FromSeconds(1), 2.0) // base delay, multiplier
.MaxBackoff(TimeSpan.FromMinutes(1))
.Jitter(true)
.MaxRestarts(20)
.StormPause(TimeSpan.FromMinutes(5)) // pause after a burst of failures
.RunAsync();
Console.WriteLine(await outcome switch
{
{ IsOk: true, ResultValue: var result } => $"stopped: {result.Stopped} after {result.Restarts} restarts",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Supervision runs through any IProcessRunner (WithRunner), so it is testable without
spawning processes, and it honours OkCodes when deciding what counts as a crash.
CliClient
A reusable handle to one program with shared defaults:
F#
let git =
(CliClient.create "git")
.WithDefaults(fun c -> c.CurrentDir("/repo").Timeout(TimeSpan.FromSeconds 30.0))
let! sha = git.RunAsync [ "rev-parse"; "HEAD" ]
let! log = git.OutputStringAsync [ "log"; "--oneline"; "-n"; "10" ]
C#
var git = new CliClient("git")
.WithDefaults(c => c.CurrentDir("/repo").Timeout(TimeSpan.FromSeconds(30)));
var sha = await git.RunAsync(["rev-parse", "HEAD"]);
var log = await git.OutputStringAsync(["log", "--oneline", "-n", "10"]);
WithDefaults configures the shared defaults with the full Command builder; client.Command args
builds a configured Command without running it.
Top-level Exec helpers
For one-off runs without first building a Command:
F#
let! sha = Exec.run "git" [ "rev-parse"; "HEAD" ]
let! info = Exec.outputString "dotnet" [ "--info" ]
C#
var sha = await Exec.run("git", ["rev-parse", "HEAD"]);
var info = await Exec.outputString("dotnet", ["--info"]);
Run a batch with bounded concurrency, collecting every result in input order (never short-circuits):
F#
let runner = JobRunner() :> IProcessRunner
let commands = files |> List.map (fun f -> Command.create "gzip" |> Command.arg f)
let! results = Exec.outputAll 4 runner commands CancellationToken.None // at most 4 live at once
C#
var runner = new JobRunner();
var commands = files.Select(f => new Command("gzip").Arg(f));
var results = await Exec.outputAll(4, runner, commands, CancellationToken.None); // at most 4 live at once
Preflight: is a program installed?
Exec.which resolves a program to a full path without running it — a doctor check for an
install wizard or a wrapper app's startup, cheaper than probing availability by actually launching
the program. It shares the exact PATH/PATHEXT-aware lookup the spawn path itself falls back on,
so it never disagrees with an actual spawn of the same program name (see
commands.md → Preflight for the full contract,
including Windows PATHEXT semantics).
F#
match Exec.which "git" with
| Ok path -> printfn $"git found at {path}"
| Error err -> eprintfn $"git is not available: {err.Message}"
C#
Console.WriteLine(Exec.which("git") switch
{
{ IsOk: true, ResultValue: var path } => $"git found at {path}",
{ IsOk: false, ErrorValue: var err } => $"git is not available: {err.Message}",
});
The same check on a CliClient resolves the client's own program, and is always a local host
check — never delegated to a runner injected via WithRunner (a ScriptedRunner used in the
wrapper's own tests has no bearing on it):
F#
match! git.EnsureAvailableAsync() with
| Ok path -> printfn $"git found at {path}"
| Error err -> eprintfn $"git is not available: {err.Message}"
C#
Console.WriteLine(await git.EnsureAvailableAsync() switch
{
{ IsOk: true, ResultValue: var path } => $"git found at {path}",
{ IsOk: false, ErrorValue: var err } => $"git is not available: {err.Message}",
});
Logging, tracing & metrics
Opt in to structured lifecycle events (spawn, exit, timeout, retry, supervisor restart) — each with a
stable EventId and a per-run RunId that ties a run's lines together. argv and the environment are
never logged — only the program name and non-secret facts.
F#
let cmd = Command.create "deploy" |> Command.logger logger // any Microsoft.Extensions.Logging ILogger
C#
var cmd = new Command("deploy").Logger(logger); // any Microsoft.Extensions.Logging ILogger
No-op and free when no logger is set. ProcessKit also emits a System.Diagnostics trace span per run
(ActivitySource ProcessKitDiagnostics.ActivitySourceName) and metrics (Meter
ProcessKitDiagnostics.MeterName) — wire them into OpenTelemetry with AddSource(...) / AddMeter(...).
See the Observability guide for the full event/instrument taxonomy.
Dependency injection
The ProcessKit.Extensions.DependencyInjection package registers an IProcessRunner:
F#
services.AddProcessKit() |> ignore
// When the container also has an ILoggerFactory, runs emit ProcessKit's lifecycle events.
// Later, injected as IProcessRunner:
type Deployer(runner: IProcessRunner) =
member _.Deploy() = Runner.run runner CancellationToken.None (Command.create "deploy")
C#
services.AddProcessKit();
// When the container also has an ILoggerFactory, runs emit ProcessKit's lifecycle events.
// Later, injected as IProcessRunner:
public class Deployer(IProcessRunner runner)
{
public Task<FSharpResult<string, ProcessError>> Deploy() =>
runner.RunAsync(new Command("deploy"), CancellationToken.None);
}
AddProcessKit uses TryAdd, so a pre-existing IProcessRunner registration is left intact.
AddProcessKit(configure) / AddProcessKit(configuration) set a default timeout / working directory;
AddProcessKitClient(name, program) registers keyed per-tool CliClients (the place for retry / encoding
defaults); and AddProcessKitGroup() backs the runner with a shared, container-managed ProcessGroup
(disposing the provider reaps the whole tree). See the Dependency injection guide.
Testing without subprocesses
ProcessKit.Testing provides subprocess-free IProcessRunners. ScriptedRunner returns
canned replies:
F#
let runner =
(ScriptedRunner())
.On([ "git"; "rev-parse"; "HEAD" ], Reply.Ok "abc123")
.When((fun cmd -> cmd.Program = "flaky"), Reply.Fail(1, "boom"))
.Fallback(Reply.Ok "")
// Inject `runner` wherever an IProcessRunner is expected — no real processes run.
let! sha = Runner.run runner CancellationToken.None (Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ])
C#
var runner = new ScriptedRunner()
.On(["git", "rev-parse", "HEAD"], Reply.Ok("abc123"))
.When(cmd => cmd.Program == "flaky", Reply.Fail(1, "boom"))
.Fallback(Reply.Ok(""));
// Inject `runner` wherever an IProcessRunner is expected — no real processes run.
var sha = await runner.RunAsync(new Command("git").Args(["rev-parse", "HEAD"]), CancellationToken.None);
RecordReplayRunner records real runs to a JSON cassette and replays them hermetically:
F#
// Record (wraps a real runner), then save:
let recorder = RecordReplayRunner.Record("fixture.json", JobRunner())
// ... drive recorder as an IProcessRunner ...
recorder.Save() |> ignore
// Replay later with no subprocess (an unmatched call is ProcessError.CassetteMiss):
match RecordReplayRunner.Replay "fixture.json" with
| Ok replay -> () // use `replay` as an IProcessRunner
| Error err -> eprintfn $"{err.Message}"
C#
// Record (wraps a real runner), then save:
var recorder = RecordReplayRunner.Record("fixture.json", new JobRunner());
// ... drive recorder as an IProcessRunner ...
recorder.Save();
// Replay later with no subprocess (an unmatched call is ProcessError.CassetteMiss):
var loaded = RecordReplayRunner.Replay("fixture.json");
if (loaded.IsOk)
{
var replay = loaded.ResultValue; // use `replay` as an IProcessRunner
}
else
Console.Error.WriteLine(loaded.ErrorValue.Message);
Cassettes also cover the byte[] capture and streaming (SpawnAsync) verbs,
RecordReplayRunner.Auto grows a cassette by recording on a miss, and
RecordReplayOptions adds arg-normalizer / redaction / file-content-stdin matching —
see testing.md → Record and replay.
Running commands
Command is the entry point of the runner layer: an immutable builder that
describes what to run and how, plus a family of consuming verbs that decide
what you get back. Every one-shot verb spawns the child into a fresh, private
kill-on-dispose process group, so an early return, an
exception, or a dropped task can never leak a process tree.
Two equivalent surfaces build the same value: the pipe-friendly module functions
(Command.create "git" |> Command.arg "log", camelCase) and the instance methods
((Command "git").Arg "log", PascalCase). They mirror each other one-for-one;
pick whichever reads better. The consuming verbs (RunAsync, OutputStringAsync, …) are
instance methods that return Task<Result<_, ProcessError>>, so the F# samples
below run inside a task { } block and use match!. Where a snippet writes
let! r = cmd.Verb(), r is the Result<_, ProcessError> you then match. From
C# the same surface is await-able fluent methods. Samples assume
open ProcessKit and open System.
- Program, arguments, working directory
- Environment
- Standard input
- Output handling
- Timeouts and retries
- Spawn flags
- Consuming verbs
- Results
- Errors
Program, arguments, working directory
F#
task {
let cmd =
Command.create "git"
|> Command.arg "log" // one at a time…
|> Command.args [ "--oneline"; "-n"; "10" ] // …or in bulk
|> Command.currentDir "/path/to/repo" // run there
match! cmd.RunAsync() with
| Ok out -> printfn $"{out}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd =
new Command("git")
.Arg("log") // one at a time…
.Args(["--oneline", "-n", "10"]) // …or in bulk
.CurrentDir("/path/to/repo"); // run there
Console.WriteLine(await cmd.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => output,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The same chain in method style — identical from C#:
F#
let cmd =
(Command "git")
.Arg("log")
.Args([ "--oneline"; "-n"; "10" ])
.CurrentDir("/path/to/repo")
C#
var cmd =
new Command("git")
.Arg("log")
.Args(["--oneline", "-n", "10"])
.CurrentDir("/path/to/repo");
Arguments are passed as a list — there is no shell between you and the child,
so there is no quoting, no word-splitting, and no injection surface. (When you
actually want a | b | c, use a pipeline, which connects the
stages in-process instead of invoking a shell.)
The program name reaches the OS verbatim: a bare name is resolved on PATH by
the OS, and setting a working directory does not re-anchor a relative
program path against it (a relative path resolves against the current platform's
rules — on Windows the parent's directory may win). Pass an absolute program
path when you combine a relative tool with currentDir.
Preflight: is a program installed?
Exec.which resolves a program to a full path without running it — a
doctor/install-wizard check ("is git even installed?") that's cheaper and
side-effect-free next to probing availability by actually launching the program
(ProbeAsync, which needs a harmless invocation to make up). It reuses the exact
PATH/PATHEXT-aware lookup the spawn path itself falls back on to name the
directories it searched, so which and an actual spawn of the same program name
never disagree on found-vs-not-found.
F#
match Exec.which "git" with
| Ok path -> printfn $"found at {path}"
| Error(ProcessError.NotFound(program, Some searched)) -> eprintfn $"'{program}' not on PATH ({searched})"
| Error err -> eprintfn $"{err.Message}"
C#
Console.WriteLine(Exec.which("git") switch
{
{ IsOk: true, ResultValue: var path } => $"found at {path}",
{ IsOk: false, ErrorValue: ProcessError.NotFound { Searched.Value: var searched } } => $"not on PATH ({searched})",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
CliClient.EnsureAvailableAsync() is the same check for a CliClient
wrapper, resolving the client's own program name. It is always a local check —
never delegated to the client's Runner — since availability is a fact about the
host's PATH/filesystem, not about how a command eventually runs; a test double
injected via WithRunner has no bearing on the result.
For one-liners the top-level helpers skip the builder entirely:
F#
task {
let! version = Exec.run "dotnet" [ "--version" ] // trimmed stdout, success required
let! status = Exec.outputString "git" [ "status"; "-s" ] // full ProcessResult
()
}
C#
var version = await Exec.run("dotnet", ["--version"]); // trimmed stdout, success required
var status = await Exec.outputString("git", ["status", "-s"]); // full ProcessResult
Environment
Three builders compose and are applied at spawn:
F#
task {
// Set one variable, unset one inherited variable.
let! _ =
(Command.create "worker"
|> Command.env "DOTNET_ENVIRONMENT" "Production"
|> Command.envRemove "GIT_DIR")
.RunAsync()
// Scorched earth: the child starts with an empty environment.
let! _ = (Command.create "hermetic-tool" |> Command.envClear).RunAsync()
()
}
C#
// Set one variable, unset one inherited variable.
await new Command("worker")
.Env("DOTNET_ENVIRONMENT", "Production")
.EnvRemove("GIT_DIR")
.RunAsync();
// Scorched earth: the child starts with an empty environment.
await new Command("hermetic-tool").EnvClear().RunAsync();
Env key valuesets a variable for the child.EnvRemove keydrops a variable the child would otherwise inherit.EnvClearstarts the child from an empty environment instead of inheriting the parent's; anyEnv/EnvRemoveyou add still apply on top.
There is no allow-list / inherit-subset mode. To run with a deliberately
minimal environment, EnvClear and then add back only what the child needs with
Env — that keeps the set explicit and visible at the call site. Environment
values are treated as secrets by the rest of the library: they are never logged
and never written to a record/replay cassette (only the variable names are).
Standard input
By default a child gets no standard input — it reads end-of-file at once and
can never hang waiting for input. Everything else is opt-in via Stdin:
| Source | Reusable on re-run? | Use for |
|---|---|---|
Stdin.Empty | n/a (no input) | The default, made explicit |
Stdin.FromString "…" | yes | Text payloads (encoded as UTF-8) |
Stdin.FromBytes bytes | yes | Binary payloads |
Stdin.FromFile path | yes (re-opened per run) | Large inputs streamed from disk |
Stdin.FromLines seq | one-shot | A sequence of lines, each written \n-terminated |
Stdin.FromStream stream | one-shot | Any readable Stream — a socket, a decompressor, … |
Stdin.FromAsyncLines asyncSeq | one-shot | An IAsyncEnumerable<string> — a channel, a tail, … |
F#
task {
let sorted =
Command.create "sort"
|> Command.stdin (Stdin.FromLines [ "banana"; "apple"; "cherry" ])
match! sorted.RunAsync() with
| Ok out -> printfn $"{out}" // apple / banana / cherry
| Error err -> eprintfn $"{err.Message}"
}
C#
var sorted =
new Command("sort")
.Stdin(Stdin.FromLines(["banana", "apple", "cherry"]));
Console.WriteLine(await sorted.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => output, // apple / banana / cherry
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The payload is written on a background task — so a large input can't deadlock
against the child's own output — and the pipe is closed (EOF) once the source is
exhausted, unless you also set KeepStdinOpen.
The two in-memory sources (FromString / FromBytes) and FromFile are
safe to send again: a retried command (or a record/replay match) re-sends the
identical bytes, and FromFile is re-opened each run. The three streaming
sources (FromLines / FromStream / FromAsyncLines) wrap a live stream or
sequence that the first run drains, so they are one-shot — prefer a reusable
source whenever a command may run more than once (under Retry
or record/replay).
Inheriting the parent's standard input (InheritStdin)
Command.InheritStdin hands the child the parent process's own standard
input directly — inherited at the OS level, with no pipe and no feeder. It is
the stdin analogue of StdioMode.Inherit for stdout/stderr, and it is what an
interactive/console program needs: an editor launched by git commit, a tool
that prompts the user on the terminal, or a straight pipe from the parent's own
stdin. The native spawn wires the child's stdin to the parent's real standard
input (a duplicated STD_INPUT_HANDLE on Windows, an inherited fd 0 on POSIX)
rather than creating a pipe.
// Let `git commit` open the user's editor on the parent's terminal.
let commit = Command.create "git" |> Command.args [ "commit" ] |> Command.inheritStdin
Because there is no stdin pipe under inherit, it is incompatible with the
pipe-based stdin knobs and rejects them at the builder boundary (an
ArgumentException, in either chaining order): a feeder source (Stdin) and
KeepStdinOpen. For the same reason RunningProcess.TakeStdin returns None
for an inherited-stdin child — there is no interactive pipe to hand out. The
capture and streaming verbs are unaffected; only the child's stdin wiring
changes. Inherit is repeatable: a Retry or a
supervisor restart simply re-inherits the parent's stdin, so it is never refused
by the one-shot-source retry guard, and a record/replay cassette
keys it by a stable "inherit" marker (distinct from a no-stdin command).
For conversational, request/response stdin — write a line, read the answer,
repeat — use KeepStdinOpen with the streaming API instead: see
Streaming & interactive I/O.
Output handling
Stream modes
Each stream is connected through a StdioMode, set with Command.Stdout /
Command.Stderr. The default is StdioMode.Piped — required for capture, line
streaming, and per-line handlers to see anything. StdioMode.Inherit lets the
child share the parent's stream (its output goes straight to your terminal and
can't be captured); StdioMode.Null discards the stream without tying up a pipe.
Merging stderr into stdout (2>&1)
Command.MergeStderr folds the child's standard error into its standard output
at the OS level — the library equivalent of a shell 2>&1. The native spawn
points the child's stderr at the very same pipe/handle as its stdout (a POSIX
dup2 of fd 2 onto stdout's target; on Windows one handle shared across
STARTUPINFO.hStdOutput/hStdError), so the two streams interleave honestly,
byte for byte on the single stdout stream — the real terminal-order view. This
is the "log exactly as the terminal shows it" case, and it is what
ProcessResult.Combined (a post-hoc concatenation of the two separately
captured streams — stdout, then stderr) cannot give you: Combined never
reproduces the true interleaving, MergeStderr does.
F#
task {
let cmd = Command.create "noisy-build" |> Command.mergeStderr
match! cmd.OutputStringAsync() with
| Ok result -> printfn $"{result.Stdout}" // stdout + stderr, in real order
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd = new Command("noisy-build").MergeStderr();
Console.WriteLine(await cmd.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => result.Stdout,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
When merging is on there is no separate stderr stream, and the API says so
rather than downgrading silently: ProcessResult.Stderr is empty, the streamed
OutputEventsAsync emits only OutputEvent.Stdout events (the stderr lines are
already interleaved into the stdout byte stream), and the separate-stderr
observation knobs are rejected in combination — StderrTee and OnStderrLine
throw ArgumentException alongside MergeStderr, in either chaining order.
The remaining stderr knobs are no-ops under merge: the merged bytes follow
stdout's settings, so StderrEncoding gives way to StdoutEncoding,
StderrLineTerminator to StdoutLineTerminator, and the Stderr StdioMode to
stdout's destination. Inside a pipeline MergeStderr is allowed
only on the last stage.
Encodings
Captured output is decoded line by line, UTF-8 by default. Invalid bytes
become the replacement character U+FFFD rather than raising an error. Override
per stream with StdoutEncoding / StderrEncoding, or both at once with
Encoding — each takes a System.Text.Encoding:
F#
task {
let cmd =
Command.create "legacy-tool"
|> Command.encoding System.Text.Encoding.Latin1 // both streams…
// |> Command.stdoutEncoding enc / |> Command.stderrEncoding enc // …or each its own
match! cmd.OutputStringAsync() with
| Ok result -> printfn $"{result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd =
new Command("legacy-tool")
.Encoding(System.Text.Encoding.Latin1); // both streams…
// .StdoutEncoding(enc) / .StderrEncoding(enc) // …or each its own
Console.WriteLine(await cmd.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => result.Stdout,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
A single persistent decoder runs over the whole stream, so a multi-byte sequence
that straddles two reads still decodes correctly and a 0x0A byte inside a wider
code unit isn't mistaken for a line break. The decoder is finalized at EOF, so an
incomplete trailing sequence follows the encoding's configured decoder fallback:
the default emits U+FFFD, while DecoderExceptionFallback raises its decoding
exception. A leading byte-order mark of the chosen
encoding is stripped once, from the decoded text only — OutputBytesAsync and the
raw tee stay byte-exact.
Buffer policies — bounding memory on chatty children
Captured lines are held in memory; a multi-gigabyte log would otherwise grow the
buffer to match. OutputBuffer bounds retention — the pipe is always fully
drained, so the child never blocks — and the line counters keep counting every
line, so a count larger than what you got back reveals that lines were dropped
(and ProcessResult.Truncated is set):
F#
// Keep the newest 1000 lines (a rolling tail; the default overflow is DropOldest):
let tail =
Command.create "verbose-build"
|> Command.outputBuffer (OutputBufferPolicy.Bounded 1000)
// …or freeze the head instead, keeping the first lines and dropping new ones:
let head =
Command.create "verbose-build"
|> Command.outputBuffer ((OutputBufferPolicy.Bounded 1000).WithOverflow OverflowMode.DropNewest)
C#
// Keep the newest 1000 lines (a rolling tail; the default overflow is DropOldest):
var tail =
new Command("verbose-build")
.OutputBuffer(OutputBufferPolicy.Bounded(1000));
// …or freeze the head instead, keeping the first lines and dropping new ones:
var head =
new Command("verbose-build")
.OutputBuffer(OutputBufferPolicy.Bounded(1000).WithOverflow(OverflowMode.DropNewest));
OverflowMode.DropOldest (the default) keeps a rolling tail; DropNewest freezes
the head; OverflowMode.Error makes the ceiling fail loud instead of dropping.
OutputBufferPolicy.Bounded 0 retains nothing — useful when a
line handler is the real consumer. Unbounded
(the Default) retains everything.
A line cap alone doesn't bound memory — without a byte cap an enormous newline-free
"line" grows whole. WithMaxBytes caps the retained bytes and the in-flight
(not-yet-terminated) line — force-flushed at the cap — so even a newline-free flood
stays bounded (set either ceiling, or both). This also covers the opposite shape: an
unbounded flood of empty lines (bare newlines). Each retained line counts its own
UTF-8 bytes plus one byte for the \n separator the reassembled text needs, so
even an empty line (0 content bytes) still costs 1 toward the cap — MaxBytes
alone (no MaxLines) genuinely bounds an empty-line flood too, not just a
newline-free one:
F#
// An 8 MiB retained-byte ring on an otherwise unbounded buffer:
let ring =
Command.create "flood"
|> Command.outputBuffer (OutputBufferPolicy.Unbounded.WithMaxBytes(8 * 1024 * 1024))
// Error if either ceiling is crossed:
let strict =
Command.create "flood"
|> Command.outputBuffer ((OutputBufferPolicy.FailLoud 10000).WithMaxBytes(8 * 1024 * 1024))
C#
// An 8 MiB retained-byte ring on an otherwise unbounded buffer:
var ring =
new Command("flood")
.OutputBuffer(OutputBufferPolicy.Unbounded.WithMaxBytes(8 * 1024 * 1024));
// Error if either ceiling is crossed:
var strict =
new Command("flood")
.OutputBuffer(OutputBufferPolicy.FailLoud(10000).WithMaxBytes(8 * 1024 * 1024));
FailLoud (and any policy with OverflowMode.Error) fails the run with
ProcessError.OutputTooLarge once the cumulative output crosses the line or byte
cap — even while a streaming consumer is draining lines as they arrive. It bounds
memory, not wall-time, so pair it with a Timeout
against a flooding child.
Raw byte captures obey the byte cap too
OutputBytesAsync captures stdout as raw bytes with no line structure, so only the
byte side of the policy applies to it — MaxLines is meaningless there and is
ignored. MaxBytes = Some cap enforces the cap per Overflow: Error returns
ProcessError.OutputTooLarge once the cumulative stdout exceeds cap (the pipe is
still drained, so the child never blocks), DropOldest keeps the last cap
bytes, and DropNewest keeps the first cap bytes — the dropping modes set
ProcessResult.Truncated. MaxBytes = None (the default) leaves the raw stdout
capture unbounded, exactly as before. ProcessResult.Truncated on a byte
capture reflects truncation of stdout or stderr, and OutputTooLarge fires if
either stream trips its fail-loud ceiling. Unlike the line-based path above, a raw
byte capture has no per-line separator surcharge — cap is the literal byte count,
since there is no line structure to reassemble.
// Keep the last 1 MiB of a binary stream; anything earlier is dropped, Truncated is set:
let tail =
Command.create "produce-archive"
|> Command.outputBuffer (OutputBufferPolicy.Unbounded.WithMaxBytes(1024 * 1024))
// …or refuse to buffer more than 1 MiB at all:
let strict =
Command.create "produce-archive"
|> Command.outputBuffer ((OutputBufferPolicy.Unbounded.WithMaxBytes(1024 * 1024)).WithOverflow OverflowMode.Error)
A pipeline captures its last stage's stdout as raw bytes, so the same
byte cap + overflow of that last stage's OutputBuffer bound the pipeline's
captured output (its MaxLines, and every intermediate stage's policy, do not apply).
This is a deliberate divergence from the Rust
ProcessKit-rsreference, whoseoutput_bytesbounds raw bytes only byTimeout, not by the buffer policy. The port applies the byte cap honestly so that a caller who setMaxBytes/FailLoudto bound memory is not handed an unbounded stdout buffer.
Line handlers and tees
OnStdoutLine / OnStderrLine run a callback on each decoded line in addition
to capture or streaming — logging, progress bars, metrics. The callback runs
synchronously on the read pump as each line arrives, so keep it cheap:
F#
task {
let cmd =
Command.create "dotnet"
|> Command.args [ "build"; "-c"; "Release" ]
|> Command.onStderrLine (fun line -> eprintfn $"[build] {line}")
match! cmd.OutputStringAsync() with
| Ok result -> printfn $"build exited {result.Code}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd =
new Command("dotnet")
.Args(["build", "-c", "Release"])
.OnStderrLine(line => Console.Error.WriteLine($"[build] {line}"));
Console.WriteLine(await cmd.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => $"build exited {result.Code}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
For a ready-made copy to a System.IO.Stream sink — a file, a socket, anything —
reach for StdoutTee / StderrTee. Each tee copies the stream's raw bytes
to the sink as they are read (byte-exact: no decoding, no added newline), in
addition to capture, and runs independently of the line handlers — set both and
both fire:
F#
task {
use logFile = System.IO.File.Create "build.log"
let cmd =
Command.create "dotnet"
|> Command.args [ "build" ]
|> Command.stdoutTee logFile
let! _ = cmd.OutputStringAsync()
()
}
C#
using var logFile = System.IO.File.Create("build.log");
var cmd =
new Command("dotnet")
.Args(["build"])
.StdoutTee(logFile);
await cmd.OutputStringAsync();
Timeouts and retries
F#
task {
let cmd =
Command.create "flaky-network-tool"
|> Command.timeout (TimeSpan.FromSeconds 30.0) // kill the tree at the deadline
|> Command.retry 3 (TimeSpan.FromMilliseconds 200.0) ProcessError.isTransient
match! cmd.RunAsync() with
| Ok out -> printfn $"{out}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd =
new Command("flaky-network-tool")
.Timeout(TimeSpan.FromSeconds(30)) // kill the tree at the deadline
.Retry(3, TimeSpan.FromMilliseconds(200), err => err.IsTransient);
Console.WriteLine(await cmd.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => output,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Timeoutkills the whole process tree at the deadline. On the capturing verbs the expiry is captured (ProcessResult.IsTimedOut,Outcome.TimedOut); on the success-checking verbs it raisesProcessError.Timeout. The full decision table lives in Timeouts, retries & cancellation.TimeoutGracesoftens the kill: on timeout it terminates gracefully (SIGTERM), waits the grace window, then force-kills only if the child is still alive. On Windows this degrades to the atomic Job-object kill.Retryruns the command up tomaxAttemptstimes in total (the first run plus up tomaxAttempts - 1retries — soretry 3is one run and up to two retries, and0/1both mean a single run), waitingdelaybetween attempts, while your classifier returnstruefor the error (ProcessError.isTransientcovers spawn races and I/O blips). The classifier sees the typedProcessError; a cancelled token stops the loop.RetryNeverexplicitly disables retrying for this command — it always runs exactly once. This differs from simply never callingRetry: aCliClientbuilt withWithDefaults(fun c -> c.Retry(...))applies that defaultRetryto every command built from its template, andRetryNeveris the one way to opt a specific command out of an inherited default. CallingRetryagain afterRetryNeverin the same chain re-enables retrying — the last of the two wins, like any other builder call.
To tie a run to a CancellationToken, use CancelOn (or pass a token to any verb's
optional token parameter, cmd.RunAsync(ct)). A cancelled run is always an error
(ProcessError.Cancelled), never a captured outcome — see
Timeouts, retries & cancellation.
Spawn flags
F#
task {
// Windows: no console window flashes up from a GUI app (a harmless no-op elsewhere).
let! _ = (Command.create "helper" |> Command.createNoWindow).RunAsync()
()
}
C#
// Windows: no console window flashes up from a GUI app (a harmless no-op elsewhere).
await new Command("helper").CreateNoWindow().RunAsync();
CreateNoWindowruns a console child withCREATE_NO_WINDOWon Windows, so a tool spawned from a GUI app doesn't flash a console window. No effect on Unix.KeepStdinOpenkeeps the child's stdin pipe open after its source is exhausted (or with no source at all), so you can write to it interactively viaRunningProcess.TakeStdin— see Streaming & interactive I/O.
Unix privilege drop & session detach
Five Unix-only builders drop the child's privileges or detach its session, for running a helper as a less-privileged user (daemons, CI runners, sandboxes):
Uid(uid)/Gid(gid)run the child under a different user / group id (setuid/setgid).User(uid, gid)is the common pair, equal to.Gid(gid).Uid(uid).Groups(gids)sets the child's supplementary groups, replacing the inherited set — the third leg of a correct drop. A bareUid/Giddrop clears the parent's supplementary groups (so the child never keeps root's), so pass the target user's groups here to grant them back (itsdocker/video/admmembership), or[]to keep the cleared default. It rides the same helper as the uid/gid drop, so it is honoured only alongside aUidorGid— set on its own it fails the spawn withProcessError.Spawnrather than being silently ignored.Setsid()detaches the child into a new session (setsid()): its own session and process group, no controlling terminal.
task {
// Drop to uid/gid 1000, grant that user's docker+video groups, and detach into a new session
// (needs privilege to run as another user).
let worker =
Command.create "worker"
|> Command.user 1000 1000
|> Command.groups [ 998; 44 ]
|> Command.setsid
let! _ = worker.RunAsync()
()
}
// Drop to uid/gid 1000, grant that user's docker+video groups, and detach into a new session.
await new Command("worker").User(1000, 1000).Groups(new[] { 998, 44 }).Setsid().RunAsync();
Honest by construction — never a silent downgrade:
- On Windows (no equivalent) any of these fails the spawn with
ProcessError.Unsupported, exactly likeUmask. - A uid/gid drop the caller can't make fails with
ProcessError.Spawn, never a child that kept the parent's ids. The up-front check is deliberately root-only: dropping to another user is allowed only when the caller is root (euid == 0). A non-root caller is refused before the spawn — including one that holdsCAP_SETUID/CAP_SETGID(a rootless container / sandbox), which is conservatively declined rather than probed (setprivremains the real arbiter, so the guard stays a simple root gate rather than a partial reimplementation of the kernel's capability model). The drop appliessetgidbeforesetuid, so it composes into a correct drop, and by default clears the parent's supplementary groups; passGroups(gids)to set the child's supplementary groups explicitly instead. Groupsis meaningful only as part of a drop. It is applied by the samesetprivhelper asUid/Gid, so setting it without aUidorGidfails the spawn withProcessError.Spawn— never a child whose groups were silently left untouched. The gids are applied verbatim (numeric, no/etc/grouplookup), and a negative gid is rejected at the builder boundary withArgumentOutOfRangeException.- Containment is preserved under
Setsid. A new session still makes the child its own process-group leader, so the kill-on-drop group teardown reaches it. (The session detach replaces the group's defaultPOSIX_SPAWN_SETPGROUPfor that one command; it is never combined with it.)
Because posix_spawn has no uid/gid attribute (and forking a managed .NET runtime
to drop privileges in the child is unsafe), a command requesting Uid/Gid is
rewritten to run through the setpriv helper (util-linux): it sets the gid/uid
and either clears the supplementary groups (--clear-groups, the default) or sets the
Groups(gids) you asked for (--groups), then execs the real program in place
(same pid, so containment is unchanged). setpriv ships on mainstream Linux; where it
is absent (macOS/BSD) a Uid/Gid/Groups drop fails with a typed
ProcessError.Spawn naming the missing helper. Setsid alone needs no helper (it is a
native posix_spawn attribute).
ProcessKit wires pipes, not a pseudo-terminal, so a tool that demands a tty
— an ssh / sudo password prompt, some credential helpers — won't get one.
Drive such tools non-interactively instead (key-based auth, ssh -o BatchMode=yes,
GIT_TERMINAL_PROMPT=0), or feed a known answer over
interactive stdin.
Consuming verbs
The builder describes the run; the verb you finish with decides what you get back.
Every verb returns Task<Result<_, ProcessError>>, and every verb takes an optional
CancellationToken (omit it, or pass one: cmd.RunAsync() / cmd.RunAsync(ct)).
| Verb | Ok payload | Non-zero exit | Use when |
|---|---|---|---|
OutputStringAsync() | ProcessResult<string> | captured (data) | You want to inspect the outcome yourself |
OutputBytesAsync() | ProcessResult<byte[]> | captured (data) | Binary stdout (images, archives, …) |
RunAsync() | trimmed string | ProcessError.Exit | "Give me the answer or fail" |
RunUnitAsync() | unit | ProcessError.Exit | You only care that it succeeded |
ExitCodeAsync() | int | the code, as Ok | The code is the answer |
ProbeAsync() | bool | 0→true, 1→false, else error | Predicate commands: git diff --quiet, grep -q |
ParseAsync(f) / TryParseAsync(f) | 'T | ProcessError.Exit | A typed value from stdout (success required) |
OutputJsonAsync<'T>() | 'T | ProcessError.Exit | Deserialize stdout as JSON (success required) |
FirstLineAsync(p) | string option | — (stream-based) | Grab one matching line, kill the rest |
StartAsync() | RunningProcess | — | A live handle: streaming, stdin, probes |
RunAsync returns stdout with trailing whitespace trimmed. ExitCodeAsync hands back a
non-zero exit as Ok data, but a signal kill or timeout errors rather than
inventing a sentinel like -1. ProbeAsync errors on any exit other than 0 or 1.
ParseAsync maps the trimmed stdout through f (a thrown parser becomes
ProcessError.Parse); TryParseAsync takes the standard .NET try-parse shape —
pass a bool TryX(string, out 'T) such as int.TryParse, with an explicit type
argument (TryParseAsync<int>(int.TryParse), since the BCL parsers are overloaded) —
and turns a false return into ProcessError.Parse. (From F#, Runner.tryParse keeps the
Result<'T, string>-returning shape, so the parser can supply its own error message.)
OutputJsonAsync<'T> is ParseAsync specialized to JSON: it deserializes the trimmed stdout via
System.Text.Json, takes an optional JsonSerializerOptions overload, and turns invalid JSON into
ProcessError.Parse exactly like a rejecting ParseAsync — give it an explicit type argument
(OutputJsonAsync<MyRecord>()), since there is no parser argument to infer 'T from. Mark an F#
record [<CLIMutable>] for the classic default-constructor-plus-settable-properties shape, or pass
options with PropertyNameCaseInsensitive = true — otherwise STJ's constructor-based
deserialization matches JSON property names to the record's constructor parameter names
case-sensitively.
FirstLineAsync returns the first
stdout line matching the predicate and kills the (private-group) child the moment
it has its answer — you never wait out a long log for one line — and returns
Ok None when stdout closes without a match.
F#
task {
// Probe: the exit code as a yes/no.
match! (Command.create "git" |> Command.args [ "diff"; "--quiet" ]).ProbeAsync() with
| Ok true -> printfn "working tree clean"
| Ok false -> printfn "there are changes"
| Error err -> eprintfn $"{err.Message}"
// Parse: a typed value from stdout.
let! version = (Command.create "node" |> Command.arg "--version").ParseAsync(fun s -> s.TrimStart('v'))
// OutputJson: deserialize stdout as JSON into a typed value (`Widget` here is
// `type Widget = { Name: string; Count: int }`; its JSON keys match the record's field names).
let! widget = (Command.create "widget-cli" |> Command.arg "get").OutputJsonAsync<Widget>()
// FirstLine: stop as soon as the interesting line appears.
match! (Command.create "git" |> Command.args [ "log"; "--oneline" ]).FirstLineAsync(fun l -> l.Contains "fix:") with
| Ok(Some line) -> printfn $"{line}"
| Ok None -> printfn "no fix commit"
| Error err -> eprintfn $"{err.Message}"
}
C#
// Probe: the exit code as a yes/no.
Console.WriteLine(await new Command("git").Args(["diff", "--quiet"]).ProbeAsync() switch
{
{ IsOk: true, ResultValue: true } => "working tree clean",
{ IsOk: true, ResultValue: false } => "there are changes",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
// Parse: a typed value from stdout.
var version = await new Command("node").Arg("--version").ParseAsync(s => s.TrimStart('v'));
// OutputJson: deserialize stdout as JSON into a typed value (`Widget` is a
// `record Widget(string Name, int Count)` here; its JSON keys match the record's properties).
var widget = await new Command("widget-cli").Arg("get").OutputJsonAsync<Widget>();
// FirstLine: stop as soon as the interesting line appears.
Console.WriteLine(await new Command("git").Args(["log", "--oneline"]).FirstLineAsync(l => l.Contains("fix:")) switch
{
{ IsOk: true, ResultValue: { Value: var line } } => line, // Some(line)
{ IsOk: true } => "no fix commit", // None
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The same vocabulary repeats on every layer. To run a verb through a specific
IProcessRunner — the dependency-injection and test seam — go through the
Runner module (Runner.run runner CancellationToken.None cmd); the verbs also
exist on CliClient, Pipeline, and as the
Exec.* one-liners.
Results
The capturing verbs (OutputStringAsync / OutputBytesAsync) hand back a
ProcessResult<'T> — a non-zero exit is data here, not an error:
F#
task {
match! (Command.create "git" |> Command.args [ "merge"; "feature" ]).OutputStringAsync() with
| Ok result ->
printfn $"code={result.Code} success={result.IsSuccess} timedOut={result.IsTimedOut}"
printfn $"took {result.Duration}, truncated={result.Truncated}"
// Opt into erroring whenever you're ready:
match ProcessResult.ensureSuccess result with
| Ok ok -> printfn $"{ok.Stdout}"
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
C#
if ((await new Command("git").Args(["merge", "feature"]).OutputStringAsync()).TryGetValue(out var result, out var runErr))
{
Console.WriteLine($"code={result.Code} success={result.IsSuccess} timedOut={result.IsTimedOut}");
Console.WriteLine($"took {result.Duration}, truncated={result.Truncated}");
// Opt into erroring whenever you're ready:
Console.WriteLine((result.EnsureSuccess()) switch
{
{ IsOk: true, ResultValue: var ok } => ok.Stdout,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
}
else
Console.Error.WriteLine(runErr.Message);
The accessors:
| Member | Meaning |
|---|---|
Stdout | Captured stdout — string (text verbs) or byte[] (bytes verbs); carries the merged stdout+stderr under MergeStderr |
Stderr | Captured stderr, as decoded text (empty under MergeStderr — the stderr is merged into Stdout) |
Code | The exit code, or None for a signal kill / timeout |
Signal | The terminating signal number (Unix), else None |
IsSuccess | The code is in AcceptedCodes ({0} by default) |
IsTimedOut | The run's own deadline expired |
Outcome | The three-way enum behind the accessors above |
Duration | Wall-clock duration of the run |
Truncated | A buffer policy dropped output |
AcceptedCodes | The exit codes treated as success — OkCodes ({0} by default) |
Combined | Stdout and stderr joined (stdout, then stderr on a new line when both are non-empty) — a post-hoc concatenation, not the real interleaving; use MergeStderr for a byte-exact 2>&1 |
OutputContainsAny(needles) | Case-insensitive search of both streams — for the "a known marker makes a non-zero exit benign" idiom below |
ProcessResult.ensureSuccess (or the instance result.EnsureSuccess()) converts a
ProcessResult<'T> — text or bytes — into a Result: the result unchanged on success,
otherwise the matching ProcessError (Exit / Signalled / Timeout).
Accepting non-zero exits
Some tools use a non-zero exit as information (grep returns 1 for "no match").
Tell ProcessKit which codes count as success with OkCodes:
F#
task {
let grep =
Command.create "grep"
|> Command.args [ "ERROR"; "app.log" ]
|> Command.okCodes [ 0; 1 ] // 1 ("no match") is success, not failure
match! grep.RunAsync() with
| Ok output -> printfn $"matches:\n{output}"
| Error err -> eprintfn $"{err.Message}" // a real failure (e.g. exit 2)
}
C#
var grep =
new Command("grep")
.Args(["ERROR", "app.log"])
.OkCodes([0, 1]); // 1 ("no match") is success, not failure
Console.WriteLine(await grep.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => $"matches:\n{output}",
{ IsOk: false, ErrorValue: var err } => err.Message, // a real failure (e.g. exit 2)
});
OkCodes sets which exit codes ProcessResult.IsSuccess, ensureSuccess, and
RunAsync / RunUnitAsync accept. The codes replace the default rather than adding to it, so
include 0 if you still want it (as [ 0; 1 ] above does); an empty set is a no-op that
keeps the previously configured codes (it never clears them).
The Outcome enum
When the distinction matters, match on Outcome instead of decoding the
Code / IsTimedOut pair. There are four cases — the fourth, Unobserved, is
the rare honest fallback for a process that concluded but whose actual exit
status could not be observed (a native API failure, or an unresolved POSIX
reap race); it is never a stand-in for a clean exit, and (like Signalled /
TimedOut) never counts as success:
F#
match result.Outcome with
| Outcome.Exited 0 -> printfn "clean"
| Outcome.Exited code -> printfn $"failed with {code}"
| Outcome.Signalled signal -> printfn $"killed by signal {signal}"
| Outcome.TimedOut -> printfn "hit its deadline"
| Outcome.Unobserved reason -> printfn $"exit status unknown: {reason}"
C#
Console.WriteLine(result.Outcome switch
{
{ IsExited: true, Code.Value: 0 } => "clean",
{ IsExited: true, Code.Value: var code } => $"failed with {code}",
{ IsSignalled: true, Signal.Value: var signal } => $"killed by signal {signal}",
{ IsSignalled: true } => "killed by an unknown signal",
{ IsUnobserved: true } => "exit status unknown",
_ => "hit its deadline", // TimedOut
});
Outcome carries the same Code / Signal / IsTimedOut accessors as
ProcessResult, so a bare Outcome (from RunningProcess.Wait or
Finished.Outcome) answers directly. There is no success accessor on Outcome —
success is OkCodes-aware, so use ProcessResult.IsSuccess.
Errors
ProcessError is a discriminated union: pattern-match it, read .Message for a
one-line description (it is also the ToString()), or use the classifiers. The
capturing verbs only error on a failure to run (spawn / not-found / I/O /
timeout / cancellation) — never on a non-zero exit; the success-checking verbs
(RunAsync / RunUnitAsync / ParseAsync / TryParseAsync) additionally turn a non-zero exit into
ProcessError.Exit.
F#
task {
match! (Command.create "deploy").RunAsync() with
| Ok out -> printfn $"{out}"
| Error(ProcessError.NotFound(program, _)) -> eprintfn $"not installed: {program}"
| Error(ProcessError.Exit(program, code, _, stderr)) -> eprintfn $"{program} exited {code}: {stderr}"
| Error(ProcessError.Timeout(program, t, _, _)) -> eprintfn $"{program} timed out after {t}"
| Error err -> eprintfn $"{err.Message}"
}
C#
Console.WriteLine(await new Command("deploy").RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => output,
{ IsOk: false, ErrorValue: ProcessError.NotFound n } => $"not installed: {n.Program}",
{ IsOk: false, ErrorValue: ProcessError.Exit e } => $"{e.Program} exited {e.Code}: {e.Stderr}",
{ IsOk: false, ErrorValue: ProcessError.Timeout t } => $"{t.Program} timed out after {t.Timeout}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
| Variant | Fields | Meaning |
|---|---|---|
ProcessError.Spawn | program, detail | The program was located but the OS couldn't start it (permissions, a bad working directory, a Windows .cmd/.bat needing cmd.exe, …). Not isNotFound. |
ProcessError.NotFound | program, Searched: string option | The program couldn't be located (isNotFound is true); searched is the probed path when known. |
ProcessError.Exit | program, code, stdout, stderr | A success-requiring verb saw a non-zero exit; both streams attached in full. |
ProcessError.Signalled | program, signal: int option, stdout, stderr | Killed by a signal with no exit code; signal carries the number on Unix, None elsewhere; the partial streams captured before the kill are attached. |
ProcessError.Timeout | program, timeout, stdout, stderr | The run's own deadline killed it; whatever it captured before the kill is attached. |
ProcessError.NotReady | program, timeout | A readiness probe gave up — distinct from a timeout. |
ProcessError.Parse | program, detail | A ParseAsync / TryParseAsync parser rejected the output, or OutputJsonAsync<'T> couldn't deserialize it as valid JSON. |
ProcessError.OutputTooLarge | program, lineLimit, byteLimit, totalLines, totalBytes | A FailLoud (OverflowMode.Error) buffer ceiling was exceeded. |
ProcessError.Stdin | program, detail | The child's stdin source could not be read — a missing/unreadable FromFile path, say — on an otherwise-successful run. A routine broken pipe (the child closed stdin early, as head does) is never reported, and a louder exit/signal/timeout failure wins instead. Also surfaces for a pipeline's first stage. |
ProcessError.CassetteMiss | program | A record/replay cassette found no matching recording — kept distinct from not-found, so isNotFound is false. |
ProcessError.Unsupported | operation | The platform can't do what was asked (e.g. a POSIX signal on Windows) and silently skipping would be wrong. |
ProcessError.Cancelled | program | The run's CancellationToken fired. Always an error. |
ProcessError.ResourceLimit | detail | A requested resource cap couldn't be enforced. |
ProcessError.Io | detail | A low-level I/O failure from ProcessKit's own machinery (driving a child, group control, cassette files). |
Two classifiers help with retry and diagnostic logic:
F#
match! cmd.RunAsync() with
| Ok _ -> ()
| Error err when ProcessError.isNotFound err -> installThenRetry () // NotFound only
| Error err when ProcessError.isTransient err -> scheduleRetry () // Spawn / Io blips
| Error err -> fail err
C#
switch (await cmd.RunAsync())
{
case { IsOk: true }:
break;
case { IsOk: false, ErrorValue: { IsNotFound: true } }: // NotFound only
installThenRetry();
break;
case { IsOk: false, ErrorValue: { IsTransient: true } }: // Spawn / Io blips
scheduleRetry();
break;
case { IsOk: false, ErrorValue: var err }:
fail(err);
break;
}
ProcessError.isNotFound is true only for NotFound; ProcessError.isTransient
is true for Spawn and Io — failures that may succeed on a retry. From C# these are
the instance forms err.IsNotFound and err.IsTransient.
To read a failure's fields without matching every case — the only practical way from C#, which
can't destructure an F# union — ProcessError exposes .Program, .Stdout, .Stderr,
.Combined, .Code, and .Signal, each an option/Option<T> populated for the cases that
carry that field (e.g. .Code is set only on Exit, .Stdout/.Stderr/.Combined on
Exit/Signalled/Timeout) and None elsewhere. The generated err.IsExit / IsSignalled /
IsTimeout / IsCancelled case testers pair with them.
Next: Streaming & interactive I/O · Timeouts, retries & cancellation · Process groups
Process groups
A ProcessGroup ties the lifetime of a whole child-process tree to a single
disposable value: every process you start into the group — and everything those
processes spawn — is killed when the group is disposed. An owner that returns
early, throws, or has its task dropped never leaks subprocesses, because the
kernel object behind the group (a Windows Job Object, a Linux cgroup v2,
or a POSIX process group) reaps even grandchildren you never knew existed.
That whole-tree containment is the reason this library exists:
System.Diagnostics.Process reaches the direct child at best, so a build tool's
compiler children, the real payload behind a cmd /c … / sh -c … wrapper, or a
test's helper servers can outlive a timeout or an exception as orphans.
You rarely create a group by hand for one-shot runs: every one-shot verb
(RunAsync, OutputStringAsync, …) already spawns into a fresh private group that dies
with the run. Reach for an explicit ProcessGroup when several children should
share one fate, or when you need the group-level verbs below — signals,
suspend/resume, member listing, resource limits, or stats.
- Creating a group
- Putting processes in
- Tearing down: dispose, terminate, shutdown
- Signals and suspend/resume
- Listing members
- Resource limits
- Stats
Creating a group
ProcessGroup.Create() builds an empty, unbounded group on the current platform.
It returns a Result<ProcessGroup, ProcessError> — match it, then bind the group
with use so it (and the tree it contains) is reaped on scope exit:
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"could not create a group: {err.Message}"
| Ok group ->
use group = group // disposes — and hard-kills the whole tree — on scope exit
// ... start children into `group` ...
()
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine($"could not create a group: {err.Message}");
return;
}
using var group = created.GetValueOrThrow(); // disposes — and hard-kills the whole tree — on scope exit
// ... start children into `group` ...
ProcessGroup.Create(options) takes a ProcessGroupOptions to tune the
graceful-shutdown window and apply whole-tree resource limits (see
Resource limits):
F#
let options = ProcessGroupOptions().WithShutdownTimeout(TimeSpan.FromSeconds 10.0)
match ProcessGroup.Create options with
| Ok group ->
use group = group
() // ...
| Error err -> eprintfn $"{err.Message}"
C#
var options = new ProcessGroupOptions().WithShutdownTimeout(TimeSpan.FromSeconds(10));
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow(); // ...
Two read-only properties report what you actually got. Options echoes back the
ProcessGroupOptions the group was created with (its ShutdownTimeout and
Limits); Mechanism reports the OS primitive containing the tree:
F#
match group.Mechanism with
| Mechanism.JobObject -> printfn "Windows Job Object"
| Mechanism.CgroupV2 -> printfn "Linux cgroup v2"
| Mechanism.ProcessGroup -> printfn "POSIX process group"
| _ -> ()
C#
Console.WriteLine(group.Mechanism switch
{
{ IsJobObject: true } => "Windows Job Object",
{ IsCgroupV2: true } => "Linux cgroup v2",
{ IsProcessGroup: true } => "POSIX process group",
_ => "unknown mechanism",
});
Which mechanism you get is not a free choice — it follows the platform and whether you asked for limits:
- Windows always uses a Job Object (
Mechanism.JobObject). - Linux uses a cgroup v2 (
Mechanism.CgroupV2) only when you request resource limits and the host can deliver them; for plain containment — and on any Linux host without delegated cgroup v2 — it uses a POSIX process group (Mechanism.ProcessGroup). - macOS / BSD always use a POSIX process group (
Mechanism.ProcessGroup).
Because the mechanism is reported rather than assumed, a weaker backend is never
a silent downgrade — you can branch on Mechanism if a capability matters. The
full per-OS matrix lives in platform-support.md.
Putting processes in
A ProcessGroup is itself an IProcessRunner, so the same run/capture
vocabulary you use on a Command works against the shared group — every child
lands in the one container.
The direct door is StartAsync(command), which returns a live RunningProcess (the
full streaming / stdin / readiness surface from
streaming.md). The key ownership rule: the group owns the
child's lifetime. Disposing the returned RunningProcess detaches only that
run's I/O; the child keeps running until you reap the whole tree
(ShutdownAsync / dispose) or kill just that run with its own Kill.
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"{err.Message}"
| Ok group ->
use group = group
match! group.StartAsync(Command.create "dev-server") with
| Ok server ->
// `server` streams/probes as usual, but the GROUP owns its lifetime.
let! _ready = server.WaitForLineAsync((fun l -> l.Contains "ready"), System.TimeSpan.FromSeconds 10.0)
()
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
var started = await group.StartAsync(new Command("dev-server"));
if (started is { IsOk: false, ErrorValue: var startErr })
{
Console.Error.WriteLine(startErr.Message);
return;
}
var server = started.GetValueOrThrow();
// `server` streams/probes as usual, but the GROUP owns its lifetime.
var ready = await server.WaitForLineAsync(l => l.Contains("ready"), TimeSpan.FromSeconds(10));
To capture a child to completion inside the shared group, drive the group
through the IProcessRunner verbs in the Runner module — they take the runner,
a CancellationToken, and the Command:
F#
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
match! Runner.outputString group CancellationToken.None (Command.create "probe-tool") with
| Ok result -> printfn $"exit={result.Code}: {result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
// `Runner.outputBytes` is the binary companion; `Runner.start` mirrors `group.StartAsync`.
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
Console.WriteLine(await Runner.outputString(group, CancellationToken.None, new Command("probe-tool")) switch
{
{ IsOk: true, ResultValue: var result } => $"exit={result.Code}: {result.Stdout}",
{ IsOk: false, ErrorValue: var runErr } => runErr.Message,
});
// `Runner.outputBytes` is the binary companion; `Runner.start` mirrors `group.StartAsync`.
Capture normalization, and a Windows caveat. A capture through a shared group goes through the same path as the default runner, so output encoding, line-ending normalization,
OkCodes, and theOutputBufferpolicy match exactly — aProcessGrouprunner is interchangeable with the default one. One platform caveat: on Windows a per-runTimeout/CancelOnhard-kills only the run's leader process (its descendants stay in the shared Job until the group is torn down). So if a descendant inherited the leader's stdout/stderr pipe and outlives it, the capture can stall past the deadline until that descendant exits or the group is disposed. POSIX kills the leader's whole process group, so it is unaffected. For a hard per-run deadline on Windows, give the run its own group (the default runner) rather than a shared one.
Because a group satisfies IProcessRunner, you can also hand it to anything that
accepts a runner so a whole fleet shares one kill-on-dispose container: pass it as
the runner to Exec.outputAll / Exec.outputAllBytes, or to
Supervisor.WithRunner so every restarted incarnation stays in the same group
(see supervision.md).
Tearing down: dispose, terminate, shutdown
There are three ways out, from blunt to graceful:
| Verb | What happens | When to use it |
|---|---|---|
dispose (use / Dispose() / DisposeAsync()) | Immediate hard kill of the whole tree, then releases the container | The safety net — always on, even on an exception or early return |
group.KillAll() | The same hard kill, but the group stays usable for further spawns; idempotent | Explicit teardown mid-flight when you want to keep the group |
group.ShutdownAsync() / group.ShutdownAsync(grace) | Graceful: on Unix SIGTERM → wait the grace window → SIGKILL survivors; on Windows the atomic Job kill. Releases the group | A clean service stop |
ProcessGroup implements both IDisposable and IAsyncDisposable, so a use
binding reaps the tree deterministically on scope exit — disposing is a pure hard
kill with no grace, which is exactly what you want as the guaranteed backstop.
For an orderly stop, prefer ShutdownAsync, which awaits a Task:
F#
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _service = group.StartAsync(Command.create "my-service")
// SIGTERM, give it 5s to flush and exit, then SIGKILL any straggler:
do! group.ShutdownAsync(TimeSpan.FromSeconds 5.0)
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
await group.StartAsync(new Command("my-service"));
// SIGTERM, give it 5s to flush and exit, then SIGKILL any straggler:
await group.ShutdownAsync(TimeSpan.FromSeconds(5));
ShutdownAsync() with no argument uses the group's configured
Options.ShutdownTimeout (the default is 2 seconds; set it with
WithShutdownTimeout). A child that handles SIGTERM and exits ends the grace
early — ShutdownAsync returns as soon as the tree is empty, not after the full
window. ShutdownAsync and dispose are idempotent with each other, so a use-bound
group you also ShutdownAsync explicitly is safe. Note that a suspended tree can
still be hard-killed (dispose / KillAll), but a graceful ShutdownAsync opens
with a SIGTERM a frozen tree cannot act on — Resume first for a clean stop
(see below).
Signals and suspend/resume
Beyond teardown, a group can broadcast a signal to every member, or freeze and
thaw the whole tree. All of these are synchronous and return
Result<unit, ProcessError>.
Signal(signal) delivers a portable Signal to every process in the group:
F#
let reload (group: ProcessGroup) =
match group.Signal Signal.Hup with // "reload your configuration"
| Ok () -> ()
| Error err -> eprintfn $"{err.Message}"
C#
void reload(ProcessGroup group)
{
if (group.Signal(Signal.Hup) is { IsOk: false, ErrorValue: var err }) // "reload your configuration"
Console.Error.WriteLine(err.Message);
}
The portable Signal values are Signal.Term, Signal.Kill, Signal.Int,
Signal.Hup, Signal.Quit, Signal.Usr1, Signal.Usr2, and the raw escape
hatch Signal.Other n for any other signal number.
| Platform | Deliverable signals |
|---|---|
| Linux (cgroup or process group), macOS / BSD | Any — Term, Kill, Int, Hup, Quit, Usr1, Usr2, Other n |
| Windows | Kill only (maps to the Job terminate); anything else → ProcessError.Unsupported |
Signal.Kill always takes the same atomic whole-tree kill path as
KillAll, so it can't miss a process forked mid-broadcast; other signals are
a best-effort per-member broadcast against a tree that may be forking at that
instant. An already-exited member is skipped, and an empty group accepts any
deliverable signal trivially. On Windows, a non-Kill signal fails fast:
F#
match group.Signal Signal.Hup with
| Ok () -> ()
| Error(ProcessError.Unsupported operation) -> eprintfn $"not on this platform: {operation}"
| Error err -> eprintfn $"{err.Message}"
C#
if (group.Signal(Signal.Hup) is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err switch
{
ProcessError.Unsupported { Operation: var op } => $"not on this platform: {op}",
_ => err.Message,
});
Suspend() freezes the whole tree (to snapshot it, to starve a runaway while you
investigate, or to pause background work) and Resume() thaws it:
F#
let pauseWhile (group: ProcessGroup) (inspect: unit -> unit) =
group.Suspend() |> ignore // the whole tree stops consuming CPU
inspect ()
group.Resume() |> ignore
C#
void pauseWhile(ProcessGroup group, Action inspect)
{
group.Suspend(); // the whole tree stops consuming CPU
inspect();
group.Resume();
}
Suspend/resume work wherever a container exists, but the machinery differs:
- Linux cgroup v2 — a single
cgroup.freezewrite; atomic over the subtree. - Linux process group, macOS / BSD — a
SIGSTOP/SIGCONTbroadcast; level-triggered, so it is idempotent. - Windows — a per-thread suspend walk over every member. Best-effort against
threads churning mid-walk, and counted: N
Suspendcalls need NResumecalls.
A practical rule: Resume before starting new work into the group, and Resume
before a graceful ShutdownAsync. See platform-support.md for
the caveats in full.
Listing members
Members() returns a point-in-time snapshot of the live member pids as an
IReadOnlyList<int>, wrapped in a Result:
F#
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _a = group.StartAsync(Command.create "worker-a")
let! _b = group.StartAsync(Command.create "worker-b")
match group.Members() with
| Ok pids -> printfn $"{pids.Count} live members: {pids}"
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
await group.StartAsync(new Command("worker-a"));
await group.StartAsync(new Command("worker-b"));
Console.WriteLine((group.Members()) switch
{
{ IsOk: true, ResultValue: var pids } => $"{pids.Count} live members: {string.Join(", ", pids)}",
{ IsOk: false, ErrorValue: var membersErr } => membersErr.Message,
});
What "members" means depends on the mechanism. On Windows (Job Object) and
the Linux cgroup v2 backend, Members() lists the whole tree — every
descendant pid. On the POSIX process-group backend it lists the tracked group
leaders only (one pid per started child); their descendants are still contained
and killed with the group, just not enumerated. An exited child still counts until
it is reaped, and because the snapshot is point-in-time, a tree that is actively
forking races it.
To wait on members rather than list them, race the started handles with
RunningProcess.WaitAny — see streaming.md.
Resource limits
Caps are a property of the group, set once at creation through
ProcessGroupOptions and enforced by the same kernel object that contains the
tree. The builder is fluent and immutable:
F#
task {
let options =
ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // bytes, whole tree (512 MiB)
.WithMaxProcesses(64) // fork-bomb ceiling
.WithCpuQuota(0.5) // half of one core
match ProcessGroup.Create options with
| Ok group ->
use group = group
let! _sandboxed = group.StartAsync(Command.create "untrusted-tool")
() // ... runs within the limited group ...
| Error err -> eprintfn $"limits unavailable: {err.Message}" // ProcessError.ResourceLimit
}
C#
var options = new ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // bytes, whole tree (512 MiB)
.WithMaxProcesses(64) // fork-bomb ceiling
.WithCpuQuota(0.5); // half of one core
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine($"limits unavailable: {err.Message}"); // ProcessError.ResourceLimit
return;
}
using var group = created.GetValueOrThrow();
await group.StartAsync(new Command("untrusted-tool")); // ... runs within the limited group ...
The three caps are:
WithMemoryMax(bytes)— a whole-tree memory ceiling, in bytes (int64).WithMaxProcesses(count)— the maximum number of processes the tree may hold.WithCpuQuota(cores)— CPU as a fraction of a single core (0.5= half a core,2.0= two cores). On Windows this is converted against the host's CPU count and is approximate (a rate cap, not an exact share); on Linux cgroup v2 it maps tocpu.max.
The configured caps are also readable back: group.Options.Limits is a
ResourceLimits whose MemoryMax (int64 option), MaxProcesses (int option),
and CpuQuota (float option) are Some only for the limits you set
(ResourceLimits.None is the empty set). You can build a ResourceLimits value
directly with the same WithMemoryMax / WithMaxProcesses / WithCpuQuota
methods if you want to inspect or compose limits before applying them.
Limits need a real container — a Windows Job Object or a Linux cgroup v2.
| Capability | Windows Job Object | Linux cgroup v2 | POSIX process group / macOS / BSD |
|---|---|---|---|
| Memory cap | ✅ whole-tree | ✅ whole-tree (memory.max) | ❌ |
| Process-count cap | ✅ | ✅ (pids.max) | ❌ |
| CPU quota | 🟡 approximate | ✅ (cpu.max) | ❌ |
Where a requested cap can't be enforced, Create fails fast with
ProcessError.ResourceLimit rather than handing back a silently-unbounded group —
so a limit is a guarantee, not a hint. That covers macOS / BSD and the Linux
process-group fallback (no whole-tree primitive at all), and a Linux host where
cgroup v2 isn't mounted. On Linux, enforcing limits also requires the process to
run at the real cgroup v2 root (cgroup v2's "no internal processes" rule lets
the controllers be enabled only there) — so an ordinary container or a
systemd-managed process fails too. The prerequisites are spelled out in
platform-support.md.
F#
match ProcessGroup.Create options with
| Ok group ->
use group = group
() // ...
| Error(ProcessError.ResourceLimit message) -> eprintfn $"cannot enforce limits here: {message}"
| Error err -> eprintfn $"{err.Message}"
C#
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err switch
{
ProcessError.ResourceLimit { Detail: var m } => $"cannot enforce limits here: {m}",
_ => err.Message,
});
return;
}
using var group = created.GetValueOrThrow(); // ...
Stats
Stats() returns a point-in-time ProcessGroupStats snapshot of the group's
resource usage, wrapped in a Result:
F#
match group.Stats() with
| Ok stats ->
printfn $"procs={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}"
| Error err -> eprintfn $"{err.Message}"
C#
Console.WriteLine((group.Stats()) switch
{
{ IsOk: true, ResultValue: var stats } => $"procs={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
ProcessGroupStats carries ActiveProcessCount (an int, always populated),
TotalCpuTime (TimeSpan option), and PeakMemoryBytes (int64 option). CPU
time and peak memory are available where the kernel accounts for the whole tree —
Windows (Job Object accounting) and the Linux cgroup v2 backend; on the
POSIX process-group backend only the live count is reported and the two
option fields stay None.
SampleStatsAsync(interval) turns the snapshot into a periodic series as an
IAsyncEnumerable<ProcessGroupStats> — the first sample immediately, then one per
interval:
F#
task {
let series = group.SampleStatsAsync(TimeSpan.FromSeconds 1.0)
let e = series.GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"rss now: {e.Current.PeakMemoryBytes}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
}
C#
await foreach (var s in group.SampleStatsAsync(TimeSpan.FromSeconds(1)))
Console.WriteLine($"rss now: {s.PeakMemoryBytes}");
From C# this is simply await foreach (var s in group.SampleStatsAsync(interval)). The
sampler is pull-based: it samples only as you pull the enumeration and runs no
background task, so it neither keeps the group alive nor leaks if you abandon it.
The series ends on the first snapshot the group can no longer report (notably once
the group has been torn down) or when the enumerator's token fires.
For a single run's end-to-end summary (exit code, duration, CPU, peak memory)
rather than a live group series, use RunningProcess.Profile — see
streaming.md.
Next: Streaming & interactive I/O · Platform support · Supervision
Streaming & interactive I/O
The one-shot verbs in Running commands buffer the whole output and
hand it back when the child exits. For a long-running or conversational child you
want the output as it arrives — and sometimes a back-channel to write to it.
Command.StartAsync() (and the equivalent IProcessRunner.Start / ProcessGroup.Start)
returns a live RunningProcess you drive yourself: stream stdout line by line,
interleave stdout and stderr, write stdin incrementally, wait for the child to
become ready, race several children, or profile a run end to end.
The samples below run inside a task { } block and use match!; the verbs that
return a value directly (WaitAsync, ProfileAsync, WaitAllAsync) use a plain let!. From C#
the same surface is await-able fluent methods, and the IAsyncEnumerable<_>
streams are await foreach.
- Lifecycle
- Streaming stdout line by line
- Interleaving stdout and stderr
- Bounding the streaming backlog
- Finishing a streamed run
- Interactive stdin
- Readiness probes
- Racing several children
- Profiling a run
Lifecycle
StartAsync() spawns the child and returns a RunningProcess without waiting for it to
exit. The handle is an IAsyncDisposable: a use binding inside task { } reaps
the whole process tree on scope exit, exactly like the disposal at the end of a
one-shot run.
F#
task {
match! (Command.create "dev-server").StartAsync() with
| Error err -> eprintfn $"could not start: {err.Message}"
| Ok proc ->
use _ = proc // disposing the handle kills the whole tree
printfn $"pid={proc.Pid} started {proc.StartTime:o}"
// ... drive the process: stream, write stdin, probe for readiness ...
printfn $"alive for {proc.Elapsed}; {proc.StdoutLineCount} stdout lines so far"
let! outcome = proc.WaitAsync() // Outcome: Exited code / Signalled sig / TimedOut
printfn $"exited: {outcome}"
}
C#
await using var proc = (await new Command("dev-server").StartAsync()).GetValueOrThrow(); // disposing the handle kills the whole tree
Console.WriteLine($"pid={proc.Pid} started {proc.StartTime:o}");
// ... drive the process: stream, write stdin, probe for readiness ...
Console.WriteLine($"alive for {proc.Elapsed}; {proc.StdoutLineCount} stdout lines so far");
var outcome = await proc.WaitAsync(); // Outcome: Exited code / Signalled sig / TimedOut
Console.WriteLine($"exited: {outcome}");
StartAsync() puts the child in a private group the handle owns: dropping the
RunningProcess kills the tree, grandchildren included. The shared-group
variant — group.StartAsync(cmd) — returns the same kind of handle, but the group
controls the tree's fate (see Process groups).
Consume the handle exactly one way — stdout is read once:
StdoutLinesAsync()/OutputEventsAsync()— stream output as it arrives (below).OutputStringAsync()/OutputBytesAsync()— capture everything, like the one-shot verbs.WaitAsync()— just theOutcome; output is discarded.FinishAsync()— after streaming stdout, collect theOutcomeand drained stderr.ProfileAsync()— capture plus periodic resource samples (profiling).
StdoutLinesAsync() / OutputEventsAsync() need a piped stdout, which is the default for
StartAsync(); if you set Command.Stdout to StdioMode.Inherit or StdioMode.Null
there is nothing to stream. The live gauges Pid, Elapsed, StartTime,
StdoutLineCount, and StderrLineCount are cheap to read at any time, including
mid-stream. There is also Kill() — "stop it now, I'll WaitAsync() for the
Outcome myself" — which begins teardown without blocking.
To stop a long-running child cleanly — let it flush logs, release locks, and run its
shutdown hooks — use StopAsync(gracePeriod) (or StopAsync() for a 2-second default,
matching ProcessGroupOptions.ShutdownTimeout). It sends the tree a soft signal (SIGTERM),
waits up to the grace window for it to exit on its own, then hard-kills whatever is still
alive, reaps the tree, and returns the honest Outcome — the same SIGTERM → grace → SIGKILL
escalation as Command.TimeoutGrace and
ProcessGroup.ShutdownAsync. It drains the child's output while it
shuts down and reuses an in-flight streaming/capturing session's wait, so it is safe to call
after StdoutLinesAsync()/OutputEventsAsync() or alongside FinishAsync/WaitAsync, and
is idempotent with Kill/Dispose. A soft signal needs a mechanism that has one: on
Windows (no per-tree graceful signal) and on a shared group from
group.StartAsync(cmd) (no per-child graceful signal) the grace is skipped and the child is
hard-killed at once — exactly as TimeoutGrace already degrades there. A handle from
StartAsync() (its own private group) gets the full graceful stop on Unix.
A command's Timeout and CancelOn token bound
the stream: at the deadline (or on cancellation) the tree is killed, the pipes
close, and the stream ends — a streamed run can't hang past its deadline. After a
cancelled run, FinishAsync() reports ProcessError.Cancelled.
Streaming stdout line by line
StdoutLinesAsync() returns an IAsyncEnumerable<string> that yields decoded lines as
the child produces them — no waiting for exit, no full-output buffering. In F#,
drive the enumerator directly:
F#
task {
match! (Command.create "git" |> Command.args [ "log"; "--oneline"; "-n"; "50" ]).StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
let e = proc.StdoutLinesAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"commit: {e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
}
C#
await using var proc = (await new Command("git").Args(["log", "--oneline", "-n", "50"]).StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine($"commit: {line}");
From C# the same loop is simply await foreach (var line in proc.StdoutLinesAsync()) { ... }.
While you stream stdout, stderr is drained in the background, so a noisy child can
never block on a full stderr pipe. The OnStdoutLine / OnStderrLine handlers and
the output buffer policy from Running commands still apply to a
streamed run — a handler sees each line on the pump, in addition to your loop.
Interleaving stdout and stderr
When the order of stdout relative to stderr matters — a build tool that prints
progress to one and diagnostics to the other — OutputEventsAsync() returns an
IAsyncEnumerable<OutputEvent> that merges both channels in arrival order. Each
OutputEvent carries IsStdout / IsStderr and the line Text:
F#
task {
match! (Command.create "dotnet" |> Command.args [ "build"; "-c"; "Release" ]).StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
let e = proc.OutputEventsAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true ->
let ev = e.Current
if ev.IsStdout then printfn $"out| {ev.Text}"
else eprintfn $"err| {ev.Text}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
}
C#
await using var proc = (await new Command("dotnet").Args(["build", "-c", "Release"]).StartAsync()).GetValueOrThrow();
await foreach (var ev in proc.OutputEventsAsync())
{
if (ev.IsStdout)
Console.WriteLine($"out| {ev.Text}");
else
Console.Error.WriteLine($"err| {ev.Text}");
}
From C#, await foreach (var ev in proc.OutputEventsAsync()) { ... }. Choose OutputEventsAsync()
or StdoutLinesAsync() for a given run — both consume stdout, so they are alternatives,
not companions.
OutputEventsAsync() tags each line with the stream it came from, keeping the two
channels distinguishable. When you instead want them merged into one stream — with the
real byte-for-byte interleaving preserved, but no origin tag — reach for
Command.MergeStderr (a shell 2>&1): the
child's stderr is folded into its stdout at the OS level, so StdoutLinesAsync() alone
yields every line in order and OutputEventsAsync() emits only Stdout events (there is
no longer a separate stderr stream to tag).
Bounding the streaming backlog
By default, the channel that feeds StdoutLinesAsync() / OutputEventsAsync() / WaitForLineAsync()
is unbounded: a producer far outrunning your consumer (a chatty child, a slow line handler) just
grows the in-flight backlog — exactly the behavior ProcessKit has always had. Command.StreamBuffer
opts in to a bounded channel instead, capping that backlog with one of four StreamFullModes:
Backpressure(the default forStreamBufferPolicy.Bounded(capacity)) — the pump stops draining the OS pipe once the channel is full, so the child itself observably blocks writing to a full stdout/stderr pipe until your consumer catches up. Bounds memory losslessly, at the cost of the child's timing — pick this for a trusted producer you genuinely want to pace against your consumer (tailing a log, a pipeline stage).DropOldest— "tail" semantics: once full, the oldest queued line is discarded to make room for the newest. Lossy but bounded.DropNewest— "head" semantics: once full, the incoming line is discarded and what's already queued is kept.Error— fail loud: once the cap is reached, the streaming enumerator throws (carryingProcessError.OutputTooLarge) instead of silently dropping anything.
Both DropOldest and DropNewest bump RunningProcess.DroppedStreamLineCount — a live counter (like
StdoutLineCount/StderrLineCount) so a lossy policy's drops are always visible, never silent:
F#
task {
let command =
(Command.create "chatty-tool")
.StreamBuffer(StreamBufferPolicy.Bounded(1000, StreamFullMode.DropOldest))
match! command.StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
let e = proc.StdoutLinesAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"{e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
if proc.DroppedStreamLineCount > 0 then
printfn $"dropped {proc.DroppedStreamLineCount} lines to stay within the bound"
}
C#
var command = new Command("chatty-tool")
.StreamBuffer(StreamBufferPolicy.Bounded(1000, StreamFullMode.DropOldest));
await using var proc = (await command.StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine(line);
if (proc.DroppedStreamLineCount > 0)
Console.WriteLine($"dropped {proc.DroppedStreamLineCount} lines to stay within the bound");
The backpressure deadlock footgun. StreamFullMode.Backpressure slows the child, not your code
— but if your consumption loop itself never resumes (it's stuck waiting on something that, in turn,
waits for the child to finish), the child can never finish either: it's blocked writing to a pipe
nobody is reading, forever. This is the same full-duplex hazard as the
interactive-stdin deadlock above, just on the read side instead of the write
side. Two things to know before opting in:
- A
Command.Timeoutkills the child at the deadline, but that alone does not free a writer your own pump is parked on if you also never read again — the child dying doesn't hand the pump anything new to write, but a pump already blocked inside aWriteAsynccall only unblocks when either the channel gets read from again or theRunningProcessitself is disposed. In other words: pairingBackpressurewithCommand.Timeoutbounds the child's lifetime, not necessarily your consumer's. - Give your own consumption loop a deadline (a
CancellationTokenpassed toGetAsyncEnumerator(token), or a read-side timeout around eachMoveNextAsync()), and make sure youDispose/DisposeAsynctheRunningProcesspromptly if you give up on it — disposal always unblocks a writer parked on backpressure, so the pump can wind down instead of leaking forever as an abandoned background task.
If you can't reason about your consumer always resuming, prefer DropOldest/DropNewest (never
blocks the child) or Error (fails loud instead of stalling) over Backpressure.
Finishing a streamed run
When the stream ends (stdout closed), collect the rest with FinishAsync(), which returns
Result<Finished, ProcessError>. Finished carries the Outcome and the Stderr
that was drained while you streamed:
F#
task {
match! (Command.create "build-everything").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
let e = proc.StdoutLinesAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"> {e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
match! proc.FinishAsync() with
| Ok finished ->
if finished.Outcome <> Outcome.Exited 0 then
eprintfn $"failed ({finished.Outcome}):\n{finished.Stderr}"
| Error err -> eprintfn $"{err.Message}"
}
C#
await using var proc = (await new Command("build-everything").StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine($"> {line}");
var finished = (await proc.FinishAsync()).GetValueOrThrow();
if (finished.Outcome is not { IsExited: true, Code.Value: 0 }) // anything but a clean exit 0
Console.Error.WriteLine($"failed ({finished.Outcome}):\n{finished.Stderr}");
Use FinishAsync() after you have streamed stdout. If you only need the exit status and
don't care about output, WaitAsync() returns the Outcome directly and discards the
captured output; if you skipped streaming altogether, OutputStringAsync() /
OutputBytesAsync() buffer and return everything just like the one-shot verbs.
Interactive stdin
Conversational tools — write a request, read the response, repeat. Keep stdin open
with KeepStdinOpen, then take the writer with TakeStdin(), which returns a
ProcessStdin option (Some once; None if stdin wasn't kept open or was already
taken):
F#
task {
// `bc` evaluates each stdin line and prints the result.
match! (Command.create "bc" |> Command.keepStdinOpen).StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
match proc.TakeStdin() with
| Some stdin ->
do! stdin.WriteLineAsync "2 + 2" // writes "2 + 2\n", flushed
do! stdin.WriteLineAsync "6 * 7"
do! stdin.FinishAsync() // send EOF so bc exits
| None -> ()
// ... then read proc.StdoutLinesAsync() for the answers.
()
}
C#
// `bc` evaluates each stdin line and prints the result.
await using var proc = (await new Command("bc").KeepStdinOpen().StartAsync()).GetValueOrThrow();
if (proc.TakeStdin() is { Value: var stdin }) // Some(stdin); None is null and won't match
{
await stdin.WriteLineAsync("2 + 2"); // writes "2 + 2\n", flushed
await stdin.WriteLineAsync("6 * 7");
await stdin.FinishAsync(); // send EOF so bc exits
}
// ... then read proc.StdoutLinesAsync() for the answers.
ProcessStdin offers WriteLineAsync(line) (appends a newline and flushes),
WriteAsync(bytes) (raw bytes, for binary input), FlushAsync(), and FinishAsync() (close
stdin / send EOF). Disposing the writer — or the whole RunningProcess — closes
stdin too; FinishAsync() just makes the EOF explicit and awaitable. The write verbs
(WriteAsync / WriteLineAsync / FlushAsync) each take an optional CancellationToken, so a
write to a child that has stopped reading (a full stdin pipe) can be bounded rather than blocking
forever — a cancelled write throws OperationCanceledException (and, as with any cancellable stream
write, may already have delivered part of its bytes, so abandon the session rather than retrying a
timed-out write). FinishAsync is idempotent and uncancellable (it mirrors DisposeAsync); bound
the writes/flush before closing, not the close.
Avoid the full-duplex deadlock. A child's stdout pipe has a finite OS buffer;
once it fills, the child blocks writing stdout until something reads it. If you
push a large interactive stdin while nothing drains the child's stdout, the child
stops reading stdin (blocked on stdout), your WriteAsync parks waiting for stdin buffer
space, and neither side progresses. The bc example above is safe because it
interleaves one small write with one read. When you both feed a sizable stdin and
the child produces output, write stdin from one task and drain stdout from another:
F#
task {
match! (Command.create "transform" |> Command.keepStdinOpen).StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
match proc.TakeStdin() with
| Some stdin ->
// Producer: feed a large stdin on its own task.
let writer =
task {
for line in bigInput do
do! stdin.WriteLineAsync line
do! stdin.FinishAsync()
}
// Consumer: drain stdout concurrently on this task.
let e = proc.StdoutLinesAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> handle e.Current
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
do! writer
| None -> ()
}
C#
await using var proc = (await new Command("transform").KeepStdinOpen().StartAsync()).GetValueOrThrow();
if (proc.TakeStdin() is { Value: var stdin }) // Some(stdin); None is null and won't match
{
// Producer: feed a large stdin on its own task.
var writer = Task.Run(async () =>
{
foreach (var line in bigInput)
await stdin.WriteLineAsync(line);
await stdin.FinishAsync();
});
// Consumer: drain stdout concurrently on this task.
await foreach (var line in proc.StdoutLinesAsync())
handle(line);
await writer;
}
For one-directional streamed input (a channel, a file tail) you don't need
interactivity at all — give the command Stdin.FromLines seq,
Stdin.FromAsyncLines asyncSeq, or Stdin.FromStream stream and let ProcessKit's
background writer feed it; those sources run concurrently with the output pumps and
never deadlock. See the stdin source table in Running commands.
Readiness probes
"Start a server, then use it" needs the server to be ready, not merely started.
Three probes replace the arbitrary sleep, each bounded by its own deadline and each
returning a Result:
F#
task {
match! (Command.create "my-server").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
// 1. A line on stdout (returns the matching line):
match! proc.WaitForLineAsync((fun line -> line.Contains "listening on"), TimeSpan.FromSeconds 10.0) with
| Ok banner -> printfn $"server says: {banner}"
| Error(ProcessError.NotReady(program, timeout)) -> eprintfn $"{program} not ready after {timeout}"
| Error err -> eprintfn $"{err.Message}"
// 2. A TCP port accepting connections:
let endpoint = IPEndPoint(IPAddress.Loopback, 8080)
match! proc.WaitForPortAsync(endpoint, TimeSpan.FromSeconds 10.0) with
| Ok() -> printfn "port is open"
| Error err -> eprintfn $"{err.Message}"
// 3. Any async predicate (an HTTP /health endpoint, a file appearing, …):
match! proc.WaitForAsync((fun () -> healthCheck ()), TimeSpan.FromSeconds 10.0) with
| Ok() -> printfn "healthy"
| Error err -> eprintfn $"{err.Message}"
}
C#
await using var proc = (await new Command("my-server").StartAsync()).GetValueOrThrow();
// 1. A line on stdout (returns the matching line):
Console.WriteLine(await proc.WaitForLineAsync(line => line.Contains("listening on"), TimeSpan.FromSeconds(10)) switch
{
{ IsOk: true, ResultValue: var banner } => $"server says: {banner}",
{ IsOk: false, ErrorValue: ProcessError.NotReady nr } => $"{nr.Program} not ready after {nr.Timeout}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
// 2. A TCP port accepting connections:
var endpoint = new IPEndPoint(IPAddress.Loopback, 8080);
Console.WriteLine(await proc.WaitForPortAsync(endpoint, TimeSpan.FromSeconds(10)) switch
{
{ IsOk: true } => "port is open",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
// 3. Any async predicate (an HTTP /health endpoint, a file appearing, …):
Console.WriteLine(await proc.WaitForAsync(() => healthCheck(), TimeSpan.FromSeconds(10)) switch
{
{ IsOk: true } => "healthy",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Probe semantics are deliberately uniform:
- A probe that can't pass within its deadline fails with
ProcessError.NotReady— distinct fromProcessError.Timeout, which is the run's own deadline. - A probe also fails fast once readiness can no longer happen: the child exits, or
(for
WaitForLineAsync) its stdout closes — no waiting out a 10s deadline on a dead server. - A failed probe never kills the child. You decide what happens next: retry, log and continue, or tear down.
- All three probes background-drain the child's piped stdout/stderr while polling, so a chatty
child that writes more than one OS pipe buffer of startup output (~64 KiB on Linux) before
becoming ready can't block in
write()and spuriously fail the probe withNotReady.WaitForLineAsynchands the drained stdout back to you (consumed up to and including the matching line — continue withFinishAsync()or further streaming afterwards);WaitForPortAsync/WaitForAsyncdiscard what they drain and stop draining once the probe concludes. Either way, a capture verb called afterward (OutputStringAsync/OutputBytesAsync/a freshStdoutLinesAsync/OutputEventsAsync) only sees output the child wrote after the probe concluded — run probes before a capturing verb if you need the complete output.
WaitForAsync takes a function returning Task<bool> (Func<Task<bool>> from C#), so any
async health check fits — re-evaluated until it returns true or the deadline elapses.
Racing several children
RunningProcess.WaitAny races several started handles and reports whichever exits
first — the natural primitive for "first answer wins" or "restart whatever died". It
returns WaitAnyResult directly (no Result wrapper), carrying the winner's Index
in the array you passed and its Outcome. The array itself must be non-null,
non-empty, and free of null elements — a violation throws (ArgumentNullException/
ArgumentException) rather than reporting through a Result, the same contract
WaitAllAsync below uses:
F#
task {
// Bound the race with a per-command Timeout — WaitAny applies none of its own.
let withDeadline name =
Command.create name |> Command.timeout (TimeSpan.FromSeconds 30.0)
match! (withDeadline "replica-a").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok a ->
use _ = a
match! (withDeadline "replica-b").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok b ->
use _ = b
let! result = RunningProcess.WaitAnyAsync [| a; b |]
printfn $"contender #{result.Index} exited first with {result.Outcome}"
}
C#
// Bound the race with a per-command Timeout — WaitAny applies none of its own.
Command withDeadline(string name) =>
new Command(name).Timeout(TimeSpan.FromSeconds(30));
await using var a = (await withDeadline("replica-a").StartAsync()).GetValueOrThrow();
await using var b = (await withDeadline("replica-b").StartAsync()).GetValueOrThrow();
var first = await RunningProcess.WaitAnyAsync([a, b]);
Console.WriteLine($"contender #{first.Index} exited first with {first.Outcome}");
To join a fixed set instead of racing it, RunningProcess.WaitAll waits for all of
them and returns every Outcome in input order (an Outcome[] directly — no Result
wrapper), under the same non-null/non-empty/no-null-element contract:
F#
let! outcomes = RunningProcess.WaitAllAsync [| a; b |]
printfn $"{outcomes.Length} children done"
C#
var outcomes = await RunningProcess.WaitAllAsync([a, b]);
Console.WriteLine($"{outcomes.Length} children done");
Both apply no per-process timeout (bound the race with a Command.Timeout, as
above) and do no output pumping — drain chatty children first, or give them a
bounded output buffer policy, so a child can't stall on a full pipe while you wait.
Profiling a run
A RunningProcess reports its own resource usage live, and ProfileAsync() turns a whole
run into a summary. The live gauges read the child process itself at any moment:
F#
task {
match! (Command.create "crunch").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
// Live, mid-run:
printfn $"pid={proc.Pid} elapsed={proc.Elapsed} cpu={proc.CpuTime} peak={proc.PeakMemoryBytes}"
// Capture + sample on an interval until exit (returns a RunProfile directly):
let! profile = proc.ProfileAsync(TimeSpan.FromMilliseconds 100.0)
printfn $"exit={profile.ExitCode} wall={profile.Duration} samples={profile.Samples}"
printfn $"cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} avgCpu={profile.AvgCpuCores}"
}
C#
await using var proc = (await new Command("crunch").StartAsync()).GetValueOrThrow();
// Live, mid-run:
Console.WriteLine($"pid={proc.Pid} elapsed={proc.Elapsed} cpu={proc.CpuTime} peak={proc.PeakMemoryBytes}");
// Capture + sample on an interval until exit (returns a RunProfile directly):
var profile = await proc.ProfileAsync(TimeSpan.FromMilliseconds(100));
Console.WriteLine($"exit={profile.ExitCode} wall={profile.Duration} samples={profile.Samples}");
Console.WriteLine($"cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} avgCpu={profile.AvgCpuCores}");
ProfileAsync() with no argument uses a default sampling interval; ProfileAsync(interval)
samples at the cadence you pick. The resulting RunProfile exposes ExitCode,
Duration (wall clock), CpuTime (user + kernel), PeakMemoryBytes, the number of
Samples taken, and AvgCpuCores — CPU time over wall time, so a value near 1.7 means
roughly 1.7 cores were busy on average.
These figures describe the started child, not a whole tree — for the tree's
aggregate use ProcessGroup.Stats / SampleStatsAsync (Process groups).
Availability follows the platform: full CPU and memory on Windows and the Linux
cgroup backend, and None where the kernel doesn't account per-process cheaply — see
Platform support.
Next: Pipelines · Timeouts, retries & cancellation · Supervision
Pipelines
Build a → b → c without a shell. Each stage's stdout is wired straight into the
next stage's stdin by an in-process relay — there is no shell string anywhere, so there
are no quoting rules, no word splitting, and no injection surface. Every stage spawns
into one shared kill-on-dispose process group, so the whole chain
lives and dies as a unit: tear the chain down (a timeout, a cancellation, an early
return) and every stage goes with it.
The relay is a copy loop, not a kernel splice. When a consumer exits early it closes the
upstream read end, so the producer stops on a broken pipe — its next write fails once
the relay's downstream is gone. On POSIX the OS may deliver that as SIGPIPE; Windows
has no SIGPIPE, so there it surfaces as a failed write instead. See
Unchecked stages for why that distinction matters.
The samples below run inside a task { } block and use match!; from C# the same
surface is await-able fluent methods.
- Building a pipeline
- The verbs
- Pipefail: the result and the ends
- Fail-loud output overflow
- Unchecked stages
- Timeouts and cancellation
- Re-running a pipeline
Building a pipeline
There are two equivalent ways to wire stages together; pick whichever reads better in
context. There is no | operator — F# reserves | for patterns and active
patterns — so the fluent .Pipe method and the Pipeline module are the only two ways
to build a chain.
The fluent way: Command.Pipe(next) turns a Command into a Pipeline, and chaining
.Pipe(...) again appends another stage. Finish with a verb.
F#
task {
// git log --format=%an | sort | uniq -c
let pipeline =
(Command.create "git" |> Command.args [ "log"; "--format=%an" ])
.Pipe(Command.create "sort")
.Pipe(Command.create "uniq" |> Command.arg "-c")
match! pipeline.RunAsync() with
| Ok authors -> printfn $"{authors}"
| Error err -> eprintfn $"{err.Message}"
}
C#
// git log --format=%an | sort | uniq -c
var pipeline = new Command("git").Args(["log", "--format=%an"])
.Pipe(new Command("sort"))
.Pipe(new Command("uniq").Arg("-c"));
Console.WriteLine(await pipeline.RunAsync() switch
{
{ IsOk: true, ResultValue: var authors } => authors,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The pipe-style module mirror builds the same value: Pipeline.create first second seeds
a two-stage chain, and Pipeline.pipe next pipeline appends a stage — so it threads
naturally through |>:
F#
task {
let pipeline =
Pipeline.create
(Command.create "git" |> Command.args [ "log"; "--format=%an" ])
(Command.create "sort")
|> Pipeline.pipe (Command.create "uniq" |> Command.arg "-c")
match! pipeline.OutputStringAsync() with
| Ok result -> printfn $"{result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var pipeline = new Command("git").Args(["log", "--format=%an"])
.Pipe(new Command("sort"))
.Pipe(new Command("uniq").Arg("-c"));
Console.WriteLine(await pipeline.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => result.Stdout,
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Pipeline.timeout and Pipeline.cancelOn round out the module mirror; they correspond
to the fluent .Timeout and .CancelOn covered under
Timeouts and cancellation. Building a pipeline spawns
nothing — a Pipeline is an immutable value, and each builder call returns a new one.
Nothing runs until you call a verb.
The verbs
A Pipeline finishes with the same verb vocabulary as a Command; each one folds the
whole chain's outcome (see pipefail below) and
returns Task<Result<_, ProcessError>>:
| Verb | On success you get | A failing stage is… |
|---|---|---|
RunAsync() | trimmed final string | …raised as the first unclean checked stage's ProcessError.Exit |
RunUnitAsync() | unit | …same success rule; the output is discarded |
OutputStringAsync() | ProcessResult<string> | …folded into the result (code / stderr / program of the first unclean stage); never an Error on its own |
OutputBytesAsync() | ProcessResult<byte[]> | …same, with the last stage's stdout captured raw — for binary pipes |
ExitCodeAsync() | int | …its attributed code (a timed-out / signalled chain errors rather than inventing a number) |
ProbeAsync() | bool | exit 0 → true, 1 → false, anything else → Error |
ParseAsync(f) / TryParseAsync(f) | 'T | …raised as that stage's ProcessError.Exit; ParseAsync requires success |
Every verb also accepts an optional CancellationToken — pipeline.RunAsync(token),
pipeline.OutputStringAsync(token), and so on — for a per-call token alongside the
chain-level CancelOn.
An Error from a capture verb such as OutputStringAsync means a stage couldn't be started
or driven at all — a spawn failure, a not-found program, broken plumbing — never a
mere non-zero exit. A non-zero exit is data in the ProcessResult.
There is deliberately no streaming verb and no FirstLineAsync on a Pipeline: a chain
consumes its last stage in full to fold the pipefail outcome, so there is no live handle
to stream from. To capture the first matching line of a finished chain, append a
head -n 1 (POSIX) / findstr (Windows) stage and capture its stdout. If you instead
need a streaming readiness probe over a chain that must keep running — wait for a banner
line, then leave it alive — a head stage would tear it down; reach for a single
Command with WaitForLineAsync instead (see streaming.md).
Pipefail: the result and the ends
The outcome follows shell pipefail (set -o pipefail):
- stdout is always the last stage's output — that is what the chain produced.
- code, stderr, and the reported program come from the rightmost stage that
did not finish successfully — a code outside its accepted
OkCodes(just0unless widened), a signal kill, or a timeout — or from the last stage when every stage succeeded.
So when an inner stage fails, the result's stdout is whatever the tail still printed, while the diagnostics point at the culprit:
F#
task {
let pipeline =
(Command.create "cat" |> Command.arg "data.txt")
.Pipe(Command.create "grep" |> Command.arg "ERROR") // suppose grep exits 2 (bad regex)
.Pipe(Command.create "wc" |> Command.arg "-l")
match! pipeline.OutputStringAsync() with
| Ok result ->
// Blame points at grep — the rightmost unclean stage — while Stdout is whatever wc managed.
printfn $"code={result.Code} program={result.Program} success={result.IsSuccess}"
// code=Some 2 program=grep success=false
| Error err -> eprintfn $"{err.Message}"
}
C#
var pipeline = new Command("cat").Arg("data.txt")
.Pipe(new Command("grep").Arg("ERROR")) // suppose grep exits 2 (bad regex)
.Pipe(new Command("wc").Arg("-l"));
Console.WriteLine(await pipeline.OutputStringAsync() switch
{
// Blame points at grep — the rightmost unclean stage — while Stdout is whatever wc managed.
{ IsOk: true, ResultValue: var result } => $"code={result.Code} program={result.Program} success={result.IsSuccess}", // code=Some 2 program=grep success=false
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The success-requiring verbs turn that same pipefail outcome into a typed error attributed
to the blamed stage. ProcessResult.ensureSuccess does it explicitly, and RunAsync does it
for you:
F#
match! pipeline.RunAsync() with
| Ok out -> printfn $"{out}"
| Error(ProcessError.Exit(program, code, _, stderr)) ->
eprintfn $"{program} exited {code}: {stderr}" // program = "grep", code = 2
| Error err -> eprintfn $"{err.Message}"
C#
Console.WriteLine(await pipeline.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => output,
{ IsOk: false, ErrorValue: ProcessError.Exit { Program: var p, Code: var c, Stderr: var s } } => $"{p} exited {c}: {s}", // program = "grep", code = 2
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The two ends of the chain behave like a single Command:
- The first stage's configured
Stdinsource is honored — feed the whole pipeline from a string, bytes, a file, or a stream. AStdinsource on any later stage is a configuration error — that stage's stdin is always rewired to the previous stage's stdout — so.Piperejects it with anArgumentExceptionnaming the offending stage. - A
KeepStdinOpenon a stage has no effect inside a chain: a pipeline exposes no live stdin handle to write into, and the relay wires each later stage's stdin itself. - Every stage's stdout is wired into a pipe — feeding the next stage's stdin, or captured
at the end — so a
Stdoutmode ofNull/Inheritset on a stage is overridden to keep the chain connected. - Every stage's stderr is captured per-stage for pipefail diagnostics under that stage's own
OutputBufferbyte cap (MaxBytes+Overflow); only the last stage's stdout reaches you. A fail-loud (OverflowMode.Error) overflow is honoured on any stage's stderr, not only the final stdout — see Fail-loud output overflow below. Command.MergeStderr(a shell2>&1) is allowed only on the last stage — its stdout is the pipeline's captured output, so merging there captures the final stage's combined stdout+stderr. On an earlier stage it is rejected (ArgumentException) the moment another stage is appended after it: the chain wires each stage's stdout into the next stage's stdin, so an OS-level merge on an intermediate stage would inject its stderr into the downstream stage's input data.- A per-stage
Timeout,Retry, orCancelOnis rejected when the stage is piped (see Timeouts and cancellation); a per-stageLoggerorStreamBufferhas no effect inside a chain — observe or bound an individual command by running it on its own. - The last stage's
OutputBufferbyte cap (MaxBytes+Overflow) bounds the captured stdout — the same way a single command'sOutputBytesAsyncdoes (Error->OutputTooLarge,DropOldest/DropNewest-> a tail/head withTruncatedset). ItsMaxLines, and every intermediate stage's stdout buffer policy, do not apply (an intermediate stdout is plumbing into the next stage, not a capture). Each stage's stderr cap, by contrast, applies on every stage — see Fail-loud output overflow.
F#
task {
let uniqueCount =
(Command.create "sort" |> Command.stdin (Stdin.FromString "b\na\nb\nc\n"))
.Pipe(Command.create "uniq")
.Pipe(Command.create "wc" |> Command.arg "-l")
match! uniqueCount.RunAsync() with
| Ok n -> printfn $"{n}" // "3"
| Error err -> eprintfn $"{err.Message}"
}
C#
var uniqueCount = new Command("sort").Stdin(Stdin.FromString("b\na\nb\nc\n"))
.Pipe(new Command("uniq"))
.Pipe(new Command("wc").Arg("-l"));
Console.WriteLine(await uniqueCount.RunAsync() switch
{
{ IsOk: true, ResultValue: var n } => n, // "3"
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Fail-loud output overflow
Every captured stream in a chain obeys its own stage's
OutputBuffer byte cap (MaxBytes +
Overflow) — not only the final stdout. Two kinds of capture are subject to a cap:
- the last stage's stdout — the pipeline's captured output, and
- every stage's stderr — drained per-stage for diagnostics, bounded so a chatty stage can never exhaust memory regardless of its position in the chain.
Under OverflowMode.Error a cap is fail-loud: once the stream exceeds it, the verb returns
ProcessError.OutputTooLarge — naming the offending stage's program and its configured caps —
exactly like a single command's byte capture, and consistently whether the overflow is on the final
stdout or on any stage's stderr (an intermediate stage's stderr fail-loud overflow is no longer
silently dropped). Under DropOldest / DropNewest the same overflow stays lossy but
non-erroring (a bounded tail/head with Truncated set), for stderr just as for stdout.
When more than one stream overflows at once — several stages, and/or a stage's stderr together with the final stdout — one deterministic error is chosen by first-offending-stage-in-pipeline-order: the leftmost stage in the chain wins (the earliest point the chain overflowed), and within a single stage its captured stdout (only the last stage has one) is preferred over its stderr. So an overflow on an earlier stage's stderr outranks the final stdout's, while the pre-existing "only the final stdout overflowed" case is reported exactly as before (same program, limits, and totals).
Unchecked stages
Strict pipefail has one classic false positive: a consumer that legitimately stops
reading early. In producer | head -1 the consumer exits 0 after one line and closes
the pipe; the producer then dies on a broken pipe — its next write fails once the
relay's downstream is gone (a failed write on Windows, possibly delivered as SIGPIPE on
POSIX). That is a perfectly normal death, but strict pipefail would blame the chain for
it. Mark the producer Command.uncheckedInPipe (fluent: .UncheckedInPipe()) and
pipefail skips it:
F#
task {
// seq 1 1000000 | head -n 1 — the producer's broken-pipe death is expected.
let first =
(Command.create "seq" |> Command.args [ "1"; "1000000" ] |> Command.uncheckedInPipe)
.Pipe(Command.create "head" |> Command.args [ "-n"; "1" ])
match! first.RunAsync() with
| Ok line -> printfn $"{line}" // "1"
| Error err -> eprintfn $"{err.Message}"
}
C#
// seq 1 1000000 | head -n 1 — the producer's broken-pipe death is expected.
var first = new Command("seq").Args(["1", "1000000"]).UncheckedInPipe()
.Pipe(new Command("head").Args(["-n", "1"]));
Console.WriteLine(await first.RunAsync() switch
{
{ IsOk: true, ResultValue: var line } => line, // "1"
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The rules:
- An unchecked stage's unclean exit — a non-zero code, or a broken-pipe death from a
consumer that closed early (a failed write on Windows;
SIGPIPEon POSIX where the OS delivers it) — is skipped when the chain decides what to report. - A checked failure always trumps an unchecked one, regardless of position:
uncheckedInPipenever shields another stage's real failure. - A chain whose only failures are unchecked reports success — the last stage's stdout
and code
0. uncheckedInPipeforgives exit status only — never a whole-chainPipeline.Timeout— and it has no effect on aCommandrun outside a pipeline, where a single run's status is already plain data in itsProcessResult.
Timeouts and cancellation
A pipeline is bounded as a whole chain — there is no per-stage timeout:
F#
task {
let pipeline =
(Command.create "producer")
.Pipe(Command.create "consumer")
.Timeout(TimeSpan.FromSeconds 30.0) // whole-CHAIN deadline
match! pipeline.OutputStringAsync() with
| Ok result -> printfn $"timedOut={result.IsTimedOut}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var pipeline = new Command("producer")
.Pipe(new Command("consumer"))
.Timeout(TimeSpan.FromSeconds(30)); // whole-CHAIN deadline
Console.WriteLine(await pipeline.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => $"timedOut={result.IsTimedOut}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Pipeline.Timeout(module mirror:Pipeline.timeout) bounds the whole chain: at the deadline the shared group is torn down and the run reports the timeout — onOutputStringAsyncasIsTimedOut, onRunAsyncas anError. Unlike a single command's captured timeout, there is no salvaged partial stdout to read back.- A per-stage
Command.Timeoutcannot bound one stage of a chain — a pipeline spawns its stages directly, so a stage's own deadline never fires. Setting one is a configuration error, so.Piperejects it with anArgumentException. Bound the whole chain withPipeline.Timeout, or run the stage as a standaloneCommandwhen it needs its own deadline. - A per-stage
Command.Retryis rejected the same way — retry is a verb-layer mechanism and pipeline stages are spawned directly, bypassing it. Retry the pipeline as a whole instead.
Cancellation has two forms:
Pipeline.CancelOn(token)(module mirror:Pipeline.cancelOn) is the chain-level control: the token is applied to every stage, so firing it tears the whole chain down and the run resolves toProcessError.Cancelled.- Each verb's optional
CancellationToken(pipeline.RunAsync(token)) ties a single call to a token without baking it into the pipeline.
A per-stage Command.CancelOn cannot cancel one stage of a chain — a pipeline spawns its
stages directly, so a stage's own token is a verb-layer mechanism the spawn bypasses and never
fires. Setting one is a configuration error, so .Pipe rejects it with an ArgumentException.
Cancel the whole chain with the chain-level Pipeline.CancelOn (or pass a token to the verb)
instead. For the full model — captured vs. raised deadlines, and how cancellation differs from a
timeout — see timeouts-and-cancellation.md.
Re-running a pipeline
A Pipeline is an immutable value: building it spawns nothing, and each verb call drives
the chain afresh, so you can hold one and run it more than once. The one caveat is
inherited from Command — when a chain runs repeatedly, feed the first stage from a
reusable stdin source (Stdin.FromString / Stdin.FromBytes / Stdin.FromFile)
rather than a stream you can only read once. See commands.md for the full
set of stdin sources and their semantics.
Next: Timeouts, retries & cancellation · Running commands · Process groups · Streaming & interactive I/O · Testing your code · Platform support · Cookbook
Timeouts, retries & cancellation
Three ways a run can end early, with three different philosophies:
- a timeout is data — the deadline was part of the run's contract, so its expiry is captured in the result, and only the success-checking verbs turn it into an error;
- a retry is a policy — the verbs replay the run while your classifier says the failure is worth another attempt;
- a cancellation is an abandonment — the caller changed its mind, so every path reports an error and there is no result worth inspecting.
The samples below run inside a task { } block and use match! / let!; from
C# the same surface is await-able fluent methods. Every builder method has a
pipe-friendly Command.* mirror (Command.timeout, Command.retry,
Command.cancelOn), shown alongside the fluent form.
- Timeouts
- Graceful timeout
- Idle timeout
- Captured vs raised: the decision table
- Retries
- Cancellation
- Pipelines and clients
- Precedence and interactions
Timeouts
Command.Timeout(duration) (mirror: Command.timeout) kills the whole process
tree at the deadline — not just the direct child, so a wrapper script's
grandchildren die too. The run's Outcome becomes Outcome.TimedOut.
F#
task {
// Captured: a non-zero exit / timeout is data on the capture verbs.
let cmd =
Command.create "slow-tool"
|> Command.timeout (TimeSpan.FromSeconds 5.0)
match! cmd.OutputStringAsync() with
| Ok result when result.IsTimedOut ->
// Code is None on a timeout; the partial output captured before the kill is kept.
printfn $"timed out; partial stdout before the kill: {result.Stdout}"
| Ok result -> printfn $"exited {result.Code}: {result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
}
C#
// Captured: a non-zero exit / timeout is data on the capture verbs.
var cmd = new Command("slow-tool")
.Timeout(TimeSpan.FromSeconds(5));
Console.WriteLine(await cmd.OutputStringAsync() switch
{
// Code is None on a timeout; the partial output captured before the kill is kept.
{ IsOk: true, ResultValue: { IsTimedOut: true } result } => $"timed out; partial stdout before the kill: {result.Stdout}",
{ IsOk: true, ResultValue: var result } => $"exited {result.Code}: {result.Stdout}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The same command finished with a success-checking verb raises the deadline as a typed error instead:
F#
task {
let cmd =
Command.create "slow-tool"
|> Command.timeout (TimeSpan.FromSeconds 5.0)
match! cmd.RunAsync() with
| Ok stdout -> printfn $"{stdout}"
| Error(ProcessError.Timeout(program, timeout, _, _)) ->
eprintfn $"{program} exceeded {timeout}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd = new Command("slow-tool")
.Timeout(TimeSpan.FromSeconds(5));
Console.WriteLine(await cmd.RunAsync() switch
{
{ IsOk: true, ResultValue: var stdout } => stdout,
{ IsOk: false, ErrorValue: ProcessError.Timeout { Program: var p, Timeout: var t } } => $"{p} exceeded {t}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
ProcessError.Timeout(program, timeout, stdout, stderr) carries the partial
stdout/stderr captured before the kill — a hung tool's last words are still
available on the error, not discarded.
Two distinct deadline families — keep them apart.
Command.Timeoutis the run's own contract (this guide): it kills the tree. The readiness probes'withinparameter (WaitForLineAsync/WaitForPortAsync/WaitForAsync, see streaming.md) is a different deadline: it givesProcessError.NotReadyand never kills the child — the caller decides what happens next.
Graceful timeout
By default the deadline hard-kills the tree at once. Add
Command.TimeoutGrace(grace) (mirror: Command.timeoutGrace) to give the tree a
chance to clean up: at the deadline it is sent SIGTERM, allowed up to the grace
window to exit, then SIGKILLed — the same SIGTERM → wait → SIGKILL tier as
ProcessGroup.ShutdownAsync. A signal-handling child that exits
ends the grace early.
F#
task {
let cmd =
Command.create "slow-tool"
|> Command.timeout (TimeSpan.FromSeconds 30.0)
|> Command.timeoutGrace (TimeSpan.FromSeconds 5.0) // SIGTERM, wait up to 5s, then SIGKILL
let! _ = cmd.OutputStringAsync()
()
}
C#
var cmd = new Command("slow-tool")
.Timeout(TimeSpan.FromSeconds(30))
.TimeoutGrace(TimeSpan.FromSeconds(5)); // SIGTERM, wait up to 5s, then SIGKILL
await cmd.OutputStringAsync();
IsTimedOut is true regardless of whether the child exited on the signal or was
SIGKILLed after the grace — the deadline is what fired. On Windows there is no
signal tier: TimeoutGrace is accepted but the deadline kills the Job Object
atomically, so the grace window has no effect there.
Idle timeout
Command.Timeout bounds the total run length. The other common failure is a run
that is still alive but stuck — it has stopped producing output. Command.IdleTimeout(duration)
(mirror: Command.idleTimeout) catches exactly that: it kills the tree when neither
stdout nor stderr produces output for duration. Every chunk of output resets the
deadline, so a run that keeps streaming stays alive; one that goes quiet is killed.
F#
task {
// Kill the build if it stops printing for 30s, however long it runs overall.
let cmd =
Command.create "long-build"
|> Command.idleTimeout (TimeSpan.FromSeconds 30.0)
match! cmd.OutputStringAsync() with
| Ok result when result.IsTimedOut -> eprintfn "stalled — no output for 30s"
| Ok result -> printfn $"exited {result.Code}"
| Error err -> eprintfn $"{err.Message}"
}
C#
// Kill the build if it stops printing for 30s, however long it runs overall.
var cmd = new Command("long-build")
.IdleTimeout(TimeSpan.FromSeconds(30));
Console.WriteLine(await cmd.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: { IsTimedOut: true } } => "stalled — no output for 30s",
{ IsOk: true, ResultValue: var result } => $"exited {result.Code}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Key facts:
- Same honest result as
Timeout. An idle kill surfaces asOutcome.TimedOut— soIsTimedOuton the capture verbs andProcessError.Timeouton the success-checking verbs, exactly like the total timeout. At the API level the two are not distinguished (both mean "killed on a deadline"); logs tell them apart (the message names an idle kill and reports the idle window, under the sameProcessTimedOutevent id). - Byte granularity, every verb. Activity is any output read from the child, measured in
bytes — so a single long line without a newline still counts as active, and it works
uniformly for the buffered capture verbs, the streaming verbs, the raw
OutputBytesAsync, and even the output-discardingWaitAsync/ProfileAsync. It is independent ofStdoutLineCount/StderrLineCount, which stay pure line counters. - The idle clock starts when consumption begins (the verb's exit wait), not at some earlier construction, so a handle you drive later is not killed for a gap before you started reading.
- Independent of
Timeout. Set both — each fires on its own condition, whichever comes first, with a single kill and a single reported outcome (no double kill).IdleTimeouthonoursTimeoutGrace(SIGTERM → grace → SIGKILL) exactly asTimeoutdoes. - A negative
durationis rejected (ArgumentOutOfRangeException); one larger than ~24.8 days is treated as no idle deadline (as withTimeout).
Like Command.Timeout, a per-stage Command.IdleTimeout cannot bound one stage of a
pipeline — a pipeline captures only the last stage's output and does not monitor per-stage
activity — so .Pipe rejects it with an ArgumentException rather than silently ignoring
it (see Pipelines and clients).
Captured vs raised: the decision table
The same timeout lands differently depending on the verb you finish with. The capture verbs treat the deadline as data; the success-checking verbs raise it.
| Verb | A timeout deadline becomes |
|---|---|
OutputStringAsync() / OutputBytesAsync() | Ok result with IsTimedOut = true, Code = None, Outcome = Outcome.TimedOut, partial output kept |
RunAsync() / RunUnitAsync() | Error (ProcessError.Timeout(program, timeout, stdout, stderr)) — partial output attached |
ExitCodeAsync() | Error (ProcessError.Timeout …) — it will not invent a sentinel code |
ProbeAsync() | Error (ProcessError.Timeout …) |
ParseAsync(f) / TryParseAsync(f) | Error (ProcessError.Timeout …) — both require success, so the deadline is raised |
StartAsync() + streaming | the stream ends at the deadline (tree killed, pipes closed); a following FinishAsync() reports Outcome.TimedOut |
ProcessResult.ensureSuccess on a captured result | Error (ProcessError.Timeout …) — the same conversion RunAsync does for you |
FirstLineAsync(p) | the stream closes at the deadline; if no line matched first, you get Ok None (it is not a success-checking verb) |
Streaming makes the "captured" half concrete — the deadline bounds the stream, and the outcome is readable afterwards:
F#
task {
let cmd =
Command.create "chatty-job"
|> Command.timeout (TimeSpan.FromSeconds 10.0)
match! cmd.StartAsync() with
| Ok proc ->
use _ = proc
let e = proc.StdoutLinesAsync().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"> {e.Current}"
| false -> go <- false // the stream ends when the deadline kills the tree
finally
e.DisposeAsync().AsTask().Wait()
match! proc.FinishAsync() with
| Ok finished when finished.Outcome.IsTimedOut -> eprintfn "killed at the deadline"
| Ok _ -> ()
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var cmd = new Command("chatty-job")
.Timeout(TimeSpan.FromSeconds(10));
await using var proc = (await cmd.StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine($"> {line}"); // the stream ends when the deadline kills the tree
var finished = await proc.FinishAsync();
if (finished is { IsOk: true, ResultValue: { Outcome.IsTimedOut: true } })
Console.Error.WriteLine("killed at the deadline");
else if (finished is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
Retries
Command.Retry(maxAttempts, delay, predicate) (mirror: Command.retry) replays a
failed run, sleeping delay between tries, retrying only while predicate
accepts the error. The predicate is a Func<ProcessError, bool> (from F#, a
plain ProcessError -> bool through the module mirror).
maxAttempts is the total number of runs (the first run plus up to
maxAttempts - 1 retries), so Retry 3 runs the command at most three times, and
0/1 both mean a single run — a command always runs at least once.
F#
task {
let cmd =
Command.create "curl"
|> Command.args [ "-fsS"; "https://example.com/api" ]
|> Command.timeout (TimeSpan.FromSeconds 10.0)
|> Command.retry
3
(TimeSpan.FromMilliseconds 250.0)
(fun err ->
// transient (spawn/I/O), a timeout, or curl's "couldn't connect" (exit 7)
ProcessError.isTransient err
|| err.IsTimeout
|| (match err with
| ProcessError.Exit(_, 7, _, _) -> true
| _ -> false))
match! cmd.RunAsync() with
| Ok body -> printfn $"{body}"
| Error err -> eprintfn $"gave up: {err.Message}"
}
C#
var cmd = new Command("curl")
.Args(["-fsS", "https://example.com/api"])
.Timeout(TimeSpan.FromSeconds(10))
.Retry(
3,
TimeSpan.FromMilliseconds(250),
err =>
// transient (spawn/I/O), a timeout, or curl's "couldn't connect" (exit 7)
err.IsTransient
|| err.IsTimeout
|| err is ProcessError.Exit { Code: 7 });
Console.WriteLine(await cmd.RunAsync() switch
{
{ IsOk: true, ResultValue: var body } => body,
{ IsOk: false, ErrorValue: var err } => $"gave up: {err.Message}",
});
The two built-in classifiers are ready to drop in as predicates:
ProcessError.isTransient(from C#,err.IsTransient) —trueforSpawnandIoerrors (spawn races, transient I/O blips) that may succeed on another try.ProcessError.isNotFound(from C#, the generatederr.IsNotFoundtester) —truefor a program-not-found failure (usually a reason to install-then-retry rather than to blindly replay).
F#
let cmd =
Command.create "flaky-tool"
|> Command.retry 5 (TimeSpan.FromMilliseconds 200.0) ProcessError.isTransient
C#
var cmd = new Command("flaky-tool")
.Retry(5, TimeSpan.FromMilliseconds(200), err => err.IsTransient);
Where retry earns its keep. Retry replays the run whenever a verb yields an
Error your predicate accepts. The success-checking verbs (RunAsync / RunUnitAsync /
ExitCodeAsync / ProbeAsync / ParseAsync / TryParseAsync) are where that matters: they turn a
non-zero exit into ProcessError.Exit and a timeout into ProcessError.Timeout,
so your classifier can act on the outcome of the run. The capture verbs
(OutputStringAsync / OutputBytesAsync) keep a non-zero exit and a timeout as data —
an Ok result — so a retry there can only ever fire on a genuine failure-to-run
(a transient spawn or I/O error), never on an exit code or a deadline.
Two ground rules:
- The classifier sees the typed
ProcessError— match on the case, on an exit code, even on the captured stderr. - A
ProcessError.Cancelledis effectively terminal: the built-in classifiers reject it, and once the run's token is cancelled the retry loop stops re-trying regardless — another attempt could only fail the same way.
For "keep a service alive whenever it exits" rather than "replay this one
operation", reach for a supervision.md Supervisor — the same
backoff shape, a different loop condition.
Cancellation
Hand any verb a System.Threading.CancellationToken; cancelling the token kills
the run's tree and makes every consuming path report ProcessError.Cancelled.
Every verb takes an optional CancellationToken (cmd.RunAsync(token),
cmd.OutputStringAsync(token), …):
F#
task {
use cts = new CancellationTokenSource()
let job = (Command.create "long-export").RunAsync(cts.Token)
// elsewhere — a shutdown signal, a sibling failure, a UI button:
cts.Cancel()
match! job with
| Error(ProcessError.Cancelled program) -> printfn $"{program} cancelled"
| _ -> ()
}
C#
using var cts = new CancellationTokenSource();
var job = new Command("long-export").RunAsync(cts.Token);
// elsewhere — a shutdown signal, a sibling failure, a UI button:
cts.Cancel();
if (await job is { IsOk: false, ErrorValue: ProcessError.Cancelled { Program: var p } })
Console.WriteLine($"{p} cancelled");
Or tie a token to a command for its whole lifetime with Command.CancelOn(token)
(mirror: Command.cancelOn) — it is linked in addition to any per-verb token,
so either source cancels the run:
F#
let cmd = Command.create "long-export" |> Command.cancelOn shutdownToken
let! _ = cmd.RunAsync() // also cancels if shutdownToken fires
C#
var cmd = new Command("long-export").CancelOn(shutdownToken);
await cmd.RunAsync(); // also cancels if shutdownToken fires
The contract, path by path:
| Situation | Behavior |
|---|---|
Cancel during RunAsync / OutputStringAsync / OutputBytesAsync / ExitCodeAsync / ProbeAsync / ParseAsync | tree killed → Error (ProcessError.Cancelled program) |
Cancel on a live handle (StdoutLinesAsync/FinishAsync after StartAsync) | not tracked — the token is checked only before the spawn, and a live handle is caller-driven. Stop the handle yourself (or register the token to do it): Kill/dispose for an immediate hard kill, or StopAsync(gracePeriod) for a graceful SIGTERM → grace → SIGKILL stop; ProcessKit does not kill the child or surface Cancelled for you here |
| Token already cancelled before the run | short-circuits before spawning — no process is ever created |
FirstLineAsync mid-run | surfaces ProcessError.Cancelled once the token fires (not Ok None) |
Under Retry | terminal — the built-in classifiers reject Cancelled and the loop stops re-trying |
Under a supervision.md Supervisor | terminal — supervision returns Cancelled instead of restarting into a still-cancelled token |
Unlike a timeout — whose expiry is captured as IsTimedOut — a cancellation is
always an error: the run was abandoned, so there is no result to synthesize. A
token cancelled before the run starts short-circuits without spawning anything.
Pipelines and clients
A whole pipeline has its own deadline and token, bounding the entire chain:
F#
task {
let pipeline =
(Command.create "producer")
.Pipe(Command.create "consumer")
.Timeout(TimeSpan.FromSeconds 30.0) // whole-chain deadline (mirror: Pipeline.timeout)
.CancelOn(shutdownToken) // whole-chain token (mirror: Pipeline.cancelOn)
match! pipeline.OutputStringAsync() with
| Ok result -> printfn $"timedOut={result.IsTimedOut}"
| Error err -> eprintfn $"{err.Message}" // ProcessError.Cancelled when the token fires
}
C#
var pipeline = new Command("producer")
.Pipe(new Command("consumer"))
.Timeout(TimeSpan.FromSeconds(30)) // whole-chain deadline (mirror: Pipeline.timeout)
.CancelOn(shutdownToken); // whole-chain token (mirror: Pipeline.cancelOn)
Console.WriteLine(await pipeline.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => $"timedOut={result.IsTimedOut}",
{ IsOk: false, ErrorValue: var err } => err.Message, // ProcessError.Cancelled when the token fires
});
Pipeline.Timeout tears the shared group down at the deadline and reports the
timeout (IsTimedOut on OutputStringAsync, Error on RunAsync) — but, unlike a single
command's captured timeout, there is no salvaged partial stdout to read back. A
per-stage Command.Timeout cannot bound one stage of a chain — a pipeline spawns its
stages directly, so a stage's own deadline never fires — so .Pipe rejects it with an
ArgumentException instead of silently ignoring it. A per-stage Command.IdleTimeout is
rejected the same way (a pipeline captures only the last stage's output and does not monitor
per-stage activity). See pipelines.md for the full chain model.
A CliClient usually builds and consumes its Commands internally, so set
the deadline and token once on the client and every command it builds carries
them:
F#
let gh =
(CliClient.create "gh")
.WithDefaults(fun c ->
c
.Timeout(TimeSpan.FromSeconds 30.0) // applied to every built command
.CancelOn(shutdownToken)) // …controller cancels → all in-flight runs die
C#
var gh = new CliClient("gh")
.WithDefaults(c =>
c
.Timeout(TimeSpan.FromSeconds(30)) // applied to every built command
.CancelOn(shutdownToken)); // …controller cancels → all in-flight runs die
Clients are cheap — scope cancellation by building one client per cancellable
scope with its own token instead of threading tokens through call signatures.
See testing.md for the CliClient wrapper pattern.
Precedence and interactions
Timeout vs cancellation. A timeout is captured; a cancellation is always an
error. When both land on the same run, cancellation wins — you asked the run
to stop mattering, so no result is synthesized and the verb reports
ProcessError.Cancelled, even on the capture verbs that would otherwise have
returned an IsTimedOut result.
Which knob for which job:
| You want | Reach for |
|---|---|
| "This run may not take longer than X" | Command.Timeout |
| "Kill it if it stops producing output" | Command.IdleTimeout |
| "Let it clean up before the kill" | Command.Timeout + Command.TimeoutGrace |
| "This operation is flaky, try a few times" | Command.Retry |
| "Stop everything when the app shuts down" | Command.CancelOn / a verb token + one shared token |
| "Bound a whole multi-stage chain" | Pipeline.Timeout / Pipeline.CancelOn |
| "Set a deadline/token once for a tool" | CliClient.WithDefaults(fun c -> c.Timeout(...).CancelOn(...)) |
| "Keep this service alive across crashes" | supervision.md Supervisor |
| "Tell me when it's ready, don't kill it" | readiness probes — streaming.md |
Next: supervision.md · streaming.md · pipelines.md · commands.md · testing.md
Supervision
A Supervisor answers a different question from retry.
Retry replays one run until it succeeds and then hands you that single result; a supervisor
keeps a child alive — it runs the command, classifies every exit against a restart policy,
waits out an exponential-backoff delay, and runs it again, until some stop condition ends
supervision. It is a minimal, platform-agnostic keeper in the spirit of runit/systemd,
built entirely on the IProcessRunner seam, so it never touches the OS directly
and is fully testable without spawning a process.
Each incarnation is one full captured run of the command, driven through the runner's
OutputStringAsync verb. The command's own Timeout, Stdin, environment, encoding, and
OkCodes therefore apply to every incarnation — with the usual
one-shot-stdin caveat for the second run onward (feed a reusable source such as
Stdin.FromString rather than a stream you can read only once). One thing that does not
carry over is the command's own Command.Retry: supervision runs the bare runner, so a
supervised command is never internally retried per incarnation. Use the supervisor's restart
policy and backoff instead — see Supervisor versus retry.
The samples below run inside a task { } block and use match!; from C# the same surface is
await-able fluent methods.
- Building a supervisor
- Policies: what counts as a crash
- Backoff and jitter
- Failure storms
- Capturing each incarnation
- Stopping
- The outcome
- Live observability
- Supervising inside a shared group
- Hermetic testing
- Errors and cancellation
- Supervisor versus retry
Building a supervisor
There are two equivalent entry points. The module function threads naturally through |>, and
the constructor reads the same from F# and C#:
F#
let supervisor = Supervisor.create (Command.create "worker") // the module function…
// …or, identically, the constructor: Supervisor(Command.create "worker")
C#
var supervisor = new Supervisor(new Command("worker")); // constructor
The builder is fluent and immutable — every method returns a new Supervisor, and building one
spawns nothing. Nothing runs until you call a verb (RunAsync):
F#
task {
let supervisor =
(Supervisor.create (Command.create "my-server" |> Command.args [ "--port"; "8080" ]))
.Restart(RestartPolicy.OnCrash) // Always | OnCrash | Never
.MaxRestarts(5) // default: unlimited
.Backoff(TimeSpan.FromMilliseconds 200.0, 2.0) // base delay, multiplier
.MaxBackoff(TimeSpan.FromSeconds 30.0) // cap on any single delay
.Jitter(true) // default: on
.StormPause(TimeSpan.FromSeconds 15.0) // crash-loop guard (off by default)
match! supervisor.RunAsync() with
| Ok outcome -> printfn $"ended after {outcome.Restarts} restarts: {outcome.Stopped}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var supervisor = new Supervisor(new Command("my-server").Args(["--port", "8080"]))
.Restart(RestartPolicy.OnCrash) // Always | OnCrash | Never
.MaxRestarts(5) // default: unlimited
.Backoff(TimeSpan.FromMilliseconds(200), 2.0) // base delay, multiplier
.MaxBackoff(TimeSpan.FromSeconds(30)) // cap on any single delay
.Jitter(true) // default: on
.StormPause(TimeSpan.FromSeconds(15)); // crash-loop guard (off by default)
Console.WriteLine(await supervisor.RunAsync() switch
{
{ IsOk: true, ResultValue: var outcome } => $"ended after {outcome.Restarts} restarts: {outcome.Stopped}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The defaults, if you set nothing, are: RestartPolicy.OnCrash, unlimited restarts, backoff
200ms × 2.0 capped at 30 s, jitter on, and the failure-storm guard off (its own
defaults — half-life 30 s, threshold 5.0 — apply only once StormPause enables it).
Policies: what counts as a crash
A crash is any run that is not a success: ProcessResult.IsSuccess is false. That honors
the command's OkCodes, so it covers an exit code outside the accepted set (default {0}), a
timeout, a signal-kill, and a failure to spawn. A command with Command.okCodes [ 0; 2 ] that
exits 2 is a success, so OnCrash treats it as a clean exit, not a crash.
RestartPolicy | Restarts after… |
|---|---|
OnCrash (default) | crashes only; a clean exit ends supervision (PolicySatisfied) |
Always | every completed run, clean or not — pair it with StopWhen / MaxRestarts, or it loops forever |
Never | nothing: one run, reported as-is (PolicySatisfied) |
RestartPolicy is [<RequireQualifiedAccess>], so write RestartPolicy.OnCrash and friends in
full.
Backoff and jitter
Before each restart the supervisor sleeps for an exponentially growing delay:
delay(n) = min(base × factor^n, MaxBackoff) × jitter
where n is an escalation exponent: it starts at 0 and climbs by one per restart, but
resets to 0 after a healthy incarnation — one that stayed up at least as long as MaxBackoff
and wasn't a hang killed by its own timeout. So a long-lived service that crashes only occasionally
restarts promptly at the base delay, while a tight crash loop — or a per-incarnation timeout/hang
loop — keeps climbing and self-throttles. (n is not the lifetime restart count, which is what
SupervisionOutcome.Restarts reports.)
jitter is drawn uniformly from [0.5, 1.5) per restart when enabled. Jitter is on by
default so a fleet of supervised workers restarted by one shared incident does not stampede
back in lockstep; call .Jitter(false) for deterministic delays. A factor below 1.0 (or
non-finite) is treated as 1.0 — a constant delay, never a shrinking one — and a base delay of
zero (or less) means no wait at all.
For a run that keeps crashing without ever clearing the healthy bar, n tracks the restart count:
base = 200ms, factor = 2.0, cap = 30s (before jitter):
n=0 → 200ms n=1 → 400ms n=2 → 800ms n=3 → 1.6s n=4 → 3.2s
n=5 → 6.4s n=6 → 12.8s n=7 → 25.6s n=8+ → 30s (capped)
F#
let supervisor =
(Supervisor.create (Command.create "worker"))
.Backoff(TimeSpan.FromSeconds 1.0, 1.5) // start at 1s, grow ×1.5
.MaxBackoff(TimeSpan.FromMinutes 2.0) // never wait longer than 2 minutes
.Jitter(false) // exact, reproducible delays
C#
var supervisor = new Supervisor(new Command("worker"))
.Backoff(TimeSpan.FromSeconds(1), 1.5) // start at 1s, grow ×1.5
.MaxBackoff(TimeSpan.FromMinutes(2)) // never wait longer than 2 minutes
.Jitter(false); // exact, reproducible delays
Failure storms
Backoff spaces out individual restarts; MaxRestarts is a lifetime cap. Neither
distinguishes a service that fails once a day from one that is suddenly crash-looping. The
opt-in failure-storm guard does. Enable it with StormPause; it is off by default.
Each failure adds 1 to a score that decays by half every FailureDecay (default 30 s):
score := score × 0.5^(Δt / FailureDecay) + 1 (Δt = time since the previous failure)
- Fails rarely — the score decays back toward
1between failures and never reaches the threshold, so the guard stays out of the way. - Crash-looping — failures arrive faster than the half-life can drain them, the score climbs
past
FailureThreshold(default5.0), and the supervisor takes one collective pause ofStormPause(jittered perJitter, like the backoff), resets the score, and resumes.
F#
task {
let supervisor =
(Supervisor.create (Command.create "worker"))
.StormPause(TimeSpan.FromSeconds 15.0) // master switch — off by default
.FailureDecay(TimeSpan.FromSeconds 30.0) // score half-life (default 30s)
.FailureThreshold(5.0) // trip point (default 5.0)
match! supervisor.RunAsync() with
| Ok outcome -> printfn $"storm pauses taken: {outcome.StormPauses}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var supervisor = new Supervisor(new Command("worker"))
.StormPause(TimeSpan.FromSeconds(15)) // master switch — off by default
.FailureDecay(TimeSpan.FromSeconds(30)) // score half-life (default 30s)
.FailureThreshold(5.0); // trip point (default 5.0)
Console.WriteLine(await supervisor.RunAsync() switch
{
{ IsOk: true, ResultValue: var outcome } => $"storm pauses taken: {outcome.StormPauses}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The fine print:
- Only failures feed the score. Crashes and spawn/IO errors count; clean exits restarted
under
RestartPolicy.Alwaysdo not. - The pause runs before the per-restart backoff — they stack — but the
MaxRestartsbudget is checked first, so a storm pause never extends an exhausted budget. FailureDecayandFailureThresholdhave no effect unlessStormPauseis set. A zero half-life keeps no history (every failure scores exactly1.0, so with the default threshold the guard never trips); a non-finite threshold never trips.- Pauses taken are reported in
SupervisionOutcome.StormPauses(always0when the guard is off).
Capturing each incarnation
A supervised process can be long-lived and chatty, so capturing its entire output across many restarts risks unbounded heap. By default the supervisor therefore keeps a bounded tail — the most recent 1000 lines — of each incarnation, even when the command's own buffer policy is unbounded. An explicit bounded or fail-loud command policy is respected as-is; only an unbounded line count is narrowed to the tail (the overflow mode and any byte cap are preserved, so a fail-loud command stays fail-loud).
Widen or narrow it with Capture:
F#
let keepEverything =
(Supervisor.create (Command.create "worker"))
.Capture(OutputBufferPolicy.Unbounded) // retain all output of every incarnation
let smallerTail =
(Supervisor.create (Command.create "worker"))
.Capture(OutputBufferPolicy.Bounded 200) // keep only the last 200 lines per run
C#
var keepEverything = new Supervisor(new Command("worker"))
.Capture(OutputBufferPolicy.Unbounded); // retain all output of every incarnation
var smallerTail = new Supervisor(new Command("worker"))
.Capture(OutputBufferPolicy.Bounded(200)); // keep only the last 200 lines per run
The captured output is what you read back from SupervisionOutcome.FinalResult after
supervision ends. For the full set of buffer policies and overflow modes, see
commands.md.
Stopping
After every completed run three gates are checked, in this order:
StopWhen(predicate)— sees the run'sProcessResult<string>and, returningtrue, ends supervision regardless of policy or budget (→StopReason.Predicate). It is checked on every exit, clean or not. The classic pairs it withAlways: "exit 0 is done, anything else is a crash to restart."- The policy —
OnCrashstops on a clean exit;Neverstops after its single run (→StopReason.PolicySatisfied). MaxRestarts(n)— at most n restarts, i.e. n + 1 total runs; an exhausted budget reports the last result (→StopReason.RestartsExhausted).MaxRestarts(0)means exactly one run.
F#
task {
let supervisor =
(Supervisor.create (Command.create "batch-worker"))
.Restart(RestartPolicy.Always) // restart on every exit…
.StopWhen(fun result -> result.Code = Some 0) // …until one exits cleanly
.MaxRestarts(50) // but give up after 50 restarts
match! supervisor.RunAsync() with
| Ok outcome when outcome.Stopped = StopReason.Predicate ->
printfn "worker finished cleanly"
| Ok outcome -> printfn $"gave up: {outcome.Stopped}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var supervisor = new Supervisor(new Command("batch-worker"))
.Restart(RestartPolicy.Always) // restart on every exit…
.StopWhen(result => result.Code is { Value: 0 }) // …until one exits cleanly
.MaxRestarts(50); // but give up after 50 restarts
Console.WriteLine(await supervisor.RunAsync() switch
{
{ IsOk: true, ResultValue: { Stopped.IsPredicate: true } } => "worker finished cleanly",
{ IsOk: true, ResultValue: var outcome } => $"gave up: {outcome.Stopped}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
StopWhen never sees a run that failed to start — a spawn error has no ProcessResult to
inspect, so it is classified by the policy alone (see
Errors and cancellation). StopReason is
[<RequireQualifiedAccess>]; match it by StopReason.Predicate / .PolicySatisfied /
.RestartsExhausted or test it with outcome.Stopped.IsPredicate and friends.
The outcome
RunAsync() resolves to a Task<Result<SupervisionOutcome, ProcessError>>. On Ok, the
SupervisionOutcome reports the last run plus the keeper's telemetry:
| Field | Meaning |
|---|---|
FinalResult | the ProcessResult<string> of the final run — the one that ended supervision |
Restarts | how many re-runs happened (the first run is not a restart, so 2 means three runs) |
Stopped | the StopReason — Predicate, PolicySatisfied, or RestartsExhausted |
StormPauses | failure-storm pauses taken (0 unless StormPause is set) |
An Ok outcome means supervision concluded, not that the child succeeded — a budget can be
exhausted on a still-crashing child. Inspect FinalResult for the child's own verdict, or turn
it into a success-or-error with ProcessResult.ensureSuccess:
F#
task {
match! (Supervisor.create (Command.create "job")).RunAsync() with
| Ok outcome ->
printfn $"runs={outcome.Restarts + 1} reason={outcome.Stopped} pauses={outcome.StormPauses}"
match ProcessResult.ensureSuccess outcome.FinalResult with
| Ok final -> printfn $"last run ok: {final.Stdout}"
| Error err -> eprintfn $"last run failed: {err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var outcome = await new Supervisor(new Command("job")).RunAsync();
if (outcome is { IsOk: true, ResultValue: var o })
{
Console.WriteLine($"runs={o.Restarts + 1} reason={o.Stopped} pauses={o.StormPauses}");
Console.WriteLine((o.FinalResult.EnsureSuccess()) switch
{
{ IsOk: true, ResultValue: var final } => $"last run ok: {final.Stdout}",
{ IsOk: false, ErrorValue: var err } => $"last run failed: {err.Message}",
});
}
else if (outcome is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
Live observability
SupervisionOutcome only arrives once supervision ends — unusable for a long-lived (potentially
never-ending) supervised service, where you want to know about a restart or a storm pause as it
happens, e.g. to feed a health check or crash-loop alert. OnRestart and OnStormPause report
those events live:
F#
let supervisor =
(Supervisor.create (Command.create "worker"))
.OnRestart(fun e -> printfn $"restart #{e.Restart} for {e.Program} after {e.Delay}")
.OnStormPause(fun e -> printfn $"storm pause #{e.StormPause} for {e.Program}: {e.Delay}")
C#
var supervisor = new Supervisor(new Command("worker"))
.OnRestart(e => Console.WriteLine($"restart #{e.Restart} for {e.Program} after {e.Delay}"))
.OnStormPause(e => Console.WriteLine($"storm pause #{e.StormPause} for {e.Program}: {e.Delay}"));
Both callbacks are invoked synchronously, from the supervision loop itself — the same async
context driving RunAsync — right before the corresponding delay is slept out. Keep handlers quick
and non-blocking: a slow handler delays every restart/pause. OnRestart fires on every restart (a
crash, a timeout, or a retried transient runner error), never for the initial run; OnStormPause
fires once per pause, only when StormPause is set. Both are purely additive — they never change
SupervisionOutcome's final Restarts/StormPauses/Stopped semantics.
Supervising inside a shared group
The supervisor runs every incarnation through an IProcessRunner — the default is a private
JobRunner (a fresh kill-on-dispose group per incarnation). Override it with WithRunner. The
headline production variant injects a ProcessGroup, which is itself an
IProcessRunner, so every incarnation — and everything it spawns — lives in one shared
kill-on-dispose container:
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"{err.Message}"
| Ok group ->
use group = group // the group outlives supervision; disposing it reaps any strays
let supervisor =
(Supervisor.create (Command.create "worker"))
.WithRunner(group)
.Restart(RestartPolicy.OnCrash)
.MaxRestarts(10)
match! supervisor.RunAsync() with
| Ok outcome -> printfn $"stopped: {outcome.Stopped}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var createErr })
{
Console.Error.WriteLine(createErr.Message);
return;
}
using var group = created.GetValueOrThrow(); // the group outlives supervision; disposing it reaps any strays
var supervisor = new Supervisor(new Command("worker"))
.WithRunner(group)
.Restart(RestartPolicy.OnCrash)
.MaxRestarts(10);
Console.WriteLine(await supervisor.RunAsync() switch
{
{ IsOk: true, ResultValue: var outcome } => $"stopped: {outcome.Stopped}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
The group is yours: it outlives supervision, so dispose it (or ShutdownAsync it) to tear down
anything still running once the keeper has stopped. One interaction to mind — do not supervise
into a group you have suspended; under the cgroup mechanism a restarted
child would start frozen (and the spawn itself can block). Resume the group first.
Hermetic testing
The same injection point makes supervision logic testable with no real process. Pass a
ScriptedRunner (from ProcessKit.Testing) that returns canned replies, and
assert the restart and stop behavior deterministically — pair it with .Jitter(false) for
reproducible timing:
F#
task {
// Fail twice, then succeed — under OnCrash this should restart twice and stop clean.
let mutable calls = 0
let runner =
(ScriptedRunner())
.When((fun _ -> calls <- calls + 1; calls <= 2), Reply.Fail(1, "boom"))
.Fallback(Reply.Ok "ready")
let supervisor =
(Supervisor.create (Command.create "worker"))
.WithRunner(runner)
.Restart(RestartPolicy.OnCrash)
.Jitter(false)
match! supervisor.RunAsync() with
| Ok outcome ->
// Restarts = 2, Stopped = PolicySatisfied (the clean third run ends OnCrash supervision).
printfn $"restarts={outcome.Restarts} reason={outcome.Stopped}"
| Error err -> eprintfn $"{err.Message}"
}
C#
// Fail twice, then succeed — under OnCrash this should restart twice and stop clean.
var calls = 0;
var runner = new ScriptedRunner()
.When(_ => { calls++; return calls <= 2; }, Reply.Fail(1, "boom"))
.Fallback(Reply.Ok("ready"));
var supervisor = new Supervisor(new Command("worker"))
.WithRunner(runner)
.Restart(RestartPolicy.OnCrash)
.Jitter(false);
Console.WriteLine(await supervisor.RunAsync() switch
{
// Restarts = 2, Stopped = PolicySatisfied (the clean third run ends OnCrash supervision).
{ IsOk: true, ResultValue: var outcome } => $"restarts={outcome.Restarts} reason={outcome.Stopped}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Reply.Ok / Reply.Fail / Reply.Exit / Reply.Signalled cover the result shapes a crash
classifier cares about. See testing.md for the full seam, including scripting by
exact argv (On) versus predicate (When) and record/replay cassettes.
Errors and cancellation
A run that produces no result at all — a spawn or I/O failure, where there is no
ProcessResult to judge — is treated as a crash: the supervisor restarts it (with backoff)
unless the policy is Never or the budget is exhausted, in which case that ProcessError
surfaces as RunAsync's Error. Because such a run never started, StopWhen does not see it; only
the policy and the budget apply.
A cancelled incarnation is terminal. If the token is already cancelled at the top of an
iteration, or an incarnation resolves to ProcessError.Cancelled, RunAsync returns that
Cancelled immediately — regardless of policy or remaining budget. The token never un-cancels,
so a restart could only produce another instantly-cancelled run; the supervisor refuses the
futile loop. Pass the token to RunAsync(token):
F#
task {
use cts = new CancellationTokenSource()
let supervised = (Supervisor.create (Command.create "worker")).RunAsync(cts.Token)
// elsewhere — a shutdown signal, a sibling failure:
cts.Cancel()
match! supervised with
| Error(ProcessError.Cancelled _) -> printfn "supervision cancelled"
| _ -> ()
}
C#
using var cts = new CancellationTokenSource();
var supervised = new Supervisor(new Command("worker")).RunAsync(cts.Token);
// elsewhere — a shutdown signal, a sibling failure:
cts.Cancel();
if (await supervised is { IsOk: false, ErrorValue: { IsCancelled: true } })
Console.WriteLine("supervision cancelled");
For the full model of captured-versus-raised deadlines and how cancellation differs from a timeout, see timeouts-and-cancellation.md.
Supervisor versus retry
The two layers answer different questions, and they compose rather than overlap:
Command.Retry | Supervisor | |
|---|---|---|
| Question | "run this once, replaying on failure" | "keep this alive across exits" |
| Scope | a single logical run | an ongoing lifecycle of many runs |
| Stops on | the first success (or attempts exhausted) | a policy / predicate / budget — including after clean exits |
| Spacing | a fixed retry delay | exponential backoff + jitter + a storm guard |
| Reports | the one successful (or last) result | a SupervisionOutcome with restart count and reason |
A supervised command's own Command.Retry is not applied per incarnation — supervision runs
the bare runner — so configure resilience through the supervisor's policy and backoff, not the
command's retry. Reach for retry when you want one value out of a flaky one-shot; reach for a
supervisor when you want a process to stay up. See
timeouts-and-cancellation.md for retry.
Next: Timeouts, retries & cancellation · Testing your code · Process groups · Running commands · Streaming & interactive I/O · Pipelines · Platform support · Cookbook
Testing your code
Code that shells out is miserable to test — unless the subprocess sits behind a
seam. In ProcessKit that seam is one small interface, IProcessRunner. It is
both the dependency-injection point (production code depends on the interface,
not on a concrete spawner) and the test seam (a test hands the same code a
subprocess-free double). The default real implementation is JobRunner — each
run lands in a fresh kill-on-dispose group; everything in this guide swaps it for
a double so your tests never touch the operating system.
The subprocess-free doubles ship in a separate
ProcessKit.TestingNuGet package, kept out of the runtimeProcessKitpackage so its on-disk/JSON record-replay surface never enters your production dependency graph. Add aProcessKit.Testingpackage reference to your test project; the types stay in theProcessKit.Testingnamespace.
F#
// One interface, three primitives — each takes a CancellationToken:
type IProcessRunner =
abstract CaptureStringAsync: Command * System.Threading.CancellationToken -> System.Threading.Tasks.Task<Result<ProcessResult<string>, ProcessError>>
abstract CaptureBytesAsync: Command * System.Threading.CancellationToken -> System.Threading.Tasks.Task<Result<ProcessResult<byte[]>, ProcessError>>
abstract SpawnAsync: Command * System.Threading.CancellationToken -> System.Threading.Tasks.Task<Result<RunningProcess, ProcessError>>
C#
// One interface, three primitives — each takes a CancellationToken:
public interface IProcessRunner
{
Task<FSharpResult<ProcessResult<string>, ProcessError>> CaptureStringAsync(Command command, CancellationToken cancellationToken);
Task<FSharpResult<ProcessResult<byte[]>, ProcessError>> CaptureBytesAsync(Command command, CancellationToken cancellationToken);
Task<FSharpResult<RunningProcess, ProcessError>> SpawnAsync(Command command, CancellationToken cancellationToken);
}
Unlike some interfaces, none of the three is defaulted: a hand-rolled
IProcessRunner implements all three (the doubles that ship with ProcessKit do
this for you). CaptureStringAsync and CaptureBytesAsync are the bulk primitives;
SpawnAsync returns a live handle for streaming and probes. These are deliberately
named apart from the consuming verbs (OutputStringAsync/RunAsync/StartAsync/…):
the verbs layer on top of the primitives — applying the command's Retry policy and
the success/parse semantics — so you implement only the three primitives and get the
whole verb vocabulary for free. (Calling a verb routes through these primitives; for a
single raw capture with no retry, call CaptureStringAsync directly.)
- The
IProcessRunnerseam - Scripting replies
- Custom doubles and mocking frameworks
- What the doubles don't cover
- Record and replay
- CliClient
- Dependency injection
The IProcessRunner seam
Write production code against IProcessRunner and let the caller supply the
runner. In production that runner is a JobRunner (or a ProcessGroup,
which is itself an IProcessRunner, so every run lands in one shared
kill-on-dispose group); in a test it is a double.
You rarely call the interface's three methods directly — the Runner module
gives every runner the full verb vocabulary, each verb taking
(runner, cancellationToken, command):
| Verb | Returns | Routes through |
|---|---|---|
Runner.run | trimmed string, success required | CaptureStringAsync |
Runner.runUnit | unit, success required | CaptureStringAsync |
Runner.outputString | ProcessResult<string> (exit code is data) | CaptureStringAsync |
Runner.outputBytes | ProcessResult<byte[]> | CaptureBytesAsync |
Runner.exitCode | int | CaptureStringAsync |
Runner.probe | bool (exit 0 → true, 1 → false) | CaptureStringAsync |
Runner.parse runner ct parser command | 'T, success required | CaptureStringAsync |
Runner.tryParse runner ct parser command | 'T (parser may fail) | CaptureStringAsync |
Runner.firstLine runner ct predicate command | string option | SpawnAsync |
Runner.start | RunningProcess | SpawnAsync |
Everything in the first eight rows reaches a child only through CaptureStringAsync
(or CaptureBytesAsync), so it runs hermetically against the subprocess-free doubles
below. The last two — firstLine and start — need a live handle and go through
SpawnAsync; both ScriptedRunner and RecordReplayRunner serve it (a RecordReplayRunner
reconstructs a live handle from the recording), so streaming and readiness code replays too —
see what the doubles don't cover.
Production code, generic over the runner:
F#
/// HEAD's commit id, run through whatever runner the caller injects.
let head (runner: IProcessRunner) (ct: CancellationToken) =
Runner.run runner ct (Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ])
C#
/// HEAD's commit id, run through whatever runner the caller injects.
Task<FSharpResult<string, ProcessError>> Head(IProcessRunner runner, CancellationToken ct) =>
runner.RunAsync(new Command("git").Args(["rev-parse", "HEAD"]), ct);
In production you pass JobRunner(); in a test you pass a double and no process
spawns. The retry policy (Command.retry) is
applied by the Runner verbs, so a double exercises your retry handling without
a subprocess too.
Scripting replies
ScriptedRunner (in the ProcessKit.Testing namespace) is the work-horse
double: it returns canned Replys for matched commands. It is immutable and
fluent — On / When add rules and Fallback sets a catch-all, each returning
a new runner:
F#
let runner =
(ScriptedRunner())
// Match when every listed token appears among the command's program and
// arguments (order-independent):
.On([ "git"; "rev-parse"; "HEAD" ], Reply.Ok "abc123\n")
// …or by any predicate over the whole Command:
.When((fun cmd -> cmd.WorkingDirectory.IsSome), Reply.Fail(128, "fatal: not a git repository"))
// …with an optional catch-all:
.Fallback(Reply.Ok "")
C#
var runner = new ScriptedRunner()
// Match when every listed token appears among the command's program and
// arguments (order-independent):
.On(["git", "rev-parse", "HEAD"], Reply.Ok("abc123\n"))
// …or by any predicate over the whole Command:
.When(cmd => cmd.WorkingDirectory is not null, Reply.Fail(128, "fatal: not a git repository"))
// …with an optional catch-all:
.Fallback(Reply.Ok(""));
The pieces:
Reply.Ok stdout— exit 0 with that stdout.Reply.Fail(code, stderr)— a non-zero exit with that stderr.Reply.Exit code— an explicit exit code with empty stdout/stderr.Reply.Signalled n— terminated by signaln(Reply.Signalled ()when the number is unavailable)..WithStdout text/.WithStderr textrefine any reply — e.g.Reply.Fail(1, "merge failed").WithStdout("CONFLICT in app.fs")to model a tool that writes to both streams.- Rule order matters: first match wins.
On([ "git"; "rev-parse"; "HEAD" ])matches any command whose program plus arguments contain all three tokens — it is a subset test, not a positional prefix. - No matching rule and no
Fallbackthrows — a missing stub fails the test loudly rather than silently returning a default, so an unexpected invocation can't slip through. - A scripted reply respects the command's
OkCodes: theProcessResultit produces carries the command's accepted-codes, soIsSuccessand the success-requiring verbs honour them.
A test (any framework — the doubles depend on none; this repo's fixtures are NUnit):
F#
[<TestFixture>]
type GitTests() =
[<Test>]
member _.``head returns the trimmed sha``() =
task {
let runner =
(ScriptedRunner())
.On([ "git"; "rev-parse"; "HEAD" ], Reply.Ok "abc123\n")
match! head runner CancellationToken.None with
| Ok sha -> Assert.That(sha, Is.EqualTo "abc123")
| Error err -> Assert.Fail err.Message
}
C#
[TestFixture]
public class GitTests
{
[Test]
public async Task Head_returns_the_trimmed_sha()
{
var runner = new ScriptedRunner()
.On(["git", "rev-parse", "HEAD"], Reply.Ok("abc123\n"));
switch (await Head(runner, CancellationToken.None))
{
case { IsOk: true, ResultValue: var sha }: Assert.That(sha, Is.EqualTo("abc123")); break;
case { IsOk: false, ErrorValue: var err }: Assert.Fail(err.Message); break;
}
}
}
A Reply.Fail behaves differently depending on the verb that consumes it — the
same honest-result rule as a real run. Through Runner.run / Runner.runUnit
(success required) a non-zero exit becomes Error(ProcessError.Exit …); through
Runner.outputString it stays Ok with result.IsSuccess = false and the code
in result.Code:
F#
task {
let runner = (ScriptedRunner()).Fallback(Reply.Fail(2, "boom"))
let grep = Command.create "grep" |> Command.args [ "needle"; "file" ]
// Success-requiring verb: the non-zero exit surfaces as an error.
match! Runner.run runner CancellationToken.None grep with
| Error(ProcessError.Exit(program, code, _, stderr)) -> () // program="grep", code=2, stderr="boom"
| _ -> ()
// Honest-result verb: the non-zero exit is data.
match! Runner.outputString runner CancellationToken.None grep with
| Ok result -> Assert.That(result.IsSuccess, Is.False)
| Error err -> Assert.Fail err.Message
}
C#
var runner = new ScriptedRunner().Fallback(Reply.Fail(2, "boom"));
var grep = new Command("grep").Args(["needle", "file"]);
// Success-requiring verb: the non-zero exit surfaces as an error.
if (await runner.RunAsync(grep) is { IsOk: false, ErrorValue: { IsExit: true } }) { } // program="grep", code=2, stderr="boom"
// Honest-result verb: the non-zero exit is data.
switch (await runner.OutputStringAsync(grep))
{
case { IsOk: true, ResultValue: var output }: Assert.That(output.IsSuccess, Is.False); break;
case { IsOk: false, ErrorValue: var err }: Assert.Fail(err.Message); break;
}
Custom doubles and mocking frameworks
IProcessRunner is a plain interface, so any .NET mocking framework (Moq,
NSubstitute, FakeItEasy) can stand in for it — handy when the interaction is
what you want to assert (was StartAsync called? with which command?) or when you
want to return specific Error outcomes. The error cases are easy: every
ProcessError case has a public constructor, so a double can return
Error(ProcessError.NotFound("git", None)), Error(ProcessError.Io "..."), and
so on directly.
Returning a ProcessResult is almost as easy. Its constructor is internal, but
the ProcessResult test factories build one directly:
ProcessResult.Success(stdout) for a clean exit, ProcessResult.Failure(stdout, stderr, exitCode)
for a non-zero exit, and ProcessResult.Create(stdout, stderr, outcome, duration) for full control
over the Outcome (e.g. Outcome.TimedOut). The captured-stdout type is inferred — C# writes
ProcessResult.Success("out"), F# writes ProcessResult.Success "out" — and the result behaves
like a real one (IsSuccess, EnsureSuccess, Code, …), so they double as fixtures for any code
that consumes a ProcessResult:
F#
let fixedSha: IProcessRunner =
{ new IProcessRunner with
member _.CaptureStringAsync(_, _) = task { return Ok(ProcessResult.Success "abc123\n") }
member _.CaptureBytesAsync(_, _) = task { return Ok(ProcessResult.Success [| 1uy; 2uy |]) }
member _.SpawnAsync(_, _) =
task { return Error(ProcessError.Unsupported "no streaming in this double") } }
C#
public sealed class FixedSha : IProcessRunner
{
public Task<FSharpResult<ProcessResult<string>, ProcessError>> CaptureStringAsync(Command command, CancellationToken ct) =>
Task.FromResult(FSharpResult<ProcessResult<string>, ProcessError>.NewOk(ProcessResult.Success("abc123\n")));
public Task<FSharpResult<ProcessResult<byte[]>, ProcessError>> CaptureBytesAsync(Command command, CancellationToken ct) =>
Task.FromResult(FSharpResult<ProcessResult<byte[]>, ProcessError>.NewOk(ProcessResult.Success(new byte[] { 1, 2 })));
public Task<FSharpResult<RunningProcess, ProcessError>> SpawnAsync(Command command, CancellationToken ct) =>
Task.FromResult(FSharpResult<RunningProcess, ProcessError>.NewError(ProcessError.NewUnsupported("no streaming in this double")));
}
For canned successes wired through a matcher, ScriptedRunner is still the most
convenient seam (it builds the result for you). Doubles can also delegate
their success path to an inner runner. A custom IProcessRunner written as an
object expression — implementing all three methods — composes cleanly. This one
injects a single transient failure before delegating, so you can test that retry
handling actually retries:
F#
let failOnce (inner: IProcessRunner) : IProcessRunner =
let mutable calls = 0
{ new IProcessRunner with
member _.CaptureStringAsync(command, ct) =
task {
calls <- calls + 1
if calls = 1 then
return Error(ProcessError.Io "transient blip") // ProcessError.isTransient -> true
else
return! inner.CaptureStringAsync(command, ct)
}
member _.CaptureBytesAsync(command, ct) = inner.CaptureBytesAsync(command, ct)
member _.SpawnAsync(command, ct) = inner.SpawnAsync(command, ct) }
C#
public sealed class FailOnce(IProcessRunner inner) : IProcessRunner
{
private int calls;
public Task<FSharpResult<ProcessResult<string>, ProcessError>> CaptureStringAsync(Command command, CancellationToken ct) =>
++calls == 1
? Task.FromResult(
FSharpResult<ProcessResult<string>, ProcessError>.NewError(ProcessError.NewIo("transient blip"))) // ProcessError.isTransient -> true
: inner.CaptureStringAsync(command, ct);
public Task<FSharpResult<ProcessResult<byte[]>, ProcessError>> CaptureBytesAsync(Command command, CancellationToken ct) =>
inner.CaptureBytesAsync(command, ct);
public Task<FSharpResult<RunningProcess, ProcessError>> SpawnAsync(Command command, CancellationToken ct) =>
inner.SpawnAsync(command, ct);
}
Wrap a ScriptedRunner with it and drive a retrying verb to prove the retry
fires — because retry lives in the verb layer over the CaptureStringAsync primitive,
failOnce's single transient error is retried away. If a double is bulk-only and you
want spawning to be a hard error, return
Error(ProcessError.Unsupported "no streaming in this double") from SpawnAsync.
What the doubles don't cover
The subprocess-free doubles center on the bulk primitives. ScriptedRunner and
RecordReplayRunner both implement CaptureStringAsync and
CaptureBytesAsync, and both serve a FakeProcess from SpawnAsync
(so the parts of the live surface a fake can replay — StdoutLinesAsync, the readiness probes —
are testable through them). The one gap is recording a live stream: a
RecordReplayRunner in record mode returns Error(ProcessError.Unsupported …) from
SpawnAsync, because a live stream can't be captured without racing the consumer — record a
streaming call through a capture verb, then replay it as a stream. The full live
RunningProcess surface (WaitForPortAsync, TakeStdin, ProfileAsync, …)
and a Pipeline are best tested against a real (possibly trivial) child
process; keep the scripted/cassette doubles for everything that flows through the capture
primitives.
Record and replay
RecordReplayRunner (also in ProcessKit.Testing) closes the loop: record real
runs to a JSON cassette once, then replay them deterministically — fast,
hermetic, no subprocess in CI.
F#
task {
// Record once against the real tool (wraps a real runner), then save:
let recorder = RecordReplayRunner.Record("fixtures/git.json", JobRunner())
let! _ = Runner.run recorder CancellationToken.None (Command.create "git" |> Command.arg "--version")
recorder.Save() |> ignore // Result<unit, ProcessError> — surfaces write errors
// Replay everywhere else — no subprocess, identical results:
match RecordReplayRunner.Replay "fixtures/git.json" with
| Ok replay ->
match! Runner.run replay CancellationToken.None (Command.create "git" |> Command.arg "--version") with
| Ok version -> () // the recorded stdout, replayed
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
C#
// Record once against the real tool (wraps a real runner), then save:
var recorder = RecordReplayRunner.Record("fixtures/git.json", new JobRunner());
await recorder.RunAsync(new Command("git").Arg("--version"), CancellationToken.None);
recorder.Save(); // Result<unit, ProcessError> — surfaces write errors
// Replay everywhere else — no subprocess, identical results:
var replayResult = RecordReplayRunner.Replay("fixtures/git.json");
if (replayResult is { IsOk: true, ResultValue: var replay })
{
// the recorded stdout, replayed
if (await replay.RunAsync(new Command("git").Arg("--version"), CancellationToken.None) is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
}
else if (replayResult is { IsOk: false, ErrorValue: var loadErr })
Console.Error.WriteLine(loadErr.Message);
Record(path, inner) wraps inner and captures each completed call; Save()
writes the cassette (it is also flushed best-effort on dispose —
RecordReplayRunner is IDisposable — but Save() is the call that surfaces a
write error). Replay(path) returns a Result<RecordReplayRunner, ProcessError>
loaded from the file.
Semantics worth knowing before you commit a cassette:
| Aspect | Behaviour |
|---|---|
| Match key | program + args + a stdin source digest (plus whether stdin was present). In-memory bytes hash their content; a Stdin.FromFile source hashes its path (opt into hashing its contents with RecordReplayOptions.WithFileStdinContentHashing). The working directory does not participate by default — a cassette recorded in one cwd still replays from another — opt in with RecordReplayOptions.WithCwdMatching() |
| Environment | now part of the match key through a redacting fingerprint of the effective environment — the EnvClear flag plus the net effect of the Env/EnvRemove overrides (removals and last-write-wins included; env-name case is insensitive on Windows, sensitive on POSIX), while repeated/no-op overrides with the same final effect still match. Override values never reach the file — only the variable names and a versioned SHA-256 fingerprint — so env secrets can't leak, yet a call with a different value, name, removal, or EnvClear no longer replays an unrelated recording |
| Miss | an unmatched call is ProcessError.CassetteMiss (distinct from a missing program) — replay never spawns a surprise subprocess; a stale cassette fails loudly |
| Duplicates of one key | replay in capture order, then the last entry repeats — a recorded before/after sequence replays faithfully, while retry/probe loops keep getting a stable final answer |
| Bytes | CaptureBytesAsync / outputBytes is supported: a bytes recording stores the exact stdout bytes (base64) and replays them byte-for-byte, including non-UTF-8 output. A text recording (or a pre-v2 cassette) replayed through the bytes verb is honestly ProcessError.Unsupported — it never hands back a lossy re-encode — so re-record that call through the bytes verb |
SpawnAsync | replay reconstructs a live handle (FakeProcess) from the recording, so StdoutLinesAsync / readiness probes / exit replay too. Record mode can't capture a live stream (it would race the consumer) and returns Unsupported — record the call through a capture verb, then replay it as a stream |
| Fidelity | for the capture verbs, a recording's truncation flag and wall-clock duration survive replay, so ProcessResult.Truncated / Duration read true on replay (not a synthetic false / 0). Streaming replay (SpawnAsync) reconstructs the recorded lines and outcome; its duration is measured live and truncation is not replayed |
| Err results | not recorded — only completed runs (a non-zero exit and a captured timeout are results and are recorded) |
| One-shot stdin | Stdin.FromStream / FromLines / FromAsyncLines can't be keyed without consuming them, so recording or replaying such a call errors |
| Format | a versioned JSON envelope — { "Version", "Entries" } (current version 3); a cassette newer than this build understands is rejected on load, while an older compatible one (a v1/v2 cassette) still loads (missing fields default — a pre-v3 entry with no env fingerprint keys as the default, un-customized environment). A partial/crafted entry (omitted fields) is normalized so replay can't trip on a missing value |
Only env values are redacted. program, args, stdout, and stderr are
stored verbatim and can carry secrets (a --password=… flag, a token echoed
to output), so review a fixture before committing it. On Unix the file is written
atomically and owner-only (0600 from creation — a temp file renamed into
place, so it is never briefly world-readable); on Windows it inherits the
containing directory's ACL, so keep secret-bearing fixtures out of world-readable
directories.
A neat trick: in tests, record against a ScriptedRunner instead of
JobRunner() — the whole record → save → replay round trip is then itself
hermetic.
Grow a cassette on miss (VCR "new episodes"). RecordReplayRunner.Auto(path, inner)
replays what the cassette already holds and, on a miss, delegates to inner, records the
result, and grows the file on Save()/dispose — so you build a cassette up incrementally
instead of curating every entry by hand. Existing entries still replay hermetically; only a
first-seen call reaches the real tool. A missing (or empty) file starts a fresh cassette. Use
strict Replay(path) in CI, where a miss should fail loudly. (Like record mode, Auto can't
capture a streaming miss — record such a call through a capture verb first.)
Matching customization & redaction (RecordReplayOptions). Pass an immutable, fluent
RecordReplayOptions to Record / Replay / Auto (use the same options on both sides,
since they change how invocations are keyed):
WithFileStdinContentHashing()— key aStdin.FromFilesource by its contents (a SHA-256 of the bytes) instead of its path, so a cassette matches on what was actually fed to the child (and matches aStdin.FromBytesof the same bytes). Opt-in: the file must exist at record and replay time, and an unreadable file surfacesProcessError.Stdin.WithArgNormalizer(args -> args)— normalize the argument list before matching, so a volatile argument (a temp directory, a nonce) no longer defeats the match — drop it, or rewrite it to a stable placeholder. The raw arguments are still stored verbatim for inspection.WithRedaction(text -> text)— scrub captured text before it is written, so a secret echoed to stdout/stderr never reaches disk. Applied at record time to a string capture's stdout/stderr and a bytes capture's stderr; abyte[]stdout capture is stored opaquely (base64) and is not passed through the redactor.WithCwdMatching()— restore the working directory (Command.CurrentDir) as part of the match key, so two otherwise-identical invocations that ran in different directories are treated as distinct recordings.CassetteEntry.Cwdalways stores the working directory verbatim for inspection regardless of this setting; only its participation in matching is opt-in.
var options = new RecordReplayOptions()
.WithArgNormalizer(args => args.Where(a => !a.StartsWith("/tmp/")).ToArray())
.WithRedaction(text => text.Replace(token, "[REDACTED]"));
// Auto (like Replay) returns a Result — it can fail to load an existing cassette.
if (RecordReplayRunner.Auto("fixtures/git.json", new JobRunner(), options) is { IsOk: true, ResultValue: var recorder })
{
// recorder replays a hit, records a miss, and grows the cassette on Save()...
}
CliClient
CliClient is the foundation for a typed wrapper around an external tool
(git, gh, kubectl, …): it owns the program name, per-client defaults, and
the runner, so your wrapper contributes only the commands and the parsers — and
because the runner is injectable, the wrapper tests hermetically with a
ScriptedRunner.
Create one with CliClient.create name (or CliClient(name)) and configure it,
each call returning a new client:
.WithDefaults(configure)— apply shared defaults with the fullCommandbuilder, e.g.client.WithDefaults(fun c -> c.CurrentDir(repo).Timeout(ts).Env("K", "V"))(timeout, working directory, environment, encoding, ok-codes, retry, logger, …).WithRunner(runner)— run every command throughrunnerinstead of the defaultJobRunner(this is the test seam)
.Command(args) builds a configured Command without running it (the template's
defaults applied), and .RunAsync(args) / .OutputStringAsync(args) / .OutputBytesAsync(args)
(plus ExitCodeAsync/ProbeAsync/ParseAsync/…) build and run through the client's runner.
.EnsureAvailableAsync() is a preflight check — "is the client's program installed?" — with no
spawn (see Preflight: is a program installed?); it
is always local, never delegated to .WithRunner's runner, so a ScriptedRunner injected for
the wrapper's own tests has no bearing on it.
F#
/// A small typed git wrapper. The CliClient is supplied, so tests inject a double.
type Git(client: CliClient) =
/// HEAD's commit id (trimmed stdout, success required).
member _.Head(repo: string) =
client.RunAsync [ "-C"; repo; "rev-parse"; "HEAD" ]
/// Is the work tree clean? The exit code *is* the answer, so probe it.
member _.IsClean(repo: string) =
client.ProbeAsync [ "-C"; repo; "diff"; "--quiet" ]
C#
/// A small typed git wrapper. The CliClient is supplied, so tests inject a double.
public class Git(CliClient client)
{
/// HEAD's commit id (trimmed stdout, success required).
public Task<FSharpResult<string, ProcessError>> Head(string repo) =>
client.RunAsync(["-C", repo, "rev-parse", "HEAD"]);
/// Is the work tree clean? The exit code *is* the answer, so probe it.
public Task<FSharpResult<bool, ProcessError>> IsClean(string repo) =>
client.ProbeAsync(["-C", repo, "diff", "--quiet"]);
}
Production wires the real runner and the per-client defaults:
F#
let git = Git((CliClient.create "git").WithDefaults(fun c -> c.Timeout(TimeSpan.FromSeconds 30.0)))
C#
var git = new Git(new CliClient("git").WithDefaults(c => c.Timeout(TimeSpan.FromSeconds(30))));
…and the wrapper tests against a scripted runner, no subprocess:
F#
[<TestFixture>]
type GitWrapperTests() =
[<Test>]
member _.``Head is trimmed``() =
task {
let scripted =
(ScriptedRunner())
.On([ "git"; "rev-parse"; "HEAD" ], Reply.Ok "abc123\n")
let git = Git((CliClient.create "git").WithRunner scripted)
match! git.Head "/repo" with
| Ok sha -> Assert.That(sha, Is.EqualTo "abc123")
| Error err -> Assert.Fail err.Message
}
C#
[TestFixture]
public class GitWrapperTests
{
[Test]
public async Task Head_is_trimmed()
{
var scripted = new ScriptedRunner()
.On(["git", "rev-parse", "HEAD"], Reply.Ok("abc123\n"));
var git = new Git(new CliClient("git").WithRunner(scripted));
switch (await git.Head("/repo"))
{
case { IsOk: true, ResultValue: var sha }: Assert.That(sha, Is.EqualTo("abc123")); break;
case { IsOk: false, ErrorValue: var err }: Assert.Fail(err.Message); break;
}
}
}
…or against a cassette recorded from the real tool once.
Dependency injection
The separate ProcessKit.Extensions.DependencyInjection package wires the seam
into Microsoft.Extensions.DependencyInjection. AddProcessKit() registers an
IProcessRunner in the container — logger-aware when the container already has
an ILoggerFactory, so runs emit ProcessKit's lifecycle events with no extra
wiring. (See the Dependency injection guide for
configured defaults, keyed per-tool clients, and a shared container-managed group.)
F#
services.AddProcessKit() |> ignore
// Consumers depend on the interface — the same seam you test against:
type Deployer(runner: IProcessRunner) =
member _.Deploy() =
Runner.run runner CancellationToken.None (Command.create "deploy")
C#
services.AddProcessKit();
// Consumers depend on the interface — the same seam you test against:
public class Deployer(IProcessRunner runner)
{
public Task<FSharpResult<string, ProcessError>> Deploy() =>
runner.RunAsync(new Command("deploy"), CancellationToken.None);
}
AddProcessKit registers via TryAdd, so a pre-existing IProcessRunner is
left intact: to substitute a double in an integration test, register your
ScriptedRunner (or RecordReplayRunner) before calling AddProcessKit,
and the real runner backs off. In a plain unit test you usually skip the
container entirely and construct Deployer(scriptedRunner) directly — the whole
point of depending on the interface.
Next: Platform support · Supervision · Running commands
Observability — logging, tracing & metrics
ProcessKit reports its run lifecycle through the three standard .NET diagnostic channels, all opt-in and free when nothing is listening:
Microsoft.Extensions.Logging— structured lifecycle events on anILoggeryou attach.System.Diagnosticstracing — oneActivity(span) per completed run, on a namedActivitySource.System.Diagnostics.Metrics— counters and a histogram on a namedMeter.
Secrets never leave the process. argv and environment values are never written to a log message, a trace tag, or a metric tag. Only the program name and non-secret facts (pid, outcome, durations, exit code / signal, retry / restart counts, run id) are emitted. This invariant holds across all three channels.
Logging
Attach any Microsoft.Extensions.Logging.ILogger with Command.Logger (F#: Command.logger):
let cmd = Command.create "deploy" |> Command.logger logger
var cmd = new Command("deploy").Logger(logger);
No logger set → no-op, no allocation. Each event is emitted through a cached LoggerMessage.Define
delegate, so when the level is disabled there is no formatting or boxing on the hot path.
Event taxonomy
Every event has a stable EventId (name + number) so you can filter or route by id — the ids are exposed
as ProcessKitDiagnostics.Events.* so you never hard-code a number — and every run-scoped event
carries a per-run RunId (plus the Pid on spawn) so a run's lines tie together even across a
concurrent fleet, and even in a sink that does not capture logging scopes.
| Event | EventId | ProcessKitDiagnostics.Events | Level | Fields |
|---|---|---|---|---|
| Process spawned | 1 ProcessSpawned | .ProcessSpawned | Debug | program, pid, run id |
| Process exited | 2 ProcessExited | .ProcessExited | Debug | program, outcome, duration, run id |
| Process timed out | 3 ProcessTimedOut | .ProcessTimedOut | Warning | program, timeout, run id |
| Run retry | 4 ProcessRetry | .ProcessRetry | Debug | program, attempt, delay, run id |
| Supervisor restart | 5 SupervisorRestart | .SupervisorRestart | Debug | program, restart #, delay |
| Supervisor storm pause | 6 SupervisorStormPause | .SupervisorStormPause | Warning | program, pause |
The RunId is stamped once per logical run at the verb layer, so a run and all its retries share one
id; a directly-spawned streaming run (StartAsync) gets a fresh per-incarnation id. It is a compact,
per-process value — a log sink already scopes by process, and cross-process correlation is the trace's job.
Tracing
ProcessKit publishes an ActivitySource named ProcessKitDiagnostics.ActivitySourceName ("ProcessKit").
A completed run yields one processkit.run span whose duration is the real run length, tagged with:
processkit.program, processkit.run_id, processkit.outcome (exited / signalled / timedout),
processkit.exit_code (when it exited), processkit.signal (when signalled), processkit.pid — never
argv or environment. The span nests under whatever Activity was current when the run started, so a
run inside an HTTP request appears under that request's trace.
Wire it into OpenTelemetry:
services.AddOpenTelemetry().WithTracing(t => t.AddSource(ProcessKitDiagnostics.ActivitySourceName));
Free when no listener subscribes (no span is created). A run that is abandoned (spawned, never finished) emits no span.
Metrics
ProcessKit publishes a Meter named ProcessKitDiagnostics.MeterName ("ProcessKit"), OpenTelemetry-
compatible. Tag cardinality is deliberately bounded — instruments are tagged by program name and a
small closed set of outcome labels, never by argv.
Units follow the OpenTelemetry/UCUM convention — dimensionless counts use a {…} annotation, and the
duration histogram is in seconds (the OTel norm for *.duration).
| Instrument | Kind | Unit | Tags |
|---|---|---|---|
processkit.runs.started | Counter | {run} | program |
processkit.runs.completed | Counter | {run} | program, outcome |
processkit.runs.active | UpDownCounter | {run} | program |
processkit.run.duration | Histogram | s | program, outcome |
processkit.retries | Counter | {retry} | program |
processkit.supervisor.restarts | Counter | {restart} | program |
processkit.supervisor.storm_pauses | Counter | {pause} | program |
services.AddOpenTelemetry().WithMetrics(m => m.AddMeter(ProcessKitDiagnostics.MeterName));
runs.active is incremented at spawn and decremented when the run's handle stops being "in flight" —
either of two events, whichever happens first: it reaches a terminal verb (Run/Output*/Wait*/
Profile/Finish, or racing it via WaitAnyAsync/WaitAllAsync), or its RunningProcess handle is
disposed without ever reaching one (a streaming handle dropped without FinishAsync). Either way
runs.active returns to zero for that run — it never leaks upward just because a caller only streamed
and disposed. Only the first of those two events counts toward runs.completed/run.duration/the trace
span: a handle disposed without a terminal verb is still not counted as completed ("an abandoned run
simply isn't counted as completed"), so runs.started/runs.completed can legitimately diverge even
though runs.active is exact.
Dependency injection
The ProcessKit.Extensions.DependencyInjection package wires ProcessKit into
Microsoft.Extensions.DependencyInjection. It stays dependency-light — only the DI, Logging,
Options, and Configuration extension packages that a DI-integration package inevitably needs, and no
hosting dependency — and every registration uses TryAdd, so a pre-existing registration of yours
always wins.
The runner
AddProcessKit() registers IProcessRunner as a singleton JobRunner. When the container also has an
ILoggerFactory, the runner is wrapped so every run it drives emits ProcessKit's lifecycle events under
the ProcessKit category (argv/env never logged — see Observability).
services.AddProcessKit();
// Injected anywhere:
public class Deployer(IProcessRunner runner)
{
public Task<FSharpResult<string, ProcessError>> Deploy() =>
runner.RunAsync(new Command("deploy"), CancellationToken.None);
}
Default settings (ProcessKitOptions)
Configure defaults applied to every DI-resolved run — from code or from configuration. Each default is applied only when the command does not set it itself, so a per-command value always wins.
// From code:
services.AddProcessKit(o =>
{
o.DefaultTimeout = TimeSpan.FromSeconds(30);
o.DefaultWorkingDirectory = "/app";
});
// …or bound from an IConfiguration section (appsettings.json "ProcessKit"):
services.AddProcessKit(configuration.GetSection("ProcessKit"));
ProcessKitOptions covers what a primitive runner can apply on the spawn path — timeout and working
directory. Retry is a verb-layer policy (the retry loop reads the command before this runner sees it),
so a retry default can't ride on the bare runner; set it — and richer per-tool defaults like encoding,
ok-codes, and environment — on a named client instead, whose template precedes the verb:
services.AddProcessKitClient("git", "git",
c => c.WithDefaults(cmd => cmd.Retry(3, TimeSpan.FromSeconds(1), e => e.IsTransient)));
Named / keyed tool clients
Register a keyed CliClient per external tool, so an app injects "the git client" or "the ffmpeg client"
by role. Each client runs through the container's registered IProcessRunner (so it is logger-aware and
honours a shared group or a test runner), and configure applies shared defaults via the CliClient
builder.
services.AddProcessKit();
services.AddProcessKitClient("git", "git", c => c.WithDefaults(cmd => cmd.CurrentDir("/repo")));
services.AddProcessKitClient("ffmpeg", "ffmpeg");
public class Repo([FromKeyedServices("git")] CliClient git)
{
public Task<FSharpResult<string, ProcessError>> Status() => git.RunAsync(["status"]);
}
A shared, container-managed process group
AddProcessKitGroup() backs IProcessRunner with a single shared ProcessGroup whose lifetime is the
container's — every run goes into one kill-on-dispose container, and disposing the provider reaps the
whole tree. Ideal for a hosted service that should leave no orphaned children when it stops. The
ProcessGroup is also registered directly, so you can inject it for tree control (Signal / Suspend /
Members / …). Call it instead of AddProcessKit() when you want a shared group.
services.AddProcessKitGroup();
// IProcessRunner now runs every command into the shared group;
// await using the provider (or host shutdown) reaps all children.
Both AddProcessKitGroup() and AddProcessKit() register IProcessRunner with TryAdd, so call one
or the other — whichever runs first wins. If AddProcessKit() runs first, IProcessRunner stays the
per-run JobRunner, and a later AddProcessKitGroup() still registers the ProcessGroup but no runs
go into it — an easy-to-miss mis-wire. AddProcessKitGroup(configure) / AddProcessKitGroup(configuration)
apply the same ProcessKitOptions defaults as the AddProcessKit overloads.
Hosting a supervised child
Use the ProcessKit.Extensions.Hosting package when a supervised child should live for the host's
lifetime. It depends only on Microsoft.Extensions.Hosting.Abstractions, discovers an existing
DI-registered IProcessRunner when one is present, starts Supervisor.RunAsync in the background, and
calls RunningProcess.StopAsync during host shutdown.
services.AddProcessKitGroup();
services.AddProcessKitHostedProcess(
"worker",
new Command("worker").Arg("--serve"),
supervisor => supervisor
.Restart(RestartPolicy.OnCrash)
.OnRestart(e => metrics.Restarts.Add(1)));
services.ConfigureProcessKitHostedProcess("worker", o =>
{
o.ShutdownGracePeriod = TimeSpan.FromSeconds(10);
});
Resolve HostedProcessService by the same key when you need the last SupervisionOutcome or stop
outcome for health reporting. It also exposes live supervision telemetry — IsSupervisionActive,
RestartCount, IsStormPaused — for anything that wants to observe the child without waiting for
supervision to end (e.g. metrics, or the health check below).
Health-checking a hosted process
AddProcessKitHostedProcessHealthCheck(name) registers a keyed IHealthCheck
(HostedProcessHealthCheck, same key as AddProcessKitHostedProcess) that maps the named hosted
process's supervision state: Healthy while it is running (including restarting within policy),
Degraded while the failure-storm guard (Supervisor.StormPause) is throttling restarts, and
Unhealthy once supervision is not active (not started yet, or ended — an error, an exhausted
restart budget, a permanent-failure give-up, or a stop-predicate match).
This is opt-in and stays in ProcessKit.Extensions.Hosting (not a separate package): its only extra
dependency, Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions, is Abstractions-only —
IHealthCheck / HealthCheckResult / HealthCheckRegistration, never the full
Microsoft.Extensions.Diagnostics.HealthChecks package that supplies AddHealthChecks() /
IHealthChecksBuilder / the concrete polling HealthCheckService. That package stays out of this
one's dependency graph, so a consumer who never calls AddProcessKitHostedProcessHealthCheck never
pulls it in either — but it also means this method cannot call AddHealthChecks() on your behalf.
Wire the registered keyed check into your own health-checks pipeline (already referenced
transitively via the ASP.NET Core shared framework in a web host; add
Microsoft.Extensions.Diagnostics.HealthChecks explicitly in a Worker Service) with
HealthCheckRegistration's factory overload:
services.AddProcessKitHostedProcess("worker", new Command("worker").Arg("--serve"));
services.AddProcessKitHostedProcessHealthCheck("worker");
services.AddHealthChecks().Add(
new HealthCheckRegistration(
"worker",
sp => sp.GetRequiredKeyedService<HostedProcessHealthCheck>("worker"),
failureStatus: null,
tags: null));
Platform support
ProcessKit treats platform behaviour as first-class. Every child you start lives inside the
operating system's own containment primitive, so the kill-on-dispose tree guarantee holds on
Windows, Linux, and macOS/BSD alike. Where a mechanism is genuinely weaker than another, the
difference is reported honestly — the active Mechanism is queryable and unsupported operations
return a typed ProcessError, never a silent downgrade. This page collects every per-OS
mechanism, capability matrix, and caveat in one place.
Containment mechanisms
A ProcessGroup wraps one of three OS primitives. Whichever it gets, disposing the group (or the
live RunningProcess from a one-shot verb) reaps the whole tree — children, grandchildren, and
anything they spawned — as a single kernel operation.
Mechanism | Platform | How containment works |
|---|---|---|
Mechanism.JobObject | Windows | A Job Object created with kill-on-close. Children are spawned suspended, assigned to the job, then resumed, so even a grandchild forked in the first instant is already contained. Teardown closes the job handle (KILL_ON_JOB_CLOSE) or terminates the job. |
Mechanism.CgroupV2 | Linux (when resource limits are requested and a usable cgroup v2 root exists) | A private cgroup under the unified hierarchy. Each child is launched through a small /bin/sh helper that joins the cgroup (writes its own pid to cgroup.procs) before execing the target in place, so the target is contained on its first instruction and a child it forks immediately inherits the limits; teardown is cgroup.kill followed by removing the cgroup directory. |
Mechanism.ProcessGroup | macOS/BSD, and the Linux default when no limits are requested | POSIX process groups. Each spawned child forms its own process-group id (pgid); teardown sends SIGKILL to the tracked pgids (killpg). |
When each mechanism is chosen
The selection at ProcessGroup.Create is deterministic per platform:
- Windows always uses a Job Object (
Mechanism.JobObject), with or without limits. When limits are requested they are applied to the job; if they cannot be applied, creation fails withProcessError.ResourceLimit. - Linux uses a cgroup v2 (
Mechanism.CgroupV2) only when resource limits are requested and cgroup v2 is mounted and usable at the real cgroup-v2 root. Without limits, Linux uses the POSIX process group (Mechanism.ProcessGroup) — so an ordinary, limit-free group on Linux reportsProcessGroup, notCgroupV2. If limits are requested but no usable cgroup exists, creation fails withProcessError.ResourceLimitrather than running unbounded. - macOS / BSD always use a POSIX process group (
Mechanism.ProcessGroup). They have no whole-tree limit primitive, so requesting limits fails fast withProcessError.ResourceLimit.
Reading the active mechanism
ProcessGroup.Mechanism reports which primitive you actually got, so code that depends on a
guarantee can check rather than assume:
F#
match ProcessGroup.Create() with
| Ok group ->
use group = group
match group.Mechanism with
| Mechanism.JobObject -> printfn "Windows Job Object — whole-tree kill, members, stats"
| Mechanism.CgroupV2 -> printfn "Linux cgroup v2 — whole-tree kill, signals, limits, stats"
| Mechanism.ProcessGroup -> printfn "POSIX process group — kill-on-dispose, leaders-only members"
| Error err -> eprintfn $"{err.Message}"
C#
using var group = ProcessGroup.Create().GetValueOrThrow();
Console.WriteLine(group.Mechanism switch
{
{ IsJobObject: true } => "Windows Job Object — whole-tree kill, members, stats",
{ IsCgroupV2: true } => "Linux cgroup v2 — whole-tree kill, signals, limits, stats",
{ IsProcessGroup: true } => "POSIX process group — kill-on-dispose, leaders-only members",
_ => "unknown mechanism",
});
The Mechanism.IsJobObject / IsCgroupV2 / IsProcessGroup properties are the same check in
boolean form, convenient from C#.
Target frameworks
ProcessKit targets .NET 8.0 and .NET 10.0, and is usable from F# and C# alike. The
containment work is done through platform P/Invoke (Win32 for the Job Object, the cgroup
filesystem and libc on Unix), so the supported runtime set is Windows, Linux, and macOS/BSD —
the desktop and server platforms these target frameworks run on.
The full test suite (minus the Stress category) runs in CI's test job matrix on
ubuntu-latest, ubuntu-24.04-arm, windows-latest, and macos-latest — so the native syscall
layer (direct syscall(2) invocations, siginfo struct layout, signal/epoll handling in
Native.Posix.fs) is verified on Linux ARM64 as well as x64, not merely asserted correct by
argument-passing convention. macOS's GitHub-hosted runner is Apple Silicon (arm64) already; Windows
CI runs on x64 only.
Trimming and NativeAOT
CLI tools — a common consumer of a process library — increasingly ship as PublishTrimmed or
NativeAOT images, so ProcessKit's runtime packages declare their compatibility explicitly and back the
claim with a CI smoke that actually publishes and runs a NativeAOT consumer.
| Package | IsTrimmable | IsAotCompatible | Notes |
|---|---|---|---|
ProcessKit | ✅ | ✅ | Containment is platform P/Invoke with no reflection, dynamic codegen, or reflection-backed printf/%A on any path except the annotated OutputJsonAsync verb (see below). |
ProcessKit.Extensions.DependencyInjection | ✅ | ✅ | Factory-based registration; the AddProcessKit/AddProcessKitGroup IConfiguration overloads are the one exception (see below). |
ProcessKit.Extensions.Hosting | ✅ | ✅ | Factory-based DI plus an IHostedService wrapper; options come from the AOT-safe Activator.CreateInstance<T>() path. |
ProcessKit.Testing | ❌ | ❌ | Not trim/AOT-safe by design — see the boundary below. This is a test-only package, referenced from test projects that are not themselves trimmed/AOT-published. |
The one annotated exception (DI). AddProcessKit(IConfiguration) and AddProcessKitGroup(IConfiguration)
bind ProcessKitOptions from configuration by reflection, which is not trim/AOT-safe. Both carry
[RequiresUnreferencedCode] / [RequiresDynamicCode], so a consumer that calls them from a trimmed/AOT app
gets a precise warning pointing at the overload — exactly as Microsoft's own DI/options packages behave. Use
the Action<ProcessKitOptions> overload (or bind configuration yourself and call configure) from an AOT app.
The OutputJsonAsync boundary (core). The typed JSON verb (Command.OutputJsonAsync<'T>,
IProcessRunner.OutputJsonAsync<'T>, CliClient.OutputJsonAsync<'T>, Pipeline.OutputJsonAsync<'T>, and the
underlying Runner.outputJson) deserializes stdout with the reflection-based
JsonSerializer.Deserialize(string, Type, JsonSerializerOptions) overload, so — like the DI IConfiguration
overloads above — all five surfaces carry [RequiresUnreferencedCode] / [RequiresDynamicCode]. Under
NativeAOT, reflection-based System.Text.Json deserialization of an arbitrary caller-supplied 'T is not
supported by default (JsonSerializer.IsReflectionEnabledByDefault = false), so calling this verb from a
NativeAOT app without a source-generated resolver fails at run time rather than silently misbehaving. To use
it from a trimmed/AOT app, pass a JsonSerializerOptions whose TypeInfoResolver comes from a
source-generated JsonSerializerContext for 'T (F# cannot itself author the System.Text.Json source
generator — it is a Roslyn/C# generator the F# compiler does not run — but a C# project's generated context
can be passed in from F# or C# alike); otherwise avoid the verb in an AOT-published app. The aot-smoke CI
job (below) does not call this verb, so it stays unaffected by this boundary.
The ProcessKit.Testing boundary. The record/replay cassette surface (RecordReplayRunner) serializes
and deserializes with reflection-based System.Text.Json. F# cannot use the System.Text.Json source
generator (it is a Roslyn/C# source generator that the F# compiler does not run), so the usual
AOT remedy is unavailable. Rather than emit silent "assembly was not verified" warnings, the package is
honestly not declared trimmable/AOT-compatible. Because it is meant to be referenced only from test
projects — code never shipped inside a trimmed/AOT application — this is a boundary in practice, not a
limitation of what you deploy.
F# runtime baseline. FSharp.Core — the F# runtime every F# assembly depends on — is not fully
trim/AOT-annotated (its printf/quotation/reflection surface), so a NativeAOT publish of any F#
application surfaces IL2104/IL3053 warnings attributed to FSharp.Core, independent of ProcessKit.
Those are a known F# baseline, not a ProcessKit defect; warnings attributed to a ProcessKit* assembly would
be. ProcessKit's own assemblies publish warning-free.
How this is validated. samples/FSharp.NativeAot is a minimal consumer of
ProcessKit and ProcessKit.Extensions.DependencyInjection, published with PublishAot=true and run by
the aot-smoke job in .github/workflows/ci.yml on both linux-x64 (POSIX
process-group backend) and win-x64 (Windows Job Object backend). It spawns a child, captures a non-zero
exit as an honest result, runs a child inside a kill-on-dispose ProcessGroup, and runs a child through a
DI-resolved IProcessRunner (AddProcessKit); the job fails if ilc attributes any warning to a ProcessKit*
assembly or if the native binary exits non-zero. So the compatibility above is exercised in a real
ahead-of-time-compiled image, not merely declared in metadata. (ProcessKit.Extensions.Hosting shares the
same factory-based, reflection-free pattern; its declaration rests on that analysis rather than a running
hosted-service image in this smoke.)
Capability matrices
In the matrices below the columns are the three mechanisms. The POSIX process group column covers macOS/BSD and the Linux default (a limit-free group), since they share one backend. Legend: ✅ full support · 🟡 supported with a documented qualification · ❌ not available.
Whole-tree teardown
| Capability | Windows (Job Object) | Linux cgroup v2 | POSIX process group |
|---|---|---|---|
| Kill-on-dispose, whole tree | ✅ | ✅ | ✅ |
Graceful ShutdownAsync (TERM → grace → KILL) | 🟡 atomic kill only | ✅ | ✅ |
ShutdownAsync(grace) on Windows has no per-job graceful signal, so it is the atomic Job terminate;
on the Unix mechanisms it is SIGTERM, then a grace window, then SIGKILL.
Signals (Signal)
| Capability | Windows (Job Object) | Linux cgroup v2 | POSIX process group |
|---|---|---|---|
Signal.Kill | ✅ maps to Job terminate | ✅ | ✅ |
Any other signal (Term, Int, Hup, Quit, Usr1, Usr2, Other n) | ❌ ProcessError.Unsupported | ✅ | ✅ |
Suspend / resume
| Capability | Windows (Job Object) | Linux cgroup v2 | POSIX process group |
|---|---|---|---|
Suspend / Resume the whole tree | ✅ per-process freeze across the job | ✅ cgroup.freeze | ✅ SIGSTOP / SIGCONT |
Member listing (Members)
| Capability | Windows (Job Object) | Linux cgroup v2 | POSIX process group |
|---|---|---|---|
Members() snapshot | ✅ whole tree | ✅ whole tree | 🟡 tracked group leaders only |
Stats (Stats / SampleStatsAsync)
| Capability | Windows (Job Object) | Linux cgroup v2 | POSIX process group |
|---|---|---|---|
ActiveProcessCount | ✅ | ✅ | ✅ |
TotalCpuTime + PeakMemoryBytes | ✅ | ✅ | ❌ active count only |
On the POSIX process-group mechanism, ProcessGroupStats.TotalCpuTime and PeakMemoryBytes are
None — only the live process count is available. Windows reads Job Object accounting; the cgroup
mechanism reads cpu.stat / memory.peak.
Resource limits (ProcessGroupOptions)
| Capability | Windows (Job Object) | Linux cgroup v2 | POSIX process group |
|---|---|---|---|
WithMemoryMax (whole tree) | ✅ | ✅ | ❌ ProcessError.ResourceLimit |
WithMaxProcesses | ✅ | ✅ | ❌ ProcessError.ResourceLimit |
WithCpuQuota | 🟡 approximate | ✅ | ❌ ProcessError.ResourceLimit |
WithCpuQuota is a fraction of a single core (0.5 = half a core, 2.0 = two cores). On Windows
it is converted against the host's CPU count and is approximate. Because limits need a real
limit-capable container, the POSIX process-group mechanism cannot enforce any of them — requesting
limits where none can apply fails at creation with ProcessError.ResourceLimit rather than
returning a silently-unbounded group.
Everything not listed here — capture, line streaming, interactive stdin, encodings, buffer policies, timeouts, retry, pipelines, supervision, readiness probes, cancellation, and the testing seams — is platform-agnostic and behaves identically everywhere. See commands.md, streaming.md, pipelines.md, supervision.md, and testing.md.
Caveats
The honest fine print — mostly consequences of OS semantics, plus a few tracked internal constraints that do not change the public surface.
POSIX process groups: a setsid child can escape. The process-group mechanism tracks each
child's pgid, and teardown signals those pgids. A descendant that deliberately starts a new
session (a setsid call) gets a fresh process group that the parent group does not track, so it
can outlive the teardown. This is the genuine weakness of the process-group mechanism; it is why
ProcessGroup.Mechanism is reported rather than papered over. The Job Object and cgroup v2
mechanisms have no such hole — membership is enforced by the kernel container, not by group
bookkeeping. When this matters, check the active mechanism.
Unix privilege drop clears supplementary groups unless you set them. A Uid/Gid/User drop
runs through the setpriv helper (util-linux), which by default clears the parent's supplementary
groups so the child never keeps root's — but a child dropped to a service user then lacks that user's
group memberships (docker, video, adm, …). Pass Command.Groups(gids) to set the child's
supplementary groups explicitly (mapped to setpriv --groups); it is honoured only alongside a
Uid/Gid drop, so requesting it without one fails with ProcessError.Spawn rather than being
silently ignored. The whole family is Unix-only: on Windows Uid/Gid/Groups/Setsid/Umask
each fail the spawn with ProcessError.Unsupported, never a silent no-op. setpriv ships on mainstream
Linux; where it is absent (macOS/BSD) a Uid/Gid/Groups drop fails with a typed ProcessError.Spawn
naming the missing helper.
Windows delivers only Signal.Kill. Windows has no general signal abstraction. Signal.Kill
maps to the Job Object terminate; every other Signal value (Term, Int, Hup, Quit,
Usr1, Usr2, Other n) returns ProcessError.Unsupported on Windows. Portable code that needs
a cooperative stop should drive the child another way (a known stdin command, a control file) and
fall back to ShutdownAsync / Signal.Kill for the hard stop. On the Unix mechanisms the full set is
delivered.
No whole-tree resource limits on macOS/BSD or the Linux process-group fallback. Limits require
a Windows Job Object or a Linux cgroup v2; the POSIX process-group mechanism has no primitive to
cap a tree's memory, process count, or CPU. Requesting any limit there makes ProcessGroup.Create
return ProcessError.ResourceLimit immediately — an unapplied cap is no protection, so the group
is never created unbounded. See Running in containers
for what this means in practice inside Docker/Kubernetes.
cgroup v2 needs the real cgroup root. The cgroup v2 mechanism is selected on Linux only when
limits are requested and a usable cgroup v2 hierarchy is available. Enabling the controllers a
limit needs (writing the parent's cgroup.subtree_control) is permitted by cgroup v2's
"no internal processes" rule only at the real hierarchy root. A cgroup namespace root — what an
ordinary container or a systemd session/scope/service sees — does not qualify and the write is
refused (surfacing as ProcessError.ResourceLimit). In practice real cgroup limit enforcement
needs a minimal init sitting at the true root; elsewhere a limit-free group simply uses the POSIX
process-group mechanism. Check ProcessGroup.Mechanism when the limit must not silently fail to
apply. See Running in containers for the container-specific consequences —
PID 1, minimal/shell-less images, and container-level limits vs ProcessGroupOptions limits.
Output is decoded as UTF-8 by default. Captured stdout/stderr text is decoded as UTF-8 unless
you say otherwise. A Windows console program that emits a legacy OEM code page will mis-decode;
set the encoding explicitly per stream with Command.StdoutEncoding / Command.StderrEncoding
(or Command.Encoding for both). For legacy code pages, register the code-page provider first
(System.Text.Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)), then pass the
Encoding you need.
POSIX pgid reuse. Process-group signalling is inherently best-effort against pid/pgid reuse: between a child exiting and the group teardown running, the OS can recycle that pgid for an unrelated process. The backend prunes dead entries on every probe to keep the window minimal, but it cannot be eliminated at the process-group layer — the cgroup v2 mechanism (used when limits are requested) closes it, since membership is kernel-enforced.
In-flight line without a byte cap, and streaming backlog. OutputBufferPolicy.MaxBytes bounds the
in-flight (not-yet-terminated) line too for the buffered verbs — it is force-flushed at the cap, so a
newline-free flood can't outgrow the buffer. Without a byte cap, a single not-yet-terminated line still
grows until end of stream (MaxBytes does not apply to the streaming verbs, which are consumer-paced
instead). By default, a streamed consumer (StdoutLinesAsync / OutputEventsAsync) that stops draining
while the child keeps writing grows the backing channel unbounded. Opt in to
Command.StreamBuffer/StreamBufferPolicy to cap that channel instead — Backpressure,
DropOldest/DropNewest, or Error; see Streaming — or
pair an untrusted or chatty child with a Command.Timeout, which bounds the run and ends the stream at
the deadline either way.
One consumption per RunningProcess. The streaming verbs compose in one session
(WaitForLineAsync → StdoutLinesAsync → FinishAsync); OutputStringAsync / OutputBytesAsync / WaitAsync / ProfileAsync are
each a standalone terminal. The handle enforces this: once one consumer has claimed the output
pipes, a second, conflicting one is refused rather than racing two readers on the same pipe — the
Result-returning verbs return ProcessError.Unsupported, while WaitAsync / ProfileAsync / StdoutLinesAsync
/ OutputEventsAsync throw InvalidOperationException. Pick one consumption model per handle.
Concurrency-friendly I/O. Waiting on a running child no longer blocks a dedicated thread on either
platform — Windows uses a thread-pool registered wait, and POSIX uses an event-driven SIGCHLD
registration (see CHANGELOG.md) — and the parent side of a child's pipes is now
genuinely asynchronous on both: Windows uses overlapped named pipes over IOCP, and Linux/macOS wrap
each stdio channel's parent end (an AF_UNIX socketpair) in a Socket/NetworkStream whose reads
and writes complete through the runtime's epoll/kqueue event loop — no thread-pool thread parked per
piped stream. So a very large WaitAllAsync, a busy Supervisor, or a wide Exec.outputAll fan-out
of many piped children no longer grows thread-pool occupancy in step with the fleet size. This is
an internal characteristic only — the Task-based public API is unchanged.
Next: Running in containers · Process groups · Running commands · Streaming & interactive I/O · docs index
Running in containers
Containers are the default deployment target for server .NET, and they are also where the
platform fine print in Platform support matters most: which containment
Mechanism a ProcessGroup actually gets, whether it behaves as PID 1, and whether its base
image even has a shell all depend on the container, not on ProcessKit. This guide collects the
container-specific consequences of that fine print in one place — it does not repeat the
mechanism/capability details already covered in Platform support, it builds
on them.
- Which mechanism you actually get in a container
- Running as PID 1
- Graceful shutdown on orchestrator SIGTERM
- Minimal images: musl/Alpine and shell-less images
- Container resource limits vs
ProcessGroupOptionslimits
Which mechanism you actually get in a container
On Linux, ProcessGroup.Create() picks Mechanism.ProcessGroup (POSIX process groups) unless you
ask for resource limits, in which case it needs a real, writable cgroup v2 hierarchy to grant
Mechanism.CgroupV2 — see
cgroup v2 needs the real cgroup root in Platform support. That
matters most inside a container, because the cgroup filesystem an ordinary container sees is a
private cgroup namespace, not the host's real root, and cgroup v2's "no internal processes"
rule only lets you enable the controllers a limit needs (writing cgroup.subtree_control) at the
real root:
- An ordinary, unprivileged container or Kubernetes pod does not expose the real cgroup v2
root. A limit-free
ProcessGroup.Create()still works fine there (you getMechanism.ProcessGroup), butProcessGroup.Create(optionsWithLimits)fails fast withProcessError.ResourceLimit— not a silent fallback to the process-group mechanism. An unenforced cap is not a real cap, so ProcessKit refuses to hand back a group that looks limited but isn't. - A privileged container run with the host's cgroup namespace (
docker run --privileged --cgroupns=host, or an equivalent host-cgroup-namespace setup) does expose the real root, so requesting limits there succeeds and grantsMechanism.CgroupV2. This is exactly the shape this repository's own CI uses to exercise the cgroup v2 backend for real (thetest-cgroup-limitsjob in.github/workflows/ci.ymlrunsdocker run --rm --privileged --cgroupns=host …); ordinary CI containers (and ordinary production containers) do not have this and are not expected to.
The practical rule: don't request ProcessGroupOptions limits from inside an ordinary
container unless you know it was started with host-cgroup-namespace privileges — check
ProcessGroup.Mechanism (or handle ProcessError.ResourceLimit from Create) rather than
assuming. If the container itself already has resource caps (the usual case — see
Container resource limits vs ProcessGroupOptions limits
below), you may not need group-level limits at all.
F#
let options =
ProcessGroupOptions()
.WithMemoryMax(256L * 1024L * 1024L)
match ProcessGroup.Create options with
| Ok group ->
use group = group
printfn $"got {group.Mechanism} with limits actually enforced"
| Error(ProcessError.ResourceLimit msg) ->
// typical inside an ordinary, unprivileged container: no real cgroup v2 root to enable
// the memory controller on, so the cap can't be enforced — fail loudly instead of running
// unbounded, then fall back to relying on the container's own memory limit instead.
eprintfn $"container has no usable cgroup v2 root: {msg}"
| Error err -> eprintfn $"{err.Message}"
C#
var options = new ProcessGroupOptions().WithMemoryMax(256L * 1024L * 1024L);
var created = ProcessGroup.Create(options);
switch (created)
{
case { IsOk: true, ResultValue: var group }:
using (group)
Console.WriteLine($"got {group.Mechanism} with limits actually enforced");
break;
case { IsOk: false, ErrorValue: ProcessError.ResourceLimit { Detail: var msg } }:
// typical inside an ordinary, unprivileged container — see the F# comment above.
Console.Error.WriteLine($"container has no usable cgroup v2 root: {msg}");
break;
case { IsOk: false, ErrorValue: var err }:
Console.Error.WriteLine(err.Message);
break;
}
Running as PID 1
A containerized app is commonly PID 1 inside its PID namespace (no init process ahead of it),
which brings the two well-known Unix PID 1 responsibilities: the kernel reparents any
orphaned descendant in the namespace to PID 1, and PID 1 gets no default disposition for
signals it hasn't explicitly handled (an unhandled SIGTERM sent to PID 1 is ignored by the
kernel, unlike for any other process).
What this means for a ProcessKit-using app:
- Zombie reaping for ProcessKit's own tree is already covered, PID 1 or not. Whatever spawned a
process —
Command, aProcessGroup, aSupervisor— is reaped by ProcessKit's own POSIX backend: a sharedSIGCHLD-drivenwaitpid(or the Linuxpidfdfast path) reaps every process it tracks the moment it exits, regardless of where in the process tree it ends up. This is not aPID 1-specific behavior — it is how the library always avoids leaving zombies behind for the processes it spawned. - Reparenting does not let a process escape
Mechanism.JobObject/Mechanism.CgroupV2containment, because those two mechanisms track membership by kernel container (the Job / the cgroup), not by parent-child ancestry — reparenting a grandchild toPID 1doesn't remove it from its Job or cgroup. The one mechanism with a real escape hatch isMechanism.ProcessGroup, and only via a deliberatesetsid()inside the child — see POSIX process groups: asetsidchild can escape in Platform support, which applies identically whether or not you'rePID 1. - Orphans outside ProcessKit's own tracking are not ProcessKit's concern. If something else in
the same container — a shell script, another library, a debugging tool you exec'd manually —
spawns processes that ProcessKit never tracked, those still reparent to your app as
PID 1when their own parent exits, and something has towait()on them or they sit as zombies until the container'sPID 1exits. ProcessKit only reaps what it spawned or was asked to track (aProcessGroup's members); it is not a general-purpose subreaper for the whole PID namespace. If your container only ever runs processes through ProcessKit, this does not come up. If it also runs ad hoc child processes outside ProcessKit, put a minimal init (tinior your container runtime's built-in equivalent — Docker's--initflag, Kubernetes'shareProcessNamespaceis unrelated) ahead of your app as the realPID 1, so it reaps those and forwards signals down to your (nowPID 2) app. - Signal delivery to your app. The
PID 1-ignores-unhandled-signals rule is about the kernel's default disposition being skipped — it does not apply once something in your process installs a handler for that signal. See Graceful shutdown on orchestrator SIGTERM below for how to make sure your app actually reacts to the orchestrator'sSIGTERMrather than silently ignoring it asPID 1.
Graceful shutdown on orchestrator SIGTERM
Docker and Kubernetes stop a container by sending SIGTERM to its PID 1, waiting up to a grace
period (Kubernetes' terminationGracePeriodSeconds, default 30s), then SIGKILLing anything still
alive. Wiring that into ProcessGroup.ShutdownAsync — SIGTERM → grace window → SIGKILL
survivors on the Unix mechanisms, the
atomic Job terminate on Windows — gives your contained tree the same two-phase shutdown the
orchestrator itself expects, instead of a hard kill on every stop:
F#
open System
let run (group: ProcessGroup) (appLifetimeToken: CancellationToken) =
task {
use _ =
appLifetimeToken.Register(fun () ->
// React to the orchestrator's SIGTERM (surfaced through your app's own signal /
// host-lifetime plumbing — see the .NET Generic Host note below) by giving the tree
// a grace window before the orchestrator's own SIGKILL would land.
group.ShutdownAsync(TimeSpan.FromSeconds 10.0) |> ignore)
match! group.StartAsync(Command.create "worker") with
| Ok _worker -> ()
| Error err -> eprintfn $"{err.Message}"
}
C#
appLifetimeToken.Register(() =>
{
// See the F# comment above.
_ = group.ShutdownAsync(TimeSpan.FromSeconds(10));
});
await group.StartAsync(new Command("worker"));
If your app is built on the .NET Generic Host (Microsoft.Extensions.Hosting), you don't have
to wire the SIGTERM handling yourself: the host already translates the orchestrator's SIGTERM
into IHostApplicationLifetime.ApplicationStopping, and the
ProcessKit.Extensions.Hosting package's
hosted process already calls RunningProcess.StopAsync during host shutdown, configurable per
registration with ConfigureProcessKitHostedProcess(name, o => o.ShutdownGracePeriod = …). Whichever
path you use, keep the grace window (ShutdownAsync's argument, or ShutdownGracePeriod, or
ProcessGroupOptions.WithShutdownTimeout) comfortably shorter than the orchestrator's own grace
period (Kubernetes' terminationGracePeriodSeconds, Docker's --stop-timeout / stop_grace_period
in Compose) — if your own grace window doesn't finish first, the orchestrator's SIGKILL reaches
your PID 1 (and, on Linux/macOS mechanisms, the whole tree with it) before ShutdownAsync gets to
run its own escalation, which is a much blunter stop than the one this library is trying to give
you.
Minimal images: musl/Alpine and shell-less images
ProcessKit's baseline path needs neither a shell nor any extra binary: spawning, capturing,
streaming, timeouts, pipelines, and POSIX-process-group / Job Object containment are all direct
posix_spawn(3) / Win32 calls. Two opt-in Unix features are the exception, and each needs a
specific external helper:
Command.Uid/Command.Gid(privilege dropping) rewrite the spawn to run throughsetpriv(util-linux), becauseposix_spawnhas no uid/gid attribute of its own.setprivships on mainstream glibc-based Linux (Debian/Ubuntu, the distributions ProcessKit's own CI runs on) but is commonly absent from a minimal musl image (a bare Alpine base, orFROM scratch/ distroless-style images) — where it's missing, the drop fails with a typedProcessError.Spawnnaming the missing helper, never a silent unprivileged run. If your image needsUid/Giddropping, installutil-linux(apk add util-linuxon Alpine) — or drop privileges another way (a distroless multi-stage image copying only the published output as a non-rootUSER, so the container never runs as root in the first place andUid/Gidis unnecessary).ProcessGroupOptionsresource limits on Linux are enforced through a private cgroup v2 whose self-migrating launcher is a tiny/bin/shscript that joins the cgroup and thenexecs the real target in place. A shell-less image (no/bin/shat all) makes that launcher unavailable, andProcessGroup.Createwith limits requested fails withProcessError.ResourceLimitnaming/bin/shas the missing piece — the same honest-failure contract as the missing real cgroup root case above. Ordinary spawning (no limits requested) needs no shell at all, so a shell-less final stage is otherwise fine.
A representative multi-stage Dockerfile for a net10.0 console app that uses ProcessKit, ending on
a musl (Alpine) runtime image:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish MyApp/MyApp.fsproj -c Release -o /app
# musl-based runtime image. Add util-linux only if the app calls Command.Uid/Gid.
FROM mcr.microsoft.com/dotnet/runtime:10.0-alpine AS final
# RUN apk add --no-cache util-linux # only needed for Command.Uid / Command.Gid
WORKDIR /app
COPY --from=build /app .
USER 10001:10001
ENTRYPOINT ["dotnet", "MyApp.dll"]
Running as a non-root USER in the image (as above) is generally the better fit for a minimal
image than asking ProcessKit to drop privileges at spawn time with Uid/Gid — it needs no extra
package and applies to the whole container, not just processes ProcessKit spawns.
Container resource limits vs ProcessGroupOptions limits
These are two different layers, and they don't require each other:
- The container's own limits — Docker's
--memory/--cpus, Kubernetes'resources.limits, the underlying cgroup the container runtime set up around the whole container — cap everything inside the container, including your app and every process ProcessKit ever spawns. These are always in effect (that's what a container is), independent of anything ProcessKit does, and they're usually the right place for an overall ceiling on the workload. ProcessGroupOptions'WithMemoryMax/WithMaxProcesses/WithCpuQuota(see Resource limits in Process groups) cap a specific group of processes you spawn — narrower than the container, and enforced (on Linux) by a nested cgroup v2 inside the container's own cgroup. As covered above, that nesting needs the real cgroup v2 root, which an ordinary container doesn't expose — so group-level limits are realistically an opt-in for containers deliberately set up to expose it (privileged + host cgroup namespace, as ProcessKit's owntest-cgroup-limitsCI job does), not something to reach for by default inside an arbitrary production container.
In most containerized deployments, the container's own memory/CPU limit is already the ceiling that
matters, and ProcessGroupOptions limits are for the narrower case of bounding one spawned tool
within a container's broader budget — a build step's compiler, an untrusted subprocess, a fork
bomb guard on a supervised worker — where the container-level cap alone can't distinguish between
that one process and the rest of the workload sharing the container.
Next: Platform support · Process groups · Dependency injection · docs index