Running commands

Previous: Scripting with F# Interactive

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

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.)

Windows raw command-line fragments

WindowsRawArg is the deliberately loud exception for a Windows program whose parser does not follow the normal MSVCRT argument rules. Each fragment is appended to lpCommandLine verbatim, after every ordinary Arg/Args value; ordinary arguments keep their standard quoting, raw fragments keep their own insertion order. This is useful for a legacy parser such as msiexec, but it gives the caller complete responsibility for quoting and token boundaries:

F#

let installer =
    Command.create "msiexec.exe"
    |> Command.args [ "/i"; "package.msi" ]
    |> Command.windowsRawArg "INSTALLDIR=\"C:\\Program Files\\Example\""

C#

var installer =
    new Command("msiexec.exe")
        .Args(["/i", "package.msi"])
        .WindowsRawArg("INSTALLDIR=\"C:\\Program Files\\Example\"");

Never interpolate user-controlled data into a raw fragment: ProcessKit performs no escaping or validation beyond rejecting NUL. On POSIX, spawning such a command returns ProcessError.Unsupported. An automatically resolved .cmd/.bat target is also refused because its extra cmd.exe parser makes a safe raw-fragment contract ambiguous; invoke cmd.exe explicitly when raw command-line control is truly required. Test doubles and record/replay cassettes keep each raw fragment as one opaque match token — they do not try to parse it — and DryRunRunner renders that token verbatim after its ordinarily quoted arguments.

POSIX argv[0] override (Arg0)

Arg0 is the POSIX counterpart of WindowsRawArg above: a deliberately loud, opt-in escape hatch, this time for overriding the child's argv[0] independently of the program it actually runs. It supports multicall binaries — BusyBox/Toybox and similar tools dispatch on their own argv[0] — and the login-shell convention of a leading - (-bash).

F#

let busybox =
    Command.create "/bin/busybox"
    |> Command.arg0 "ls" // busybox dispatches to its built-in `ls` applet
    |> Command.args [ "-la" ]

C#

var busybox =
    new Command("/bin/busybox")
        .Arg0("ls") // busybox dispatches to its built-in `ls` applet
        .Args(["-la"]);

Only the argument vector the child observes changes: Program (/bin/busybox above) alone still drives PATH/PreferLocal resolution, preflight, and spawn diagnostics (ProcessError) — exactly as if Arg0 had never been called. arg0 must be non-empty and must not contain an embedded NUL, rejected with ArgumentException at the builder boundary.

Unix-only, with the same honest ProcessError.Unsupported on Windows as every other Unix-only knob (CreateProcessW takes one raw command line, not an argv array with an independent first element). It is refused with the same typed error — at spawn time, on POSIX — when combined with a knob whose spawn path re-execs the target by name through a helper that has no CLI seam of its own for a distinct argv[0]: a Uid/Gid/Groups/KillOnParentDeath drop (the setpriv helper, below), Pty (the setsid --ctty helper), a run under ProcessGroup's Linux cgroup backend (the /bin/sh migration launcher), a ResourceLimits.CpuTimeMax run on the POSIX process-group mechanism (the /bin/sh RLIMIT_CPU shim — see Resource limits), or any Command.Rlimit value (the util-linux prlimit helper). Honouring the override there would mean either applying it to the wrong process (the helper's own argv[0]) or inventing a new native shim — refused loudly instead. A lone Setsid (no privilege drop) does not route through any such helper, so it composes with Arg0 normally.

Test doubles and record/replay cassettes reflect the override wherever they already reflect the program and its arguments: DryRunRunner renders it as (argv0: <value>), RecordedInvocation.Arg0 (ScriptedRunner.Received) carries it, and a RecordReplayRunner cassette entry's Arg0 field is part of the replay match key — a recording made with one argv[0] never replays for a call with a different one (or none), since a multicall binary can genuinely behave differently depending on it.

The program name normally 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.

Windows PATHEXT shims (.cmd/.bat). The one exception is a Windows bare name whose only PATH match carries a non-.exe extension — the .cmd/.bat shims that npm, yarn, az, and many dotnet-tool wrappers ship. The OS's own bare-name search appends only .exe, so it would report such a program as not found even though Exec.which locates it (both use the same PATHEXT-aware lookup). ProcessKit closes that gap: for a bare name it substitutes the resolved absolute path into the launch, and routes a .cmd/.bat through cmd.exe /d /c (a batch file is not a directly-launchable image). For a command that leaves the child's PATH alone (see below), a .exe match, a path-form program, and a name that resolves to nothing are all launched exactly as before — the OS's richer bare-name search is never overridden. Arguments to a .cmd/.bat wrapper are quoted for cmd.exe's own grammar (not just the ordinary argv rules), so a metacharacter like &, |, <, >, or " in an argument is delivered as a literal, never executed (the "BatBadBut" class, CVE-2024-24576). An argument carrying a character cmd.exe cannot escape at all — a %, a !, or a line break — is an honest ProcessError.Spawn refusal rather than an unsafe launch.

Windows: the launch searches the child's PATH, not the process's. Windows resolves a bare program name in the parent's context — the OS searches the current process's PATH, not the environment block the child is handed — so a command that sets, removes, or clears the child's PATH (Env("PATH", …), EnvRemove("PATH"), EnvClear) would otherwise launch whatever the process's PATH happens to hold under that name. ProcessKit resolves such a command itself, against its effective child PATH, and hands the OS the resolved absolute path, so what runs is the executable ResolveProgram() names for the same command.

That swaps the child's PATH in for the process's; it does not narrow the search down to it. The rest of the OS search order is reproduced around that PATH — the application directory, the process's current directory (the command's currentDir takes effect only after the image has been chosen), and the system and Windows directories are all searched before it, exactly as ResolveProgram() reports. So a name one of those directories holds still comes from there whatever the child PATH says: setting a child PATH is not a way to pin one particular image — pass an absolute program path, or use PreferLocal (consulted before all of the above), when that is what you need. The run fails NotFound — carrying the same Searched — before anything is spawned only when that whole search, the child's PATH included, finds nothing. A command that leaves the child's PATH alone is unaffected: the OS's own bare-name search reads the very PATH the resolver walked, so it still applies in full.

POSIX: an explicit child PATH is resolved before spawn. posix_spawnp has the same parent/child split: libc searches the launching process's PATH, while the separate envp block becomes the selected image's environment. ProcessKit therefore resolves a bare name itself whenever Env("PATH", …), EnvRemove("PATH"), or EnvClear explicitly sets its PATH policy, even when the resulting string happens to equal the process value. The ordinary and LaunchDetached paths receive the absolute executable that ResolveProgram() reports; a miss returns its identical ProcessError.NotFound / Searched before posix_spawnp runs. ProcessKit also resolves an inherited absent or empty process PATH, because libc may otherwise apply a default system path or search the current directory while ResolveProgram() intentionally treats that PATH as empty.

Within a non-empty POSIX PATH, each empty component means the effective working directory at that component's exact position: :tools checks the working directory before tools, tools: checks it afterwards, and tools::other checks it between the two named entries. Non-empty relative entries are anchored to the same directory. For Command.ResolveProgram() and the corresponding launch, that directory is the command's CurrentDir when set, otherwise the process's current directory; Exec.which uses the process's current directory because it resolves the process PATH. A wholly empty or absent PATH still contains no entries and does not imply a current-directory search.

Unlike Windows, POSIX adds no application/current/system directories around PATH: the shared resolver checks PreferLocal, then the effective PATH. Only an untouched, non-empty inherited child PATH keeps libc's native bare-name search; path-form programs, CurrentDir, and Arg0 retain their existing behavior (Platform support → Caveats).

Preferring a project-local tool (PreferLocal)

PreferLocal adds a directory to a priority search list consulted before PATH when resolving a bare-name program — the way you reach for a project-local tool (node_modules/.bin, .venv/bin, tools/, a binary next to the solution) over a global one of the same name, without hand-building the path and losing cross-platform executable resolution.

F#

let cmd =
    Command.create "eslint" // a bare name…
    |> Command.preferLocal "node_modules/.bin" // …looked up here first,
    |> Command.preferLocal "tools" // then here,
    |> Command.currentDir "/path/to/project" // then finally on PATH

C#

var cmd =
    new Command("eslint")
        .PreferLocal("node_modules/.bin")
        .PreferLocal("tools")
        .CurrentDir("/path/to/project");

The directories are searched in the order added, and only then the inherited PATH. Each lookup uses the same PATHEXT-aware (Windows) / executable-bit (POSIX) probe the PATH walk itself uses, so a Windows .cmd/.bat shim resolves — and launches through cmd.exe /d /c — exactly as it would on PATH, and a POSIX file without an executable bit is skipped just the same. A prefer-local match is always handed to the OS as its resolved absolute path, whatever its extension — the OS never searches these directories on its own.

A relative prefer-local directory resolves against the command's CurrentDir when one is set (so a project-relative tools/ anchors to where the child will actually run, not the parent's current directory); otherwise it resolves against the process's current directory. Only a bare name is affected: a path-form program (./tool, /usr/bin/tool, C:\tools\tool.exe) is launched directly and ignores prefer-local, exactly as it ignores PATH. Exec.which is deliberately unchanged — it answers "is this installed on the host", a preflight question, whereas prefer-local is a per-command launch concern.

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.

which (process PATH) vs ResolveProgram (the command's own PATH)

Exec.which and CliClient.EnsureAvailableAsync answer a host-wide question — is this tool installed? — so they resolve against the current process's PATH, with no prefer-local. That is the right check for a doctor step, but it is the wrong answer for a command that carries a different environment: if you set Command.Env("PATH", …) (or EnvClear then a fresh Env("PATH", …)), or lean on PreferLocal, the child searches a PATH the process's own does not describe, so which can say found where the run fails NotFound, or vice versa.

Command.ResolveProgram() (and CliClient.ResolveProgram() for the client's template) closes that gap: it resolves against the effective child PATH — the command's Env/EnvRemove/EnvClear applied, PreferLocal directories consulted first — through the same resolver the real spawn uses. It never spawns and has no side effects (a few stats), and on a miss it returns the identical ProcessError.NotFound / Searched a real run of the same command would fail with. Reach for which to ask "is it on the host"; reach for ResolveProgram to ask "will this command, with its environment and prefer-local, find its program".

F#

let build =
    Command.create "eslint"
    |> Command.env "PATH" "/opt/project/node_modules/.bin" // the child's PATH, not the process's

match build.ResolveProgram() with
| Ok path -> printfn $"the run will launch {path}"
| Error(ProcessError.NotFound(program, searched)) -> eprintfn $"'{program}' not found (searched {searched})"
| Error err -> eprintfn $"{err.Message}"

C#

var build = new Command("eslint")
    .Env("PATH", "/opt/project/node_modules/.bin"); // the child's PATH, not the process's

Console.WriteLine(build.ResolveProgram() switch
{
    { IsOk: true, ResultValue: var path } => $"the run will launch {path}",
    { IsOk: false, ErrorValue: var err }  => err.Message,
});

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 value sets a variable for the child.
  • EnvRemove key drops a variable the child would otherwise inherit.
  • EnvClear starts the child from an empty environment instead of inheriting the parent's; any Env / EnvRemove you 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 a record/replay cassette stores only the variable names plus a hashed fingerprint of the effective environment. The one exception is a cassette that records a NotFound failure: it keeps the search path that lookup walked — the child's effective PATH, including one you set here with Env("PATH", …) — verbatim, so scrub such a fixture with RecordReplayOptions.WithRedaction or review it before committing it. A secret in the command line is a different exposure: program and args are stored as invoked by default, and the opt-in RecordReplayOptions.WithCommandProjection hook is what rewrites them on disk — the cassette then keys on a hashed fingerprint of the invoked command line instead, so scrubbing what is stored never changes what replays (see record and replay and Hardening → Secrets in logs, traces, metrics, and cassettes).

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:

SourceReusable on re-run?Use for
Stdin.Emptyn/a (no input)The default, made explicit
Stdin.FromString "…"yesText payloads (encoded with StdinEncoding; UTF-8 by default)
Stdin.FromBytes bytesyesBinary payloads
Stdin.FromFile pathyes (re-opened per run)Large inputs streamed from disk
Stdin.FromLines seqone-shotA sequence of lines, each \n-terminated and encoded with StdinEncoding
Stdin.FromStream streamone-shotAny readable Stream — a socket, a decompressor, …
Stdin.FromAsyncLines asyncSeqone-shotAn IAsyncEnumerable<string> encoded line by line with StdinEncoding

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. KeepStdinOpen holds it open past the source instead, for a live StartAsync handle whose caller takes the writer with RunningProcess.TakeStdin — but only until the run reaches a verb that drives it to completion: a verb that finds the writer still untaken ends the child's input itself, so a child that reads stdin to EOF exits rather than hanging that verb. Take the writer before you call such a verb — see Who owns the kept-open writer for the exact rule and the verbs it covers.

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).

One-shot stdin sources feed one incarnation

A one-shot source is owned by at most one incarnation, ever, and ProcessKit enforces that rather than leaving it to you:

  • The boundary that actually creates a child takes the source before it spawns, so a second consumer — a later run, one racing it, another verb, or another runner — is refused with ProcessError.Unsupported while it still has no child of its own, instead of being started and then handed the exhausted remains (usually nothing at all). Two concurrent runs can no longer split one stream between two children.
  • Nothing about how the command is driven buys a second launch a free pass. A retrying run holds the source for the whole of its run, but that hold is lent to one launch at a time and never covers a payload a child has already read — so a decorator that calls its inner runner twice with the same command, or a command a runner hook kept and started later, is refused exactly like any other second consumer, with or without a Retry policy.
  • Every path that can drain the source is behind that boundary: the capture verbs (RunAsync / OutputStringAsync / OutputBytesAsync / ExitCodeAsync / ProbeAsync), a streaming StartAsync handle, both Pipeline paths through stage 0 — where the refusal starts no stage of the chain at all — a supervised incarnation's own spawn, JobRunner, and a ProcessGroup, owned or shared.
  • A launch that produced no child (NotFound, a failed spawn, an up-front capability refusal, a released group) hands the source back intact for the next attempt or run, and so does a runner that spawns nothing at all — a DryRunRunner preview neither reads the source nor keeps it, so the real run you preview still gets the whole payload.
  • A child that was started keeps the source spent even if its stdin feed then fails (a ProcessError.Stdin): that child did read it, so there is nothing intact to replay.

Repeatable sources (Stdin.FromString / FromBytes / FromFile / Stdin.Empty) and InheritStdin are never involved in this: they feed every run, as often as you like. Sharing one stream or sequence across runs is therefore a typed error you are told about before anything spawns — not silent wrong input — so give each run its own source, or a repeatable one.

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.

Additional POSIX file descriptors

Command.ExtraFd(targetFd) (or Command.extraFd targetFd) creates a full-duplex socketpair and maps the child end to a unique descriptor numbered 3 or greater. After StartAsync, RunningProcess.TakeExtraFd(targetFd) claims the parent-side Stream once. The stream remains owned by the run and closes during teardown.

This is intended for child protocols with a separate control or status channel. It is POSIX-only: Windows reports ProcessError.Unsupported. Pipelines have no single per-stage handle from which to claim the channel, and detached launches and the in-memory/cassette test runners cannot preserve its lifecycle, so they also reject the setting honestly instead of ignoring it.

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. Because neither mode exposes a separate parent-side stream, the matching OnStdoutLine/StdoutTee or OnStderrLine/StderrTee setting is rejected with ArgumentException in either chaining order. The other stream remains independent.

Command.StdoutToFile(path, append) / Command.StderrToFile(path, append) add a fourth destination: the stream is redirected straight to a file at the OS level, handed to the child as its std handle/fd on the spawn (an inheritable file handle in STARTUPINFO on Windows; a file fd via a posix_spawn file action on POSIX), so the child writes the file directly — no parent pump, and the file outlives the parent. append = false creates/truncates, true appends. Like Null/Inherit, a redirected stream has no parent-side view, so the knobs that need one are rejected in combination — see the redirect-to-file section in the streaming guide for the full contract and the allowed/rejected combinations. As a destination setter it composes last-wins with Stdout/Stderr.

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. The live handle's stderr-only verbs say the same thing out loud instead of answering about a stream that does not exist: StderrChunksAsync and the stderr readiness waits (WaitForStderrLineAsync / WaitForStderrTailAsync) fail with ProcessError.Unsupported naming the merge — reach for their stdout counterparts, where those bytes actually are. Inside a pipeline MergeStderr is allowed only on the last stage.

Encodings

Text stdin is encoded and captured output is decoded UTF-8 by default. Invalid output bytes become the replacement character U+FFFD rather than raising an error. Override stdin alone with StdinEncoding, either captured stream with StdoutEncoding / StderrEncoding, or all three at once with Encoding — each takes a System.Text.Encoding:

F#

task {
    let cmd =
        Command.create "legacy-tool"
        |> Command.encoding System.Text.Encoding.Latin1 // stdin and both streams…
    // |> Command.stdinEncoding enc / |> Command.stdoutEncoding enc / |> Command.stderrEncoding enc

    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); // stdin and both streams…
// .StdinEncoding(enc) / .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,
});

Legacy Windows console output

The default is right for every modern tool, but a Windows console program written before UTF-8 — ping, netstat, chkdsk, most of the built-in tooling, any application still built against the ANSI/OEM CRT — writes its non-ASCII text in a code page, so a UTF-8 decode turns every accented or Cyrillic character into U+FFFD. ConsoleEncoding() is the one-line fix: it resolves the code page this host's console actually uses and applies it to text stdin and both captured streams.

F#

task {
    let cmd =
        Command.create "legacy-tool"
        |> Command.args [ "--report" ]

    match! cmd.ConsoleEncoding().OutputStringAsync() with
    | Ok result -> printfn $"{result.Stdout}"
    | Error err -> eprintfn $"{err.Message}"
}

C#

var cmd = new Command("legacy-tool").ConsoleEncoding();

Console.WriteLine(await cmd.OutputStringAsync() switch
{
    { IsOk: true, ResultValue: var result } => result.Stdout,
    { IsOk: false, ErrorValue: var err }   => err.Message,
});

What it resolves, on Windows: the output code page of this process's console — what chcp reports, and what a child inherits — or the system OEM code page when the process has no console at all (a GUI application, a service). Off Windows there is no second, legacy console encoding to discover, so the call is a genuine no-op: the same UTF-8 the default already uses, with no platform call at all. The same answer is available on its own as ConsoleEncoding.current () — a plain System.Text.Encoding — for a pipeline, a CliClient, or a single stream via StdoutEncoding.

When the code page is read. ConsoleEncoding.current () reads it live, on every call. The builder knob calls it once, as that link in the chain is built, and stores the resulting Encoding in the command: a Command is immutable, so nothing re-reads the code page at spawn time or while the child runs. A chcp issued after the command was built is therefore not picked up — the command keeps decoding with the code page that was active when ConsoleEncoding() ran. That is invisible for a command built and launched in one breath, and it is worth knowing for one built once and reused: a long-lived CliClient, a template command kept in a field. To honour a later code-page change there, build the command again, or apply Encoding(ConsoleEncoding.current ()) to it just before the launch.

It stays opt-in, and it is an ordinary builder knob: without the call nothing changes, and an Encoding/StdinEncoding/StdoutEncoding/StderrEncoding later in the chain overrides it (and it overrides them) — the last one wins. A console code page the runtime has no data for falls back to UTF-8 rather than failing the command.

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 onlyOutputBytesAsync 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.

Which verb you use decides what a drop means

A dropping ceiling changes what the capture holds, not whether the run succeeded — but a tail or a head reads exactly like whole output once it becomes a plain string, so the verbs that present it that way refuse it instead: RunAsync, and the ParseAsync/TryParseAsync/ OutputJsonAsync verbs built on it, fail with ProcessError.OutputTooLarge when the policy dropped anything, so a parser is never handed a clipped document. Use OutputStringAsync/OutputBytesAsync when you want the bounded payload: they return the whole ProcessResult, with Truncated telling you output was lost. RunUnitAsync discards output by contract and stays successful either way, as do ExitCodeAsync/ProbeAsync, which never look at the text. Output that lands exactly on a cap was not truncated, so every verb still returns it whole.

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-rs reference, whose output_bytes bounds raw bytes only by Timeout, not by the buffer policy. The port applies the byte cap honestly so that a caller who set MaxBytes/FailLoud to 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,
});

The same framing decides what a readiness wait on stderr sees. StderrEncoding and StderrLineTerminator are what turn the child's diagnostic bytes into the lines RunningProcess.WaitForStderrLineAsync matches — exactly the lines OnStderrLine receives — and WaitForStderrTailAsync matches the text between those terminators, for a prompt that never gets one (Password: ). Neither wait takes stderr away from anything: the same lines still reach OnStderrLine, the StderrTee and the captured Finished.Stderr/ProcessResult.Stderr exactly once. See Streaming → readiness on stderr.

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. Handlers and tees require the matching stream to remain Piped; combining one with StdioMode.Null or StdioMode.Inherit is rejected at the builder boundary:

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,
});
  • Timeout kills 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 raises ProcessError.Timeout. The full decision table lives in Timeouts, retries & cancellation.
  • TimeoutGrace softens 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.
  • CancelGrace does the same for a cancellation (CancelOn or a verb token), with its own soft signal (CancelSignal, default Signal.Term) and no deadline required. It is independent of TimeoutGrace/StopSignal, and the outcome is unchanged: a cancelled run still reports ProcessError.Cancelled.
  • Retry runs the command up to maxAttempts times in total (the first run plus up to maxAttempts - 1 retries — so retry 3 is one run and up to two retries, and 0/1 both mean a single run), waiting delay between attempts, while your classifier returns true for the error (ProcessError.isTransient covers spawn races and I/O blips). The classifier sees the typed ProcessError; a cancelled token stops the loop. maxAttempts must not be negative; 0 and 1 remain the valid single-run boundary values. The delay must be zero or positive; negative values are rejected when the command is built, while values beyond the runtime timer maximum (about 24.8 days) are clamped when armed. If the classifier throws, the current attempt is terminal: the verb returns ProcessError.RetryPredicate, whose Original field is the failed attempt's original ProcessError and whose Detail contains the callback exception message. The callback exception never escapes as a raw task fault, and no additional attempt is started. A one-shot stdin source (Stdin.FromStream / FromLines / FromAsyncLines) feeds a single incarnation, and retry works within that: the first attempt runs like any other, but a second one follows only after a failure that precedes a live child — NotFound, Spawn, or a launch refused as Unsupported — because nothing has read the source yet. After anything that may have reached a child (Exit, Timeout, Signalled, Stdin, OutputTooLarge, OutputIncomplete, Cancelled, or the ambiguous Io) the run ends with that first error, without consulting your classifier, rather than feeding a second child an exhausted source. A run with a retry budget also holds the source for the whole run, not just for one attempt, so it stays off-limits to another run in the gaps between attempts; a concurrent run over the same stream or sequence is refused with ProcessError.Unsupported while that hold lasts. The hold is a loan: it is handed back unless some attempt actually launched a child over the source, so an already-cancelled token, a cancellation during the backoff, a throwing classifier, and an attempt that threw before launching all leave the source to the next run — as does a run through a runner that spawns nothing at all, such as a DryRunRunner preview.
  • RetryBackoff uses the same attempt/classifier contract with a growing baseDelay × factor^n pause, capped by maxDelay before optional [0.5, 1.5) jitter. It applies the same non-negative maxAttempts rule; base/cap delays must be non-negative and factor must be finite and at least 1.0; the pipe-friendly mirror is Command.retryBackoff.
  • RetryNever explicitly disables retrying for this command — it always runs exactly once. This differs from simply never calling Retry: a CliClient built with WithDefaults(fun c -> c.Retry(...)) applies that default Retry to every command built from its template (the same is true of RetryBackoff), and RetryNever is the one way to opt a specific command out of an inherited default. Calling either retry builder again after RetryNever re-enables retrying — the last policy call wins, like any other builder setting.

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();
  • CreateNoWindow runs a console child with CREATE_NO_WINDOW on Windows, so a tool spawned from a GUI app doesn't flash a console window. No effect on Unix.
  • KeepStdinOpen keeps the child's stdin pipe open after its source is exhausted (or with no source at all), so you can write to it interactively via RunningProcess.TakeStdin. The writer has exactly one owner: take it before you drive the handle to completion, because a completion verb that finds it untaken ends the child's input itself and a later TakeStdin then returns None — 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 bare Uid/Gid drop 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 (its docker / video / adm membership), or [] to keep the cleared default. It rides the same helper as the uid/gid drop, so it is honoured only alongside a Uid or Gid — set on its own it fails the spawn with ProcessError.Spawn rather 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 like Umask.
  • 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 holds CAP_SETUID / CAP_SETGID (a rootless container / sandbox), which is conservatively declined rather than probed (setpriv remains the real arbiter, so the guard stays a simple root gate rather than a partial reimplementation of the kernel's capability model). The drop applies setgid before setuid, so it composes into a correct drop, and by default clears the parent's supplementary groups; pass Groups(gids) to set the child's supplementary groups explicitly instead.
  • Groups is meaningful only as part of a drop. It is applied by the same setpriv helper as Uid/Gid, so setting it without a Uid or Gid fails the spawn with ProcessError.Spawn — never a child whose groups were silently left untouched. The gids are applied verbatim (numeric, no /etc/group lookup), and a negative gid is rejected at the builder boundary with ArgumentOutOfRangeException.
  • 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 default POSIX_SPAWN_SETPGROUP for that one command; it is never combined with it.)
  • Arg0 cannot combine with a Uid/Gid/Groups drop. The setpriv helper below re-execs the target by name and has no CLI seam for a distinct argv[0], so pairing the two fails the spawn with ProcessError.Unsupported rather than silently applying the override to setpriv's own argv[0] — see POSIX argv[0] override.

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). The helper is loaded only from a trusted system directory (/usr/bin, /bin, /usr/sbin, /sbin) and launched by absolute path, never looked up on PATH — a setpriv the caller's PATH could point at would otherwise run with the caller's (often root) privileges before the drop; see Hardening → Where the Unix helper binaries come from. setpriv ships there on mainstream Linux; where no trusted directory holds it (macOS/BSD, and non-FHS layouts such as NixOS) 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.

Per-process resource limits (Rlimit)

Rlimit(resource, soft, hard) caps one Unix per-process resource for the child (setrlimit(2)), applied before its program starts and inherited individually by every descendant it forks. Six resources are typed as RlimitResource:

ResourceUnitCaps
CpusecondsCPU time (SIGXCPU at the soft value, SIGKILL at the hard one)
Corebytescore dump size — 0, 0 disables dumps for a child handling secrets
Databytesthe data segment (the allocation arena)
FileSizebytesthe largest file the child may create or extend
NoFilecountsimultaneously open file descriptors
Stackbytesthe main thread's stack

F#

task {
    // No core dumps, at most 64 open descriptors, and no file over 4 MiB.
    let sandboxed =
        Command.create "untrusted-tool"
        |> Command.rlimit RlimitResource.Core 0L 0L
        |> Command.rlimit RlimitResource.NoFile 64L 128L
        |> Command.rlimit RlimitResource.FileSize 4194304L 4194304L

    let! _ = sandboxed.RunAsync()
    ()
}

C#

// No core dumps, at most 64 open descriptors, and no file over 4 MiB.
await new Command("untrusted-tool")
    .Rlimit(RlimitResource.Core, 0, 0)
    .Rlimit(RlimitResource.NoFile, 64, 128)
    .Rlimit(RlimitResource.FileSize, 4_194_304, 4_194_304)
    .RunAsync();

The soft value is what is in force; hard is the ceiling the child may raise its own soft value back up to. Calls for different resources accumulate, and repeating one replaces its pair in place (last write wins). soft must be non-negative and no greater than hard — both are rejected at the builder boundary with ArgumentOutOfRangeException. There is no "unlimited" value: this builder exists to lower what the child inherited (raising a hard limit needs privilege, and the kernel's refusal stops the launch rather than quietly ignoring the request).

A config-driven caller can name a resource by its stable identifier"cpu", "core", "data", "file_size", "no_file", "stack" — through RlimitResource.TryFromName (an option, None on a miss) or RlimitResource.FromName (the same lookup, raising ArgumentException and listing every accepted spelling). The identifiers are held stable across a major version, RlimitResource.All enumerates them, and an unknown one is always an honest miss: never a limit silently applied to the wrong axis or to none at all. A tool that would rather read the set than transcribe it finds the same six spellings in the generated dictionary spec/identifiers.json in the repository, under ProcessKit.RlimitResource — see Stable identifiers.

How it composes, and how it fails — never a silent downgrade:

  • With ResourceLimits.CpuTimeMax (the whole-tree CPU-time cap of process groups) — both target CPU time, so the stricter value wins on each of the soft and hard values, and the two are applied together in one step. Adding either can only tighten the effective cap.
  • On Windows — there is no setrlimit equivalent, so a spawn carrying any rlimit fails with ProcessError.Unsupported, on the contained and the detached path alike. Whole-tree Job Object caps are available instead through ProcessGroupOptions.
  • On a POSIX host without the helper — the limits are applied by util-linux's prlimit, which sets them on itself and execs the target in place (same pid, so containment, Priority, and a PTY are unaffected). Like setpriv, it is loaded only from a trusted system directory (/usr/bin, /bin, /usr/sbin, /sbin) and never from PATH; a host holding it in none of them (macOS/BSD, a minimal image) fails with ProcessError.ResourceLimit rather than running the child uncapped. prlimit is used rather than a /bin/sh ulimit shim because it takes bytes for every size resource, while ulimit's block unit differs between shells — a value applied at half or double what was asked for is exactly the silent divergence a limit must not have.
  • With Arg0 — refused with ProcessError.Unsupported, because the helper execs the target by name and has no seam for a distinct argv[0] (the same refusal a Uid/Gid drop, Pty, or a cgroup-backend run gives).

Linux I/O scheduling priority (IoPriority)

IoPriority(priority) sets the child's — and its whole spawned tree's — Linux I/O-scheduling priority, so bulk disk work yields to whoever is using the same device interactively. It is a separate axis from the CPU-scheduling Priority and not a substitute for it: Priority decides how much processor the child gets, this decides how its block-device requests are ordered. A background job usually wants both.

Three classes are typed as IoPriorityClass, and values are built by the factories on IoPriority:

ValueClassLevelPrivilege
IoPriority.IdleIdlenone — the kernel ignores the fieldnone
IoPriority.BestEffort levelBestEffort0 (highest) … 7 (lowest)none
IoPriority.RealTime levelRealTime0 (highest) … 7 (lowest)CAP_SYS_ADMIN (or CAP_SYS_NICE on Linux 5.14+)

Lower levels mean higher priority — the kernel's own convention, and the opposite of how Priority reads. BestEffort 7 is the usual "yield, but keep making progress" setting; Idle is the politest of all, running only while the device is otherwise idle.

F#

task {
    // A bulk indexer that must not slow down whoever is using this disk.
    let! _ =
        (Command.create "indexer"
         |> Command.arg "--rebuild"
         |> Command.ioPriority (IoPriority.BestEffort 7))
            .RunAsync()

    ()
}

C#

// A bulk indexer that must not slow down whoever is using this disk.
await new Command("indexer")
    .Arg("--rebuild")
    .IoPriority(IoPriority.BestEffort(7))
    .RunAsync();

A level outside 0..IoPriority.MaxLevel (7) is rejected with ArgumentOutOfRangeException where the value is built — never clamped into range, and never carried as far as the kernel. Repeating the builder replaces the request (last write wins), and the default leaves the inherited I/O priority untouched.

A config-driven caller can name a class by its stable identifier"idle", "best_effort", "real_time" — through IoPriorityClass.TryFromName (an option, None on a miss) or IoPriorityClass.FromName (the same lookup, raising ArgumentException and listing every accepted spelling), with IoPriorityClass.All enumerating the set. The identifiers are held stable across a major version and appear in the generated dictionary spec/identifiers.json under ProcessKit.IoPriorityClass — see Stable identifiers. The level is a plain number the caller supplies, so it is not part of that vocabulary.

When it takes effect, and how far it reaches. Linux copies the creating task's I/O priority into a new task and the value survives exec, so ProcessKit arms the spawning thread with the request across the spawn itself and restores it immediately after. The consequence worth knowing is that the priority is in force for the child's first block-device request — before its program runs — rather than from some moment after it started, and every descendant it later forks inherits it. It rides through every helper exec on the POSIX path untouched, so it composes with Uid/Gid, Pty, Arg0, Rlimit and a cgroup-contained group with no extra helper binary and no argv change (unlike Rlimit, it needs no util-linux on the host).

How it fails — never a silent downgrade:

  • On Windows, macOS, and the BSDsioprio_set(2) is a Linux system call with no equivalent there, so the spawn fails with ProcessError.Unsupported rather than running the child at the inherited priority. Windows' nearest relatives are a whole-tree disk rate cap (ProcessGroupOptions.WithIoMax) and the CPU-side Priority; neither is an honest stand-in, so neither is substituted.
  • On a detached launchCommand.LaunchDetached refuses it with ProcessError.Unsupported on every platform, Linux included. The setting is applied by the owner across the spawn, and that verb deliberately gives ownership up. (The CPU-axis Priority is honoured there, because it is not owner-applied.)
  • Without the privilege — a RealTime request the kernel refuses fails the spawn with ProcessError.Spawn, never a quiet fall back to best-effort.
  • On a kernel without the call — a build (or a seccomp filter) answering ENOSYS, or a Linux architecture whose ioprio_set system call number ProcessKit does not know, is a typed ProcessError.Unsupported.

What it does not promise. The class and level are recorded on the child unconditionally, but whether they change the order requests are actually served in is the block device's I/O scheduler's decision: Linux honours I/O priorities under BFQ (and the historical CFQ), while mq-deadline, kyber, and none — the common defaults for NVMe — largely ignore them. This builder asks the kernel for a priority; it cannot promise a scheduler that acts on it.

DryRunRunner renders the request as (io_priority: best_effort:7), so a preview never reports a weaker command than the one that would run.

Windows privilege drop (restricted token & integrity level)

Two Windows-only builders are the counterpart of the Unix drop above. They do not change who the child runs as — there is no setuid on Windows — they hand it a weakened copy of the caller's own token:

  • WindowsRestrictedToken() starts the child under a restricted token (CreateRestrictedToken with DISABLE_MAX_PRIVILEGE): same user, same SIDs, same file ACLs — but no privilege beyond the always-present SeChangeNotifyPrivilege. A child that inherits an administrator's token can otherwise debug other processes, load drivers, take ownership, or shut the machine down; a restricted one cannot.
  • WindowsIntegrityLevel(level) lowers the child's mandatory integrity level to WindowsIntegrityLevel.Medium, Low, or Untrusted. Windows' no-write-up policy then denies it write access to anything labelled above that level — the user's own files, HKCU, the windows of medium-integrity processes — regardless of the DACL that would otherwise allow it.

The two are independent axes: privileges are what the child may do, integrity is what it may write to. Set both and they compose onto one token.

F#

task {
    // Windows: no privileges, and no write access above Low integrity.
    let plugin =
        Command.create "untrusted-plugin"
        |> Command.windowsRestrictedToken
        |> Command.windowsIntegrityLevel WindowsIntegrityLevel.Low

    let! _ = plugin.RunAsync()
    ()
}

C#

// Windows: no privileges, and no write access above Low integrity.
await new Command("untrusted-plugin")
    .WindowsRestrictedToken()
    .WindowsIntegrityLevel(WindowsIntegrityLevel.Low)
    .RunAsync();

Honest by construction, exactly like the Unix family — mirror image, same rules:

  • On POSIX either builder fails the spawn with ProcessError.Unsupported (a restricted token and a mandatory integrity label have no POSIX equivalent; use Uid/Gid/Groups there), never a silently unhardened child.
  • Only lowering is offered. Windows refuses to raise a token's integrity and a restricted token can only lose rights, so neither builder is a privilege escalation path — and there is deliberately no "High" variant that could only ever fail.
  • The child's stdio still works at Low or Untrusted: the pipes were opened by the parent and handed over as inherited handles, whose access check already happened. What the child loses is write access to new objects — which at Untrusted is nearly everything, so many programs cannot run there at all. That surfaces as the child's own failure (a non-zero exit), not as a ProcessKit error.
  • If a host's policy refuses to let ProcessKit assign the derived token at all, the spawn fails with a typed ProcessError.Spawn naming that refusal — it never falls back to starting the child unhardened.

Two combinations are rejected at the builder boundary with ArgumentException, in either chaining order:

  • With Pty — a PTY run goes through the ConPTY spawn call, which does not carry the hardened token, so the child would quietly keep the parent's full one.
  • With Uid / Gid / User / Groups / Umask / Setsid — each half is Unsupported on the platform the other half needs, so a command carrying both could not run on any host. Build the platform's own command instead of one command that is refused everywhere.

For the group-level companion — Job Object UI restrictions that stop a contained tree touching the clipboard, desktops, or ExitWindows — see Process groups → Windows UI restrictions, and Hardening untrusted children for the whole perimeter.

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)).

VerbOk payloadNon-zero exitUse when
OutputStringAsync()ProcessResult<string>captured (data)You want to inspect the outcome yourself
OutputBytesAsync()ProcessResult<byte[]>captured (data)Binary stdout (images, archives, …)
RunAsync()trimmed stringProcessError.Exit"Give me the answer or fail"
RunUnitAsync()unitProcessError.ExitYou only care that it succeeded
ExitCodeAsync()intthe code, as OkThe code is the answer
ProbeAsync()bool0true, 1false, else errorPredicate commands: git diff --quiet, grep -q
ParseAsync(f) / TryParseAsync(f)'TProcessError.ExitA typed value from stdout (success required)
OutputJsonAsync<'T>()'TProcessError.ExitDeserialize stdout as JSON (success required)
FirstLineAsync(p)string option— (stream-based)Grab one matching line, kill the rest
StartAsync()RunningProcessA 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. For trimmed/NativeAOT applications, pass source-generated JsonTypeInfo<'T> metadata to the OutputJsonAsync(typeInfo) overload instead: it has no reflection requirement. From F#, use Runner.outputJsonTyped runner cancellationToken typeInfo command. 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.

Exactly one verb deliberately breaks that shape — LaunchDetached, the opt-out from containment described next.

Detached launch (spawn-and-forget)

Every verb above puts the child in a kill-on-dispose container, and that guarantee is the point of the library. But some launches only make sense without it: a self-updater that has to outlive the process it is replacing, a restart-myself relaunch, a daemon or agent handed off to the OS. LaunchDetached is the single, loudly named opt-out for those — a separate verb, never a flag on the ordinary path, so the containment guarantee stays unqualified everywhere else.

F#

// Hand the updater off and exit — it must survive this process.
match (Command.create "updater" |> Command.args [ "--apply"; "2.1.0" ]
       |> Command.stdoutToFile "/var/log/updater.log" true).LaunchDetached() with
| Ok child -> printfn $"updater running as pid {child.Pid}"
| Error err -> eprintfn $"{err.Message}"

// The one-liner form.
let started = Exec.detach "updater" [ "--apply"; "2.1.0" ]

C#

// Hand the updater off and exit — it must survive this process.
Console.WriteLine(new Command("updater").Args(["--apply", "2.1.0"])
        .StdoutToFile("/var/log/updater.log", append: true)
        .LaunchDetached() switch
{
    { IsOk: true, ResultValue: var child } => $"updater running as pid {child.Pid}",
    { IsOk: false, ErrorValue: var err }   => err.Message,
});

It returns a DetachedProcessPid, Program, StartTime — and nothing else. It is a diagnostic snapshot, not a handle: no Dispose, no wait, no stream, no kill, because the child is no longer ProcessKit's to manage. Pid alone is not an identity (the OS reuses pids); the pair Pid + StartTime is, and it is captured while the pid is still pinned, so it can never describe an already-recycled process.

What you are giving up — all of it, deliberately:

  • No containment. The child is in no Job Object (Windows) and in its own new session (setsid, POSIX). Nothing this process does — Dispose, GC, or dying — reaches it. ProcessGroup-level knobs (ResourceLimits, ProcessGroupOptions) are not merely ignored: they live on the container this verb refuses to create.
  • No public exit. DetachedProcess has no wait operation or Outcome, exit code, duration, or ProcessResult. On POSIX, a private reaper consumes the direct leader's wait status while this process lives solely to prevent zombies; that internal ownership is not exposed as lifecycle control. Windows does not observe the detached child's exit.
  • No output. There is no parent left to drain a pipe, so StdioMode.Piped — the builder default — is wired to the null device here. Keep output with StdoutToFile/StderrToFile (the child writes the file itself, with no pump), or share the caller's own console with Stdout(StdioMode.Inherit). MergeStderr still works: it is an OS-level 2>&1 onto whichever destination stdout got.
  • No test seam. The launch bypasses IProcessRunner, so ScriptedRunner / RecordReplayRunner do not intercept it — it is an opt-out from running under ProcessKit, not a run. Put your own seam in front of it if a test must launch nothing.

Every incompatible knob is refused, never ignored. Each returns a typed ProcessError.Unsupported naming the knob, before anything is spawned — so a Timeout can never look applied when nothing will ever enforce it:

RefusedBecause
Ptya pseudo-terminal is a live parent-side device that must be owned and pumped
KillOnParentDeathit asks the OS to kill the child with us — the opposite of detaching
IoPrioritythe Linux I/O priority is applied by the owner across the spawn itself, and this verb gives ownership up (the CPU-axis Priority is honoured — it is not owner-applied)
Timeout / TimeoutGrace / IdleTimeouta deadline needs a parent watchdog that can still kill
CancelOn / CancelGracecancelling means killing, the very control this verb gives up (and nothing is left to run the grace)
Stdin (a feeder source)feeding stdin needs a parent-side pump (InheritStdin is supported)
KeepStdinOpenit retains the parent's end of the stdin pipe for interactive writing
OnStdoutLine / OnStderrLine / StdoutTee / StderrTeeall are fed by the parent's own copy of the output
StreamBufferit bounds a streaming backlog, and nothing streams here
Retryretrying is a verb-layer policy over an observed failure; the spawn happens exactly once (RetryNever opts a command out of an inherited CliClient default)

Knobs only a capturing verb ever reads — StdoutEncoding/StderrEncoding, the line terminators, OutputBuffer, OkCodes — are no-ops here, exactly as they are on the verbs that ignore them today. Everything the OS can honour on its own is honoured: CurrentDir, Env/EnvClear/PreferLocal, the file redirects, MergeStderr, InheritStdin, CreateNoWindow, WindowsCtrlSignals, Priority, Umask, and the Unix Uid/Gid/Groups drop (through the same setpriv helper as a contained spawn).

Platform notes — documented divergences, not silent ones:

  • POSIX. The child gets a new session with no controlling terminal (Setsid() asks for exactly this, so setting it alongside is redundant, never a conflict), so a terminal hangup cannot reach it. Because posix_spawn cannot reparent, it remains this process's direct child while the parent lives: if it exits first, a private reaper consumes the direct leader's wait status, so a long-lived host does not accumulate zombies. If the parent exits first, the OS reparents the child and its new supervisor owns reaping; the public descriptor remains only a pid + start-time snapshot with no lifecycle-control API. If the post-spawn Priority setup is refused, ProcessKit instead kills the entire new session/process group and reaps the direct leader before returning the typed ProcessError.Spawn; a descendant cannot survive that failed launch.
  • Windows. The child is created running and assigned to no Job, and no handle to it is kept. It still shares the caller's console unless you add CreateNoWindow() (or WindowsCtrlSignals(), which makes it the root of its own console process group), so in the default wiring a console-close event still reaches it — the closest Windows analogue of the POSIX session detach is CreateNoWindow().

The verb opts out of the containment ProcessKit creates; it cannot opt out of a container somebody put your own process in. On Windows, a child of a job-bound process joins that same job by kernel rule (ProcessKit does not request CREATE_BREAKAWAY_FROM_JOB: most ambient jobs forbid it, so asking would turn a working launch into a spawn failure). On Linux the child inherits your cgroup, so a systemctl stop of your unit still reaps it. If a launch must survive that, hand the work to the platform's own supervisor (a service manager, systemd-run, a scheduled task) rather than to a child process.

LaunchDetached is synchronous (like ProcessGroup.Create and ResolveProgram): it does one bounded OS call and there is no run to await, so it returns the Result directly rather than a Task that never yields.

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:

MemberMeaning
StdoutCaptured stdout — string (text verbs) or byte[] (bytes verbs); carries the merged stdout+stderr under MergeStderr
StderrCaptured stderr, as decoded text (empty under MergeStderr — the stderr is merged into Stdout)
CodeThe exit code, or None for a signal kill / timeout
SignalThe terminating signal number (Unix), else None
IsSuccessThe code is in AcceptedCodes ({0} by default)
IsTimedOutThe run's own deadline expired
OutcomeThe three-way enum behind the accessors above
DurationWall-clock duration of the run
TruncatedOutput is incomplete: a buffer policy dropped some, or a bounded post-exit drain cut the tail (details)
AcceptedCodesThe exit codes treated as success — OkCodes ({0} by default)
CombinedStdout 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 has no meaningful semantics (no exit could count as success) and is rejected at the builder boundary with ArgumentException, like every other invalid builder input.

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 run whose actual exit status was never observed. Reason says which case it was; the two common ones are a process that concluded but whose status could not be read (a native API failure, or an unresolved POSIX reap race), and a tree ProcessKit hard-killed — through RunningProcess.Kill() or StopAsync — that was not reaped inside the bounded post-kill window, where the child may still be alive and a background reaper owns the remaining wait (see Zombie or orphaned processes). 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,
});
VariantFieldsMeaning
ProcessError.Spawnprogram, detailThe program was located but the OS couldn't start it (permissions, a bad working directory, or a .cmd/.bat argument that can't be safely quoted for the cmd.exe wrapper — a %/!/newline, see Program, arguments, working directory). Not isNotFound.
ProcessError.NotFoundprogram, Searched: string optionThe program couldn't be located (isNotFound is true); searched is the probed path when known.
ProcessError.Exitprogram, code, stdout, stderrA success-requiring verb saw a non-zero exit; both streams attached in full.
ProcessError.Signalledprogram, signal: int option, stdout, stderrKilled 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.Timeoutprogram, timeout, stdout, stderrThe run's own deadline killed it; whatever it captured before the kill is attached.
ProcessError.NotReadyprogram, timeoutA readiness probe gave up — distinct from a timeout.
ProcessError.Parseprogram, detailA ParseAsync / TryParseAsync parser rejected the output, or OutputJsonAsync<'T> couldn't deserialize it as valid JSON.
ProcessError.RetryPredicateprogram, original, detailA Retry / RetryBackoff classifier threw. original preserves the failed attempt's typed error; this is terminal and never retried.
ProcessError.JsonRpcprogram, method, code, detail, data: string optionA JSON-RPC session peer answered a request with an error object instead of a result; code/detail are the peer's own, data its optional payload as raw JSON.
ProcessError.OutputTooLargeprogram, lineLimit, byteLimit, totalLines, totalBytesA FailLoud (OverflowMode.Error) buffer ceiling was exceeded, or a verb that presents stdout as complete (RunAsync and the parse/JSON verbs built on it) refused a capture a DropOldest/DropNewest ceiling had truncated. Always a volume against a ceiling. A total of 0 means that unit was not counted for the capture, not that it measured zero — the message quotes only the counted ones.
ProcessError.OutputIncompleteprogramThe same verbs that present stdout as complete refused a capture the bounded post-exit output drain cut short — something that inherited the child's stdout/stderr outlived it, so the tail was never read. No ceiling was crossed (there need not be one), which is why it quotes no limit and no total, and why no OutputBuffer setting changes it. OutputStringAsync/OutputBytesAsync hand back the partial capture with Truncated instead.
ProcessError.Stdinprogram, detailThe 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. A source that fails only after the child has exited is still reported: an otherwise-successful run waits a short bounded window for a still-reading source to conclude, then stops it rather than waiting on one that never will. Also surfaces for a pipeline's first stage.
ProcessError.CassetteMissprogramA record/replay cassette found no matching recording — kept distinct from not-found, so isNotFound is false.
ProcessError.UnsupportedoperationThe platform can't do what was asked (e.g. a POSIX signal on Windows) and silently skipping would be wrong.
ProcessError.CancelledprogramThe run's CancellationToken fired. Always an error. One further, token-free producer exists: a supervision session whose graceful StopAsync landed before its very first incarnation was started, leaving neither an outcome nor a start failure to report.
ProcessError.ResourceLimitdetailA requested resource cap couldn't be enforced.
ProcessError.IodetailA 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.

.StdoutBytes: byte[] option is a further accessor on Exit/Signalled/Timeout, carrying the exact pre-decode stdout bytes — but only when the failure was produced from a byte[] capture: OutputBytesAsync (on a Command or a Pipeline) followed by ProcessResult.ensureSuccess / EnsureSuccess() on the resulting ProcessResult<byte[]>. It is None for every text-based failure — including one raised by RunAsync/ParseAsync/ OutputJsonAsync and their pipeline twins, which are always built over ProcessResult<string> and so never populate it — because the bytes are never reconstructed from the already-decoded Stdout text.


Next: Process groups