Async child-process management for .NET with a kernel-backed no-orphan guarantee: every process you start — and everything it spawns — lives in a kill-on-dispose container (a Windows Job Object, a Linux cgroup v2, or a POSIX process group), so no descendant ever outlives your program.
Beyond spawning a subprocess: run-and-capture, line streaming, interactive stdin, shell-free pipelines, readiness probes, timeouts & cancellation, supervision with restart/backoff, and a mockable runner seam for subprocess-free tests.
open ProcessKit
task {
let! version = (Command.create "dotnet" |> Command.arg "--version").Run()
match version with
| Ok v -> printfn $"{v}"
| Error err -> eprintfn $"{err.Message}"
}System.Diagnostics.Process reaches (at most) the direct child. The processes it spawned — a
build tool's compiler children, the real payload behind a wrapper (cmd /c …, sh -c …), a
test's helper servers — survive a timeout, an exception, or a dropped task, and keep running as
orphans.
ProcessKit spawns every child into the operating system's own containment primitive — a Job Object on Windows, a cgroup v2 on Linux (with a process-group fallback), a POSIX process group on macOS/BSD — so teardown is a kernel operation over the whole tree, not a best-effort signal to one pid:
- Nothing escapes silently. Disposing the handle or group reaps every descendant,
grandchildren included. Where a mechanism has a genuine weakness (a
setsidchild escapes a POSIX process group), the activeMechanismis reported instead of pretending — never a silent downgrade. - Async-first. Run-and-capture, line streaming, interactive stdin, readiness probes,
shell-free pipelines, supervision — all return
Task<…>and stream asIAsyncEnumerable<…>. - Honest results. A non-zero exit is data (
ProcessResult) until you ask for success; a timeout is captured in the result; a cancellation is always an error; every platform divergence is typed or documented. - Testable. One interface seam (
IProcessRunner) swaps the real spawner for scripted doubles or record/replay cassettes — no subprocess in your tests.
| whole-tree kill-on-dispose | async | limits / stats | streaming · pipelines · supervision | |
|---|---|---|---|---|
System.Diagnostics.Process |
— | partial | — | — |
| ProcessKit | ✓ | ✓ | ✓ | ✓ |
The first column is the differentiator: a child's descendants are contained and reaped as a unit (Job Object / cgroup v2 / process group), not just the direct child.
Status: 2.0 — F# rewrite. ProcessKit 2.x is a ground-up F# library that supersedes the author's earlier C#
ProcessKitpackage (published through 1.3.2); its first release is 2.0.0. The public API targets Semantic Versioning: breaking changes land only in a new major version. SeeCHANGELOG.md.
dotnet add package ProcessKit
# optional — Microsoft.Extensions.DependencyInjection integration (AddProcessKit)
dotnet add package ProcessKit.Extensions.DependencyInjectionTargets .NET 8.0 and .NET 10.0. Usable from F# and C# alike — the F# samples below use
task { } + match!; from C# the same surface is await-able fluent methods.
Every run starts with the same builder; the verb you finish with decides what you get back. Every
verb returns Task<Result<_, ProcessError>>:
| You want | Call | You get |
|---|---|---|
| stdout, success required | .Run() |
trimmed string; non-zero exit / timeout / kill → Error |
| the full outcome, exit code as data | .OutputString() / .OutputBytes() |
ProcessResult<_> — code, stdout, stderr, IsTimedOut; never errors on a non-zero exit |
| just the exit code | .ExitCode() |
int (a timed-out / killed run errors instead of inventing -1) |
| a yes/no answer | .Probe() |
bool — exit 0 → true, 1 → false, anything else errors |
| a typed value from stdout | .Parse(f) / .TryParse(f) |
'T — success required |
| the first matching output line | .FirstLine(p) |
string option — None when stdout closes without a match |
| a live handle — streaming, stdin, probes | .Start() |
RunningProcess |
The same vocabulary repeats on every layer (IProcessRunner, CliClient, Pipeline), and
Exec.run "git" [ "status" ] / Exec.outputString … skip the builder for one-liners.
open ProcessKit
task {
// Capture output; a non-zero exit does not error on its own.
match! (Command.create "git" |> Command.args [ "rev-parse"; "HEAD" ]).OutputString() with
| Ok result -> printfn $"HEAD is {result.Stdout.Trim()}"
| Error err -> eprintfn $"{err.Message}"
// Require success and get trimmed stdout directly.
match! (Command.create "dotnet" |> Command.arg "--version").Run() with
| Ok version -> printfn $"{version}"
| Error err -> eprintfn $"{err.Message}"
// Feed stdin.
let sort = Command.create "sort" |> Command.stdin (Stdin.FromString "banana\napple\n")
match! sort.OutputString() with
| Ok sorted -> printfn $"{sorted.Stdout}"
| Error err -> eprintfn $"{err.Message}"
// Share one kill-on-dispose group across several children; disposing the group reaps the
// whole tree.
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _server = group.Start(Command.create "some-server")
// ... work ...
do! group.Shutdown(TimeSpan.FromSeconds 5.0) // graceful: SIGTERM → wait → SIGKILL (Unix); atomic on Windows
| Error err -> eprintfn $"{err.Message}"
}This README is the quick tour. The docs/ guide set goes deeper on every
capability, with more examples and the platform fine print collected in one place. New here? Skim
the Cookbook first — it maps "I want to …" tasks to working snippets — then
read Running commands end to end:
| Guide | Covers |
|---|---|
| Cookbook | Task → snippet recipes for everything below; the fastest way in |
| Running commands | The full Command builder and every consuming verb, with error semantics |
| Process groups | Containment, teardown, signals, suspend/resume, members, limits, stats |
| Streaming & interactive I/O | Line streaming, conversational stdin, readiness probes, WaitAny, profiling |
| Pipelines | Shell-free a → b → c, pipefail attribution, chain timeouts |
| Timeouts, retries & cancellation | Captured vs raised deadlines, retry classifiers, CancellationToken |
| Supervision | Restart policies, backoff & jitter, stop conditions, outcomes |
| Testing your code | The IProcessRunner seam, scripted / record-replay doubles, cassettes, CliClient |
| Platform support | Mechanisms, every capability matrix, and each caveat |
Where the project is headed: the roadmap.
There are no compile-time feature flags to choose: a single ProcessKit package ships the whole
surface, and the optional capabilities are just modules you use when you need them. The
kill-on-dispose tree guarantee is unconditional.
| Capability | Where |
|---|---|
Tree control — Signal / Suspend / Resume / Members |
ProcessGroup |
| Resource caps — memory / process count / CPU | ProcessGroupOptions → ProcessGroup.Create |
Stats & profiling — Stats / SampleStats / Profile |
ProcessGroup, RunningProcess |
| Record / replay cassettes | ProcessKit.Testing.RecordReplayRunner |
Lifecycle logging (Microsoft.Extensions.Logging) |
Command.WithLogger |
| Dependency-injection wiring | ProcessKit.Extensions.DependencyInjection (separate package) |
ProcessGroupOptions can bound the whole tree's memory, process count, and CPU at creation, so a
runaway or untrusted child tree can't exhaust the host:
open ProcessKit
task {
let options =
ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // 512 MiB across the tree
.WithMaxProcesses(64)
.WithCpuQuota(0.5) // half of one core
match ProcessGroup.Create options with
| Ok group ->
use group = group
let! _job = group.Start(Command.create "untrusted-tool")
() // ... work ...
| Error err -> eprintfn $"limits unavailable: {err.Message}" // ProcessError.ResourceLimit
}WithCpuQuota is a fraction of a single core (0.5 = half a core, 2.0 = two cores); on
Windows it is converted against the host's CPU count and is approximate. Limits need a real
container — a Windows Job Object or a Linux cgroup v2 — so there is no whole-tree limit on
macOS/BSD or the Linux process-group fallback. When a requested limit can't be enforced,
Create returns ProcessError.ResourceLimit instead of a silently-unbounded group.
Deeper: Process groups → resource limits.
Beyond the kill/shutdown teardown verbs, a group can broadcast a signal to every member or freeze and thaw the whole tree:
open ProcessKit
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _server = group.Start(Command.create "my-server")
group.Signal Signal.Hup |> ignore // e.g. "reload configuration"
group.Suspend() |> ignore // freeze the whole tree…
group.Resume() |> ignore // …and let it run again
| Error err -> eprintfn $"{err.Message}"
}Signals are POSIX-only: on Windows just Signal.Kill is deliverable (it maps to the Job Object
terminate) and anything else returns ProcessError.Unsupported. Suspend/resume work everywhere a
container exists — cgroup.freeze on Linux, SIGSTOP/SIGCONT on macOS/BSD and the
process-group fallback, per-thread suspension on Windows.
Deeper: Process groups → signals, suspend/resume.
Members() snapshots the live member pids, and RunningProcess.WaitAny races several running
processes, reporting whichever exits first — the natural primitive for supervising a few
long-lived children:
open ProcessKit
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! a = group.Start(Command.create "server-a")
let! b = group.Start(Command.create "server-b")
match a, b with
| Ok a, Ok b ->
match group.Members() with
| Ok pids -> printfn $"live pids: {pids}"
| Error _ -> ()
match! RunningProcess.WaitAny [| a; b |] with
| Ok result -> printfn $"contender #{result.Index} exited first with {result.Outcome}"
| Error err -> eprintfn $"{err.Message}"
| _ -> ()
| Error err -> eprintfn $"{err.Message}"
}Members() lists the whole tree on Windows (Job Object) and Linux (cgroup); the POSIX
process-group backend lists the tracked group leaders only. WaitAny applies no per-process
timeout (bound the race with a Command.Timeout) and does no output pumping — drain chatty
children first.
Deeper: Process groups → members · Streaming → racing children.
WaitAll joins a fixed set of started handles, returning every outcome in order;
Exec.outputAll runs a whole batch of commands with a concurrency cap, so fanning out
hundreds of commands can't exhaust file descriptors or the process table:
open System.Threading
open ProcessKit
task {
let runner = JobRunner() :> IProcessRunner
// 200 conversions, but never more than 8 processes alive at once.
let commands = [ for i in 0..199 -> Command.create "convert" |> Command.arg $"{i}.png" ]
let! results = Exec.outputAll 8 runner commands CancellationToken.None
let failed = results |> Array.filter (fun r -> match r with Ok o -> not o.IsSuccess | Error _ -> true)
printfn $"{failed.Length} conversions failed"
}Exec.outputAll is collect-all: each element is one command's independent Result, so a
non-zero exit never short-circuits the batch — the caller folds the outcomes. Pass a
ProcessGroup (which is itself an IProcessRunner) instead of JobRunner() to keep every child
in one shared kill-on-dispose group. Exec.outputAllBytes is the identical fan-out with each
result captured as byte[].
A point-in-time Stats() becomes a series with SampleStats, and a single run can be profiled
end-to-end:
open ProcessKit
open System
task {
// A one-shot summary of a single run:
match! (Command.create "crunch").Start() with
| Ok proc ->
use _ = proc
let! profile = proc.Profile()
printfn $"exit={profile.ExitCode} took={profile.Duration} peak={profile.PeakMemoryBytes} avgCpu={profile.AvgCpu}"
| Error err -> eprintfn $"{err.Message}"
}Stats()/SampleStats report full CPU/memory on Windows and the Linux cgroup backend, and active
counts only on the POSIX process-group fallback; Profile samples the started child itself.
Deeper: Process groups → stats · Streaming → profiling a run.
Where Command.Retry replays one run until it succeeds, a Supervisor keeps a child alive:
it restarts the command per policy whenever it exits, with bounded restarts and exponential
backoff (jittered by default so a restarted fleet doesn't stampede):
open ProcessKit
open System
task {
let supervisor =
(Supervisor.create (Command.create "my-server" |> Command.args [ "--port"; "8080" ]))
.Restart(RestartPolicy.OnCrash) // Always | OnCrash | Never
.MaxRestarts(5)
.Backoff(TimeSpan.FromMilliseconds 200.0, 2.0) // base, multiplier (cap: MaxBackoff)
.StormPause(TimeSpan.FromSeconds 15.0) // crash-loop guard (off by default)
match! supervisor.Run() with
| Ok outcome -> printfn $"ended after {outcome.Restarts} restarts: {outcome.Stopped}"
| Error err -> eprintfn $"{err.Message}"
}Run() reports a SupervisionOutcome — the final run's result, the restart count, and why
supervision stopped. The opt-in failure-storm guard distinguishes "fails rarely" from
"crash-looping": past FailureThreshold the supervisor takes one collective StormPause instead
of hammering restarts at backoff speed. Supervision runs through the IProcessRunner seam: pass
.WithRunner(group) to keep every incarnation in one shared kill-on-dispose group, or a
ScriptedRunner to test supervision logic hermetically.
Deeper: Supervision.
"Start a server, then use it" needs the server to be ready, not merely started. Three probes replace the arbitrary sleep:
open ProcessKit
open System
task {
match! (Command.create "my-server").Start() with
| Ok proc ->
use _ = proc
// Wait for the startup banner (returns the matching line)…
match! proc.WaitForLine((fun l -> l.Contains "listening on"), TimeSpan.FromSeconds 10.0) with
| Ok banner -> printfn $"server says: {banner}"
| Error err -> eprintfn $"never became ready: {err.Message}" // ProcessError.NotReady
// …or for a TCP port to accept connections, or any async health check:
// do! proc.WaitForPort(endpoint, TimeSpan.FromSeconds 10.0)
// do! proc.WaitFor((fun () -> healthCheck ()), TimeSpan.FromSeconds 10.0)
()
| Error err -> eprintfn $"{err.Message}"
}A probe that doesn't pass within its deadline — or that can no longer pass (the child exits; for
WaitForLine, its stdout closes) — fails with ProcessError.NotReady (distinct from a timeout)
and does not kill the child: the caller decides what happens next.
Deeper: Streaming → readiness probes.
a → b → c without a shell string — stages connected in-process (a relay, not a shell), so no
quoting or injection surface, and every stage lives in one shared kill-on-dispose group:
open ProcessKit
task {
let pipeline =
(Command.create "git" |> Command.args [ "log"; "--format=%an" ])
.Pipe(Command.create "sort")
.Pipe(Command.create "uniq" |> Command.arg "-c")
match! pipeline.OutputString() with
| Ok out -> printfn $"{out.Stdout}"
| Error err -> eprintfn $"{err.Message}"
}The outcome is pipefail: Stdout is the last stage's output, while the exit code, stderr, and
reported program come from the first stage that didn't exit cleanly (or the last stage when all
succeed). For a consumer that legitimately stops reading early (the producer | head -1 shape),
mark that stage Command.uncheckedInPipe and pipefail skips it. Pipeline.Timeout bounds the
whole chain.
Deeper: Pipelines.
open ProcessKit
task {
// Set / unset individual variables, or clear the environment entirely.
let! _ =
(Command.create "worker"
|> Command.env "DOTNET_ENVIRONMENT" "Production"
|> Command.envRemove "GIT_DIR")
.Run()
// Scorched earth: the child starts with an empty environment.
let! _ = (Command.create "hermetic-tool" |> Command.envClear).Run()
// Windows: no console window flashing up from a GUI app (a harmless no-op elsewhere).
let! _ = (Command.create "helper" |> Command.createNoWindow).Run()
()
}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.
Deeper: Running commands → environment.
Hand a command a CancellationToken; cancelling the token kills the process tree, and every
consuming path reports ProcessError.Cancelled:
open ProcessKit
open System.Threading
task {
use cts = new CancellationTokenSource()
let job = (Command.create "long-job").Run(cts.Token)
// elsewhere — a shutdown signal, a sibling failure, a UI button:
cts.Cancel()
match! job with
| Error(ProcessError.Cancelled _) -> printfn "cancelled"
| _ -> ()
}Unlike a timeout — whose expiry is captured in the result as IsTimedOut — cancellation is
always an error: the run was abandoned, so there is no result to inspect. A token cancelled
before the run starts short-circuits without spawning anything. Tie a token to a command for its
whole lifetime with Command.CancelOn(token), or set it once on a CliClient with
WithDefaults(fun c -> c.CancelOn token).
Deeper: Timeouts, retries & cancellation.
The one-shot helpers above buffer the whole output. For long-running or conversational children,
Start() returns a live RunningProcess you can drive asynchronously.
StdoutLines() is an IAsyncEnumerable<string> — process each line as it arrives, no waiting for
the child to exit. From C# this is await foreach (var line in proc.StdoutLines()); from F#,
enumerate it (open FSharp.Control for TaskSeq, or use the enumerator directly):
open ProcessKit
task {
match! (Command.create "git" |> Command.args [ "log"; "--oneline"; "-n"; "50" ]).Start() with
| Ok proc ->
use _ = proc
let e = proc.StdoutLines().GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"commit: {e.Current}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
// After the stream ends, collect the outcome and stderr (drained in the background).
match! proc.Finish() with
| Ok finished -> if finished.Outcome <> Outcome.Exited 0 then eprintfn $"{finished.Stderr}"
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}The command's Timeout bounds the stream: at the deadline the tree is killed, the pipes
close, and the stream ends.
Keep stdin open with KeepStdinOpen, take the writer with TakeStdin(), then interleave writes
and reads:
open ProcessKit
task {
match! (Command.create "bc" |> Command.keepStdinOpen).Start() with
| Ok proc ->
use _ = proc
match proc.TakeStdin() with
| Some stdin ->
do! stdin.WriteLine "2 + 2"
do! stdin.WriteLine "6 * 7"
do! stdin.Finish() // send EOF so bc finishes
| None -> ()
// …then read proc.StdoutLines() for the answers.
()
| Error err -> eprintfn $"{err.Message}"
}For a large interactive stdin, write from one task and read
StdoutLines()from another — otherwise the child can block writing stdout while you block writing stdin, a full-duplex deadlock. The non-interactiveStdin.From*sources are written on a background task and never deadlock.
Deeper: Streaming & interactive I/O.
CliClient turns a typed wrapper around an external tool (git, gh, …) into just its parsers —
the runner is injectable, so the wrapper is hermetically testable with a ScriptedRunner (no
subprocess):
open ProcessKit
open System
task {
let git =
(CliClient.create "git")
.WithDefaults(fun c -> c.CurrentDir("/repo").Timeout(TimeSpan.FromSeconds 30.0))
match! git.Run [ "rev-parse"; "HEAD" ] with
| Ok sha -> printfn $"{sha}"
| Error err -> eprintfn $"{err.Message}"
}Deeper: Testing your code → CliClient.
RecordReplayRunner turns real runs into a JSON cassette once, then replays them
deterministically — fast, hermetic, no subprocess in CI:
open ProcessKit
open ProcessKit.Testing
task {
// Record once against the real tool, then save:
let recorder = RecordReplayRunner.Record("fixtures/git.json", JobRunner())
let! _ = Runner.run recorder System.Threading.CancellationToken.None (Command.create "git" |> Command.arg "--version")
recorder.Save() |> ignore
// Replay everywhere else — no subprocess, identical results:
match RecordReplayRunner.Replay "fixtures/git.json" with
| Ok replay -> () // use `replay` as an IProcessRunner
| Error err -> eprintfn $"{err.Message}" // ProcessError.CassetteMiss on an unmatched call
}Entries are matched by program + args + cwd + a stdin source digest; environment override
values never reach the file (only the variable names). program, args, stdout, and stderr
are stored verbatim and can carry secrets — review a fixture before committing it; on Unix the
file is written 0600.
Deeper: Testing your code → record/replay.
Opt into structured lifecycle events (spawn, exit, timeout, retry, supervisor restart) with
Command.WithLogger — argv and the environment are never logged, only the program name and
non-secret facts. The separate ProcessKit.Extensions.DependencyInjection package registers an
IProcessRunner for Microsoft.Extensions.DependencyInjection consumers with AddProcessKit()
(logger-aware when the container has an ILoggerFactory).
Deeper: Testing your code → the runner seam.
Issues and pull requests are welcome — see CONTRIBUTING.md and the Code of Conduct. To report a security issue, follow SECURITY.md.
Licensed under the MIT License © Anton Zhelezniakou.