ProcessKit cookbook
Task-oriented, idiomatic examples for every part of the public API. The run and capture
verbs return Task<Result<_, ProcessError>>, so the F# samples below run inside a task { }
block and use match! (a few RunningProcess members — WaitAsync, ProfileAsync — return their
value directly). Where a snippet writes let! r = cmd.Verb() for brevity, r is the
Result<_, ProcessError> you then match. From C# the same surface is await-able fluent
methods.
- Running a command
- Capturing output
- Error handling
- Exit codes and probing
- Accepting non-zero exits
- Parsing output
- Standard input
- Pipelines
- Streaming and interactive I/O
- Readiness probes
- Timeouts, cancellation, retry
- Process groups and tree control
- Resource limits
- Stats and profiling
- Supervision
- CliClient
- Top-level Exec helpers
- Preflight: is a program installed?
- Logging, tracing & metrics
- Dependency injection
- Testing without subprocesses
Running a command
Build a Command (an immutable value), then call a verb. RunAsync requires a zero (or
accepted) exit and returns stdout with trailing whitespace trimmed.
F#
task {
let cmd = Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ]
match! cmd.RunAsync() with
| Ok sha -> printfn $"HEAD is {sha}"
| Error err -> eprintfn $"git failed: {err.Message}"
}
C#
var cmd = new Command("git").Args(["rev-parse", "HEAD"]);
Console.WriteLine(await cmd.RunAsync() switch
{
{ IsOk: true, ResultValue: var sha } => $"HEAD is {sha}",
{ IsOk: false, ErrorValue: var err } => $"git failed: {err.Message}",
});
The builder is fluent and immutable — each method returns a new Command:
F#
let cmd =
Command.create "dotnet"
|> Command.args [ "build"; "-c"; "Release" ]
|> Command.currentDir "/repo"
|> Command.env "DOTNET_NOLOGO" "1"
C#
var cmd = new Command("dotnet")
.Args(["build", "-c", "Release"])
.CurrentDir("/repo")
.Env("DOTNET_NOLOGO", "1");
The same in method style (identical from C#):
F#
let cmd = (Command "dotnet").Args([ "build"; "-c"; "Release" ]).CurrentDir("/repo")
C#
var cmd = new Command("dotnet").Args(["build", "-c", "Release"]).CurrentDir("/repo");
Use RunUnitAsync when you only care that it succeeded:
F#
match! (Command.create "mkdir" |> Command.arg "out").RunUnitAsync() with
| Ok () -> ()
| Error err -> eprintfn $"{err.Message}"
C#
if (await new Command("mkdir").Arg("out").RunUnitAsync() is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
Capturing output
OutputStringAsync / OutputBytesAsync return a ProcessResult<_> — a non-zero exit is data here,
not an error. Inspect Stdout, Stderr, Code, IsSuccess, Duration, Outcome.
F#
match! (Command.create "ls" |> Command.arg "-la").OutputStringAsync() with
| Ok result ->
printfn $"exit={result.Code} success={result.IsSuccess} in {result.Duration}"
printfn $"{result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
C#
switch (await new Command("ls").Arg("-la").OutputStringAsync())
{
case { IsOk: true, ResultValue: var result }:
Console.WriteLine($"exit={result.Code} success={result.IsSuccess} in {result.Duration}");
Console.WriteLine(result.Stdout);
break;
case { IsOk: false, ErrorValue: var err }:
Console.Error.WriteLine(err.Message);
break;
}
OutputBytesAsync is the binary companion (ProcessResult<byte[]>), for non-text output.
Error handling
ProcessError is a discriminated union — pattern-match it, or use .Message for a short
description. The capture verbs only error on failure to run (spawn / not-found / I/O /
timeout / cancellation), never on a non-zero exit.
F#
match! (Command.create "definitely-not-a-program").OutputStringAsync() with
| Ok result -> printfn $"{result.Stdout}"
| Error(ProcessError.NotFound(program, _)) -> eprintfn $"not installed: {program}"
| Error(ProcessError.Timeout(program, timeout, _, _)) -> eprintfn $"{program} timed out after {timeout}"
| Error err -> eprintfn $"{err.Message}"
C#
Console.WriteLine(await new Command("definitely-not-a-program").OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var result } => result.Stdout,
{ IsOk: false, ErrorValue: ProcessError.NotFound n } => $"not installed: {n.Program}",
{ IsOk: false, ErrorValue: ProcessError.Timeout t } => $"{t.Program} timed out after {t.Timeout}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Classifiers help with retry/diagnostic logic:
F#
match! cmd.RunAsync() with
| Ok _ -> ()
| Error err when ProcessError.isNotFound err -> installThenRetry ()
| Error err when ProcessError.isTransient err -> scheduleRetry () // spawn / I/O blips
| Error err -> fail err
C#
switch (await cmd.RunAsync())
{
case { IsOk: true }:
break;
case { IsOk: false, ErrorValue: { IsNotFound: true } }:
installThenRetry();
break;
case { IsOk: false, ErrorValue: { IsTransient: true } }:
scheduleRetry(); // spawn / I/O blips
break;
case { IsOk: false, ErrorValue: var err }:
fail(err);
break;
}
The success-requiring verbs (RunAsync / RunUnitAsync) additionally turn a non-zero exit into
ProcessError.Exit(program, code, stdout, stderr).
Exit codes and probing
F#
let! code = (Command.create "grep" |> Command.args [ "pattern"; "file" ]).ExitCodeAsync() // Ok 0 / Ok 1 / ...
let! found = (Command.create "which" |> Command.arg "git").ProbeAsync() // Ok true if exit 0
C#
var code = await new Command("grep").Args(["pattern", "file"]).ExitCodeAsync(); // Ok 0 / Ok 1 / ...
var found = await new Command("which").Arg("git").ProbeAsync(); // Ok true if exit 0
ProbeAsync is true when the command runs and exits zero — handy for feature detection.
Accepting non-zero exits
Some tools use non-zero exits as information (e.g. grep returns 1 for "no match"). Tell
ProcessKit which codes count as success:
F#
let grep =
Command.create "grep"
|> Command.args [ "ERROR"; "app.log" ]
|> Command.okCodes [ 0; 1 ] // 1 ("no match") is not a failure
match! grep.RunAsync() with
| Ok output -> printfn $"matches:\n{output}"
| Error err -> eprintfn $"{err.Message}" // a real failure (e.g. exit 2)
C#
var grep = new Command("grep")
.Args(["ERROR", "app.log"])
.OkCodes([0, 1]); // 1 ("no match") is not a failure
Console.WriteLine(await grep.RunAsync() switch
{
{ IsOk: true, ResultValue: var output } => $"matches:\n{output}",
{ IsOk: false, ErrorValue: var err } => err.Message, // a real failure (e.g. exit 2)
});
OkCodes sets which exit codes ProcessResult.IsSuccess, RunAsync/RunUnitAsync, and supervisor crash
detection accept — the codes replace the default {0} (include 0 to keep it, as [ 0; 1 ] does
above).
Parsing output
ParseAsync maps stdout through a function (requires success); TryParseAsync uses the standard
.NET try-parse shape, so C# can pass int.TryParse (and friends) with an explicit type argument
(TryParseAsync<int>(int.TryParse) — needed because the BCL parsers are overloaded) and a false
return becomes ProcessError.Parse — F# reaches for the Result-returning Runner.tryParse instead;
OutputJsonAsync<'T> deserializes stdout as JSON via System.Text.Json (same explicit-type-argument
need, since there is no parser argument to infer 'T from — OutputJsonAsync<int>()), takes an
optional JsonSerializerOptions overload, and turns invalid JSON into ProcessError.Parse just like
a rejecting parser; FirstLineAsync returns the first stdout line matching a predicate.
F#
let! version = (Command.create "node" |> Command.arg "--version").ParseAsync(fun s -> s.TrimStart('v'))
let! widget = (Command.create "widget-cli" |> Command.arg "get").OutputJsonAsync<Widget>()
let! port = (Command.create "myserver").FirstLineAsync(fun line -> line.StartsWith "Listening on ")
C#
var version = await new Command("node").Arg("--version").ParseAsync(s => s.TrimStart('v'));
var count = await new Command("git").Args(["rev-list", "--count", "HEAD"]).TryParseAsync<int>(int.TryParse);
var widget = await new Command("widget-cli").Arg("get").OutputJsonAsync<Widget>();
var port = await new Command("myserver").FirstLineAsync(line => line.StartsWith("Listening on "));
A plain F# record deserializes through STJ's constructor-based deserialization by default, matching
JSON keys to the record's field names case-sensitively; mark the record [<CLIMutable>] for the
classic default-constructor-plus-settable-properties shape, or pass options with
PropertyNameCaseInsensitive = true for case-insensitive matching.
Standard input
Feed input with a Stdin source:
F#
let cmd =
Command.create "grep"
|> Command.arg "needle"
|> Command.stdin (Stdin.FromString "haystack\nneedle\nmore")
C#
var cmd = new Command("grep")
.Arg("needle")
.Stdin(Stdin.FromString("haystack\nneedle\nmore"));
Sources: Stdin.FromString, FromBytes, FromFile path, FromStream stream,
FromLines seq, FromAsyncLines asyncSeq, and Stdin.Empty. For interactive writing, see
streaming.
Pipelines
Pipe wires each stage's stdout into the next stage's stdin — no shell — and runs the whole
chain in one kill-on-dispose group. The exit status follows shell pipefail.
F#
let pipeline =
(Command.create "cat" |> Command.arg "access.log")
.Pipe(Command.create "grep" |> Command.arg "ERROR")
.Pipe(Command.create "wc" |> Command.arg "-l")
match! pipeline.RunAsync() with
| Ok count -> printfn $"{count} error lines"
| Error err -> eprintfn $"{err.Message}"
C#
var pipeline = new Command("cat").Arg("access.log")
.Pipe(new Command("grep").Arg("ERROR"))
.Pipe(new Command("wc").Arg("-l"));
Console.WriteLine(await pipeline.RunAsync() switch
{
{ IsOk: true, ResultValue: var count } => $"{count} error lines",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
A pipeline supports the same verbs as a command (RunAsync/OutputStringAsync/ExitCodeAsync/…) plus
Timeout / CancelOn. Let a stage fail without failing the pipeline with
Command.uncheckedInPipe. The pipe-style module mirror is Pipeline.create / Pipeline.pipe.
Streaming and interactive I/O
StartAsync() returns a live RunningProcess. Stream stdout line by line as an
IAsyncEnumerable. (use ensures the tree is killed on scope exit.)
F#
task {
match! (Command.create "dotnet" |> Command.arg "watch").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok proc ->
use _ = proc
let lines = proc.StdoutLinesAsync()
let e = lines.GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"> {e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
}
C#
await using var proc = (await new Command("dotnet").Arg("watch").StartAsync()).GetValueOrThrow();
await foreach (var line in proc.StdoutLinesAsync())
Console.WriteLine($"> {line}");
From C# this is simply await foreach (var line in proc.StdoutLinesAsync()) { ... }.
OutputEventsAsync() interleaves stdout and stderr as OutputEvent values (IsStdout/IsStderr,
.Text). Write to a running process's stdin via TakeStdin():
F#
match proc.TakeStdin() with
| Some stdin ->
do! stdin.WriteLineAsync "command one"
do! stdin.FlushAsync()
do! stdin.FinishAsync() // close stdin (EOF)
| None -> ()
C#
if (proc.TakeStdin() is { Value: var stdin }) // Some(stdin); None is null and won't match
{
await stdin.WriteLineAsync("command one");
await stdin.FlushAsync();
await stdin.FinishAsync(); // close stdin (EOF)
}
Race or await several started processes with RunningProcess.WaitAny / WaitAllAsync.
Readiness probes
Wait for a started process to become ready before proceeding:
F#
match! (Command.create "myserver").StartAsync() with
| Ok proc ->
use _ = proc
// Wait up to 10s for a log line, a TCP port, or a custom predicate.
match! proc.WaitForLineAsync((fun l -> l.Contains "ready"), TimeSpan.FromSeconds 10.0) with
| Ok _ -> printfn "server is up"
| Error err -> eprintfn $"never became ready: {err.Message}" // ProcessError.NotReady on timeout
| Error err -> eprintfn $"{err.Message}"
C#
await using var proc = (await new Command("myserver").StartAsync()).GetValueOrThrow();
// Wait up to 10s for a log line, a TCP port, or a custom predicate.
Console.WriteLine(await proc.WaitForLineAsync(l => l.Contains("ready"), TimeSpan.FromSeconds(10)) switch
{
{ IsOk: true } => "server is up",
{ IsOk: false, ErrorValue: var err } => $"never became ready: {err.Message}", // ProcessError.NotReady on timeout
});
Also WaitForPortAsync(endpoint, timeout) and WaitForAsync(predicateReturningTask, timeout).
Timeouts, cancellation, retry
F#
let cmd =
Command.create "slow-job"
|> Command.timeout (TimeSpan.FromSeconds 30.0) // kill at the deadline -> Outcome.TimedOut
|> Command.retry 3 (TimeSpan.FromMilliseconds 200.0) (fun err -> ProcessError.isTransient err)
C#
var cmd = new Command("slow-job")
.Timeout(TimeSpan.FromSeconds(30)) // kill at the deadline -> Outcome.TimedOut
.Retry(3, TimeSpan.FromMilliseconds(200), err => err.IsTransient);
TimeoutGrace sends SIGTERM, waits a grace window, then SIGKILL (atomic on Windows). Tie a
run to a CancellationToken with CancelOn, or pass a token to any verb's optional
token parameter (cmd.RunAsync(ct)). A cancelled run is always an Error (ProcessError.Cancelled).
Process groups and tree control
A ProcessGroup is a kill-on-dispose container for a whole process tree (Windows Job
Object / Linux cgroup v2 / POSIX process group). It is itself an IProcessRunner.
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"{err.Message}"
| Ok group ->
use group = group // disposes (and reaps the whole tree) on scope exit
match! group.StartAsync(Command.create "build-everything") with
| Ok _proc ->
group.Signal Signal.Term |> ignore // signal the whole tree
group.Suspend() |> ignore // freeze it
group.Resume() |> ignore // thaw it
match group.Members() with
| Ok pids -> printfn $"{pids.Count} processes in the tree"
| Error _ -> ()
do! group.ShutdownAsync(TimeSpan.FromSeconds 5.0) // graceful: SIGTERM -> grace -> SIGKILL
| Error err -> eprintfn $"{err.Message}"
}
C#
using var group = ProcessGroup.Create().GetValueOrThrow(); // disposes (and reaps the whole tree) on scope exit
await group.StartAsync(new Command("build-everything"));
group.Signal(Signal.Term); // signal the whole tree
group.Suspend(); // freeze it
group.Resume(); // thaw it
if (group.Members() is { IsOk: true, ResultValue: var pids })
Console.WriteLine($"{pids.Count} processes in the tree");
await group.ShutdownAsync(TimeSpan.FromSeconds(5)); // graceful: SIGTERM -> grace -> SIGKILL
Portable Signal values: Term, Kill, Int, Hup, Quit, Usr1, Usr2,
Signal.Other n. On Windows only Kill is delivered. Share one container across a fleet by
passing the group as the IProcessRunner (e.g. to a Supervisor).
Resource limits
Cap the whole tree's memory, process count, or CPU. Enforced by a Windows Job Object or a
Linux cgroup v2; where no limit-capable container exists, creation fails fast with
ProcessError.ResourceLimit rather than running unbounded.
F#
let options =
ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // 512 MiB
.WithMaxProcesses(64)
.WithCpuQuota(1.5) // 1.5 cores
match ProcessGroup.Create options with
| Ok group ->
use group = group // ... run within the limited group
()
| Error err -> eprintfn $"limits unavailable: {err.Message}"
C#
var options = new ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // 512 MiB
.WithMaxProcesses(64)
.WithCpuQuota(1.5); // 1.5 cores
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine($"limits unavailable: {err.Message}");
return;
}
using var group = created.GetValueOrThrow(); // ... run within the limited group
Stats and profiling
F#
match group.Stats() with
| Ok stats ->
printfn $"active={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}"
| Error _ -> ()
// A periodic series (IAsyncEnumerable) for live dashboards:
let series = group.SampleStatsAsync(TimeSpan.FromSeconds 1.0)
C#
if (group.Stats() is { IsOk: true, ResultValue: var stats })
Console.WriteLine($"active={stats.ActiveProcessCount} cpu={stats.TotalCpuTime} peak={stats.PeakMemoryBytes}");
// A periodic series (IAsyncEnumerable) for live dashboards:
var series = group.SampleStatsAsync(TimeSpan.FromSeconds(1));
Per-run profiling captures exit code, duration, CPU, and peak memory:
F#
match! (Command.create "heavy-job").StartAsync() with
| Ok proc ->
use _ = proc
let! profile = proc.ProfileAsync()
printfn $"exit={profile.ExitCode} cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} samples={profile.Samples}"
| Error _ -> ()
C#
await using var proc = (await new Command("heavy-job").StartAsync()).GetValueOrThrow();
var profile = await proc.ProfileAsync();
Console.WriteLine($"exit={profile.ExitCode} cpu={profile.CpuTime} peak={profile.PeakMemoryBytes} samples={profile.Samples}");
Supervision
Keep a command alive with policy-driven restarts, exponential backoff + jitter, and a failure-storm guard.
F#
let outcome =
(Supervisor.create (Command.create "worker"))
.Restart(RestartPolicy.OnCrash)
.Backoff(TimeSpan.FromSeconds 1.0, 2.0) // base delay, multiplier
.MaxBackoff(TimeSpan.FromMinutes 1.0)
.Jitter(true)
.MaxRestarts(20)
.StormPause(TimeSpan.FromMinutes 5.0) // pause after a burst of failures
.RunAsync()
match! outcome with
| Ok result -> printfn $"stopped: {result.Stopped} after {result.Restarts} restarts"
| Error err -> eprintfn $"{err.Message}"
C#
var outcome = new Supervisor(new Command("worker"))
.Restart(RestartPolicy.OnCrash)
.Backoff(TimeSpan.FromSeconds(1), 2.0) // base delay, multiplier
.MaxBackoff(TimeSpan.FromMinutes(1))
.Jitter(true)
.MaxRestarts(20)
.StormPause(TimeSpan.FromMinutes(5)) // pause after a burst of failures
.RunAsync();
Console.WriteLine(await outcome switch
{
{ IsOk: true, ResultValue: var result } => $"stopped: {result.Stopped} after {result.Restarts} restarts",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Supervision runs through any IProcessRunner (WithRunner), so it is testable without
spawning processes, and it honours OkCodes when deciding what counts as a crash.
CliClient
A reusable handle to one program with shared defaults:
F#
let git =
(CliClient.create "git")
.WithDefaults(fun c -> c.CurrentDir("/repo").Timeout(TimeSpan.FromSeconds 30.0))
let! sha = git.RunAsync [ "rev-parse"; "HEAD" ]
let! log = git.OutputStringAsync [ "log"; "--oneline"; "-n"; "10" ]
C#
var git = new CliClient("git")
.WithDefaults(c => c.CurrentDir("/repo").Timeout(TimeSpan.FromSeconds(30)));
var sha = await git.RunAsync(["rev-parse", "HEAD"]);
var log = await git.OutputStringAsync(["log", "--oneline", "-n", "10"]);
WithDefaults configures the shared defaults with the full Command builder; client.Command args
builds a configured Command without running it.
Top-level Exec helpers
For one-off runs without first building a Command:
F#
let! sha = Exec.run "git" [ "rev-parse"; "HEAD" ]
let! info = Exec.outputString "dotnet" [ "--info" ]
C#
var sha = await Exec.run("git", ["rev-parse", "HEAD"]);
var info = await Exec.outputString("dotnet", ["--info"]);
Run a batch with bounded concurrency, collecting every result in input order (never short-circuits):
F#
let runner = JobRunner() :> IProcessRunner
let commands = files |> List.map (fun f -> Command.create "gzip" |> Command.arg f)
let! results = Exec.outputAll 4 runner commands CancellationToken.None // at most 4 live at once
C#
var runner = new JobRunner();
var commands = files.Select(f => new Command("gzip").Arg(f));
var results = await Exec.outputAll(4, runner, commands, CancellationToken.None); // at most 4 live at once
Preflight: is a program installed?
Exec.which resolves a program to a full path without running it — a doctor check for an
install wizard or a wrapper app's startup, cheaper than probing availability by actually launching
the program. It shares the exact PATH/PATHEXT-aware lookup the spawn path itself falls back on,
so it never disagrees with an actual spawn of the same program name (see
commands.md → Preflight for the full contract,
including Windows PATHEXT semantics).
F#
match Exec.which "git" with
| Ok path -> printfn $"git found at {path}"
| Error err -> eprintfn $"git is not available: {err.Message}"
C#
Console.WriteLine(Exec.which("git") switch
{
{ IsOk: true, ResultValue: var path } => $"git found at {path}",
{ IsOk: false, ErrorValue: var err } => $"git is not available: {err.Message}",
});
The same check on a CliClient resolves the client's own program, and is always a local host
check — never delegated to a runner injected via WithRunner (a ScriptedRunner used in the
wrapper's own tests has no bearing on it):
F#
match! git.EnsureAvailableAsync() with
| Ok path -> printfn $"git found at {path}"
| Error err -> eprintfn $"git is not available: {err.Message}"
C#
Console.WriteLine(await git.EnsureAvailableAsync() switch
{
{ IsOk: true, ResultValue: var path } => $"git found at {path}",
{ IsOk: false, ErrorValue: var err } => $"git is not available: {err.Message}",
});
Logging, tracing & metrics
Opt in to structured lifecycle events (spawn, exit, timeout, retry, supervisor restart) — each with a
stable EventId and a per-run RunId that ties a run's lines together. argv and the environment are
never logged — only the program name and non-secret facts.
F#
let cmd = Command.create "deploy" |> Command.logger logger // any Microsoft.Extensions.Logging ILogger
C#
var cmd = new Command("deploy").Logger(logger); // any Microsoft.Extensions.Logging ILogger
No-op and free when no logger is set. ProcessKit also emits a System.Diagnostics trace span per run
(ActivitySource ProcessKitDiagnostics.ActivitySourceName) and metrics (Meter
ProcessKitDiagnostics.MeterName) — wire them into OpenTelemetry with AddSource(...) / AddMeter(...).
See the Observability guide for the full event/instrument taxonomy.
Dependency injection
The ProcessKit.Extensions.DependencyInjection package registers an IProcessRunner:
F#
services.AddProcessKit() |> ignore
// When the container also has an ILoggerFactory, runs emit ProcessKit's lifecycle events.
// Later, injected as IProcessRunner:
type Deployer(runner: IProcessRunner) =
member _.Deploy() = Runner.run runner CancellationToken.None (Command.create "deploy")
C#
services.AddProcessKit();
// When the container also has an ILoggerFactory, runs emit ProcessKit's lifecycle events.
// Later, injected as IProcessRunner:
public class Deployer(IProcessRunner runner)
{
public Task<FSharpResult<string, ProcessError>> Deploy() =>
runner.RunAsync(new Command("deploy"), CancellationToken.None);
}
AddProcessKit uses TryAdd, so a pre-existing IProcessRunner registration is left intact.
AddProcessKit(configure) / AddProcessKit(configuration) set a default timeout / working directory;
AddProcessKitClient(name, program) registers keyed per-tool CliClients (the place for retry / encoding
defaults); and AddProcessKitGroup() backs the runner with a shared, container-managed ProcessGroup
(disposing the provider reaps the whole tree). See the Dependency injection guide.
Testing without subprocesses
ProcessKit.Testing provides subprocess-free IProcessRunners. ScriptedRunner returns
canned replies:
F#
let runner =
(ScriptedRunner())
.On([ "git"; "rev-parse"; "HEAD" ], Reply.Ok "abc123")
.When((fun cmd -> cmd.Program = "flaky"), Reply.Fail(1, "boom"))
.Fallback(Reply.Ok "")
// Inject `runner` wherever an IProcessRunner is expected — no real processes run.
let! sha = Runner.run runner CancellationToken.None (Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ])
C#
var runner = new ScriptedRunner()
.On(["git", "rev-parse", "HEAD"], Reply.Ok("abc123"))
.When(cmd => cmd.Program == "flaky", Reply.Fail(1, "boom"))
.Fallback(Reply.Ok(""));
// Inject `runner` wherever an IProcessRunner is expected — no real processes run.
var sha = await runner.RunAsync(new Command("git").Args(["rev-parse", "HEAD"]), CancellationToken.None);
RecordReplayRunner records real runs to a JSON cassette and replays them hermetically:
F#
// Record (wraps a real runner), then save:
let recorder = RecordReplayRunner.Record("fixture.json", JobRunner())
// ... drive recorder as an IProcessRunner ...
recorder.Save() |> ignore
// Replay later with no subprocess (an unmatched call is ProcessError.CassetteMiss):
match RecordReplayRunner.Replay "fixture.json" with
| Ok replay -> () // use `replay` as an IProcessRunner
| Error err -> eprintfn $"{err.Message}"
C#
// Record (wraps a real runner), then save:
var recorder = RecordReplayRunner.Record("fixture.json", new JobRunner());
// ... drive recorder as an IProcessRunner ...
recorder.Save();
// Replay later with no subprocess (an unmatched call is ProcessError.CassetteMiss):
var loaded = RecordReplayRunner.Replay("fixture.json");
if (loaded.IsOk)
{
var replay = loaded.ResultValue; // use `replay` as an IProcessRunner
}
else
Console.Error.WriteLine(loaded.ErrorValue.Message);
Cassettes also cover the byte[] capture and streaming (SpawnAsync) verbs,
RecordReplayRunner.Auto grows a cassette by recording on a miss, and
RecordReplayOptions adds arg-normalizer / redaction / file-content-stdin matching —
see testing.md → Record and replay.