processkit — documentation
processkit is a child-process toolkit for Rust in two layers:
┌─────────────────────────────────────────────────────────────────┐
│ Runner layer (async, tokio) │
│ Command · RunningProcess · Pipeline · Supervisor · CliClient │
│ capture / streaming / interactive stdin / readiness probes │
│ testing seam: ProcessRunner → ScriptedRunner / RecordReplay… │
├─────────────────────────────────────────────────────────────────┤
│ Group layer (kill-on-drop containment) │
│ ProcessGroup: spawn / adopt / signal / suspend / members / │
│ stats / limits / shutdown │
├─────────────────────────────────────────────────────────────────┤
│ OS mechanisms │
│ Windows Job Object · Linux cgroup v2 · POSIX process group │
└─────────────────────────────────────────────────────────────────┘
Every Command run gets containment for free: the one-shot helpers spawn into
a fresh private group that dies with the run, so a panicking caller never
leaks a process tree. The layers are also usable independently — a raw
ProcessGroup can contain children you spawn yourself, and the runner's
test doubles never touch the OS at all.
Guides
New to the crate? Start with the Cookbook — short task-to-snippet recipes for everything the crate does — then read Running commands end to end (it's the vocabulary every other guide builds on). Reach for the rest as the need arises, and keep Platform support handy before you ship: it collects every per-OS caveat in one place.
| Guide | Covers |
|---|---|
| Cookbook | "I want to …" → working snippet, for every capability; each recipe links to its deep guide |
| Running commands | The Command builder end to end: args, env, stdin sources, encodings, buffer policies, line handlers, timeouts, retry, privileges — and every consuming verb (run, output_string, probe, …) with its error semantics |
| Process groups | Kill-on-drop containment: creating groups, spawning/adopting, teardown verbs, whole-tree signals, suspend/resume, member listing, resource limits, stats sampling |
| Streaming & interactive I/O | start() and the live RunningProcess: line streaming, interactive stdin, readiness probes (wait_for_line / wait_for_port / wait_for), racing children with wait_any, per-run profiling |
| Pipelines | a | b | c without a shell (the | operator works too): wiring, pipefail attribution, unchecked_in_pipe() stages for the | head pattern, timeouts, stdin/stdout at the ends, re-running chains |
| Timeouts, retries & cancellation | How a deadline is captured vs when it errors, retry policies and their classifier, and cancellation: per-command tokens and the client-level default_cancel_on |
| Errors | Every Error variant, where it comes from and the recommended reaction, the subtle look-alikes (Timeout vs Cancelled, NotReady vs Timeout, NotFound vs Spawn, CassetteMiss), the classifiers (is_not_found, is_timeout, code()/signal()/…), matching under #[non_exhaustive], and how retries/supervision use the classifiers |
| Supervision | Keeping a child alive: restart policies, backoff & jitter math, the failure-storm guard, stop conditions, outcomes, supervising inside a shared group |
| Testing your code | The ProcessRunner seam — bulk and streaming: ScriptedRunner (incl. scripted start() with canned, paced lines), RecordingRunner, MockRunner, record/replay cassettes, and building hermetically-testable CLI wrappers with CliClient |
| Platform support | The containment mechanisms, every per-feature support matrix in one place, and the platform caveats worth knowing before you ship |
| Running in containers | Docker/Kubernetes specifics: which mechanism you actually get, PID 1 signal/reaping behavior, graceful shutdown on the orchestrator's SIGTERM, minimal musl/Alpine images, and container limits vs. the crate's own limits |
| Upgrading | Per-version consumer upgrade notes — what changed on each release and the exact change to make across a major bump |
| What's next | Where the containment/runner approach is headed beyond this Rust crate |
Feature flags
Every flag is additive and only gates visibility — the kill-on-drop tree guarantee is unconditional in every configuration.
| Feature | Default | Adds | Extra dependency |
|---|---|---|---|
stats | off | ProcessGroup::stats / sample_stats, RunningProcess::cpu_time / peak_memory_bytes / profile | windows-sys/ProcessStatus (Windows) |
process-control | on | Signal, ProcessGroup::{signal, suspend, resume, members, adopt} | — |
limits | off | Whole-tree resource caps on ProcessGroupOptions (max_memory / max_processes / cpu_quota), Error::ResourceLimit; implies stats | — |
mock | off | A mockall-generated MockRunner for expectation-style tests (test-only; its expect_* surface is semver-exempt, tracking mockall — prefer ScriptedRunner/RecordingRunner for a stable double) | mockall |
tracing | off | Events on the processkit target: spawn/exit, timeout & cancel firing, group teardown, retries, supervisor storms, teardown anomalies (never argv/env) | tracing |
record | off | RecordReplayRunner JSON cassettes over the runner seam | serde, serde_json |
[dependencies]
processkit = { version = "…", features = ["limits"] }
The 60-second tour
use processkit::{Command, ProcessGroup, Stdin}; #[tokio::main] async fn main() -> processkit::Result<()> { // One-shot: capture everything. A non-zero exit is data, not an Err. let head = Command::new("git").args(["rev-parse", "HEAD"]).output_string().await?; println!("HEAD = {}", head.stdout().trim()); // Success-checking: non-zero exit / timeout / signal-kill become typed errors. let version = Command::new("cargo").arg("--version").run().await?; // Stdin, timeout, streaming, pipelines, supervision … see the guides. let sorted = Command::new("sort") .stdin(Stdin::from_string("b\na\n")) .timeout(std::time::Duration::from_secs(5)) .run() .await?; // Containment: anything spawned through a group dies with it. let group = ProcessGroup::new()?; let _server = group.start(&Command::new("dev-server")).await?; drop(group); // the server — and everything *it* spawned — is reaped let _ = (version, sorted); Ok(()) }
API reference
The rustdoc on docs.rs is the authoritative per-item reference; these guides are the narrative layer on top — they explain how the pieces compose, with the platform fine print collected in Platform support.
These examples are compiler-checked. Every fenced Rust block across these
guides and the root README.md is compiled (and, unless annotated no_run or
ignore, actually run) as an ordinary doctest by cargo test --all-features
(as CI does) — a signature change that stops matching a guide's snippet fails
CI instead of silently lying to a reader. The hidden harness only builds under
--all-features, so a plain cargo test with the default features does
not exercise this check. See src/doc_examples.rs for the (test-only,
hidden) harness.
Other languages
Not on Rust? processkit-py wraps
this crate's core in a Python (PyO3/asyncio) API — this crate stays the single
source of truth for the containment/runner logic underneath.
Python wrapper
Redirecting to the Python wrapper documentation...
.NET version
Redirecting to the .NET version documentation...
Cookbook
Task-oriented recipes: find the thing you're trying to do, copy the snippet,
follow the link when you need the fine print. Every snippet assumes a tokio
runtime and use processkit::Command; unless shown otherwise.
- Run a command and get its output
- Inspect a failure instead of erroring
- Ask a yes/no question
- Accept non-zero exit codes as success
- Bound a run with a timeout
- Let a tool clean up on timeout
- Show a useful error message
- Check a tool is installed without running it
- Feed the child's stdin
- Stream output as it arrives
- Talk to an interactive child
- Pipe commands without a shell
- Start a server and wait until it's ready
- Tear down several children as a unit
- React to whichever child exits first
- Sandbox an untrusted tool
- Keep a crash-prone service running
- Retry a flaky command
- Cancel runs on shutdown
- Measure what a run cost
- Contain a process you didn't spawn
- Test code that runs processes — without processes
- Test streaming code — without processes
- Wrap a CLI tool behind a typed API
Run a command and get its output
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let head = Command::new("git").args(["rev-parse", "HEAD"]).run().await?; Ok(()) }
run() requires a zero exit and returns stdout with trailing whitespace
trimmed; a non-zero exit, spawn failure, or timeout is a typed Error. For a
one-liner without the builder: processkit::run("git", ["rev-parse", "HEAD"]).
Fine print: Running commands → consuming verbs.
Inspect a failure instead of erroring
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("git").args(["merge", "topic"]).output_string().await?; if !result.is_success() { eprintln!("merge exited {:?}: {}", result.code(), result.stderr()); } Ok(()) }
output_string() (and output_bytes() for raw bytes) treats the exit code as
data — Err means the run couldn't happen at all. Call
result.ensure_success()? later to convert a stored failure into the same
typed error run() would have produced.
Fine print: Running commands → results and errors.
Ask a yes/no question
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let dirty = !Command::new("git").args(["diff", "--quiet"]).probe().await?; Ok(()) }
probe() maps exit 0 → true, exit 1 → false, and anything else to an
error — the git diff --quiet / grep -q convention without manual code
matching.
Accept non-zero exit codes as success
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // `grep` exits 1 when it finds no match — not a failure for this call. let found = Command::new("grep") .args(["needle", "haystack.txt"]) .ok_codes([0, 1]) .output_string() .await?; let matched = found.code() == Some(0); // 0 = matched, 1 = no match (both "success") Ok(()) }
ok_codes widens what the checking verbs (run/run_unit) and
is_success/ensure_success treat as success — for tools whose non-zero exit is a
normal result (grep 1 = no match, diff 1 = differs, rsync's code families). It
does not change exit_code (always the raw code) or probe (always the 0/1
convention). An empty set is ignored, so the default stays exit 0.
Bound a run with a timeout
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("slow-tool") .timeout(Duration::from_secs(30)) .output_string() .await?; if result.timed_out() { eprintln!("gave up after 30s; partial output: {}", result.stdout()); } Ok(()) }
At the deadline the whole tree is killed. On the capture verbs the timeout is
captured (timed_out(), partial output kept); on the success-checking verbs
(run, exit_code) it surfaces as Error::Timeout.
Let a tool clean up on timeout
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("dev-server") .timeout(Duration::from_secs(30)) .timeout_grace(Duration::from_secs(5)) // SIGTERM, wait up to 5s, then SIGKILL .output_string() .await?; Ok(()) }
timeout_grace turns the hard deadline kill into a graceful one: SIGTERM (or the
signal from timeout_signal, with the process-control feature), up to the grace
window to exit, then SIGKILL. A signal-handling child exits early; timed_out()
stays true. Windows has no signal tier — the deadline kills atomically.
Fine print: Timeouts → graceful timeout.
Fine print: Timeouts, retries & cancellation.
Show a useful error message
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { if let Err(e) = Command::new("git").args(["merge", "topic"]).run().await { eprintln!("merge failed: {}", e.diagnostic().unwrap_or("(no output)")); } Ok(()) }
Error::diagnostic() picks the most explanatory captured text — stderr,
falling back to stdout (git writes CONFLICT … there) — so callers don't
re-implement the same heuristic.
Check a tool is installed without running it
use processkit::Command; fn main() { // The crate-level shortcut for a bare tool: match processkit::which("git") { Ok(path) => println!("git is at {}", path.display()), Err(e) if e.is_not_found() => eprintln!("git is not installed"), Err(e) => eprintln!("could not resolve git: {e}"), } // On a builder it honors that command's `prefer_local` / env, resolving // exactly what a real run would launch: let _ = Command::new("eslint") .prefer_local("./node_modules/.bin") .resolve_program(); }
which / Command::resolve_program() locate a program without spawning it
— a side-effect-free doctor / preflight check ("is the tool installed?") for a
friendly up-front error. Resolution reuses the crate's own launch-path logic
(the same PATH/PATHEXT/execute-bit and prefer_local handling a real run uses),
so a hit is exactly what would be launched and a miss is exactly the
Error::NotFound (is_not_found()) a run would raise. It is synchronous — no
tokio runtime needed. A wrapped tool's client offers the same via
CliClient::resolve_program().
Fine print: Running commands → preflight.
Feed the child's stdin
use processkit::Command; use processkit::Stdin; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // A string you already have: let sorted = Command::new("sort") .stdin(Stdin::from_string("banana\napple\n")) .run() .await?; // …or any async source: a reader (file, socket) or a stream of lines. let from_file = Stdin::from_reader(tokio::fs::File::open("input.txt").await?); let from_chan = Stdin::from_lines(tokio_stream::iter(vec!["one".to_owned()])); let _ = (sorted, from_file, from_chan); Ok(()) }
One-shot sources (from_reader/from_lines) feed a single run; re-running the
same Command afterwards fails loud (an Error::Io at launch, D10) instead
of silently seeing empty stdin. For a conversation, see the next recipe but one.
Fine print: Running commands → standard input.
Stream output as it arrives
use processkit::Command; use processkit::Finished; #[tokio::main] async fn main() -> processkit::Result<()> { use processkit::prelude::StreamExt; // re-exported; provides `.next()` let mut run = Command::new("cargo").args(["build", "--verbose"]).start().await?; let mut lines = run.stdout_lines()?; while let Some(line) = lines.next().await { println!("build: {line}"); } let Finished { outcome, stderr, .. } = run.finish().await?; // outcome + buffered stderr Ok(()) }
No waiting for exit, no full-output buffering; stderr is drained in the
background so the child can't block. A timeout on the command bounds the
stream itself. Prefer a callback? .on_stdout_line(|l| …) runs one per line
while any capture verb drives the run.
Fine print: Streaming & interactive I/O.
Talk to an interactive child
use processkit::Command; use processkit::prelude::StreamExt; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut run = Command::new("bc").keep_stdin_open().start().await?; let mut stdin = run.take_stdin().expect("stdin was kept open"); stdin.write_line("2 + 2").await?; stdin.finish().await?; // EOF — bc exits let mut answers = run.stdout_lines()?; while let Some(answer) = answers.next().await { println!("{answer}"); } Ok(()) }
keep_stdin_open() hands you an async writer instead of closing stdin at
spawn; interleave writes with reads for request/response tools. Its writer
methods return std::io::Result (idiomatic for a writer) — convert with
.map_err(processkit::Error::Io)? in a processkit::Result function, or use
Box<dyn std::error::Error>.
Fine print: Streaming & interactive I/O → interactive stdin.
Pipe commands without a shell
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let authors = Command::new("git").args(["log", "--format=%an"]) .pipe(Command::new("sort")) .pipe(Command::new("uniq").arg("-c")) .output_string() .await?; Ok(()) }
Native pipes — no shell string, no quoting, no injection surface. The outcome
is pipefail: stdout comes from the last stage, the reported failure from
the first stage that didn't exit cleanly. All stages share one kill-on-drop
group. The | operator is equivalent sugar:
(a | b | c).output_string().
For a consumer that legitimately stops reading early — the | head -1 shape,
where the producer's broken-pipe death (its next write fails once the downstream
closes, or SIGPIPE where the OS delivers it) is expected — mark the producer
unchecked_in_pipe() so that death doesn't fail the chain:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let first = (Command::new("seq").args(["1", "1000000"]).unchecked_in_pipe() | Command::new("head").args(["-n", "1"])) .run() .await?; Ok(()) }
Fine print: Pipelines → unchecked stages.
Start a server and wait until it's ready
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let mut server = Command::new("my-server").args(["--port", "8080"]).start().await?; // Pick the probe that matches how the server announces readiness: server.wait_for_line(|l| l.contains("listening"), Duration::from_secs(10)).await?; // server.wait_for_port("127.0.0.1:8080".parse().unwrap(), Duration::from_secs(10)).await?; // server.wait_for(|| async { http_health().await }, Duration::from_secs(10)).await?; // …use the server; dropping `server` kills its whole tree. Ok(()) }
A probe that can't succeed fails fast with Error::NotReady and never kills
the child — you decide what happens next. No more sleep(2) and hoping.
Fine print: Streaming & interactive I/O → readiness probes.
Tear down several children as a unit
use processkit::Command; use processkit::ProcessGroup; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _db = group.start(&Command::new("dev-db")).await?; let _api = group.start(&Command::new("dev-api")).await?; // Either: graceful — SIGTERM, bounded wait, optional SIGKILL escalation… group.shutdown().await?; // …or just drop(group): hard kill-on-drop of everything, grandchildren included. Ok(()) }
The group is the unit of fate: a panic or early return anywhere reaps every
member. Configure the grace window via ProcessGroupOptions.
Fine print: Process groups.
React to whichever child exits first
use processkit::Command; use processkit::{ProcessGroup, wait_any}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let mut a = group.start(&Command::new("worker-a")).await?; let mut b = group.start(&Command::new("worker-b")).await?; let (idx, outcome) = wait_any(&mut [&mut a, &mut b]).await?; println!("worker #{idx} exited first with {outcome:?}"); // `a` and `b` are only borrowed — the loser is still usable here. Ok(()) }
Fine print: Streaming & interactive I/O → racing children.
Sandbox an untrusted tool
use processkit::Command; use processkit::{ProcessGroup, ProcessGroupOptions}; #[tokio::main] async fn main() -> processkit::Result<()> { // Cap the whole tree (requires the `limits` feature; Windows Job / Linux cgroup): let group = ProcessGroup::with_options( ProcessGroupOptions::default() .max_memory(512 * 1024 * 1024) .max_processes(64) .cpu_quota(0.5), )?; let result = group .start( &Command::new("untrusted-tool") .inherit_env(["PATH"]) // allow-list: everything else is cleared .timeout(std::time::Duration::from_secs(60)), ) .await? .output_string() .await?; Ok(()) }
Unenforceable limits are a hard Error::ResourceLimit, never a silently
unbounded group. On Unix, add .uid(…)/.gid(…) to drop privileges (note the
cgroup-mechanism caveat in the guide).
Fine print: Process groups → resource limits · Running commands → privileges.
Keep a crash-prone service running
use processkit::Command; use processkit::{RestartPolicy, Supervisor}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("my-service")) .restart(RestartPolicy::OnCrash) .max_restarts(5) .backoff(Duration::from_millis(200), 2.0) .storm_pause(Duration::from_secs(15)) // crash-loop guard (off by default) .run() .await?; println!( "stopped after {} restarts ({} storm pauses): {:?}", outcome.restarts, outcome.storm_pauses, outcome.stopped ); Ok(()) }
Exponential backoff with jitter by default; stop_when(…) ends supervision on
a condition; .with_runner(&group) keeps every incarnation inside one shared
kill-on-drop group. storm_pause arms the failure-storm guard: failures feed
a decaying score, and past the threshold the supervisor takes one collective
pause instead of hammering restarts — "fails rarely" and "crash-looping" stop
being the same case.
Fine print: Supervision, failure storms.
Retry a flaky command
use processkit::Command; use processkit::Error; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let fetched = Command::new("git") .args(["fetch", "--quiet"]) .timeout(Duration::from_secs(10)) .retry(3, Duration::from_millis(200), |e| { matches!(e, Error::Timeout { .. }) || e.diagnostic().is_some_and(|m| m.contains("Could not resolve host")) }) .run() .await?; Ok(()) }
The classifier sees the typed error and decides whether this failure is worth
another attempt; each attempt is a fresh process. retry replays a run to
success — for keeping a process alive, use a Supervisor (previous recipe).
Fine print: Timeouts, retries & cancellation → retry.
Cancel runs on shutdown
use processkit::Command; use processkit::CancellationToken; #[tokio::main] async fn main() -> processkit::Result<()> { let token = CancellationToken::new(); let job = tokio::spawn({ let token = token.child_token(); async move { Command::new("long-job").cancel_on(token).run().await } }); // On Ctrl-C / shutdown signal / sibling failure: token.cancel(); // kills the tree; the run resolves to Error::Cancelled let outcome = job.await; // Err(Error::Cancelled { .. }) inside Ok(()) }
Cancellation is always an error (the run was abandoned, there is no result),
beats a simultaneous timeout, and is terminal for retry and Supervisor
alike.
For a typed wrapper whose commands never cross your code, set the token once on the client — every command it builds carries it:
#![allow(unused)] fn main() { use processkit::{CancellationToken, CliClient}; let token = CancellationToken::new(); let gh = CliClient::new("gh").default_cancel_on(token.child_token()); // token.cancel() → every in-flight command of THIS client dies. }
Fine print: Timeouts, retries & cancellation → cancellation, client-level default.
Measure what a run cost
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { // One run, summarized (requires the opt-in `stats` feature): let profile = Command::new("crunch").start().await?.profile(Duration::from_millis(100)).await?; println!("outcome={:?} took={:?} peak_rss={:?} avg_cpu_cores={:?}", profile.outcome, profile.duration, profile.peak_memory_bytes, profile.avg_cpu_cores()); Ok(()) }
For a live series over a whole group, group.sample_stats(every) yields a
Stream of snapshots. CPU/memory need a real container (Windows Job / Linux
cgroup); elsewhere you still get process counts.
Fine print: Process groups → stats · Streaming → profiling.
Contain a process you didn't spawn
use processkit::{Error, ProcessGroup}; fn main() -> processkit::Result<()> { // `tokio`/`std` calls return `io::Error`, which the crate does NOT auto-convert // into `processkit::Error` (there is no blanket `From<io::Error>`, by design) — // map it explicitly, or use a `Box<dyn std::error::Error>` / `anyhow` return in // your own code so both error types `?` freely. let child = tokio::process::Command::new("legacy-launcher") .spawn() .map_err(Error::Io)?; let group = ProcessGroup::new()?; // `adopt` is part of `process-control` (default-on) group.adopt(&child)?; // from now on the group's teardown covers it Ok(()) }
Adoption is best-effort by mechanism — on Windows/cgroup the whole running tree joins; on the POSIX process-group backends an exec'd child is contained individually (its future forks too, where it could be re-grouped). The guide spells out exactly what each mechanism can promise.
Fine print: Process groups → adopt · Platform support.
Test code that runs processes — without processes
use processkit::Command; use processkit::testing::{Reply, ScriptedRunner}; #[tokio::main] async fn main() -> processkit::Result<()> { // Your code takes any `R: ProcessRunner`; in tests, hand it a script. // Rules match on a prefix of the *program name followed by its arguments* // (the first element is the program): let runner = ScriptedRunner::new() .on(["git", "rev-parse"], Reply::ok("abc123\n")) .on(["git", "push"], Reply::fail(128, "remote: permission denied")) .fallback(Reply::ok("")); // my_deploy(&runner).await? — no subprocess, fully deterministic. Ok(()) }
RecordingRunner wraps any runner and captures every Invocation for
assertions; MockRunner (feature mock) gives mockall expectations; and
the record feature's RecordReplayRunner records real runs into a JSON
cassette once and replays them hermetically in CI.
Fine print: Testing your code.
Test streaming code — without processes
use processkit::{Command, Outcome, ProcessRunner, Finished}; use processkit::testing::{Reply, ScriptedRunner}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let runner = ScriptedRunner::new() .on(["gh", "run", "watch"], Reply::lines(["queued", "in_progress", "completed"]) .with_line_delay(Duration::from_millis(50))); // paced delivery let mut run = runner.start(&Command::new("gh").args(["run", "watch", "123"])).await?; run.wait_for_line(|l| l.contains("completed"), Duration::from_secs(5)).await?; let Finished { outcome, .. } = run.finish().await?; assert_eq!(outcome, Outcome::Exited(0)); Ok(()) }
A scripted start() feeds the canned lines through the same pump
machinery a real child uses, so stdout_lines, the readiness probes, and
finish behave identically — and with_line_delay is deterministic
under #[tokio::test(start_paused = true)]. Canned output also replays
through on_stdout_line/on_stderr_line handlers on the bulk verbs, so
progress-reporting paths test hermetically too.
Fine print: Testing → scripted streaming.
Wrap a CLI tool behind a typed API
#![allow(unused)] fn main() { use processkit::{cli_client, ProcessRunner, Result}; cli_client!(pub struct Git => "git"); impl<R: ProcessRunner> Git<R> { pub async fn current_branch(&self) -> Result<String> { // A verb takes the args directly (D7); pass a built `command(..)` only // when you need to customize it (per-call timeout, stdin, …). self.core.run(["branch", "--show-current"]).await } pub async fn is_clean(&self) -> Result<bool> { self.core.probe(["diff", "--quiet"]).await } } }
The generated struct carries a runner and per-client defaults
(default_timeout, default_env); your methods are just argument lists and
parsers — and because the runner is injectable, the whole wrapper is testable
with the previous recipe's ScriptedRunner.
Fine print: Testing your code → CliClient.
Running commands
Command is the entry point of the runner layer: a builder describing 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-drop
process group, so an early return, panic, or dropped
future can never leak a process tree.
- Program, arguments, working directory
- Resolving a locally-installed tool:
prefer_local - Environment
- Standard input
- Output handling
- Timeouts and retries
- Privileges and spawn flags
- Consuming verbs
- Results and errors
Program, arguments, working directory
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("git") .arg("log") // one at a time… .args(["--oneline", "-n", "10"]) // …or in bulk .current_dir("/path/to/repo") // run there .run() .await?; Ok(()) }
Arguments are passed as an array — 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.)
The program name reaches the OS verbatim — two deliberate non-goals
(conveniences some libraries layer on, e.g. duct): a bare name is resolved
on PATH by the OS, never rewritten to ./name; and current_dir does not
re-anchor a relative program path against the new directory — whether
Command::new("./tool").current_dir(dir) resolves tool relative to dir
is the platform's behavior (Unix: yes; Windows: the parent's directory may
win). Pass absolute program paths when combining the two.
For quick one-liners the free functions skip the builder:
#[tokio::main] async fn main() -> processkit::Result<()> { let version = processkit::run("cargo", ["--version"]).await?; // trimmed stdout, success required let result = processkit::output_string("git", ["status", "-s"]).await?; // full ProcessResult Ok(()) }
Resolving a locally-installed tool: prefer_local
prefer_local adds a directory to check before the system PATH when
resolving a bare-name program for this one run — for a project's own
node_modules/.bin, a target/debug build, or a vendored toolchain, without
hand-rolling a PATH override:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("eslint") .prefer_local("./node_modules/.bin") .arg("src/") .output_string() .await?; Ok(()) }
Resolution order. Repeated calls accumulate, in priority order: the
directory from the first call is probed first, then the second, and so on,
with the system PATH tried last as the final fallback:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { Command::new("tool") .prefer_local("./vendor/bin") // checked first .prefer_local("./target/debug") // checked second .run() // then the system PATH .await?; Ok(()) }
Resolution reuses the exact same PATHEXT-aware lookup as the PATH search
(the same internal probe_dir helper — not a separate implementation), so a
.exe/.cmd/.bat on Windows is found under a prefer_local directory
exactly as it would be on PATH.
Only a bare name is affected. If the program passed to Command::new is
a path — absolute, or relative with a separator ("./tool", "../bin/x") —
prefer_local has no effect at all: the existing contract that such a program
is never looked up on PATH (or here) is unchanged.
Interaction with PATH/inherit_env/env. prefer_local only changes
where the parent looks to resolve the program for this one launch. It does
not rewrite or extend the PATH the child sees in its own environment —
that is governed entirely by env/inherit_env/env_clear, as usual. When
the program is found under a prefer_local directory, the child is simply
spawned via that resolved absolute path instead of the bare name; a
grandchild the program itself spawns does not inherit this reach — only the
one program named in this Command benefits.
Interaction with current_dir. A relative prefer_local directory (as in
the examples above) is probed against the process's actual current
directory, never against whatever is set via current_dir on the same
Command. The resolved match is then always turned into an absolute path
before being handed to the OS, so it can't later be reinterpreted against the
child's working directory once current_dir is set — unlike a relative-path
program passed straight to Command::new, which is subject to that
footgun (see Program, arguments, working directory
above).
Diagnostics. If resolution fails everywhere, Error::NotFound's
searched field includes the prefer_local directories too — first, in
priority order, ahead of the PATH directories — so the diagnostic never
hides that they were checked.
Preflight: resolve a program without running it
Sometimes you want to know whether an external tool is available before you
run it — a doctor check at startup, a friendly "is git installed?" error
up front — with no side effects. resolve_program locates a command's
program and returns its absolute path without spawning anything:
use processkit::Command; fn main() -> processkit::Result<()> { // `which` is the crate-level shortcut for a bare tool. let git = processkit::which("git")?; // Ok(/usr/bin/git) or Err(NotFound) println!("git lives at {}", git.display()); // On a builder it honors that command's own `prefer_local` and env, so it // resolves exactly what a real run of that command would launch. let eslint = Command::new("eslint") .prefer_local("./node_modules/.bin") .resolve_program()?; println!("eslint lives at {}", eslint.display()); Ok(()) }
No divergence from a real run. Resolution reuses the crate's own
launch-path logic — the same PATH/PATHEXT/execute-bit resolution and
prefer_local handling a spawn performs, not a second copy — so a
resolve_program hit is exactly the executable a run would launch, and a miss
is exactly the Error::NotFound (with the same searched diagnostic and
is_not_found() classification) a run would raise. A command that relocates the
child's PATH (env/env_remove of PATH, env_clear, inherit_env) is
resolved against that effective child PATH, so preflight still matches the
spawn.
It is a synchronous, cheap filesystem probe (a few stats) — no async
runtime is required, and no process is ever started. Contrast probe(), which
runs the tool to read its exit code; resolve_program only locates it.
fn main() { match processkit::which("definitely-not-installed") { Ok(path) => println!("found: {}", path.display()), Err(e) if e.is_not_found() => eprintln!("tool not installed"), Err(e) => eprintln!("resolution error: {e}"), } }
For a tool wrapped behind a CliClient, CliClient::resolve_program() does the
same for the client's program, honoring its env defaults.
Environment
Four builders compose, applied in a fixed order at spawn:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { Command::new("worker") .env("RUST_LOG", "debug") // set one variable .env_remove("GIT_DIR") // unset one inherited variable .run().await?; // Allow-list mode: clear everything, copy only the named parent variables. Command::new("sandboxed-tool") .inherit_env(["PATH", "HOME", "LANG"]) .env("MODE", "ci") // explicit env/env_remove still apply on top .run().await?; // Scorched earth: the child starts with an empty environment. Command::new("hermetic-tool").env_clear().run().await?; Ok(()) }
inherit_env is the sandboxing middle ground: it implies env_clear, then
copies the listed variables from the parent at each spawn (so a retry sees
fresh values), and repeated calls accumulate names. A name the parent doesn't
have is skipped, not set to empty.
Standard input
By default stdin is closed at spawn — the child reads EOF immediately and
can never hang waiting for input. Everything else is opt-in via
stdin(Stdin::…):
| Source | Reusable on re-run? | Use for |
|---|---|---|
Stdin::empty() | — | The default, explicit |
Stdin::from_string("…") | ✅ | Text payloads |
Stdin::from_bytes(vec![…]) | ✅ | Binary payloads |
Stdin::from_iter_lines(["a", "b"]) | ✅ | Anything iterable; each item is written \n-terminated |
Stdin::from_file(path) | ✅ (re-opened per run) | Large inputs streamed from disk |
Stdin::from_reader(reader) | ❌ one-shot | Any AsyncRead — a socket, a decompressor, … |
Stdin::from_lines(stream) | ❌ one-shot | Any Stream<Item = String> — a channel, a tail, … |
use processkit::{Command, Stdin}; #[tokio::main] async fn main() -> processkit::Result<()> { let sorted = Command::new("sort") .stdin(Stdin::from_iter_lines(["banana", "apple", "cherry"])) .run() .await?; assert_eq!(sorted, "apple\nbanana\ncherry"); Ok(()) }
The payload is written on a background task (so a large input can't deadlock
against the child's output) and the pipe is dropped afterwards to signal EOF.
The two one-shot sources are consumed by their first run: a retried or
cloned command reusing them fails loud the second time — re-running a
consumed from_reader/from_lines source is an Error::Io (InvalidInput)
at launch (D10), not a silent empty stdin. Prefer the reusable sources when
a command may run more than once.
For conversational, request/response stdin — write a line, read the answer,
repeat — use keep_stdin_open() and the streaming API instead: see
Streaming & interactive I/O.
Inheriting the parent's stdin: inherit_stdin()
inherit_stdin() hands the child the parent's own standard input — it reads
directly from whatever this process's stdin is (a terminal, a file, a pipe)
rather than from a crate-managed pipe. It is the stdin counterpart of
stdout(StdioMode::Inherit) / stderr(StdioMode::Inherit): the child shares
the parent stream instead of the crate mediating it.
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // `git commit` opens $EDITOR on the parent's terminal; the child talks to the // real tty directly. stdout/stderr are still captured as usual. Command::new("git").arg("commit").inherit_stdin().run().await?; Ok(()) }
Reach for it when a child must talk to the real terminal — git commit opening
$EDITOR, a tool prompting for a password or a yes/no — or to forward the
parent's piped stdin straight through. Until a pseudo-terminal exists (a future
direction, not yet provided) this covers the common non-tty-negotiating
interactive cases without the crate having to pump bytes. Because the child reads
the parent's stdin directly, the crate neither feeds nor captures that input, and
take_stdin() returns None (as for a non-keep_stdin_open run). Capturing and
streaming the child's output is unaffected.
Why a dedicated verb rather than a Stdin::Inherit source or a mode enum.
For stdout/stderr the three StdioMode variants map cleanly onto one setter, but
stdin's "piped" case is not modeless — it needs a payload (which source? what
bytes?), already expressed by stdin(Stdin::…), and its "null" case is
Stdin::empty(). Folding inheritance into that same stdin(Stdin) field would
make "inherit and a source" collapse to silent last-write-wins, impossible to
flag. A separate inherit_stdin() keeps the two intents in distinct fields so an
incompatible pairing is a detectable, rejectable error instead.
Accordingly, inherit_stdin() is mutually exclusive with either way the crate
would otherwise drive stdin — a configured stdin(Stdin::…) source (including an
explicit Stdin::empty()) or keep_stdin_open()'s interactive pipe. Setting
inherit_stdin() together with one of those is a contradiction (feed the child a
source and let it read the terminal?), so it is refused at the launch boundary
with a typed Error::Io (InvalidInput) — the same failure mode as re-running a
consumed one-shot source — rather than silently letting one win. Drop the other
stdin knob to resolve it. The refusal is enforced on the same launch seam the
hermetic test doubles route through, so a ScriptedRunner rejects the conflict
exactly as a live run does.
Output handling
Encodings
Output is decoded line by line, UTF-8 by default (invalid bytes become
U+FFFD, never an error). Legacy-encoding tools can override per stream:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("legacy-tool") .encoding(encoding_rs::SHIFT_JIS) // both streams… // .stdout_encoding(…) / .stderr_encoding(…) // …or each its own .output_string() .await?; Ok(()) }
(processkit::prelude::Encoding re-exports encoding_rs::Encoding, so any of its
encodings works — the single-byte and ASCII-compatible multibyte ones
(WINDOWS_1252, GBK, SHIFT_JIS, …) and the non-ASCII-compatible ones
(UTF_16LE/UTF_16BE): output is fed through one persistent decoder and split
on decoded newlines, so a 0x0A byte inside a UTF-16 code unit is not mistaken
for a line break. A leading byte-order mark of the chosen encoding is stripped
once at the stream start.)
Buffer policies — bounding memory on chatty children
Captured lines are held in memory; a multi-gigabyte log would normally grow
the buffer to match. output_buffer bounds retention (the pipe is always
fully drained, so the child never blocks):
use processkit::{Command, OutputBufferPolicy, OverflowMode}; #[tokio::main] async fn main() -> processkit::Result<()> { let tail = Command::new("verbose-build") .output_buffer(OutputBufferPolicy::bounded(1_000)) // keep the newest 1000 lines .output_string() .await?; // …or keep the head instead of the tail: let head_policy = OutputBufferPolicy::bounded(1_000).with_overflow(OverflowMode::DropNewest); Ok(()) }
DropOldest (the default) keeps a rolling tail; DropNewest freezes the
head. bounded(0) retains nothing — useful when a line handler (below) is the
real consumer. Under a line cap, dropped or not, every line still feeds
the handlers and the line counters.
The line cap alone does not bound memory — one enormous newline-free "line"
(base64 -w0) is held whole. Add with_max_bytes to cap the retained bytes
too (either ceiling, or both); the byte cap also bounds the pump's in-flight
assembly buffer, so a never-terminated flood can't exhaust memory. One
consequence: a line whose own length exceeds the byte cap can't be assembled, so
it is dropped whole — counted, but not delivered to a per-line handler or
stdout_tee (don't set a byte cap if a tee must see arbitrarily long lines):
#![allow(unused)] fn main() { use processkit::{Command, OutputBufferPolicy}; let policy = OutputBufferPolicy::unbounded().with_max_bytes(8 << 20); // 8 MiB ring let strict = OutputBufferPolicy::fail_loud(10_000).with_max_bytes(8 << 20); // error on either }
fail_loud makes the ceiling error instead of dropping: the run fails with
Error::OutputTooLarge once the cumulative output (lines or bytes) crosses the
cap — even when a streaming consumer is draining lines as they arrive. It bounds
memory, not wall-time, so pair it with timeout against a flooding child.
Even under a drop policy (DropOldest/DropNewest), the checking verbs that
hand back stdout as if complete — run, parse, try_parse — refuse
silently-truncated output (B12): if the policy dropped lines they fail with
Error::OutputTooLarge rather than feed a parser a truncated tail. The lenient
capture verbs (output_string / output_bytes) are unaffected — they return
the partial result with truncated() set for you to inspect.
Line handlers — tee output as it arrives
on_stdout_line / on_stderr_line run a callback on each decoded line in
addition to capture or streaming — logging, progress bars, metrics:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("cargo") .args(["build", "--release"]) .on_stderr_line(|line| eprintln!("[build] {line}")) .output_string() .await?; Ok(()) }
The handler runs on the read pump — keep it cheap. The contract is forgiving and precisely specified:
- A panicking handler does not poison the run. The panic is caught, the
handler is disabled for the rest of the run (surfaced as a
tracingwarn when that feature is on), and pumping continues — the final result still carries every line. You can safely re-export this callback seam to your own users without auditing their closures. - Ordering: invocations are FIFO within a stream; there is no ordering between stdout and stderr handlers (two independent pumps). On the consuming verbs, all handler calls happen-before the awaited future resolves — finalize a progress bar the moment the call returns. (One documented exception: a leaked pipe held open past the child's death is cut off after a bounded teardown grace.)
- Handlers are hermetically testable:
ScriptedRunnerreplays canned output through them — see Testing → scripting replies.
For a ready-made tee to an async sink — a file, socket, or any
[tokio::io::AsyncWrite] — reach for stdout_tee / stderr_tee instead of
hand-writing a handler. Each decoded line is written to the sink (plus a \n)
as it is produced, awaited on the pump so a slow sink applies backpressure
(the pump slows, the pipe fills, the child blocks) rather than blocking the
runtime; a write error disables the tee with a tracing warn instead of being
swallowed. It runs independently of on_stdout_line — set both and both
fire per line.
Timeouts and retries
use processkit::{Command, Error}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("flaky-network-tool") .timeout(Duration::from_secs(30)) // kill the tree at the deadline .retry(3, Duration::from_millis(200), |e| { // up to 3 attempts total matches!(e, Error::Timeout { .. }) // …but only retry timeouts }) .run() .await?; Ok(()) }
timeoutkills the whole process tree at the deadline. On the capturing verbs the expiry is captured (ProcessResult::timed_out), on the success-checking verbs it raisesError::Timeout— the full decision table lives in Timeouts, retries & cancellation.retryapplies to the success-checking verbs only —run,run_unit,exit_code,probe,checked,parse, andtry_parse(seven in all; each runs through the retry loop). The classifier sees the typed error and decides. The non-erroringoutput_string/output_bytespaths never retry, and neither doesfirst_line(its stream search is single-attempt).
Privileges and spawn flags
Spawn-time controls for sandboxing and service launch:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // Unix: drop privileges (uid + gid + supplementary groups) and detach. Command::new("worker") .gid(1000) // applied before uid (a gid change needs privilege) .groups([1000]) // replace the inherited (often root's) supplementary groups .uid(1000) // dropped last .setsid() // new session: survives the controlling terminal .run().await?; // Windows: no console window flashing up from a GUI app. Command::new("helper").create_no_window().run().await?; // Hardening: take the direct child down even if THIS process is SIGKILLed // (Drop never runs). Windows has this for free; Linux arms PDEATHSIG. Command::new("worker").kill_on_parent_death().start().await?; Ok(()) }
uid / gid / groups / setsid are POSIX-only — on Windows the run
fails with Error::Unsupported rather than silently skipping a privilege drop.
A correct drop sets all three of uid/gid/groups: dropping the uid alone
leaves the child holding the parent's (often root's) supplementary groups.
create_no_window is a harmless no-op outside Windows.
kill_on_parent_death is best-effort by design: guaranteed on Windows
(regardless of the knob), direct-child-only on Linux, unavailable on
macOS/BSD — the graceful-exit guarantee via Drop holds everywhere either
way. Containment is preserved in every combination; the platform fine print
(the Linux cgroup × uid interaction, setsid × process-group coordination,
the pdeathsig thread caveat) is collected in
Platform support.
Scheduling: CPU priority and umask
Two more spawn-time knobs, reusing the same seams as the builders above —
Unix pre_exec, Windows creation_flags — for background/batch children
that shouldn't starve the foreground, and for controlling the permissions of
files a child creates:
use processkit::{Command, Priority}; #[tokio::main] async fn main() -> processkit::Result<()> { // Run at a lower CPU-scheduling priority — supported on BOTH platforms. Command::new("batch-job") .priority(Priority::BelowNormal) .run().await?; // Unix only: files this child creates get 0644/0755 instead of 0666/0777. Command::new("worker").umask(0o022).run().await?; Ok(()) }
priority maps onto nice/setpriority on Unix and a priority class on
Windows (Idle/BelowNormal/Normal/AboveNormal/High); unlike the
privilege builders, every variant is supported on both platforms, so this
knob never yields Error::Unsupported. One caveat: lowering nice below its
inherited value on Unix — raising priority via Priority::AboveNormal/High,
or even requesting Priority::Normal under a positively-niced parent (e.g. a
niced CI/batch launcher) — needs CAP_SYS_NICE/root; without it the OS
rejects the change and the spawn fails loud (Error::Spawn), never silently
downgrading to a lower priority.
umask is Unix-only — like setsid/groups, requesting it on Windows fails
with Error::Unsupported rather than being silently ignored.
Interactive auth / TTY. 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 (PTY support is not implemented; the
trade-off is recorded in decisions/permissions-privileges-pty-network.md). Drive
such tools non-interactively instead: key-based auth, ssh -o BatchMode=yes, GIT_SSH_COMMAND / GIT_TERMINAL_PROMPT=0, or feed a known
answer over interactive stdin. Conversational
tools that read stdin without needing a tty already work today via
keep_stdin_open + stdout_lines.
Consuming verbs
| Verb | Returns | Non-zero exit | Timeout | Use when |
|---|---|---|---|---|
output_string() | ProcessResult<String> | captured | captured (timed_out) | You want to inspect the outcome yourself |
output_bytes() | ProcessResult<Vec<u8>> | captured | captured | Binary stdout (images, archives, …) |
run() | trimmed stdout String | Error::Exit | Error::Timeout | "Give me the answer or fail" |
exit_code() | i32 | the code, Ok | Error::Timeout | The code is the answer |
probe() | bool | 0→true, 1→false, else Error::Exit | Error::Timeout | Predicate commands: git diff --quiet, grep -q |
first_line(pred) | Option<String> | — (stream-based) | Error::Timeout | Grab one matching line, kill the rest |
start() | live RunningProcess | — | bounds the stream | Streaming, interactive I/O, probes |
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // probe(): the exit code as a boolean. let clean = Command::new("git").args(["diff", "--quiet"]).probe().await?; // first_line(): stop as soon as the interesting line appears. let first_match = Command::new("git") .args(["log", "--oneline"]) .first_line(|l| l.contains("fix:")) .await?; Ok(()) }
first_line returns Ok(None) when stdout closes without a match, and kills
the (private-group) child once it has its answer — you never wait out a long
log for one line. A cancel_on token that fires
while the search is still running surfaces as Error::Cancelled, so a readiness
probe with a shutdown token can't misread token-driven teardown as "the line
never appeared" — while a run that genuinely ends with no match still reports
Ok(None), even if the token happens to fire an instant later.
Results and errors
The capturing verbs hand back a ProcessResult:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("git").args(["merge", "feature"]).output_string().await?; result.code(); // Option<i32> — None = killed (timeout/signal), no code result.signal(); // Option<i32> — the signal number (Unix), else None result.is_success(); // code in ok_codes (default {0}) result.timed_out(); // the run's own deadline expired result.outcome(); // the explicit three-way enum behind the accessors above result.stdout(); // &str (or &[u8] from output_bytes) result.stderr(); // &str result.combined(); // stdout + stderr concatenated result.diagnostic(); // stderr if non-empty, else stdout — the human-facing line // (git/jj put "CONFLICT …" on stdout!) // Opt into erroring whenever you're ready: let ok = result.ensure_success()?; // Exit / Timeout / Signalled (signal-kill) as typed errors Ok(()) }
When the three-way distinction matters, match on Outcome instead of
mentally decoding the code()/timed_out() pair:
use processkit::Outcome; #[tokio::main] async fn main() -> processkit::Result<()> { let result = processkit::Command::new("git").args(["merge", "feature"]).output_string().await?; match result.outcome() { Outcome::Exited(0) => println!("clean"), Outcome::Exited(code) => println!("failed with {code}"), Outcome::Signalled(signal) => println!("killed by signal {signal:?}"), Outcome::TimedOut => println!("hit its deadline"), _ => {} // non_exhaustive: future dispositions } Ok(()) }
For a single query you usually don't need the match (and its
#[non_exhaustive] wildcard): Outcome carries the same code() /
signal() / timed_out() accessors as ProcessResult, so a bare Outcome
(from RunningProcess::wait or Finished::outcome) answers directly —
outcome.code(), outcome.signal(), outcome.timed_out(). There is no
Outcome::is_success (success is ok_codes-aware — use
ProcessResult::is_success).
The error enum is structured and #[non_exhaustive]:
| Variant | Meaning |
|---|---|
Error::Spawn { program, source } | The program was located but the OS couldn't start it (permissions, a bad working directory, a Windows .cmd/.bat needing cmd.exe, …) — not is_not_found() |
Error::NotFound { program, searched } | The program couldn't be located (the single "not found" representation — is_not_found() is true); searched is Some(dirs) for a bare-name PATH lookup, None otherwise |
Error::Exit { program, code, stdout, stderr, stdout_bytes } | Non-zero exit, both streams attached in full (the Display message is bounded, but the fields carry the complete captured text for classification); stdout_bytes is Some(exact bytes) for a checking verb built over output_bytes, None on the text path — read via Error::stdout_bytes() (the variant is #[non_exhaustive]) |
Error::Signalled { program, signal, stdout, stderr, stdout_bytes } | The process was killed by a signal (no exit code); signal carries the number on Unix, None elsewhere; the partial streams captured before the kill are attached (reach them via diagnostic()); stdout_bytes as above |
Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes } | A fail_loud buffer's line or byte ceiling was exceeded |
Error::Timeout { program, timeout, stdout, stderr, stdout_bytes } | The run's own deadline killed it; whatever the run captured before the kill is attached — a hung tool's last stderr line tails the Display and is reachable via diagnostic(); stdout_bytes as above |
Error::NotReady { program, timeout } | A readiness probe gave up |
Error::Parse { program, message } | A try_parse parser (on Command, ProcessRunnerExt, CliClient, or Pipeline) rejected the output (the Display/Debug of message is bounded to a 200-byte preview; the field carries the full text) |
Error::Stdin { program, source } | Feeding the child's stdin failed for a non-broken-pipe reason on an otherwise-successful run (a louder failure — exit/signal/timeout — wins instead); a routine broken pipe never surfaces |
Error::CassetteMiss { program } | (record feature) a cassette replay found no matching recording (stale/incomplete cassette) — kept distinct from a missing program, so is_not_found() is false |
Error::Unsupported { operation } | The platform can't do what was asked (and silently skipping would be wrong) |
Error::Cancelled { program } | the run's token was cancelled |
Error::ResourceLimit { kind, reason, detail } | (limits feature) a requested cap couldn't be enforced — kind (LimitKind::Memory/Processes/Cpu) says which limit, reason (LimitReason::Invalid/Unsupported/Unenforceable) says why, without parsing detail's English text; read via Error::limit_kind()/limit_reason() (the variant is #[non_exhaustive]) |
Error::Io(source) | A low-level IO error from the crate's own machinery (driving a child, group control, cassette files) — never an arbitrary foreign io::Error (no blanket From, D13) |
Error::diagnostic() returns the most useful human-facing line out of a
failure that captured output — Exit, and (D12) Timeout / Signalled (the
partial streams of a hung-then-killed or crashed tool). Each of those variants'
one-line Display also appends a bounded excerpt of that diagnostic (the last
non-empty line, capped at 200 bytes), so a bare eprintln!("{e}") reads
`git` exited with code 2: fatal: boom — actionable in a log line without
dumping multi-KiB streams into it.
Next: Streaming & interactive I/O · Timeouts, retries & cancellation · Process groups
Process groups
A ProcessGroup ties the lifetime of a whole child-process tree to a Rust
value: every process spawned into the group — and everything those processes
spawn — is killed when the group is dropped. An exiting, panicking, or
?-returning owner never leaks subprocesses; the kernel object enforcing this
(Job Object / cgroup / POSIX process group) catches even grandchildren you
never knew about. (Killing grandchildren is the problem duct.py's gotchas
list files under "currently unsolved" for pipe-based designs — kernel
containment is the solution, and the reason this crate exists.)
- Creating a group
- Putting processes in
- Tearing down: drop, terminate, shutdown
- Signalling the whole tree
- Suspending and resuming
- Listing members
- Resource limits
- Stats and sampling
Creating a group
use processkit::{ProcessGroup, ProcessGroupOptions}; use std::time::Duration; fn main() -> processkit::Result<()> { // Defaults: 2s graceful-shutdown grace, escalate to SIGKILL. let group = ProcessGroup::new()?; // Tuned: let group = ProcessGroup::with_options( ProcessGroupOptions::default() .shutdown_timeout(Duration::from_secs(10)) .escalate_to_kill(true), )?; // Which kernel mechanism is actually containing the tree? println!("{:?}", group.mechanism()); // JobObject | CgroupV2 | ProcessGroup Ok(()) }
mechanism() reports what you actually got: CgroupV2 quietly falls back to
ProcessGroup on Linux hosts without cgroup delegation (see
Platform support).
You rarely create a group explicitly for one-shot runs: every
Command::run()-style call makes a private group automatically. Reach for an
explicit group when several children should share one fate, or when you need
the group verbs below.
Putting processes in
Three doors, in order of preference:
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let group = ProcessGroup::new()?; // 1. start(): the full Command experience (capture, streaming, timeouts) in a // SHARED group. The handle does not own the group — dropping the handle // kills that child, dropping the group kills everyone. let server = group.start(&Command::new("dev-server")).await?; // 2. spawn(): the raw escape hatch for a tokio::process::Command you already // have. You get the bare Child back; pipes and reaping are your problem. // spawn() takes the command BY VALUE (reuse would stack pre-exec hooks). let raw = tokio::process::Command::new("background-helper"); let child = group.spawn(raw)?; // 3. adopt(): contain a child that was spawned OUTSIDE the group. let external = tokio::process::Command::new("legacy-launcher").spawn()?; group.adopt(&external)?; let _ = (server, child); Ok(()) }
adopt moves only the named process: descendants it already has keep their
old containment (future forks are captured — on Windows/cgroup). A few sharp
edges worth knowing:
- A child that already exited but has not been reaped (no
wait()yet — a zombie whose pid/handle is still valid) is a successful no-op: there is nothing left to contain, soadoptreturnsOkon the containment backends. - A child that already exited and was reaped (
wait()ed) has no pid left —adoptreturns an error rather than silently tracking nothing. - On the POSIX process-group mechanism, a child that has already
exec'd can't be re-grouped (POSIX forbids it), so it is tracked individually: the child itself is signalled/killed with the group, but its future forks are not. The caller keeps theChildhandle and is responsible for reaping.
Tearing down: drop, terminate, shutdown
| Verb | What happens | When |
|---|---|---|
drop(group) | Immediate hard kill of the whole tree (kill-on-close) | The safety net — always on |
group.kill_all() | The same hard kill, group stays usable (cgroup-kill / Job Object / process-group backends). On a pre-5.14 Linux kernel lacking cgroup.kill, the per-pid SIGKILL fallback returns Err if the tree doesn't drain (a fork bomb still out-spawning, or D-state zombies) | Explicit teardown mid-flight; idempotent |
group.shutdown().await | Unix: SIGTERM → wait shutdown_timeout → SIGKILL survivors (if escalate_to_kill); Windows: atomic job kill when escalate_to_kill, else the survivors are spared (handle closed without kill-on-close). Consumes the group (shutdown_ref(&self) is the same teardown, borrowing — for a group held behind an Arc/supervisor) | Graceful service stop |
use processkit::{Command, ProcessGroup, ProcessGroupOptions}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::with_options( ProcessGroupOptions::default() .shutdown_timeout(Duration::from_secs(5)) .escalate_to_kill(true), )?; let _service = group.start(&Command::new("my-service")).await?; // SIGTERM, give it 5s to flush and exit, SIGKILL stragglers: group.shutdown().await?; Ok(()) }
A child that handles SIGTERM ends the grace early — shutdown returns
as soon as the tree is empty, not after the full timeout. One subtlety: the
liveness probe sees an exited-but-unreaped child (a zombie) as alive on the
process-group backends, so keep wait()ing your handles concurrently if you
want the early return. Drop can't await, which is why the graceful tier
lives in this async method — dropping without calling it performs only the
hard kill.
Signalling the whole tree
signal/suspend/resume/members/adopt— this section and the two below — require the default-onprocess-controlfeature. The teardown verbs above are core and always present.
use processkit::{Command, ProcessGroup, Signal}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _server = group.start(&Command::new("my-server")).await?; group.signal(Signal::Hup)?; // "reload your configuration" group.signal(Signal::Usr1)?; // whatever the tool defines group.signal(Signal::Other(34))?; // raw signal number escape hatch Ok(()) }
| Platform | Deliverable signals |
|---|---|
| Linux (cgroup or pgroup), macOS/BSD | Any — Term, Kill, Int, Hup, Quit, Usr1, Usr2, Other(n) |
| Windows | Kill only (maps to the Job Object terminate); anything else → Error::Unsupported |
Signal::Kill always takes the same atomic whole-tree kill path as
kill_all (cgroup.kill / killpg / job terminate), so it cannot miss
a process forked mid-broadcast. Other signals are a per-member broadcast —
best-effort against a tree that is forking at that exact moment. An empty
group accepts any deliverable signal trivially. On the cgroup mechanism a
real per-member delivery failure (e.g. EPERM from a member that changed uid, or
a seccomp/container restriction) is surfaced as an Err rather than swallowed —
an ESRCH race (the member already exited) is still success; the pgroup
(macOS/BSD, Linux-without-cgroup) backend remains purely best-effort.
Suspending and resuming
Freeze a tree (to snapshot it, to starve a runaway while you investigate, to pause background work), then thaw it:
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _cruncher = group.start(&Command::new("cpu-hog")).await?; group.suspend()?; // the whole tree stops consuming CPU // … inspect, snapshot, wait for the user … group.resume()?; Ok(()) }
Per-platform machinery — and its visible differences:
| Platform | Mechanism | Notes |
|---|---|---|
| Linux cgroup | one cgroup.freeze write | Atomic over the subtree; freeze is group state |
| Linux pgroup, macOS/BSD | SIGSTOP / SIGCONT broadcast | Idempotent (level-triggered) |
| Windows | per-thread SuspendThread walk | Counted: N suspends need N resumes; best-effort against mid-walk thread churn |
Two caveats that bite in practice:
- Spawning into a suspended group diverges. Under the cgroup mechanism a
child spawned or adopted while the group is frozen starts frozen — and
start()may never return untilresume(the forked child joins the cgroup beforeexec, so it can freeze before completing the spawn handshake). Windows and the pgroup backends freeze only members present at the call. Rule of thumb: resume before starting new work. - A suspended tree can still be hard-killed (drop /
kill_all/Signal::Killall act on frozen processes), but a gracefulshutdownstarts with aSIGTERMthe frozen tree can't act on — it would wait out the whole grace. Resume first for a clean shutdown.
Listing members
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _a = group.start(&Command::new("worker-a")).await?; let _b = group.start(&Command::new("worker-b")).await?; let pids: Vec<u32> = group.members()?; println!("live members: {pids:?}"); Ok(()) }
What "members" means depends on the mechanism: Windows and Linux-cgroup list the whole tree (every descendant pid); the POSIX process-group backends list the tracked group leaders (one pid per started/adopted child) — their descendants are contained but not enumerated. An exited child still counts until it is reaped. The snapshot is point-in-time: a tree that is forking races it.
To wait on members rather than list them, race the handles with
wait_any.
Resource limits
Requires the limits feature. Caps are a property of the group, set once
at creation and enforced by the same kernel object that contains the tree:
use processkit::{Command, ProcessGroup, ProcessGroupOptions}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::with_options( ProcessGroupOptions::default() .max_memory(512 * 1024 * 1024) // bytes, whole tree .max_processes(64) // fork-bomb ceiling .cpu_quota(0.5), // half of one core )?; let _sandboxed = group.start(&Command::new("untrusted-tool")).await?; Ok(()) }
| Capability | Windows Job Object | Linux cgroup v2 | pgroup / macOS / BSD |
|---|---|---|---|
| Memory cap | ✅ whole-tree | ✅ whole-tree (memory.max) | ❌ |
| Process-count cap | ✅ | ✅ (pids.max) | ❌ |
| CPU quota | 🟡 approximate (rate vs. total CPU) | ✅ (cpu.max) | ❌ |
cpu_quota is a fraction of a single core (2.0 = two cores). Limits
need a real container; when a requested cap can't be enforced — no Job
Object/cgroup, or a Linux cgroup whose controllers can't be enabled —
with_options returns Error::ResourceLimit { kind, reason, detail } instead
of handing back a silently-unbounded group: kind names the limit
(max_memory/max_processes/cpu_quota), reason says whether the value was
simply invalid, the platform has no whole-tree mechanism at all
(Unsupported), or a mechanism exists but rejected this request
(Unenforceable) — branch on these instead of parsing detail. On Linux this
needs the process to run at the
real cgroup-v2 root: the crate enables the controllers in this process's own
cgroup, which cgroup v2's "no internal processes" rule allows only for the real
hierarchy root — not a cgroup-namespace root (so an ordinary container fails
too), not under systemd — and the crate doesn't migrate your process. See the
limits prerequisites in
Platform support. The uid()-drop
interaction lives under its Caveats.
Stats and sampling
Requires the opt-in stats feature (features = ["stats"], or limits).
use processkit::prelude::StreamExt; use processkit::{Command, ProcessGroup}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _worker = group.start(&Command::new("worker")).await?; // Point-in-time: let snap = group.stats()?; println!( "procs={} cpu={:?} peak_rss={:?}", snap.active_process_count, snap.total_cpu_time, snap.peak_memory_bytes, ); // …or a series: first sample immediate, then every 250ms; missed ticks are // skipped; the stream ends when the group can no longer report. let mut samples = group.sample_stats(Duration::from_millis(250)); while let Some(s) = samples.next().await { println!("rss now: {:?}", s.peak_memory_bytes); } Ok(()) }
CPU time and peak memory are available where the kernel accounts for the
whole tree (Windows, Linux cgroup); the process-group backends report the
member count only — the Option fields stay None. The sampler borrows
the group, so it can neither outlive it nor keep it (and the kill-on-drop
guarantee) alive. For a single run's end-to-end summary, see
profile.
Next: Streaming & interactive I/O · Platform support · Supervision
Streaming & interactive I/O
The one-shot verbs in Running commands buffer the whole output.
For long-running or conversational children, Command::start() returns a live
RunningProcess you drive yourself: stream stdout as it arrives, write stdin
incrementally, probe for readiness, race several children, or profile a run.
- Lifecycle
- Streaming stdout
- Interactive stdin
- Readiness probes
- Racing children with
wait_any - Per-run telemetry
Lifecycle
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let mut run = Command::new("dev-server").start().await?; run.pid(); // Option<u32> — None once the child is reaped run.elapsed(); // time since spawn // Consume the handle exactly one way: // output_string() / output_bytes() → capture everything (same as the one-shot verbs) // wait() → just the Outcome; output is discarded // finish() → after streaming stdout (below) // profile(every) → resource samples; output discarded, like wait() (stats feature) let outcome = run.wait().await?; // Outcome: Exited(code) / Signalled(sig) / TimedOut Ok(()) }
start() puts the child in a private group the handle owns: dropping the
RunningProcess kills the whole tree, exactly like dropping a one-shot run's
future. The shared-group variant — group.start(&cmd) — gives the same handle
but the group controls the tree's fate (see
Process groups).
There is also an explicit run.start_kill() for "stop it now, I'll wait()
for the code myself".
Streaming stdout
stdout_lines() yields decoded lines as the child produces them — no waiting
for exit, no full-output buffering. StreamExt (in processkit::prelude,
re-exported from tokio-stream) provides .next():
use processkit::prelude::StreamExt; use processkit::{Command, Finished, Outcome}; #[tokio::main] async fn main() -> processkit::Result<()> { let mut run = Command::new("cargo") .args(["build", "--release"]) .start() .await?; let mut lines = run.stdout_lines()?; while let Some(line) = lines.next().await { println!("build: {line}"); } // The stream ended (stdout closed). Collect the outcome and stderr — // stderr was drained in the background the whole time, so a noisy child // could never block on a full pipe. let Finished { outcome, stderr, .. } = run.finish().await?; if outcome != Outcome::Exited(0) { eprintln!("build failed ({outcome:?}):\n{stderr}"); } Ok(()) }
Things to know:
- Call
stdout_lines()once. It is fallible: a secondstdout_lines/output_eventscall (stdout is consumed once), or a non-piped stdout (StdioMode::Inherit/Null), returnsErrrather than a silently-empty stream. - The command's
timeoutbounds the stream: at the deadline the tree (own-group handle) or the direct child (shared-group handle) is killed, the pipes close, and the stream ends — a streamed run can't hang past its deadline. Acancel_ontoken ends it the same way; the followingfinishthen reportsError::Cancelled. Details in Timeouts & cancellation. - Line counters tick live:
run.stdout_line_count()/stderr_line_count()are cheap progress gauges even while you stream. - The buffer policy and line handlers apply to streamed runs too — a handler sees each line on the pump, in addition to your loop.
- The whole streaming surface is hermetically testable: a
ScriptedRunner'sstart()returns a handle whose canned lines flow through the same pump machinery —stdout_lines, the readiness probes, andfinishbehave identically with no subprocess. See Testing → scripted streaming.
Carriage-return progress output
Tools like curl, pip, and apt redraw a progress bar in place with a
carriage return (\rProgress: 50%\rProgress: 100%) and emit no \n until the
very end. By default the pump splits on \n only, so that whole sequence is a
single, ever-growing line: nothing streams live, and under a byte cap the one
over-cap line is dropped whole.
Set line_terminator(LineTerminator::CarriageReturn) to treat a bare \r as a
line terminator too. Each carriage-return frame then arrives as its own line —
live, one at a time:
use processkit::prelude::StreamExt; use processkit::{Command, LineTerminator}; #[tokio::main] async fn main() -> processkit::Result<()> { let mut run = Command::new("pip") .args(["install", "big-package"]) // Frame the stream you actually consume: `stdout_lines()` surfaces // stdout only (stderr is drained in the background and discarded), so // put the CR framing on stdout. If a tool draws its bar on stderr, // set `line_terminator(..)` for both streams and read `output_events()`. .stdout_line_terminator(LineTerminator::CarriageReturn) .start() .await?; let mut lines = run.stdout_lines()?; while let Some(frame) = lines.next().await { // Overwrite your own display with the latest frame. print!("\r{frame}"); } run.finish().await?; Ok(()) }
The chosen framing is one shared definition of a line for every sink: the
streaming verbs, the on_stdout_line/on_stderr_line
handlers, a stdout_tee/stderr_tee (which
writes each frame followed by \n), and output_string all see the same
per-frame lines. A \r\n pair stays a single terminator (no empty line between
them), so ordinary CRLF text reads identically to the default; only a \r not
followed by a \n splits a frame. The OutputBufferPolicy byte cap now bounds
an individual runaway frame — a frame whose content exceeds the cap is skipped
as it streams (never assembled whole) — rather than dropping the whole progress
stream. Use stdout_line_terminator / stderr_line_terminator for one stream,
or line_terminator for both.
Interactive stdin
Conversational tools — write a request, read the response, repeat. Keep stdin
open with keep_stdin_open(), take the writer with take_stdin():
use processkit::prelude::StreamExt; use processkit::{Command, Finished, Outcome}; // `ProcessStdin`'s writer methods return `std::io::Result`; `Box<dyn Error>` // mixes them with the crate's `Result` (or `.map_err(processkit::Error::Io)?`). #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // `bc` evaluates each stdin line and prints the result. let mut run = Command::new("bc").keep_stdin_open().start().await?; let mut stdin = run.take_stdin().expect("stdin was kept open"); let mut answers = run.stdout_lines()?; stdin.write_line("2 + 2").await?; // writes "2 + 2\n", flushed println!("= {}", answers.next().await.unwrap()); stdin.write_line("6 * 7").await?; println!("= {}", answers.next().await.unwrap()); stdin.finish().await?; // send EOF — bc exits let Finished { outcome, .. } = run.finish().await?; assert_eq!(outcome, Outcome::Exited(0)); Ok(()) }
ProcessStdin offers write(&[u8]), write_line(&str) (newline + flush),
flush(), and finish() (EOF). Dropping the writer — or the whole
RunningProcess — closes stdin too; finish() just makes the EOF explicit
and awaitable.
Avoid the full-duplex deadlock. A child's stdout pipe has a finite OS
buffer; once it fills, the child blocks writing stdout until something reads
it. If you push a large interactive stdin while nothing drains the child's
stdout, the child stops reading stdin (blocked on stdout), your write parks
waiting for stdin buffer space, and neither side progresses. The bc example
above is safe because it interleaves one write with one read; when you both feed
a sizable stdin and the child produces output, drain stdout_lines from one
task while writing stdin from another. (The non-interactive Stdin::from_*
sources are safe — the crate writes them on a background task that runs
concurrently with the output pumps.)
For one-directional streamed input (a channel, a file tail) you don't need
interactivity — give the command Stdin::from_lines(stream) /
Stdin::from_reader(reader) and let the background writer feed it; see the
stdin source table.
Readiness probes
"Start a server, then use it" needs ready, not merely started. Three probes replace the arbitrary sleep, each bounded by its own deadline:
async fn health_check() -> bool { true } use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let mut run = Command::new("my-server").start().await?; // 1. A line on stdout (returns the matching line): let banner = run .wait_for_line(|l| l.contains("listening on"), Duration::from_secs(10)) .await?; // 2. A TCP port accepting connections: run.wait_for_port("127.0.0.1:8080".parse().unwrap(), Duration::from_secs(10)) .await?; // 3. Any async predicate (an HTTP /health endpoint, a file appearing, …): run.wait_for(|| async { health_check().await }, Duration::from_secs(10)) .await?; // ready — use the server… let _ = banner; Ok(()) }
Probe semantics, deliberately uniform:
- A probe that can't pass within its deadline fails with
Error::NotReady— distinct fromError::Timeout, which is the run's own deadline. - A probe also fails fast once readiness can no longer happen: the child
exits, or (for
wait_for_line) its stdout closes — no waiting out a 30s deadline on a dead server. - A failed probe never kills the child. You decide: retry, log and continue, or tear down.
- All three probes background-drain stdout/stderr while they poll, so a
child with a large startup burst can't stall in
write()on a full OS pipe buffer.wait_for_lineconsumes stdout up to (and including) the match — continue withfinish.wait_for_port/wait_fordrain the same way but never hand any of it back mid-probe;wait/output_stringafterward still see the full captured output, butoutput_bytesor a freshstdout_lines/output_eventscall do not compose with any of the three probes (same as callingwait_for_linefirst).
Racing children with wait_any
The free function wait_any races several running processes and reports
whichever exits first — the natural primitive for "restart whatever died" or
"first answer wins":
use processkit::{Command, ProcessGroup, wait_any}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let mut a = group.start(&Command::new("replica-a")).await?; let mut b = group.start(&Command::new("replica-b")).await?; let (index, outcome) = wait_any(&mut [&mut a, &mut b]).await?; println!("contender #{index} exited first with {outcome:?}"); // Only borrows: the loser is still usable. let survivor = if index == 0 { &mut b } else { &mut a }; Ok(()) }
wait_any takes &mut borrows, applies no timeout of its own (wrap it in
tokio::time::timeout to bound the race), and does no output pumping — drain
chatty children first or give them bounded
buffer policies.
Per-run telemetry
With the opt-in stats feature, a running child reports its own
resource usage, and profile() turns a whole run into a summary:
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let run = Command::new("crunch").start().await?; run.cpu_time(); // Option<Duration> — user+kernel so far run.peak_memory_bytes(); // Option<u64> // …or capture + sample on an interval until exit: let profile = Command::new("crunch") .start().await? .profile(Duration::from_millis(100)) .await?; println!( "outcome={:?} wall={:?} cpu={:?} peak_rss={:?} avg_cpu_cores={:?} ({} samples)", profile.outcome, // Exited(code) / Signalled(sig) / TimedOut profile.duration, profile.cpu_time, profile.peak_memory_bytes, profile.avg_cpu_cores(), // cpu / wall — e.g. Some(1.7) ≈ 1.7 cores busy profile.samples, ); Ok(()) }
These read the child process itself (not a whole tree — that's
ProcessGroup::stats), and
availability follows the platform: full CPU/memory on Windows and Linux,
None where the kernel doesn't account per-process cheaply — see
Platform support.
Next: Pipelines · Timeouts, retries & cancellation · Supervision
Pipelines
a | b | c without a shell. Each stage's stdout feeds the next stage's
stdin through an in-process relay (a tokio::io::copy task per boundary) — there
is no shell string anywhere, so no quoting rules, no word splitting, no injection
surface. Each stage spawns into its own kill-on-drop
process group sub-group, so a per-stage
Command::timeout tears down that stage's whole subtree
(grandchildren of a forking sh -c … included); a chain-wide teardown fans the
kill across every stage's sub-group, so the chain still lives and dies as a
unit. (The relay is an implementation detail, not a kernel splice: a producer
whose consumer exits early stops on a broken pipe when the relay's next write
fails, rather than instantly via SIGPIPE.)
- Building and running
- Semantics: pipefail and the ends
- Unchecked stages
- Timeouts
- Re-running a pipeline
Building and running
Command::pipe(next) starts a Pipeline; chain more stages with
Pipeline::pipe; drive it with output_string() or run():
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // git log --format=%an | sort | uniq -c let authors = Command::new("git").args(["log", "--format=%an"]) .pipe(Command::new("sort")) .pipe(Command::new("uniq").arg("-c")) .run() // require every stage to succeed .await?; println!("{authors}"); Ok(()) }
The verbs mirror Command's, each operating on the pipefail outcome:
| Verb | Returns | A failing stage is… |
|---|---|---|
output_string() | ProcessResult<String> | …reported in the result (code/stderr/program of the first unclean stage) |
output_bytes() | ProcessResult<Vec<u8>> | …same, with the last stage's stdout captured raw (binary pipes) |
run() | trimmed final stdout | …raised as that stage's Error::Exit; fails loud on a truncated capture |
checked() | full ProcessResult<String> | …raised as Error::Exit (untrimmed stdout) |
run_unit() | () | …raised as Error::Exit (output discarded) |
exit_code() | i32 | …its attributed code (no code → Error::Timeout/Signalled) |
probe() | bool | 0 → true, 1 → false, else Err |
parse(|s| …) / try_parse(|s| …) | T | …raised as Error::Exit; fails loud on a truncated capture |
Err from output_string itself means a stage couldn't be started or
driven at all (spawn failure, broken plumbing) — never a mere non-zero exit.
The streaming first_line probe is deliberately not a pipeline verb: a chain
consumes its last stage in full to fold the pipefail outcome. To capture the
first matching line of a finished chain, add a | head -n1 (Unix) / grep -m1
/ findstr stage and capture. This does not cover a streaming readiness
probe over a chain that must keep running (e.g. wait for a banner line, then
leave the chain alive) — | head would tear it down; use a single Command
with first_line for that.
The | operator is sugar for the same thing — a | b | c ≡
a.pipe(b).pipe(c). Parenthesize the chain before a terminal verb, since
method calls bind tighter than |:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let authors = (Command::new("git").args(["log", "--format=%an"]) | Command::new("sort") | Command::new("uniq").arg("-c")) .run() .await?; Ok(()) }
Semantics: pipefail and the ends
The outcome is pipefail, like set -o pipefail in a shell:
stdoutis always the last stage's output — that's what the chain produced.code,stderr, and the reported program come from the culprit stage: the leftmost stage that didn't exit cleanly (non-zero, signal-killed, or timed out), but preferring a real failure over a downstreamSIGPIPEvictim — a stage killed only because a later stage closed the pipe early. If every failure is such a broken-pipe victim, the leftmost one wins; when every stage succeeded, the last stage speaks.
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("cat").arg("data.txt") .pipe(Command::new("grep").arg("ERROR")) // suppose grep exits 2 (bad pattern) .pipe(Command::new("wc").arg("-l")) .output_string() .await?; // Diagnostics point at grep — the first unclean stage — while stdout is // whatever wc managed to print: assert_eq!(result.code(), Some(2)); println!("blamed: {}", result.ensure_success().unwrap_err()); // names `grep` Ok(()) }
Failure tears the chain down proactively. The moment a stage ends with a
checked failure (a non-zero exit outside its ok_codes, a signal kill, or its
own per-stage timeout), every stage's sub-group is torn down at once — the failure does
not wait to trickle out through closing pipes. This matters for a quiet
sibling that would otherwise hang: an upstream producer that never writes never
dies of a broken pipe, so under a purely passive teardown a downstream failure
could be held open indefinitely by that silent producer. Now the failure
surfaces immediately, and the killed siblings are treated as victims (like a
downstream SIGPIPE death) — the stage that actually failed keeps the blame.
The one death that does not trigger this is an
unchecked_in_pipe() stage's: its unclean exit is forgiven,
so it leaves the rest of the chain running. (A stuck stage that never fails —
a healthy producer that simply never finishes — is still bounded only by
Pipeline::timeout or cancellation.)
The ends of the chain behave like a single Command:
- The first stage's configured
stdinsource is honored — feed the whole pipeline from a string, file, or stream. - Inner stages read from the pipe, full stop: any
stdinsource orkeep_stdin_openconfigured on them is overridden. - Inner stages' stderr is captured per-stage for pipefail diagnostics; only the last stage's stdout reaches you.
use processkit::{Command, Stdin}; #[tokio::main] async fn main() -> processkit::Result<()> { let unique_count = Command::new("sort") .stdin(Stdin::from_iter_lines(["b", "a", "b", "c"])) .pipe(Command::new("uniq")) .pipe(Command::new("wc").arg("-l")) .run() .await?; assert_eq!(unique_count.trim(), "3"); Ok(()) }
Unchecked stages
Strict pipefail has one classic false positive: a consumer that legitimately
stops reading early. In producer | head -1 the consumer exits 0 after one
line and closes the pipe; the producer then stops on a broken pipe — its
next write fails once the relay's downstream is gone (a broken-pipe write error,
or SIGPIPE where the OS delivers it) — a perfectly normal death that strict
pipefail would blame the chain for. Mark that stage unchecked_in_pipe():
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // seq 1 1000000 | head -1 — the producer's broken-pipe death is expected. let first = (Command::new("seq").args(["1", "1000000"]).unchecked_in_pipe() | Command::new("head").args(["-n", "1"])) .run() .await?; assert_eq!(first.trim(), "1"); Ok(()) }
The rules (a design borrowed from duct's unchecked() — the idea, not the
code):
- An unchecked stage's unclean exit — a non-zero code, a broken-pipe write
failure (or
SIGPIPEwhere the OS delivers it) from a consumer that closed early, or its own per-stage timeout kill — is skipped when the chain decides what to report. - A checked failure always trumps an unchecked one, regardless of
position:
uncheckednever shields another stage's real failure. - A chain whose only failures are unchecked reports success with the last
stage's stdout and its real exit code preserved (not a fabricated
0— the accepted-code set is widened to include it). The carve-out is for an exit status only: a last stage killed by a signal or its own timeout is not a status to forgive and still surfaces as the failure. uncheckedforgives exit status only — never a whole-chainPipeline::timeout, and it has no effect on aCommandrun outside a pipeline (a single run's status is already plain data in itsProcessResult).
Timeouts
Two scopes, deliberately distinct:
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("producer") .timeout(Duration::from_secs(10)) // per-STAGE: kills just `producer` .pipe(Command::new("consumer")) .timeout(Duration::from_secs(30)) // whole-CHAIN: Pipeline::timeout .output_string() .await?; Ok(()) }
Pipeline::timeoutbounds the whole chain: at the deadline every stage's sub-group is torn down and the result reportstimed_out(no partial stdout — unlike a single command's captured timeout).- A per-stage
Command::timeoutkills that stage's whole subtree — its own sub-group, grandchildren of a forkingsh -c …included, not just its direct child. Every stage is evaluated by the same pipefail rule (D14): a stage that hit its own deadline — inner or last — surfaces onrun()as that stage'sError::Timeout, reporting that stage's own deadline (not the chain's, and never0ns).
Cancellation has two forms. Pipeline::cancel_on(token) is the chain-level
control: the token gap-fills into every stage that doesn't already carry its
own Command::cancel_on (an explicit per-stage token is left intact), so firing
it tears the whole chain down and the run resolves to Error::Cancelled. (A
cancel_on token on an individual stage Command also cancels that stage and
errors the pipeline, but
the pipeline-level builder is the clearer authority.) See
Timeouts & cancellation.
Re-running a pipeline
A Pipeline is Clone and re-runnable — stages are re-cloned per run. The
one caveat is inherited from Command: a one-shot stdin source on the
first stage (Stdin::from_reader / from_lines) is consumed by the first run;
re-running then fails loud (an Error::Io at launch, D10) rather than
silently feeding empty stdin. Use the reusable sources
(from_string / from_bytes / from_iter_lines / from_file) when a chain
runs more than once.
Next: Timeouts, retries & cancellation · Running commands · Process groups
Timeouts, retries & cancellation
Three ways a run ends early, with three different philosophies:
-
a timeout is data — the deadline was part of the run's contract, so its expiry is captured in the result (and only the success-checking verbs turn it into an error);
-
a retry is a policy — the success-checking verbs replay the run while your classifier says the failure is transient;
-
a cancellation is an abandonment — the caller changed its mind, so every path reports an error; there is no result worth inspecting.
Timeouts
Command::timeout(d) kills the whole process tree at the deadline — not
just the direct child, so a wrapper script's grandchildren die too.
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { // Captured: inspect the flag yourself. let result = Command::new("slow-tool") .timeout(Duration::from_secs(5)) .output_string() .await?; if result.timed_out() { println!("partial output before the kill: {}", result.stdout()); } // Raised: the checking verbs convert the flag into a typed error. let err = Command::new("slow-tool") .timeout(Duration::from_secs(5)) .run() .await .unwrap_err(); assert!(matches!(err, processkit::Error::Timeout { .. })); Ok(()) }
Where each verb lands:
| Verb | Deadline expiry becomes |
|---|---|
output_string() / output_bytes() | Ok result with timed_out() == true, code() == None, partial output kept |
run() / exit_code() / probe() / checked() | Error::Timeout { program, timeout, stdout, stderr } — the partial output captured before the kill is attached (err.diagnostic() surfaces a hung tool's last words) |
first_line(pred) | Error::Timeout (the line never arrived in time) |
start() + streaming | the stream ends at the deadline (tree killed, pipes closed); finish then reports the kill (outcome == Outcome::TimedOut) |
ensure_success() on a captured result | Error::Timeout, checked before the exit code |
Pipeline | chain deadline → timed_out result; per-stage deadlines fold into pipefail |
Two distinct deadline families to keep apart:
Command::timeout— the run's own contract, this section.- The readiness probes'
withinparameter — givesError::NotReadyand never kills the child.
Graceful timeout
By default the deadline hard-kills at once. Add timeout_grace(d) to give the
tree a chance to clean up: at the deadline it is sent SIGTERM (or the signal chosen
with timeout_signal, which needs the process-control feature), allowed up to the
grace window to exit, then SIGKILLed — the same SIGTERM → wait → SIGKILL tier as
ProcessGroup::shutdown. A signal-handling child that exits ends
the grace early.
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("slow-tool") .timeout(Duration::from_secs(30)) .timeout_grace(Duration::from_secs(5)) // SIGTERM, wait up to 5s, then SIGKILL .output_string() .await?; Ok(()) }
timed_out() is true regardless of whether the child exited on the signal or was
SIGKILLed after the grace — the deadline is what fired. Windows has no signal
tier: timeout_grace is accepted but the deadline kills the job atomically.
The explicit RunningProcess::shutdown(grace) verb (stop a started
handle on demand) composes with a Command::timeout: its own SIGTERM → grace →
SIGKILL is the single teardown (it does not also fire the run's timeout
teardown), and if the deadline has already elapsed when you call shutdown,
the outcome is reported as Outcome::TimedOut — the grace you pass governs the
teardown timing.
Retries
retry(max_attempts, backoff, classifier) replays a failed run — up to
max_attempts total attempts, sleeping backoff between tries, retrying
only while the classifier accepts the error:
use processkit::{Command, Error}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("curl") .args(["-fsS", "https://example.com/api"]) .timeout(Duration::from_secs(10)) .retry(3, Duration::from_millis(250), |e| { // transient: network timeouts and curl's "couldn't connect" (7) matches!(e, Error::Timeout { .. }) || matches!(e, Error::Exit { code: 7, .. }) }) .run() .await?; Ok(()) }
Ground rules:
- Retries apply to the success-checking paths only (
run,exit_code,probe,ProcessRunnerExt::checked— and everything built on them, e.g.CliClient). The non-erroringoutput_stringcapture never retries: it didn't fail. - The classifier sees the typed error — match on variants, codes, even the captured stderr.
- Each attempt re-runs the same
Command— so a command whose stdin is a one-shot source (table), consumed by the first run, is not retried at all: the first attempt's error is returned as-is, since a second attempt could only replay empty stdin. Use a reusable stdin source if a stdin-bearing command must retry. (A one-shot source re-run outside the retry loop — aSupervisorincarnation, a pipeline re-run — instead fails loud with anError::Io(InvalidInput) at launch.) - A
Cancellederror is never retried, classifier or not — the token stays cancelled forever, so another attempt could only fail the same way.
For "keep it alive" (restart a service whenever it exits) rather than
"replay this one operation", use a Supervisor — same
backoff shape, different loop condition.
Cancellation
Hand any command a CancellationToken (re-exported at the crate root);
cancelling the token kills the run's tree and makes every consuming path
report Error::Cancelled:
use processkit::{CancellationToken, Command}; #[tokio::main] async fn main() -> processkit::Result<()> { let shutdown = CancellationToken::new(); // Wire the same parent token into many jobs via child tokens: let job = tokio::spawn({ let token = shutdown.child_token(); async move { Command::new("long-export").cancel_on(token).run().await } }); // Ctrl-C handler, sibling failure, UI button, … shutdown.cancel(); assert!(matches!( job.await.unwrap(), Err(processkit::Error::Cancelled { .. }) )); Ok(()) }
The contract, path by path:
| Situation | Behavior |
|---|---|
Cancel during run / output_string / output_bytes / wait / profile / exit_code / probe | tree killed, Error::Cancelled { program } |
Cancel during streaming (stdout_lines) | the stream ends; the following finish reports Error::Cancelled |
| Token already cancelled before the run | short-circuits before spawning — no process is ever created |
Cancel on a shared-ProcessGroup handle | kills the child itself, leaves the group's siblings alone (same scope as a timeout) |
A Pipeline stage's token cancels | that stage dies; the cancellation errors the whole pipeline and the private group reaps the other stages |
Under retry | terminal — never retried, whatever the classifier says |
Under a Supervisor | terminal — supervision returns Err(Cancelled) instead of restarting into a still-cancelled token |
wait_any mid-run | surfaces Err(Cancelled) — each racer's wait path resolves to Cancelled when its token fires, the same as a bulk verb (a pre-cancelled token still hits the pre-spawn short-circuit) |
first_line mid-run | surfaces Error::Cancelled once the token fires — a cancelled stream that closes without a match is reported as cancellation, not Ok(None) |
Client-level default
A typed wrapper built on CliClient usually constructs
and consumes its Commands internally — there is no place to chain a
per-call cancel_on. Set the token once on the client; every command it
builds carries it:
#![allow(unused)] fn main() { use processkit::{CancellationToken, CliClient}; let token = CancellationToken::new(); let gh = CliClient::new("gh").default_cancel_on(token.child_token()); // ... controller cancels `token` → every in-flight command of THIS client // dies (whole tree), surfacing Error::Cancelled to the awaiting call. }
Clients are cheap — scope cancellation by building one client per
cancellable scope with its own (child) token, instead of threading tokens
through call signatures. cli_client!-generated wrappers re-emit the builder,
so Git::new().default_cancel_on(t) works for downstream crates too.
Precedence: a per-command cancel_on chained on a built command
replaces the client default (explicit beats default, like a per-command
timeout after default_timeout). To honor both sources, wire it
explicitly — CancellationToken has no built-in merge: derive a child of the
default (let c = default.child_token()), hand the command
cancel_on(c.clone()), and have the second source call c.cancel(). Or
simpler: build a dedicated client per scope.
Precedence and interactions
Timeout vs. cancellation. A timeout is captured; a cancellation is always an error. When both land on the same run, cancellation wins — you asked the run to stop mattering, so no result is synthesized:
use processkit::{CancellationToken, Command}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let token = CancellationToken::new(); token.cancel(); let err = Command::new("tool") .timeout(Duration::from_millis(1)) // would have been a Timeout… .cancel_on(token) // …but cancellation takes priority .run() .await .unwrap_err(); assert!(matches!(err, processkit::Error::Cancelled { .. })); Ok(()) }
Which knob for which job:
| You want | Reach for |
|---|---|
| "This run may not take longer than X" | Command::timeout |
| "This operation is flaky, try a few times" | Command::retry |
| "Stop everything when the app shuts down" | cancel_on + one shared token |
| "Keep this service alive across crashes" | Supervisor |
| "Tell me when it's ready, don't kill it" | readiness probes |
Next: Supervision · Streaming & interactive I/O · Running commands
Errors
One Error enum covers every failure mode — spawn, exit, timeout,
cancellation, IO — so a caller pattern-matches on a typed variant instead of
parsing strings. Several variants look alike at a glance but carry different
contracts; this guide is the one page that lays them all out side by side:
which variant fires from where, how to classify it, and what to do about it.
- Variant reference
- Variants that look alike but aren't
- Classifiers
- Matching under
#[non_exhaustive] - Errors and retries
- Errors and supervision
Variant reference
| Variant | Where it comes from | Recommended reaction |
|---|---|---|
Error::Spawn { program, source } | The program was located but the OS refused to start it — permission denied, a bad working directory, a Windows .cmd/.bat needing cmd.exe, ETXTBSY, … | Inspect source; is_permission_denied() for an ACL/executable-bit problem, is_transient() for a bare-retry-clears-it condition. Not is_not_found() — the program was found. |
Error::NotFound { program, searched } | The program could not be located at all — not installed, not on PATH, or a path that doesn't resolve | is_not_found() is true; surface a "is it installed?" hint. searched (Some(dirs) for a bare-name PATH lookup, None otherwise) is for a diagnostic only — never log it, it echoes the PATH value. |
Error::CassetteMiss { program } (record feature) | A cassette replay found no recording matching the invocation — a stale or incomplete cassette, not a missing program | Fix or re-record the cassette. Not is_not_found() — do not let an "optional dependency" wrapper swallow this as "tool not installed". |
Error::Exit { program, code, stdout, stderr, stdout_bytes } | The process ran to completion but exited non-zero | Branch on code(); diagnostic() for the best one-line human message (stderr, else stdout — git/jj put decisive text on stdout). |
Error::Timeout { program, timeout, stdout, stderr, stdout_bytes } | Command::timeout elapsed and the tree was killed, on a checking verb | is_timeout(). Whatever was captured before the kill is attached — diagnostic() often explains the hang. Consider composing into a retry classifier: e.is_timeout() || e.is_transient(). |
Error::NotReady { program, timeout } | A readiness probe (wait_for_line / wait_for_port / wait_for) did not pass within its own deadline | Not a run failure — the child is still running (a probe deadline never kills it). Decide whether to keep waiting, shutdown() the handle, or surface the failure. |
Error::Parse { program, message } | The run succeeded but try_parse (or a caller's own parser feeding Error::parse) could not make sense of the output | message is caller-built and carries the parse failure; bounded in Display/Debug, full text on the field. |
Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes } | A fail_loud capture ceiling (OutputBufferPolicy::max_lines/max_bytes) was exceeded; the run itself may have succeeded | Raise the ceiling, switch to a lossy/streaming policy, or treat as a genuine failure — the pipe was fully drained either way, so the child never blocked. |
Error::ResourceLimit { kind, reason, detail } (limits feature) | A requested cap on ProcessGroupOptions couldn't be enforced — no whole-tree container on this platform, or the OS rejected it | Read limit_kind() / limit_reason() rather than parsing detail; an unenforced limit is no protection, so treat this as a hard stop, not a warning. |
Error::Unsupported { operation } | An operation isn't supported by the active containment mechanism on this platform (e.g. any Signal but Kill on Windows Job Objects) | Branch on platform ahead of time (see Platform support), or catch and degrade. |
Error::Cancelled { program } | The run's CancellationToken fired and its tree was killed | is_cancelled(). This is an abandonment, not a failure to diagnose — the caller already knows why. Never retried (see Errors and retries); terminal under a Supervisor too. |
Error::Signalled { program, signal, stdout, stderr, stdout_bytes } | The process was killed by a signal (Unix only; a ScriptedRunner/cassette replay can also report Signalled(None)) | is_signalled(). No exit code to check — always a failure. diagnostic() surfaces whatever was captured before the crash. |
Error::Stdin { program, source } | Feeding the child's stdin failed for a reason other than a routine broken pipe, on an otherwise-successful run | A diagnostic of a silently-truncated input the child may have already acted on. The io-level classifiers (is_transient, is_not_found, is_permission_denied) deliberately return false here — the run already succeeded, so a blanket retry would just re-run a command that worked. |
Error::Io(source) | A low-level IO error from the crate's own machinery — driving a child, controlling a process group, reading/writing a cassette file | Never an arbitrary foreign io::Error (there is deliberately no blanket From<std::io::Error>); every Io here was raised at a known site inside the crate. |
Variants that look alike but aren't
Timeoutis captured,Cancelledis always an error.output_string/output_bytesreturnOkwithtimed_out() == trueon a deadline — the caller decides whether that counts as failure. A cancellation, by contrast, reportsErr(Error::Cancelled)on every consuming path, streaming included, because it's a deliberate caller action, not run data. When a run both hits its deadline and gets cancelled, cancellation wins (checked first). See Precedence and interactions.NotReadyis notTimeout.Command::timeoutis the run's own contract and kills the tree; a readiness probe'swithindeadline is a separate clock layered on top of an already-running child, and giving up on it never kills anything.is_timeout()isfalseforNotReady.NotFoundvs.Spawn.NotFoundis the single representation of "program not found" — bare name or path, any platform, one variant,is_not_found() == true.Spawnis every other OS-level launch failure once the program was located (permissions, a badcwd, a.cmd/.batneedingcmd.exe) —is_not_found()isfalsethere, so a "not installed?" hint never fires on the wrong condition (e.g. a bad working directory).CassetteMissis notis_not_found. A stale/incomplete cassette and a genuinely missing program are different failures a wrapper needs to tell apart — treating a cassette miss as "optional tool not installed" would silently hide a test-fixture bug.Exitvs.Signalled. Both carry captured streams and aDisplaydiagnostic tail, butExithas acode()and may or may not be a failure (is_success()/ok_codesdecide);Signalledhas no code at all —code()isNone— and is always terminal.
Classifiers
| Classifier | true for | Notes |
|---|---|---|
is_not_found() | NotFound only | The "is the program installed?" check. false for Spawn, CassetteMiss, and everything else. |
is_timeout() | Timeout only | The Error twin of ProcessResult::timed_out(). false for NotReady. |
is_cancelled() | Cancelled only | A caller that initiated the stop can swallow this rather than log/retry it as a real failure. |
is_signalled() | Signalled only | true even when the kernel reported no signal number (signal() is then None) — the reliable "died by a signal?" check. |
is_permission_denied() | Spawn / Io carrying PermissionDenied | IO/spawn-level only. |
is_transient() | Spawn / Io carrying an interrupted/would-block/busy/lock condition | IO/spawn-level only, never exit codes or timeouts by design; compose explicitly: e.is_transient() || e.is_timeout(). |
code() | Exit (Some(code)) | None for every other variant — a timeout or signal-kill carries no exit code. Same accessor name as ProcessResult::code() / Outcome::code(). |
signal() | Signalled with a known number | None for every other variant, and for a Signalled where the kernel reported no number. |
program() | Every variant that names one | None only for Unsupported, Io, and (limits feature) ResourceLimit — the ones with no single program to attribute. |
limit_kind() / limit_reason() (limits feature) | ResourceLimit only | Read structured fields instead of parsing detail's English text. |
diagnostic() | Exit / Timeout / Signalled (Some) | Stderr if it carries text, else stdout (git/jj put decisive output there), else None. |
Matching under #[non_exhaustive]
Error — and several of its struct-like variants — are #[non_exhaustive]:
a future release can add a variant or field without that being a breaking
change, but it also means every downstream match must carry a catch-all
arm.
use processkit::{Command, Error}; #[tokio::main] async fn main() -> processkit::Result<()> { let err = Command::new("maybe-missing-tool").run().await.unwrap_err(); match err { Error::NotFound { .. } => eprintln!("is it installed?"), Error::Timeout { .. } => eprintln!("hit its deadline"), Error::Cancelled { .. } => { /* caller-initiated, nothing to log */ } Error::Exit { code, .. } => eprintln!("exited with {code}"), // #[non_exhaustive]: a future variant (or a today's variant behind a // feature this build doesn't enable, e.g. ResourceLimit) falls here. other => eprintln!("run failed: {other}"), } Ok(()) }
Prefer the classifiers above (is_not_found(), is_timeout(), …) over
destructuring when all you need is a yes/no answer — they read the variant
without you having to keep the catch-all arm in sync as fields are added.
Errors and retries
Command::retry(max_attempts, backoff, classifier)
replays a failed run while your classifier accepts the typed error — the
classifier is exactly this guide's variant table, read by the caller:
use processkit::{Command, Error}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("curl") .args(["-fsS", "https://example.com/api"]) .timeout(Duration::from_secs(10)) .retry(3, Duration::from_millis(250), |e: &Error| { // Transient: our own deadline, or a spawn/IO condition a bare retry clears. e.is_timeout() || e.is_transient() }) .run() .await?; Ok(()) }
Error::Cancelled is never retried, whatever the classifier says — the
token stays cancelled forever, so another attempt could only fail the same
way. See Retries for the full ground
rules (stdin re-use, which verbs retry at all).
Errors and supervision
A Supervisor restarts a crashing service rather than
replaying one operation, but it faces the same "is this permanent?"
question — its
give_up_when(classifier)
gate is the same classifiers applied to a GiveUpAttempt:
use processkit::{Command, GiveUpAttempt, Supervisor}; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("maybe-typo'd-binary")) .give_up_when(|attempt| match attempt { // The child never started at all — e.g. is_not_found() for ENOENT. GiveUpAttempt::Failed(err) => err.is_not_found(), // A completed run your own domain knows is a permanent crash. GiveUpAttempt::Crashed(res) => res.code() == Some(78), _ => false, }) .run() .await?; println!("stopped: {:?}", outcome.stopped); Ok(()) }
GiveUpAttempt::Failed(&Error) is the spawn/IO path (no ProcessResult was
ever produced); GiveUpAttempt::Crashed(&ProcessResult<String>) is a
completed-but-failing run. A recognized-permanent failure reports
StopReason::GaveUp instead of restarting forever. Error::Cancelled is
terminal here too — supervision returns Err(Cancelled) instead of
restarting into a still-cancelled token, give_up_when or not.
Next: Running commands · Timeouts, retries & cancellation · Supervision
Supervision
Where retry answers "run this once,
replaying on failure", a Supervisor answers the different question "keep
this alive": restart a child per policy whenever it exits, with bounded
restarts, exponential backoff, and jitter — a minimal runit/systemd-style
keeper, platform-agnostic because it sits entirely on the
ProcessRunner seam.
- The shape
- Policies: what counts as a crash
- Backoff and jitter
- Failure storms
- Stopping
- Giving up on permanent failures
- Outcomes
- Supervising inside a shared group
- Errors and cancellation
The shape
use processkit::{Command, RestartPolicy, Supervisor}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("my-server").args(["--port", "8080"])) .restart(RestartPolicy::OnCrash) // default .max_restarts(5) // default: unlimited .backoff(Duration::from_millis(200), 2.0) // default: 200ms × 2.0 .max_backoff(Duration::from_secs(30)) // default: 30s cap .jitter(true) // default: on .stop_when(|res| res.code() == Some(0)) // optional exit condition .run() .await?; println!( "ended after {} restarts, reason: {:?}, last exit: {:?}", outcome.restarts, outcome.stopped, outcome.final_result.code(), ); Ok(()) }
Each incarnation is one full captured run of the command (so the command's
own timeout, stdin, env, … all apply per run — with the usual
one-shot-stdin caveat for the second run
onward).
Policies: what counts as a crash
A crash is any run that is not a success (ProcessResult::is_success,
which honors the command's ok_codes): an exit code outside the accepted set
(default {0}), a timeout, a signal-kill, or a spawn failure. A command with
ok_codes([0, 2]) that exits 2 is a success, so OnCrash treats it as clean,
not a crash.
RestartPolicy | Restarts after… |
|---|---|
OnCrash (default) | crashes only; a clean exit ends supervision (PolicySatisfied) |
Always | every completed run, clean or not — pair it with stop_when/max_restarts or it loops forever |
Never | nothing: one run, reported as-is |
Backoff and jitter
The n-th restart (0-based) sleeps
delay(n) = min(base × factor^n, max_backoff) × jitter
with jitter drawn uniformly from [0.5, 1.5) per restart. Jitter is on by
default so a fleet of supervised workers restarted by the same incident
doesn't stampede back in lockstep; jitter(false) gives deterministic delays
(useful in tests with a paused tokio clock). A non-finite or < 1.0 factor is
treated as 1.0 — constant delay, never a shrinking one.
base=200ms, factor=2.0, cap=30s:
restart #0 → ~200ms #1 → ~400ms #2 → ~800ms … #7 → ~25.6s #8+ → 30s (cap)
The exponent n is not the lifetime restart count — it resets whenever a
run stays up at least as long as max_backoff. So a long-lived service that
crashes now and then restarts near base each time (it demonstrated health
between crashes), while only a tight loop — each incarnation shorter than the cap
— climbs to the ceiling. The floor is on uptime, not exit kind: under Always
a worker that exits (cleanly or not) in under max_backoff is treated as
flapping and escalates, which is what stops an exit 0 spin loop from hammering
at the base delay. A single jittered delay can reach up to 1.5 × max_backoff.
Failure storms
Backoff spaces individual restarts; max_restarts is a lifetime cap.
Neither distinguishes a service that fails once a day from one that is
suddenly crash-looping. The opt-in storm guard does (a design borrowed
from Go's suture supervisor — the
idea, not the code):
use processkit::{Command, Supervisor}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("worker")) .storm_pause(Duration::from_secs(15)) // master switch — off by default .failure_decay(Duration::from_secs(30)) // score half-life (default 30s) .failure_threshold(5.0) // trip point (default 5.0) .run() .await?; println!("storm pauses taken: {}", outcome.storm_pauses); Ok(()) }
Each failed run adds 1 to a score that halves every failure_decay:
score = score × 0.5^(Δt / failure_decay) + 1
- Fails rarely: the score decays back toward
1between failures and never reaches the threshold — the guard stays out of the way. - Failure storm: failures arrive faster than the half-life drains them, the
score climbs past
failure_threshold, and the supervisor takes one collective pause ofstorm_pause(jittered into[0.5, 1.5)like the backoff), resets the score, and resumes.
Only failures feed the score — crashes and spawn errors, not clean exits
restarted under RestartPolicy::Always. The pause stacks with (runs before)
the per-restart backoff, and the max_restarts budget is checked first, so a
storm pause never extends an exhausted budget. Pauses taken are reported in
SupervisionOutcome::storm_pauses.
Stopping
Four gates, checked in this order after every completed run:
stop_when(predicate)— sees the run'sProcessResult; returningtrueends supervision regardless of policy (→StopReason::Predicate). "Exit 0 is done, anything else is a crash" is the classic:stop_when(|res| res.code() == Some(0))underRestartPolicy::Always.- The policy —
OnCrashstops on a clean exit (→PolicySatisfied). give_up_when(classifier)— only consulted for a crash the policy would otherwise restart; recognizing it as permanent ends supervision (→StopReason::GaveUp). See Giving up on permanent failures.max_restarts(n)— at most n restarts = n + 1 total runs; an exhausted budget reports the last result (→RestartsExhausted).max_restarts(0)means exactly one run.
give_up_when is checked before max_restarts, so a recognized-permanent
crash reports the more specific GaveUp even when the budget hasn't run out
yet — and before the failure-storm guard, so giving up
never pays for a storm pause it was going to end anyway.
Giving up on permanent failures
Without more, the supervisor cannot tell a transient crash from a
permanent one — a command that can never succeed (a missing binary, a
config error that crashes on startup, a permanently-taken port) restarts
forever under the default unlimited OnCrash, throttled only by backoff.
give_up_when lets you recognize the unrecoverable case and stop instead:
use processkit::{Command, GiveUpAttempt, Supervisor}; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("maybe-typo'd-binary")) .give_up_when(|attempt| match attempt { // The child never even started — e.g. ENOENT for a mistyped name. GiveUpAttempt::Failed(err) => err.is_not_found(), // A completed crash your own domain knows is permanent, e.g. a // documented "config invalid, do not restart" exit code. GiveUpAttempt::Crashed(res) => res.code() == Some(78), _ => false, }) .run() .await?; println!("stopped: {:?}", outcome.stopped); Ok(()) }
GiveUpAttempt distinguishes the two shapes a permanent failure can take:
Crashed(&ProcessResult<String>)— a completed run that counts as a crash. A match reportsStopReason::GaveUpin theSupervisionOutcome, same as any other stop reason.Failed(&Error)— the child never started at all (spawn/IO failure, e.g.ENOENT). There is noProcessResultto report here, so a match surfaces the classified error directly asrun()'sErr— the same contract an exhausted budget already has on this path (see Errors and cancellation).
Unset by default: a permanent failure restarts forever exactly as before,
bounded only by max_restarts / the storm guard — adding give_up_when never
changes existing behavior until you set it.
Outcomes
run() resolves to a SupervisionOutcome:
use processkit::{Command, Supervisor}; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("job")).run().await?; outcome.final_result; // ProcessResult<String> of the LAST run outcome.restarts; // how many restarts happened (not counting run #1) outcome.stopped; // StopReason::{Predicate, PolicySatisfied, GaveUp, RestartsExhausted} outcome.storm_pauses; // failure-storm pauses taken (0 unless storm_pause is set) Ok(()) }
Note run() returning Ok does not mean the child succeeded — it means
supervision concluded. Inspect final_result (or ensure_success() it) for
the child's own verdict.
Supervising inside a shared group
The supervisor runs through any ProcessRunner. The headline production
variant injects a ProcessGroup so every incarnation —
and everything it spawns — lives in one kill-on-drop container:
use processkit::{Command, ProcessGroup, RestartPolicy, Supervisor}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let outcome = Supervisor::new(Command::new("worker")) .with_runner(&group) // &group is itself a ProcessRunner .restart(RestartPolicy::OnCrash) .max_restarts(10) .run() .await?; // The group outlives supervision: drop it (or shutdown) to reap any strays. Ok(()) }
Mind one interaction: don't supervise into a group you've suspended — under the cgroup mechanism the restarted child would start frozen (and the spawn itself can block). Resume first.
The same injection point makes supervision logic hermetically testable — script a sequence of fake results and assert the restart/stop behavior with no real process; see Testing your code.
Errors and cancellation
A run that produces no result at all (spawn/IO failure) can't be judged by
stop_when (it needs a ProcessResult) — but it is visible to
give_up_when as GiveUpAttempt::Failed. The policy treats it as a crash and
restarts (with backoff) unless the policy is Never, give_up_when
classifies it as permanent, or the budget is exhausted — any of those surfaces
the error itself as run()'s Err.
A cancelled incarnation is
terminal: run() returns
Err(Error::Cancelled) immediately. The token never un-cancels, so a restart
could only produce another instantly-cancelled run — the supervisor refuses
the futile loop.
Next: Testing your code · Timeouts, retries & cancellation · Process groups
Testing your code
Code that shells out is miserable to test — unless the subprocess is behind a
seam. In processkit that seam is one small trait. Only output_string is required;
output_bytes (raw-byte stdout) and start (a live handle for streaming/probes)
are defaulted, so a minimal double implements just output_string:
#[async_trait]
pub trait ProcessRunner: Send + Sync {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>>;
// Defaulted (route through `start`); override for byte/streaming support:
async fn output_bytes(&self, command: &Command) -> Result<ProcessResult<Vec<u8>>>;
async fn start(&self, command: &Command) -> Result<RunningProcess>;
}
Production code takes a runner (generically or as &dyn ProcessRunner); tests
hand it a double. Five doubles ship with the crate, plus a macro that makes
whole CLI wrappers testable for free.
- The
ProcessRunnerseam - Scripting replies:
ScriptedRunner - Asserting invocations:
RecordingRunner - Echoing without spawning:
DryRunRunner - Expectation-style:
MockRunner - Record/replay cassettes:
RecordReplayRunner - Wrapping a CLI tool:
CliClient
The ProcessRunner seam
JobRunner is the real implementation (each run in a fresh private group); a
ProcessGroup is also a runner (runs land in that
shared group); and impl ProcessRunner for &R means a borrowed runner
works wherever an owned one does — inject &group or &recording without
giving ownership away.
Every runner — real or double — gets the convenience helpers of
ProcessRunnerExt for free: run (trimmed stdout, success required),
run_unit, exit_code, probe (exit code as a boolean), checked
(success-checked full result), and parse/try_parse (feed stdout to a
closure). These are all callable on a &dyn ProcessRunner; being generic over
the closure, parse/try_parse/first_line simply can't be dispatched through
a dyn ProcessRunnerExt object (the ext trait isn't object-safe). Retry
policies work through the seam too, so
a double exercises your retry handling hermetically.
The seam covers streaming as well as bulk runs: ProcessRunner::start
returns a live RunningProcess, and a ScriptedRunner's start hands back a
scripted handle whose canned lines flow through the same pump machinery a real
child uses — stdout_lines, wait_for_line, and finish behave
identically, with no subprocess (see
Scripted streaming below). An output_string-only custom
runner keeps compiling: start is defaulted to Error::Unsupported.
use processkit::{Command, ProcessRunner, ProcessRunnerExt, Result}; #[tokio::main] async fn main() -> processkit::Result<()> { // Production code: generic over the runner. async fn current_branch(runner: &impl ProcessRunner) -> Result<String> { runner .run(&Command::new("git").args(["branch", "--show-current"])) .await } Ok(()) }
Scripting replies
ScriptedRunner returns canned Replys for matched commands — the
work-horse double:
use processkit::{Command, ProcessRunnerExt}; use processkit::testing::{Reply, ScriptedRunner}; #[tokio::main] async fn main() -> processkit::Result<()> { #[tokio::test] async fn detects_the_branch() { let runner = ScriptedRunner::new() // Match by program + argument PREFIX (element-wise; first element is // the program name, in registration order): .on(["git", "branch", "--show-current"], Reply::ok("main\n")) // …or by any predicate over the full Command: .when( |cmd| cmd.working_dir().is_some(), Reply::fail(128, "fatal: not a git repository"), ) // …with an optional catch-all: .fallback(Reply::ok("")); assert_eq!(current_branch(&runner).await.unwrap(), "main"); } Ok(()) }
The pieces:
Reply::ok(stdout)— exit 0.Reply::fail(code, stderr)— non-zero with stderr.Reply::lines(["a", "b"])— exit 0 with the lines joined (and streamed one by one on a scriptedstart).Reply::timeout()— a timed-out run (the checking helpers raiseError::Timeoutfrom it, carrying the command's own configured deadline). On a scriptedstartit resolves immediately as timed-out; to exercise a real deadline race, useReply::pending()+ aCommand::timeout..with_stdout(text)— attach stdout to any of them (e.g. theCONFLICT …text git prints on a failing merge)..with_line_delay(d)— pace a scripted stream's lines.Reply::pending()— parks the call until the command's cancellation token (per-commandcancel_onor the client-leveldefault_cancel_on) fires, resolving withError::Cancelled— so a test can prove an orchestration actually cancels a blocked call, not just that it formats a canned error — or until the command'stimeoutdeadline elapses, resolving timed-out (Outcome::TimedOut) on the bulk verbs andstartalike, like a child killed for overrunning its deadline. Whichever fires first wins. With neither a token nor a timeout it parks forever, like a hung child.- Rules are tried in registration order; first match wins. Prefix
matching is element-wise over the program name then the arguments (the
first element is the program) —
on(["git", "foo"])matchesgit foo barbut notgit foobar(and notrm foo). Useon_sequenceto serve an ordered sequence of replies (each once, then the last repeats) for a fail-then-succeed scenario. - No match and no fallback is a loud error (
Error::Spawn, not-found) — an unexpected invocation can't slip through a test silently. - Bulk runs also replay the canned lines through the command's
on_stdout_line/on_stderr_linehandlers, so a wrapper's progress-reporting path is exercised without a subprocess.
Scripted streaming
ScriptedRunner::start returns a live RunningProcess backed by the canned
reply instead of an OS child. The canned stdout/stderr feed the same pump
machinery a real child uses, so the whole streaming surface works
hermetically — stdout_lines yields the lines, wait_for_line probes them,
finish reports the canned outcome and stderr:
use processkit::prelude::StreamExt; use processkit::testing::{Reply, ScriptedRunner}; use processkit::{Command, Finished, Outcome, ProcessRunner}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { #[tokio::test] async fn server_becomes_ready() { let runner = ScriptedRunner::new() .on(["server", "serve"], Reply::lines(["booting", "listening on 8080"])); let mut run = runner.start(&Command::new("server").arg("serve")).await.unwrap(); run.wait_for_line(|l| l.contains("listening"), Duration::from_secs(5)) .await .unwrap(); // satisfied by the canned banner — no subprocess let Finished { outcome, .. } = run.finish().await.unwrap(); assert_eq!(outcome, Outcome::Exited(0)); } Ok(()) }
Reply::lines([...]) scripts the stdout lines; .with_line_delay(d) paces
them (deterministic under #[tokio::test(start_paused = true)]), and the
scripted run "exits" after the last line. The honest boundaries: a scripted
handle has no OS identity (pid() is None, profile reports empty
samples), does not compose into a real Pipeline, and does not model
interactive stdin. Reply::pending() scripts a run that never exits on its
own — cancel or time it out through the command's own knobs. A command
timeout does bound a scripted stream (it ends at the deadline and reports
Outcome::TimedOut, like a real child), but a scripted handle has no signal
tier, so — like on Windows — it ignores timeout_grace and ends at once.
Asserting invocations
RecordingRunner wraps another runner and records every Invocation — what
was asked — so a test asserts inputs, not just outputs:
use processkit::{Command, ProcessRunnerExt}; use processkit::testing::{RecordingRunner, Reply, ScriptedRunner}; #[tokio::main] async fn main() -> processkit::Result<()> { #[tokio::test] async fn passes_the_right_flags() { let runner = RecordingRunner::new( ScriptedRunner::new().fallback(Reply::ok("done")), ); runner .run(&Command::new("gh").args(["pr", "create", "--draft"]).current_dir("/repo")) .await .unwrap(); let call = runner.only_call(); // panics unless exactly one call assert_eq!(call.args_str(), ["pr", "create", "--draft"]); assert!(call.has_flag("--draft")); assert_eq!(call.cwd.as_deref().map(|c| c.to_str().unwrap()), Some("/repo")); assert!(!call.has_stdin); } Ok(()) }
An Invocation captures the routing knobs — program, args, cwd,
envs (explicit overrides, None = removal), has_stdin — not the
I/O-shaping ones (timeout, encodings, buffer policy); assert those through a
when predicate over the Command itself. calls() returns the full list
when more than one run is expected.
Echoing without spawning: DryRunRunner
DryRunRunner never spawns a process at all: it renders each command through
Command::command_line — the crate's own
display quoting, not a hand-rolled shell-escaper — and hands back a synthetic
successful result. It's the seam behind a tool's own --dry-run/--echo
mode: wire your production code to it (instead of JobRunner) and it shows
what would run instead of running it.
use processkit::{Command, ProcessRunner}; use processkit::testing::DryRunRunner; #[tokio::main] async fn main() -> processkit::Result<()> { #[tokio::test] async fn dry_run_shows_the_command_without_running_it() { let runner = DryRunRunner::new(); let out = runner .output_string(&Command::new("rm").args(["-rf", "build"])) .await .unwrap(); assert!(out.is_success()); // synthetic — no process ever ran assert_eq!(runner.only_command(), "rm -rf build"); } Ok(()) }
Unlike ScriptedRunner, there is nothing to script — a dry run has no real
output to fake, only a command line to show — so every call unconditionally
succeeds on both output_string and start: empty stdout/stderr, and an
exit code drawn from the command's own ok_codes (0 by default) so
is_success() and the ergonomic run/run_unit/checked/parse verbs
agree it succeeded even for a command whose ok_codes excludes 0. The
rendered lines are available two ways, usable together or alone:
- a collected snapshot, in the style of
RecordingRunner::calls—commands()(all of them, in order) /only_command()(panics unless exactly one call was made); - a live
on_invocation(|line| …)callback, invoked with the rendered line as each call happens — e.g. printing it to the terminal immediately, in addition to (not instead of) the collected snapshot:
#![allow(unused)] fn main() { use processkit::testing::DryRunRunner; let runner = DryRunRunner::new().on_invocation(|line| println!("+ {line}")); }
Expectation-style: MockRunner
With the mock feature, mockall generates a MockRunner for
expectation-style tests (call counts, argument matchers, ordered
expectations) — the right tool when the interaction is the contract.
Note:
MockRunner'sexpect_*surface is generated bymockalland is exempt from this crate's semver guarantees — it tracks themockalldependency, not a frozen API. For a stable double, preferScriptedRunner(canned replies) orRecordingRunner(input assertions) above.
#![allow(unused)] fn main() { use processkit::testing::MockRunner; let mut mock = MockRunner::new(); mock.expect_output_string() .times(1) .returning(|_cmd| todo!("build a Result<ProcessResult<String>>")); }
MockRunnerdoes not inherit the defaults. Unlike a hand-written runner (whereoutput_bytes/startare defaulted),mockall::automockreplaces every method with an expectation — so a verb that routes throughstartoroutput_bytesneeds its ownexpect_start()/expect_output_bytes(), or the unset call panics ("no expectation").ScriptedRunnerprovides the defaults and the streaming seam out of the box.
For most tests ScriptedRunner/RecordingRunner read better; reach for the
mock when you need mockall's matching machinery.
Record/replay cassettes
With the record feature, RecordReplayRunner closes the loop: record
real runs to a JSON cassette once, then replay them deterministically —
fast, hermetic, byte-stable, no subprocess in CI:
use processkit::{Command, JobRunner, ProcessRunnerExt}; use processkit::testing::RecordReplayRunner; #[tokio::main] async fn main() -> processkit::Result<()> { // Record once against the real tool (an opt-in `--record` test run, say): let runner = RecordReplayRunner::record("fixtures/git.json", JobRunner::new()); let version = runner.run(&Command::new("git").arg("--version")).await?; runner.save()?; // the error-surfacing flush // (best-effort on drop too) // Replay everywhere else: let runner = RecordReplayRunner::replay("fixtures/git.json")?; assert_eq!(runner.run(&Command::new("git").arg("--version")).await?, version); Ok(()) }
Semantics worth knowing before you commit a cassette:
| Aspect | Behavior |
|---|---|
| Match key | program + args + a stdin source digest (hashed, never persisted: in-memory bytes hash their content, a from_file source hashes its path) — no stdin (absent or Stdin::empty()) keys distinctly; lossy UTF-8 on the text parts. cwd is not part of the key by default — a cassette recorded from one absolute working directory still replays when the same invocation runs from another (a dev box vs. a CI workspace); cwd is still stored on the entry, verbatim, for visibility. Opt in to a stricter key with match_on_cwd / match_on_env (below) |
| Environment | values never reach the file — only sorted variable names, so env secrets can't leak through a committed fixture. Env is not matched by default, so irrelevant env differences can't cause spurious misses. Opt in with match_on_env(["NAME", …]) to also key on selected variables' values — still via a digest, so raw values remain off-disk (see Opt-in stricter matching) |
| Duplicates of one key | replay in capture order, then the last entry repeats — a recorded sequence (git rev-parse HEAD before/after a commit) replays faithfully, while retry/probe loops keep getting a stable final answer |
| Miss | strict Error::CassetteMiss (distinct from a missing program — is_not_found() is false) — replay never spawns a surprise subprocess; a stale cassette fails loudly |
| Timeouts | a recorded timed-out run replays as one, surfacing Error::Timeout with the replaying command's deadline |
| Format | pretty-printed JSON with a version field; unknown versions / corrupt files / an entry with a contradictory outcome / a file over 64 MiB are Error::Io(InvalidData), a missing file keeps NotFound |
| Err results | recorded and replayed faithfully (Error::Spawn/NotFound/Stdin/OutputTooLarge/Unsupported/Io — with its ErrorKind preserved by name — plus an Other fallback); replaying such an entry surfaces the reconstructed error instead of Error::CassetteMiss. Error::Cancelled is the one exception — never recorded, since replay short-circuits on the replaying command's own token first |
Verbs (output_string + start) | a cassette is verb-agnostic: record through either and replay through either. Replaying start hands back a scripted RunningProcess whose recorded lines flow through the command's real pumps (stdout_lines / wait_for_line / finish), no subprocess. Recording a start captures the run whole (the child runs to completion before the handle returns), so an interactive run fed stdin mid-stream can't be recorded that way — bound it with Command::timeout or script it with ScriptedRunner |
output_bytes | unsupported (Error::Unsupported) in both modes — a lossy-UTF-8 text fixture can't reproduce exact raw bytes; capture bytes from a real or scripted runner |
Only env values are redacted. program, args, cwd, stdout, and
stderr are stored verbatim and can carry secrets (a --password=… flag, a
token echoed to output), so review a fixture before committing it. On Unix the
file is written 0600 and the write refuses to follow a symlink at the
cassette path (O_NOFOLLOW, so a planted link can't redirect the secret-bearing
write — it fails loud instead). On Windows the file inherits the containing
directory's ACL, so restrict that directory (or use a per-user temp dir, not a
world-writable shared one) for secret-bearing fixtures.
A neat trick: in tests, record against a ScriptedRunner instead of
JobRunner — the whole record→save→replay round trip is then itself
hermetic.
Opt-in stricter matching (cwd / selected env values)
The portable default keys on program + args + stdin only. It deliberately
leaves cwd and the environment out of the key: a cassette recorded in one
absolute working directory (a dev box, a tempdir) then replays cleanly from a
different one (a CI workspace), and an env variable that differs between the
record and replay machines but doesn't change the tool's output can't cause a
spurious miss. That portability is the right default for most tools.
But some tools' output genuinely depends on where they run or on a specific environment variable — and with those out of the key, two such invocations collide on one entry: the first recording silently answers for both on replay. When that matters, opt in to a stricter key:
use processkit::{Command, JobRunner, ProcessRunnerExt}; use processkit::testing::RecordReplayRunner; #[tokio::main] async fn main() -> processkit::Result<()> { // Record: also key on the working directory and on LC_ALL's value. let runner = RecordReplayRunner::record("fixtures/tool.json", JobRunner::new()) .match_on_cwd() .match_on_env(["LC_ALL"]); let cmd = Command::new("tool").current_dir("/repo").env("LC_ALL", "C"); let _ = runner.run(&cmd).await?; runner.save()?; // Replay: set the *same* policy — it must match on both sides. let runner = RecordReplayRunner::replay("fixtures/tool.json")? .match_on_cwd() .match_on_env(["LC_ALL"]); // A differing cwd or LC_ALL value now MISSES (a loud `CassetteMiss`) instead // of replaying the wrong entry; the same cwd + LC_ALL hits. let cmd = Command::new("tool").current_dir("/repo").env("LC_ALL", "C"); let _ = runner.run(&cmd).await?; Ok(()) }
Notes:
- Env values still never reach the file.
match_on_envkeys on an FNV digest of the selected(name, value)pairs, not the raw values — the cassette continues to store variable names only, so no env secret is written to disk even under the stricter policy. (cwdis keyed the same way; it is also already stored verbatim on the entry, as before.) - Symmetric by contract. Set the same policy on the record and replay runner, exactly as you target the same tool. A mismatched policy simply misses (it never serves a wrong entry) — including replaying a policy-keyed cassette with no policy, or vice versa.
- Only the named variables participate. Variables you don't name stay out of
the key, preserving portability for env differences that don't matter. A named
variable that is set, removed (
env_remove), or untouched are three distinct keys. - On-disk format. A policy-keyed entry carries an extra opaque
match_digestnumber; a cassette recorded without a policy omits it and is byte-identical to before but for the bumpedversion(now4). Older cassettes (nomatch_digest) load and replay unchanged under a no-policy replayer.
Wrapping a CLI tool
CliClient is the foundation for typed wrappers around external tools
(git, jj, gh, kubectl, …): it owns the program name, per-client
defaults, and the runner; your wrapper contributes only commands and parsers.
The cli_client! macro generates the boilerplate:
use processkit::{cli_client, Error, ProcessRunner, Result}; use std::path::Path; use std::time::Duration; cli_client!( /// A typed `git` client. pub struct Git => "git" ); impl<R: ProcessRunner> Git<R> { /// HEAD's commit id. pub async fn head(&self, repo: &Path) -> Result<String> { self.core.run(self.core.command_in(repo, ["rev-parse", "HEAD"])).await } /// Is the work tree clean? (exit code IS the answer) pub async fn is_clean(&self, repo: &Path) -> Result<bool> { self.core.probe(self.core.command_in(repo, ["diff", "--quiet"])).await } /// Branch list, parsed — the parser is fallible and returns the crate's /// `Result`, typically an `Error::Parse` naming the program. pub async fn branches(&self, repo: &Path) -> Result<Vec<String>> { self.core .try_parse( self.core.command_in(repo, ["branch", "--format=%(refname:short)"]), |out| { let list: Vec<String> = out.lines().map(str::to_owned).collect(); if list.is_empty() { Err(Error::parse("git", "no branches")) } else { Ok(list) } }, ) .await } } #[tokio::main] async fn main() -> processkit::Result<()> { // Production: the real runner, with per-client defaults. let git = Git::new().default_timeout(Duration::from_secs(30)); let head = git.head(Path::new(".")).await?; Ok(()) }
The generated type is Git<R: ProcessRunner = JobRunner> with Git::new(),
Git::with_runner(runner), default_timeout / default_env /
default_env_remove builders, and a module-private core: CliClient<R> (reach
it as self.core from the wrapper's own methods) whose helpers
speak the crate-wide verb vocabulary: run (trimmed stdout), output_string (full
result), run_unit (success only), exit_code, probe, plus parse
(infallible) and try_parse (fallible → Error::Parse).
And the payoff — the wrapper tests hermetically with any double:
#![allow(unused)] fn main() { #[tokio::test] async fn head_is_trimmed() { let git = Git::with_runner( ScriptedRunner::new().on(["git", "rev-parse", "HEAD"], Reply::ok("abc123\n")), ); assert_eq!(git.head(Path::new("/repo")).await.unwrap(), "abc123"); } }
…or with a cassette recorded against the real tool once.
Next: Platform support · Supervision · Running commands
Platform support
processkit supports Unix and Windows only — it requires tokio::process
and OS job / process-group primitives that have no equivalent on bare targets
like wasm. Building for such a target fails at compile time (a compile_error!
guard, or earlier in tokio's own dependencies). Within the supported set, it
treats platform support as first-class: every capability is either fully
implemented, honestly partial (documented and typed), or refused with
Error::Unsupported — never silently skipped. This page collects all the
matrices and fine print in one place.
CI coverage
.github/workflows/ci.yml's test job runs the full real-subprocess suite
(--include-ignored, so kill-on-drop is actually exercised) on glibc x86_64
(ubuntu-latest), glibc aarch64 (ubuntu-24.04-arm), Windows and macOS. The
aarch64 leg is the only place the native Linux syscall/layout code
(sys/{linux,pgroup,unix,pid_gate}.rs) actually runs on Linux/glibc-aarch64 —
elsewhere aarch64 is only cargo check-compiled against aarch64-apple-darwin
(Darwin, in the msrv job), never executed. A separate test-musl job runs the
full suite a further time inside a real rust:alpine container — musl libc and a
busybox userland, not merely a cross-compiled x86_64-unknown-linux-musl
binary executed by glibc userland tools — because musl/Alpine is the de-facto
standard for the container images this crate actually runs in, and its libc,
signal, and userland-utility details genuinely differ from glibc's. Alpine's
busybox already covers every external utility the suite spawns (sh, cat,
sleep, yes, head, grep, sort, id, printf, env, seq) except
one: its ps applet has no -p PID filter, so the job installs procps
(procps-ng) to get one that does. gcc/musl-dev (needed to link) already
ship in the base image.
Two container-runtime quirks — unrelated to musl/Alpine itself, but specific
to running any test suite inside a plain container — need working around,
via the job's (and the just test-musl recipe's) --init and
--cap-add=SYS_NICE options:
- No subreaper. A plain container's PID 1 is just the job's own entry
process, not a real init — so a killed process's orphaned grandchildren
become zombies that are never reaped and still probe alive via
kill(pid, 0). That silently breaks every test that asserts a forked grandchild is actually gone after teardown.--initruns tini as PID 1, a real subreaper, which is what a properly configured container host provides. Any container's own PID 1 has the identical gap for its own orphaned grandchildren — see Running in containers → PID 1. CAP_SYS_NICEdropped by default. Docker excludes it from the default capability set even for a root container user, so raising scheduling priority failsEPERMregardless of uid (lowering it never needs the capability). One test exercisesPriority::High(a negativenice) together with a privilege drop;--cap-add=SYS_NICErestores just that one narrow, low-blast-radius capability rather than skipping the test.
Run the same job locally with just test-musl (requires Docker); see the
recipe in justfile for details.
Containment mechanisms
ProcessGroup::mechanism() reports which one you actually got:
Mechanism | Platform | How containment works |
|---|---|---|
JobObject | Windows | A Job Object with kill-on-close; children are created suspended, assigned to the job, then resumed — so even a grandchild forked in the first instant is contained |
CgroupV2 | Linux (with delegation) | A private cgroup; children join in pre_exec, before exec, so descendants can never escape; teardown is cgroup.kill |
ProcessGroup | macOS, BSDs, Linux fallback | POSIX process groups (setpgid); teardown is killpg; tracked per started/adopted child |
On Linux the cgroup backend requires controller delegation, and resource
limits specifically need this process to run at the real cgroup-v2 root. The
crate creates the limit cgroup under this process's own cgroup and enables the
controllers in that cgroup's subtree_control, which cgroup v2's "no internal
processes" rule allows only for the real hierarchy root (the one exempt cgroup). A
cgroup namespace root does not qualify — it only virtualizes the view — so an
ordinary (private-cgroupns) container fails EBUSY just like a systemd
session/scope/service. The crate does not migrate your process into a sub-cgroup
to work around it, so in practice limits apply only at a minimal non-systemd init
sitting at the real root. Without a usable cgroup it quietly falls back to ProcessGroup —
unless you requested resource limits, which fail fast
instead (Error::ResourceLimit), because an unapplied cap is no protection. The
error's reason distinguishes the two ways this happens: LimitReason::Unsupported
when no cgroup v2 is mounted at all (or on macOS/BSD, which has no whole-tree
container of any kind), LimitReason::Unenforceable when cgroup v2 exists but this
process isn't at the real hierarchy root (the delegation case above) or the OS
otherwise rejected the request.
Capability matrices
Teardown & containment
| Capability | Windows JobObject | Linux cgroup | Linux pgroup | macOS/BSD |
|---|---|---|---|---|
| Kill-on-drop, whole tree | ✅ | ✅ | ✅ groups-based | ✅ groups-based |
Graceful shutdown (TERM → grace → KILL) | 🟡 atomic kill only | ✅ | ✅ | ✅ |
adopt an external child | ✅ (future forks contained) | ✅ (future forks contained) | 🟡 exec'd child tracked individually | 🟡 same |
Windows has no signal tier, so a graceful shutdown collapses to the atomic Job
kill — but it still honors escalate_to_kill: false spares the survivors
(closes the Job handle without KILL_ON_JOB_CLOSE) rather than killing them, so
the Windows column is "atomic kill when it kills", not an unconditional kill.
Signals & freezing
| Capability | Windows | Linux cgroup | Linux pgroup | macOS/BSD |
|---|---|---|---|---|
Arbitrary signal (Hup, Usr1, Other(n), …) | ❌ Kill only | ✅ | ✅ | ✅ |
suspend / resume | 🟡 per-thread counts | ✅ cgroup.freeze | ✅ SIGSTOP/CONT | ✅ SIGSTOP/CONT |
On the cgroup mechanism, a non-Kill signal (and the SIGSTOP/SIGCONT
fallback used for suspend/resume on pre-5.2 kernels without cgroup.freeze)
surfaces a real per-member delivery failure (e.g. EPERM) as an Err rather
than swallowing it — consistent with the "never silently skipped" philosophy; an
ESRCH race (the member already exited) is still success.
Inspection & accounting
| Capability | Windows | Linux cgroup | Linux pgroup | macOS/BSD |
|---|---|---|---|---|
members() | ✅ whole tree | ✅ whole tree | 🟡 leaders only | 🟡 leaders only |
| Group CPU / peak memory | ✅ | ✅ | ❌ count only | ❌ count only |
Per-run cpu_time / peak_memory_bytes / profile | ✅ | ✅ | ✅ (/proc) | ❌ None |
members() is gated on the process-control feature; the CPU / memory /
profile rows are gated on the stats feature.
Resource limits (limits feature)
| Capability | Windows | Linux cgroup | Linux pgroup | macOS/BSD |
|---|---|---|---|---|
max_memory (whole tree) | ✅ | ✅ | ❌ | ❌ |
max_processes | ✅ | ✅ | ❌ | ❌ |
cpu_quota | 🟡 approximate | ✅ | ❌ | ❌ |
Spawn-time controls
| Capability | Windows | Unix (all) |
|---|---|---|
inherit_env allow-list | ✅ | ✅ |
uid / gid drop | ❌ Unsupported | ✅ |
setsid | ❌ Unsupported | ✅ |
create_no_window | ✅ | no-op |
kill_on_parent_death | ✅ always on (kernel) | Linux: direct child; macOS/BSD: no-op |
priority | ✅ (priority class) | ✅ (nice/setpriority) |
umask | ❌ Unsupported | ✅ |
Everything not listed — capture, streaming, interactive stdin, encodings, buffer policies, timeouts, retry, pipelines, supervision, readiness probes, the test doubles, cassettes, cancellation — is platform-agnostic and behaves identically everywhere.
Caveats
The honest fine print, mostly consequences of OS semantics:
Windows: termination is an exit code, never Signalled (D18). Windows has
no signal abstraction, so a killed process reports
Outcome::Exited,
not Outcome::Signalled. TerminateProcess / TerminateJobObject(_, 1) is
Exited(1) — indistinguishable from a voluntary exit(1) — and Ctrl-C
surfaces as Exited(-1073741510) (STATUS_CONTROL_C_EXIT as a signed i32).
The crate reports the platform truth rather than fabricating a Signalled from
an NTSTATUS code (that mapping would be a lossy guess). When you need to know
the run was killed, use a ProcessGroup deadline or a cancellation token (which
surface as TimedOut / Error::Cancelled on every platform). Outcome::Signalled
is therefore Unix-only.
Linux cgroup delegation. Creating the per-group cgroup needs write access
to the cgroup v2 hierarchy. Dev boxes typically lack it → the pgroup fallback.
CI inside containers usually has it. Check mechanism() when behavior must
not silently degrade. For the container-specific version of this — what a
plain docker run actually gets, and why resource limits stay unenforceable
even under --privileged — see
Running in containers → mechanism
and
Running in containers → resource limits.
uid()/gid() × the cgroup mechanism. The OS applies the uid drop
before pre_exec hooks, and the cgroup join runs in pre_exec — as the
already-dropped user, who can't write the root-owned cgroup.procs. The spawn
fails with a permission error (never an uncontained child). Privilege drop
composes cleanly with the process-group mechanism.
setsid() × process groups. A new session implies a new process group;
the crate coordinates the two (the containment tracking follows the new
session's group), so setsid keeps the kill-on-drop guarantee instead of
breaking out of it.
kill_on_parent_death() is thread-scoped on Linux. PR_SET_PDEATHSIG
fires when the spawning thread dies, not only the process. On a
multi-threaded tokio runtime a retired worker thread could kill the child
early; spawn from a current-thread runtime for the strongest guarantee. It
covers the direct child only — with the parent SIGKILLed, nothing tears
the cgroup/pgroup down, so grandchildren survive. The
parent-died-before-arming race is closed by re-checking getppid() in the
child against the spawner's pid captured before the fork — which stays
correct when the spawner itself is PID 1 (a container entrypoint).
Windows: the suspended-spawn handshake. Children are created
CREATE_SUSPENDED, assigned to the job, then resumed — closing the classic
race where a fast child forks before it's in the job. A consequence: on the raw
ProcessGroup::spawn escape hatch, any creation flags the caller set are
overwritten — the child is forced to CREATE_SUSPENDED alone, because Win32
exposes no way to read the flags back and OR the suspend bit in. The
Command-driven paths don't have this limitation: their extras (incl.
create_no_window) travel alongside the OS command and are OR'd in.
Windows: nested suspends. SuspendThread keeps per-thread counts — two
suspend() calls need two resume()s. The POSIX backends are level-triggered
(idempotent). Suspension is also best-effort against a tree that is spawning
threads mid-walk.
Spawning into a suspended cgroup group. The freeze is group state: a
child spawned or adopted while suspended joins frozen — the forked child
joins the cgroup before exec, so it can freeze before completing the
spawn handshake and start() may never return until resume. Resume
before starting new work; details in
Process groups.
Frozen trees and graceful shutdown. Hard kills penetrate a frozen tree
(SIGKILL / cgroup.kill / job terminate), but a graceful shutdown leads
with a SIGTERM the frozen processes can't handle — it waits out the full
grace. Resume first. For the orchestrator's own SIGTERM to your
container's PID 1 (a related but distinct signal from the one shutdown
sends to the tree it manages), see
Running in containers → graceful shutdown.
pgroup backends: leaders, zombies, pid reuse. members() lists tracked
group leaders only; an exited-but-unreaped child (zombie) still probes as
alive (keep wait()ing handles if you need prompt liveness, e.g. for
shutdown's early return); and pid-based signalling is inherently
best-effort against pid reuse — the crate prunes dead entries on every probe
to keep the window minimal.
Next: Process groups · Running in containers · docs index
Running in containers
Containers are where most processkit-using services actually run, and it's
the environment with the most sharp edges: which containment mechanism you
get depends on privileges the orchestrator may or may not grant, your process
is usually PID 1 (with everything that implies for signal delivery and
reaping), and the image itself is often minimal (musl/Alpine, sometimes no
shell at all). None of this is new machinery — every fact below is already
documented in Platform support — this page is the
container-shaped tour through it, with the Dockerfile fragments and gotchas
that only show up once you actually run the crate inside docker run /
Kubernetes.
- Which containment mechanism you get
- PID 1: signals, zombies, and what's contained
- Graceful shutdown on the orchestrator's
SIGTERM - Minimal images: musl/Alpine, no shell, no
setpriv - Container resource limits vs the crate's
limits
Which containment mechanism you get
On Linux, ProcessGroup's cgroup backend needs write access to the cgroup v2
hierarchy at the real hierarchy root (see
Platform support → containment mechanisms
for exactly why). A plain, unprivileged docker run container gets neither:
/sys/fs/cgroup is typically mounted read-only, so the crate quietly
falls back to the ProcessGroup (POSIX process-group) mechanism — kill-on-drop
still works, but the whole-tree accounting and limits capabilities of the
cgroup backend do not (confirmed by running the crate inside docker run
with no extra flags: mechanism() reports ProcessGroup, and
/sys/fs/cgroup refuses even a touch). --privileged (or an equivalent
cgroup-namespace delegation) makes the filesystem writable enough for
mechanism() to report CgroupV2, but the container's cgroup is still a
namespace root, not the real hierarchy root — /proc/self/cgroup reads
0::/ either way — so resource limits
stay unenforceable even then.
use processkit::{Mechanism, ProcessGroup}; fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; if group.mechanism() == Mechanism::ProcessGroup { // The common case inside an unprivileged Linux container: kill-on-drop // still holds (see the next section), but `members()`'s CPU/memory // totals and `limits` are not available — see the capability // matrices in Platform support. `CgroupV2`/`JobObject` get those too. } Ok(()) }
Never assume a mechanism; check mechanism() if your service's behavior
must not silently degrade (e.g. it relies on limits for sandboxing an
untrusted child) — see
Container resource limits
for the fail-fast alternative when a cap truly matters. Whichever mechanism
you land on, whole-tree kill-on-drop itself is unconditional — the fallback
only narrows accounting and limits, never containment.
PID 1: signals, zombies, and what's contained
Inside a container your process is almost always PID 1 — Docker and
Kubernetes don't run a real init unless you ask for one. PID 1 has two kernel
duties an ordinary process doesn't: it's the implicit reparent target for
every orphaned descendant in the container's PID namespace, and — for signals
without an installed handler — some default dispositions are ignored instead
of applied (irrelevant to SIGKILL/SIGSTOP, which are never blockable, but
relevant to graceful termination — see the next section).
Neither duty is something processkit does for you, and neither needs to be:
- Containment (killing the tree) is unaffected. The whole point of the
cgroup/pgroup mechanisms is that
kill_all/shutdown/Dropreach every descendant, including ones spawned after your direct child — that's true whether or not your process happens to be PID 1. - Reaping an orphan that isn't yours is not. A grandchild your own
child forked and then exited without waiting for still needs someone to
call
wait()on it once it dies, or it lingers as a zombie (kill(pid, 0)still reports it alive) forever. Once that grandchild reparents to PID 1, "someone" has to be PID 1 itself — and an ordinary process,processkit-managed or not, doesn't indiscriminately reap processes it never spawned.
This is exactly the gap Platform support's CI section
documents and works around with --init for the crate's own test suite —
and it reproduces identically for any container. Run a process that
orphans a short-lived grandchild as the container's PID 1 with a plain
docker run (no --init), and the grandchild is left a permanent zombie
after it exits; the identical container run with --init (which runs
tini as the real PID 1, a subreaper) shows
no zombie at all — confirmed by running both side by side. Baking tini into
the image itself works identically, and is the only option on Kubernetes,
which has no --init-equivalent flag:
FROM alpine:latest
RUN apk add --no-cache tini
COPY my-app /usr/local/bin/my-app
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/my-app"]
(Confirmed against a real pk-container-demo image built from this exact
pattern: with tini as ENTRYPOINT, an orphaned grandchild is reaped with no
--init flag at all; the same image invoked without tini — the bare binary
as PID 1 — leaks the identical zombie that the plain docker run case above
does.) sh -c 'sleep 0.3 &'-style orphans are the illustrative worst case;
in practice this only bites processes with descendants that outlive their
direct parent — most single-service containers with no forking children
never hit it. When in doubt, run with a subreaper as PID 1; it's a no-op cost
otherwise.
Graceful shutdown on the orchestrator's SIGTERM
Docker (docker stop) and Kubernetes both stop a container by sending
SIGTERM to PID 1 — your process — then wait a grace period (Docker's
--stop-timeout / docker stop -t, Kubernetes'
terminationGracePeriodSeconds, both default to a small handful of seconds)
before escalating to SIGKILL. That SIGTERM targets your process, not
the tree processkit manages — the two are related but distinct signals:
catching the orchestrator's SIGTERM is ordinary application code (e.g.
tokio::signal::unix::signal
with tokio's own signal feature, or a crate like signal-hook); reacting to
it by tearing down the child tree gracefully is
ProcessGroup::shutdown
(or RunningProcess::shutdown for a single
start()ed service): SIGTERM the tree, wait up to shutdown_timeout, then
SIGKILL any survivor.
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _server = group.start(&Command::new("app-server")).await?; // Left to the application: install a handler for the orchestrator's own // SIGTERM (tokio::signal::unix::signal(SignalKind::terminate()) with // tokio's `signal` feature, or the `signal-hook`/`ctrlc` crates) and // resolve this future when it fires. wait_for_orchestrator_sigterm().await; // SIGTERM the tree, wait shutdown_timeout, SIGKILL stragglers: group.shutdown().await?; Ok(()) } async fn wait_for_orchestrator_sigterm() { // … }
(This exact pattern — tokio::signal::unix::signal(SignalKind::terminate())
followed by group.shutdown() — was built and run for real as a small
processkit-consuming binary: docker stop on the resulting container
delivered SIGTERM to PID 1, the handler fired, shutdown() tore the tree
down, and the process exited well inside Docker's default 10-second grace —
no SIGKILL needed.)
Two things worth setting deliberately, both already documented at the
ProcessGroup/Command level:
- The orchestrator's grace period must exceed your own. If
shutdown_timeout(or a per-Commandtimeout_grace) is longer than Docker's--stop-timeout/ Kubernetes'terminationGracePeriodSeconds, the orchestrator sends the hardSIGKILLto your still-shutting-down process before your own escalation ever gets a chance to run — set the outer grace at least as generous as the inner one. - A frozen tree can't shut down gracefully. If you've called
group.suspend(), the frozen processes can't run theirSIGTERMhandler —shutdown()waits out the whole grace and then hard-kills. Resume first; see Platform support's frozen-tree caveat.
Minimal images: musl/Alpine, no shell, no setpriv
Two properties of a lean final image turn out to be non-issues for this
crate specifically, because of how it does privilege drop and spawning —
confirmed by building and running against rust:alpine/alpine:latest:
- No
setpriv/su-exec/gosuneeded foruid()/gid()/groups(). The privilege drop issetgroups→setgid→setuidcalled directly as raw syscalls inside the child'spre_exechook (see Running commands → privileges) — the crate never shells out to an external helper binary, so a final stage that lacks one entirely still drops privileges correctly. Confirmed by running the drop (.uid(...).gid(...).groups(...)) inside a container and getting back the target identity with no such binary invoked. - No shell needed to spawn.
Command/ProcessGroup::spawnalwaysexecs the target program directly with an argv array — there is nosh -cstep anywhere in the crate's own spawn path (pipelines wire pipes at the OS level, not through a shell either — see Pipelines). AFROM scratch-style final stage with no shell at all works, as long as the programs you spawn are themselves present — the crate not needing a shell doesn't exempt a command you invoke assh -c "…"yourself.
# Builder: musl/Alpine — the same base the crate's own CI `test-musl` job and
# `just test-musl` build against (see Platform support → CI coverage), so the
# toolchain and libc pairing is already exercised.
FROM rust:alpine AS builder
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
# Runtime: no shell, no setpriv/su-exec/gosu — the crate needs none of them.
FROM alpine:latest
COPY --from=builder /src/target/release/my-app /usr/local/bin/my-app
ENTRYPOINT ["/usr/local/bin/my-app"]
(Built and run for real, end to end: cargo build --release inside
rust:alpine, then the resulting binary run from a bare alpine:latest
final stage — privilege drop and a no-shell spawn both succeeded exactly as
above.) Add tini to the final stage — see PID 1 — if
anything you spawn can outlive its own children.
Container resource limits vs the crate's limits
The orchestrator's own cgroup limits (Docker's --memory/--cpus,
Kubernetes' resources.limits) apply to the whole container, enforced by
the kernel regardless of anything processkit does — a container that hits
its memory limit gets OOM-killed independent of any max_memory the crate
was asked to set. That outer limit is not the same thing as the crate's own
limits feature (max_memory /
max_processes / cpu_quota on ProcessGroupOptions), which caps a
specific tree within the container and needs this process to sit at the
real cgroup v2 hierarchy root — a requirement an ordinary container
essentially never meets, privileged or not (see
Which containment mechanism you get).
Confirmed by actually requesting a limit from inside a container:
plain docker run: Error::ResourceLimit { kind: Memory, reason: Unenforceable,
detail: "…Read-only file system…" }
docker run --privileged: Error::ResourceLimit { kind: Memory, reason: Unenforceable,
detail: "…cgroup v2's 'no internal processes' rule…Resource busy…" }
Both fail the same way the crate documents for any non-delegated host —
Error::ResourceLimit with reason: LimitReason::Unenforceable — never a
silently-unbounded group (see
Errors → ResourceLimit and
Platform support → containment mechanisms
for the delegation requirement in full). In practice: rely on the
orchestrator's own memory/CPU limits as the outer boundary for anything
running in a container, and reserve the crate's limits feature for hosts
where this process genuinely owns the cgroup root — a minimal, non-systemd
init on bare metal or a VM, not a container. mechanism()/kill-on-drop
containment keep working either way; only the limits cap itself is
unavailable.
Next: Platform support · Process groups · docs index
Upgrading processkit
Per-version notes for consumers moving their dependency forward: what breaks, who it affects, and the exact change to make. The CHANGELOG is the full record; this page is the "I depend on it, what do I do" view.
Versioning. From 1.0.0 onward
processkitfollows Semantic Versioning: the public API is stable, and any breaking change lands only in a new major version, so2.xupgrades are backward-compatible. The default Cargo requirementprocesskit = "2"(or"2.1"to also require the latest breaking release) already does the right thing — it allows2.*but not3.0. Skim the relevant section here before each major bump. (Themockfeature'smockall-generatedexpect_*surface stays semver-exempt — it tracks themockallversion.)
2.1.0 (from 1.2.x)
2.0.0 and 1.3.0 were withdrawn — upgrade straight from 1.2.x to 2.1.0.
2.0.0was published in error and yanked;1.3.0accidentally shipped this breaking batch under a minor bump and was yanked too.2.1.0is the first supported release of the changes below — the crate follows semver, so this break lands in a major as intended. There is nothing extra to do for the skip; the migration from a1.2.xdependency is exactly the notes here.
Mostly mechanical renames — caught by the compiler — plus two
#[non_exhaustive] tightenings on Error (also compiler-caught, once you stop
destructuring the affected variants field-exhaustively) and one genuine
behavior change on output_bytes that a build alone won't surface.
Renames (mechanical — compiler-caught)
| Before | After |
|---|---|
Error::OutputTooLarge { line_limit, byte_limit, .. } | Error::OutputTooLarge { max_lines, max_bytes, .. } |
ResourceLimits::memory_max (field, limits feature) / .memory_max(n) builder | ResourceLimits::max_memory / .max_memory(n) |
ProcessGroup::terminate_all() | ProcessGroup::kill_all() |
RunProfile::avg_cpu() | RunProfile::avg_cpu_cores() |
RunProfile::exit_code (field) | profile.code() (method — same Option<i32>) |
use processkit::Encoding; | use processkit::prelude::Encoding; |
use processkit::StreamExt; | use processkit::prelude::StreamExt; |
result.output_contains_any(&["a", "b"]) | result.output_contains_any(["a", "b"]) (now impl IntoIterator<Item = impl AsRef<str>> — a bare array, Vec<String>, or slice all work directly, without the &; the old &["a", "b"] call still compiles too) |
The terminate_all / avg_cpu entries were deprecated forwarding aliases since
1.1.0 (see the 1.1.0 changelog entry); this
release removes them outright. RunProfile::exit_code duplicated
outcome.code(), which RunProfile::code() already exposed — the field is gone,
the method is the one accessor now.
Error's data-carrying variants are now individually #[non_exhaustive]
Exit, Timeout, Signalled, Spawn, NotFound, Parse, OutputTooLarge,
Stdin, and — with the limits feature — ResourceLimit can no longer be
struct-literal-constructed or field-exhaustively destructured outside the crate.
Before:
match err {
Error::Exit { program, code, stdout, stderr } => { /* ... */ }
_ => {}
}
After — add .. to the pattern (or, better, use the existing accessors instead
of destructuring at all):
#![allow(unused)] fn main() { use processkit::Error; fn handle(err: Error) { match &err { Error::Exit { program, code, stdout, stderr, .. } => { let _ = (program, code, stdout, stderr); } _ => {} } // or, accessor-based and immune to the next field addition: if let Some(code) = err.code() { // err.program() / err.stdout() / err.stderr() / err.combined() also work let _ = code; } } }
This is prep for future field additions to any of these variants without
another breaking change — the Exit/Timeout/Signalled variants already
gained one such field this release (next entry).
Error::Exit / Timeout / Signalled gain a stdout_bytes field
A new field, stdout_bytes: Option<Vec<u8>>, carries the exact captured
stdout bytes for a checking-verb error built over output_bytes
(e.g. output_bytes().await?.ensure_success()?); read it through
Error::stdout_bytes() -> Option<&[u8]>, not by destructuring the variant
directly (they are #[non_exhaustive] — see above). None on the text path
(output_string/run/checked/…), where the decoded stdout string is
already the whole story.
Error::ResourceLimit is restructured (limits feature)
| Before | After |
|---|---|
Error::ResourceLimit { message: String } | Error::ResourceLimit { kind: LimitKind, reason: LimitReason, detail: String } |
Fix a match:
// Before
Error::ResourceLimit { message } => warn!("limit rejected: {message}"),
// After
Error::ResourceLimit { detail, .. } => warn!("limit rejected: {detail}"),
// or, branch on the structured classification instead of parsing text:
if let (Some(kind), Some(reason)) = (err.limit_kind(), err.limit_reason()) {
match (kind, reason) {
(LimitKind::Memory, LimitReason::Unsupported) => { /* ... */ }
_ => {}
}
}
output_bytes now honors the byte cap on stdout too — a behavior change
Not compiler-caught: if you configured an OutputBufferPolicy byte ceiling
(with_max_bytes) and called output_bytes, the cap previously bounded only
the line-pumped stderr; raw stdout capture was unbounded regardless of
the configured max_bytes. It now applies to both streams:
OverflowMode::Errorpast the cap now errors on stdout overflow too, withError::OutputTooLarge { max_lines: None, .. }(raw bytes have no lines).- The drop modes (head/tail) now bound retained stdout bytes the same way they
already bounded stderr, and set
ProcessResult::truncated.
If nothing sets a byte cap, capture stays unbounded exactly as before — nothing
to do. If you do set one and rely on output_bytes returning the full
stdout regardless, re-check that call site: it now truncates/errors like every
other capture path under the same policy.
Cassette replay: cwd no longer part of the match key — no action needed
RecordReplayRunner (record feature) replays a cassette recorded from one
absolute working directory against the same invocation run from a different
one, instead of CassetteMissing — cwd is still stored on each entry for
visibility, it just no longer discriminates two otherwise-identical recorded
runs. The on-disk format revision bumped to 3, but this is not a compatibility
gate: a cassette written by a 1.x build still loads and replays fine. The one
edge case: an existing cassette that had two entries differing only in cwd
now collides on replay, and the first-recorded entry answers for both —
re-record it if that matters for your fixtures.
Verify the upgrade
cargo update -p processkit
cargo build # the renames and non_exhaustive tightenings are compiler-caught
cargo test # catches the output_bytes byte-cap behavior change if you rely on it
1.0.0 (from 0.11.x)
A few breaking changes, all caught by the compiler — if it builds after the bump, you're done.
OutputLine.text is now an accessor
OutputLine (the per-line payload of RunningProcess::output_events) no longer
exposes text as a public field — read it via line.text() -> &str (or
line.into_text() -> String to take ownership). This frees the line
representation to evolve. Fix: line.text → line.text().
Error::ResourceLimit is now a struct variant
Error::ResourceLimit(String) became Error::ResourceLimit { message: String }
(parity with the other rich variants, room for structured detail later). Fix a
match Error::ResourceLimit(m) → Error::ResourceLimit { message: m }.
(Only relevant with the limits feature.)
The text-capture verb is renamed output → output_string
The verb that runs to completion and returns the full ProcessResult<String>
is now spelled output_string on every layer, matching output_bytes (and
the spelling Command/Pipeline/RunningProcess already used). Two reasons:
the same operation no longer has two names depending on the type, and a bare
output clashed with std::process::Command::output, which returns bytes —
the explicit name removes that footgun.
Affected if you call ProcessRunner::output, CliClient::output, the free
fn processkit::output, or implement a custom ProcessRunner / use MockRunner.
The symptom is a build error like "no method named output" /
"cannot find function output in crate processkit".
Fix — rename the calls (mechanical):
| Before | After |
|---|---|
runner.output(&cmd) / client.output(args) | runner.output_string(&cmd) / client.output_string(args) |
processkit::output(prog, args) | processkit::output_string(prog, args) |
impl ProcessRunner { async fn output(..) } | async fn output_string(..) (the required method) |
mock.expect_output() | mock.expect_output_string() |
output_bytes is unchanged, and Command/Pipeline/RunningProcess callers
need no change (those already used output_string).
0.11.0 (from 0.10.x)
Two breaking changes, both small and caught by the compiler — if it builds after the bump, you're done. Plus one internal fix that needs no action.
1. stats is now opt-in — a Cargo.toml change
The default feature set is now just process-control; stats is no longer on by
default. (It gates a specialized metrics surface the core never needs; on
Windows it links an OS library — the ProcessStatus FFI used solely for the
peak-memory readout — but unlike mock/tracing/record it pulls in no extra
crate.)
Affected if you use any metrics API: ProcessGroup::stats /
ProcessGroupStats, RunningProcess::cpu_time / peak_memory_bytes, or
RunProfile / RunningProcess::profile. The symptom is a build error like
"no method named stats / cpu_time / peak_memory_bytes / profile" or
"cannot find type ProcessGroupStats / RunProfile".
Fix — add the feature:
[dependencies]
processkit = { version = "0.11", features = ["stats"] }
If you already enable limits, do nothing — limits still implies stats.
If you don't use metrics: nothing to do. Your default build is now slightly
leaner (no Windows ProcessStatus dependency).
2. OutputEvent carries OutputLine — a code change
Affects only callers of RunningProcess::output_events (the ordered
lifecycle+output event stream). The per-line payload changed from a bare String
to a #[non_exhaustive] OutputLine struct with a public text field.
Before:
use processkit::OutputEvent;
while let Some(ev) = events.next().await {
match ev {
OutputEvent::Stdout(s) => println!("out: {s}"),
OutputEvent::Stderr(s) => eprintln!("err: {s}"),
_ => {}
}
}
After — read line.text (in 1.0 this becomes line.text(); see the
1.0.0 section above):
match ev {
OutputEvent::Stdout(line) => println!("out: {}", line.text),
OutputEvent::Stderr(line) => eprintln!("err: {}", line.text),
_ => {}
}
Or, when you don't care which stream produced the line, use the new accessor:
#![allow(unused)] fn main() { fn handle(ev: processkit::OutputEvent) { if let Some(text) = ev.text() { println!("{text}"); } } }
OutputLine is #[non_exhaustive]: you receive it from the crate and read its
fields — you don't construct it, and a match on it should use ... The change
exists to reserve room for per-line metadata (e.g. a timestamp or a monotonic line
index) in a later release without another break.
3. Cancel-precedence fix ("Issue 7") — no action
A run that reaps on its own is no longer at risk of being misreported as
Err(Cancelled) by a cancellation token that fires in the narrow window between
the reap and the disposition check. This is an internal correctness fix with no
public-API change. If you carried a workaround that tolerated a spurious
Cancelled on a self-completing run, you can remove it.
Verify the upgrade
cargo update -p processkit
cargo build # both breaking changes are compiler-caught
cargo test
Upgrading from older than 0.10
The jumps below 0.10 predate this guide. Read the dated sections of the
CHANGELOG for each minor you cross — every breaking entry there
is marked Breaking and carries its own migration note. Notable recent
non-breaking additions you gain along the way: Command::checked / run_unit
(0.10.2) and the record-cassette symlink/Display-injection hardening (0.10.2).
What's next
ProcessKit is a Rust library today, published as processkit on crates.io. The plan is to bring the same approach — kernel-backed whole-tree containment, honest error semantics, and testable seams — to other ecosystems: a Go package, an F# library, a Kotlin library, and a Python wrapper. Each implementation will follow the same philosophy and be documented here as it ships.