Skip to content

Latest commit

 

History

History
1525 lines (1253 loc) · 70.1 KB

File metadata and controls

1525 lines (1253 loc) · 70.1 KB

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.

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.

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 never written to a record/replay cassette (only the variable names are).

Standard input

By default a child gets no standard input — it reads end-of-file at once and can never hang waiting for input. Everything else is opt-in via Stdin:

Source Reusable on re-run? Use for
Stdin.Empty n/a (no input) The default, made explicit
Stdin.FromString "…" yes Text payloads (encoded 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).

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

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,
});

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.
  • 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. 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.
  • RetryBackoff uses the same attempt/classifier contract with a growing baseDelay × factor^n pause, capped by maxDelay before optional [0.5, 1.5) jitter. 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 — 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.)

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.

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

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 0true, 1false, 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.

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 exit. Nobody waits on it, so there is no Outcome, no exit code, no duration, and no ProcessResult. 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 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:

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. Because posix_spawn cannot 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-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:

Member Meaning
Stdout Captured stdout — string (text verbs) or byte[] (bytes verbs); carries the merged stdout+stderr under MergeStderr
Stderr Captured stderr, as decoded text (empty under MergeStderr — the stderr is merged into Stdout)
Code The exit code, or None for a signal kill / timeout
Signal The terminating signal number (Unix), else None
IsSuccess The code is in AcceptedCodes ({0} by default)
IsTimedOut The run's own deadline expired
Outcome The three-way enum behind the accessors above
Duration Wall-clock duration of the run
Truncated A buffer policy dropped output
AcceptedCodes The exit codes treated as success — OkCodes ({0} by default)
Combined Stdout and stderr joined (stdout, then stderr on a new line when both are non-empty) — a post-hoc concatenation, not the real interleaving; use MergeStderr for a byte-exact 2>&1
OutputContainsAny(needles) Case-insensitive search of both streams — for the "a known marker makes a non-zero exit benign" idiom below

ProcessResult.ensureSuccess (or the instance result.EnsureSuccess()) converts a ProcessResult<'T> — text or bytes — into a Result: the result unchanged on success, otherwise the matching ProcessError (Exit / Signalled / Timeout).

Accepting non-zero exits

Some tools use a non-zero exit as information (grep returns 1 for "no match"). Tell ProcessKit which codes count as success with OkCodes:

F#

task {
    let grep =
        Command.create "grep"
        |> Command.args [ "ERROR"; "app.log" ]
        |> Command.okCodes [ 0; 1 ] // 1 ("no match") is success, not failure

    match! grep.RunAsync() with
    | Ok output -> printfn $"matches:\n{output}"
    | Error err -> eprintfn $"{err.Message}" // a real failure (e.g. exit 2)
}

C#

var grep =
    new Command("grep")
        .Args(["ERROR", "app.log"])
        .OkCodes([0, 1]); // 1 ("no match") is success, not failure

Console.WriteLine(await grep.RunAsync() switch
{
    { IsOk: true, ResultValue: var output } => $"matches:\n{output}",
    { IsOk: false, ErrorValue: var err }   => err.Message, // a real failure (e.g. exit 2)
});

OkCodes sets which exit codes ProcessResult.IsSuccess, ensureSuccess, and RunAsync / RunUnitAsync accept. The codes replace the default rather than adding to it, so include 0 if you still want it (as [ 0; 1 ] above does); an empty set 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 process that concluded but whose actual exit status could not be observed (a native API failure, or an unresolved POSIX reap race); it is never a stand-in for a clean exit, and (like Signalled / TimedOut) never counts as success:

F#

match result.Outcome with
| Outcome.Exited 0 -> printfn "clean"
| Outcome.Exited code -> printfn $"failed with {code}"
| Outcome.Signalled signal -> printfn $"killed by signal {signal}"
| Outcome.TimedOut -> printfn "hit its deadline"
| Outcome.Unobserved reason -> printfn $"exit status unknown: {reason}"

C#

Console.WriteLine(result.Outcome switch
{
    { IsExited: true, Code.Value: 0 }               => "clean",
    { IsExited: true, Code.Value: var code }        => $"failed with {code}",
    { IsSignalled: true, Signal.Value: var signal } => $"killed by signal {signal}",
    { IsSignalled: true }                           => "killed by an unknown signal",
    { IsUnobserved: true }                          => "exit status unknown",
    _                                                => "hit its deadline", // TimedOut
});

Outcome carries the same Code / Signal / IsTimedOut accessors as ProcessResult, so a bare Outcome (from RunningProcess.Wait or Finished.Outcome) answers directly. There is no success accessor on Outcome — success is OkCodes-aware, so use ProcessResult.IsSuccess.

Errors

ProcessError is a discriminated union: pattern-match it, read .Message for a one-line description (it is also the ToString()), or use the classifiers. The capturing verbs only error on a failure to run (spawn / not-found / I/O / timeout / cancellation) — never on a non-zero exit; the success-checking verbs (RunAsync / RunUnitAsync / ParseAsync / TryParseAsync) additionally turn a non-zero exit into ProcessError.Exit.

F#

task {
    match! (Command.create "deploy").RunAsync() with
    | Ok out -> printfn $"{out}"
    | Error(ProcessError.NotFound(program, _)) -> eprintfn $"not installed: {program}"
    | Error(ProcessError.Exit(program, code, _, stderr)) -> eprintfn $"{program} exited {code}: {stderr}"
    | Error(ProcessError.Timeout(program, t, _, _)) -> eprintfn $"{program} timed out after {t}"
    | Error err -> eprintfn $"{err.Message}"
}

C#

Console.WriteLine(await new Command("deploy").RunAsync() switch
{
    { IsOk: true, ResultValue: var output }               => output,
    { IsOk: false, ErrorValue: ProcessError.NotFound n } => $"not installed: {n.Program}",
    { IsOk: false, ErrorValue: ProcessError.Exit e }     => $"{e.Program} exited {e.Code}: {e.Stderr}",
    { IsOk: false, ErrorValue: ProcessError.Timeout t }  => $"{t.Program} timed out after {t.Timeout}",
    { IsOk: false, ErrorValue: var err }                 => err.Message,
});
Variant Fields Meaning
ProcessError.Spawn program, detail The program was located but the OS couldn't start it (permissions, a bad working directory, 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 err

C#

switch (await cmd.RunAsync())
{
    case { IsOk: true }:
        break;
    case { IsOk: false, ErrorValue: { IsNotFound: true } }: // NotFound only
        installThenRetry();
        break;
    case { IsOk: false, ErrorValue: { IsTransient: true } }: // Spawn / Io blips
        scheduleRetry();
        break;
    case { IsOk: false, ErrorValue: var err }:
        fail(err);
        break;
}

ProcessError.isNotFound is true only for NotFound; ProcessError.isTransient is true for Spawn and Io — failures that may succeed on a retry. From C# these are the instance forms err.IsNotFound and err.IsTransient.

To read a failure's fields without matching every case — the only practical way from C#, which can't destructure an F# union — ProcessError exposes .Program, .Stdout, .Stderr, .Combined, .Code, and .Signal, each an option/Option<T> populated for the cases that carry that field (e.g. .Code is set only on Exit, .Stdout/.Stderr/.Combined on Exit/Signalled/Timeout) and None elsewhere. The generated err.IsExit / IsSignalled / IsTimeout / IsCancelled case testers pair with them.


Next: Process groups