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
- Environment
- Standard input
- Output handling
- Timeouts and retries
- Spawn flags
- Consuming verbs
- Results
- Errors
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.)
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.
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). 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.
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 PATHC#
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.
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.
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 ProcessResultThree 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).
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 with StdinEncoding; UTF-8 by default) |
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 \n-terminated and encoded with StdinEncoding |
Stdin.FromStream stream |
one-shot | Any readable Stream — a socket, a decompressor, … |
Stdin.FromAsyncLines asyncSeq |
one-shot | An 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, 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).
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.inheritStdinBecause 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.
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.
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.
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.
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,
});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 only — OutputBytesAsync and the
raw tee stay byte-exact.
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.
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.
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. 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();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. 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 returnsProcessError.RetryPredicate, whoseOriginalfield is the failed attempt's originalProcessErrorand whoseDetailcontains the callback exception message. The callback exception never escapes as a raw task fault, and no additional attempt is started.RetryBackoffuses the same attempt/classifier contract with a growingbaseDelay × factor^npause, capped bymaxDelaybefore optional[0.5, 1.5)jitter. Base/cap delays must be non-negative andfactormust be finite and at least1.0; the pipe-friendly mirror isCommand.retryBackoff.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 (the same is true ofRetryBackoff), andRetryNeveris the one way to opt a specific command out of an inherited default. Calling either retry builder again afterRetryNeverre-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.
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.
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). 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.
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 (CreateRestrictedTokenwithDISABLE_MAX_PRIVILEGE): same user, same SIDs, same file ACLs — but no privilege beyond the always-presentSeChangeNotifyPrivilege. 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 toWindowsIntegrityLevel.Medium,Low, orUntrusted. 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; useUid/Gid/Groupsthere), 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
LoworUntrusted: 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 atUntrustedis 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.Spawnnaming 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 isUnsupportedon 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.
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. 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.
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 DetachedProcess — Pid, 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 exit. Nobody waits on it, so there is no
Outcome, no exit code, no duration, and noProcessResult. Its exit is invisible to this process by construction. - 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 withStdoutToFile/StderrToFile(the child writes the file itself, with no pump), or share the caller's own console withStdout(StdioMode.Inherit).MergeStderrstill works: it is an OS-level2>&1onto whichever destination stdout got. - No test seam. The launch bypasses
IProcessRunner, soScriptedRunner/RecordReplayRunnerdo 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:
| Refused | Because |
|---|---|
Pty |
a pseudo-terminal is a live parent-side device that must be owned and pumped |
KillOnParentDeath |
it asks the OS to kill the child with us — the opposite of detaching |
Timeout / TimeoutGrace / IdleTimeout |
a deadline needs a parent watchdog that can still kill |
CancelOn |
cancelling means killing, the very control this verb gives up |
Stdin (a feeder source) |
feeding stdin needs a parent-side pump (InheritStdin is supported) |
KeepStdinOpen |
it retains the parent's end of the stdin pipe for interactive writing |
OnStdoutLine / OnStderrLine / StdoutTee / StderrTee |
all are fed by the parent's own copy of the output |
StreamBuffer |
it bounds a streaming backlog, and nothing streams here |
Retry |
retrying 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. Becauseposix_spawncannot reparent, it remains this process's direct child in the kernel's table: it genuinely survives our exit (init adopts it then), but if it exits first, while we are still running, its zombie entry lingers until we exit — ProcessKit never reaps what it does not contain. A long-lived host that launches many short-lived children should use the contained verbs; that is what containment is for. If the post-spawnPrioritysetup is refused, ProcessKit instead kills the entire new session/process group and reaps the direct leader before returning the typedProcessError.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()(orWindowsCtrlSignals(), 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 isCreateNoWindow().
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.
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).
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.
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.
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, 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.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.RetryPredicate |
program, original, detail |
A Retry / RetryBackoff classifier threw. original preserves the failed attempt's typed error; this is terminal and never retried. |
ProcessError.JsonRpc |
program, method, code, detail, data: string option |
A 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.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. 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.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 errC#
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: Process groups