Skip to content

ZelAnton/ProcessKit-fSharp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

70 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

ProcessKit

CI NuGet License: MIT .NET

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}"
}

Why ProcessKit?

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 setsid child escapes a POSIX process group), the active Mechanism is 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 as IAsyncEnumerable<…>.
  • 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.

How it compares

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# ProcessKit package (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. See CHANGELOG.md.

Install

dotnet add package ProcessKit
# optional — Microsoft.Extensions.DependencyInjection integration (AddProcessKit)
dotnet add package ProcessKit.Extensions.DependencyInjection

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

Picking a verb

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

Quick start

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}"
}

Documentation

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.

One package, full surface

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

Capping a group's resources

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.

Signalling and pausing the whole tree

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.

Inspecting the tree and racing children

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.

Running many at once

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

Sampling stats over time

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.

Supervising a long-lived child

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.

Waiting for a child to be ready

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

Pipelines without a shell

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.

Environment and spawn flags

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.

Cancelling a run

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.

Async streaming and interactive I/O

The one-shot helpers above buffer the whole output. For long-running or conversational children, Start() returns a live RunningProcess you can drive asynchronously.

Stream stdout line by line

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.

Interactive stdin — write requests, read responses

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-interactive Stdin.From* sources are written on a background task and never deadlock.

Deeper: Streaming & interactive I/O.

Wrapping a CLI tool

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.

Recording and replaying runs

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.

Observability and dependency injection

Opt into structured lifecycle events (spawn, exit, timeout, retry, supervisor restart) with Command.WithLoggerargv 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.

Contributing

Issues and pull requests are welcome — see CONTRIBUTING.md and the Code of Conduct. To report a security issue, follow SECURITY.md.

License

Licensed under the MIT License © Anton Zhelezniakou.

About

F# async child-process management for tokio: whole-tree kill-on-drop (no orphans), plus streaming, pipelines, timeouts, and supervision library.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors