
Async child-process management for Rust + tokio with a kernel-backed no-orphan guarantee: every process you start — and everything it spawns — lives in a kill-on-drop container (a Windows Job Object, a Linux cgroup v2, or a POSIX process group), so no descendant ever outlives your program.
Beyond spawning a subprocess: run-and-capture, line streaming, interactive stdin, real pseudo-terminal (PTY) sessions, shell-free pipelines, readiness probes, timeouts & cancellation, supervision with restart/backoff, and a mockable runner seam for subprocess-free tests.
cargo add processkit
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let version = Command::new("cargo").arg("--version").run().await?; println!("{version}"); Ok(()) }
Continue: find a recipe · learn the command model · browse the API
Why processkit?
std::process and tokio::process reach (at most) the direct child. The
processes it spawned — a build tool's compiler children, the real payload
behind a wrapper (cmd /c …, sh -c …), a test's helper servers — can survive
a timeout, a panic, or a dropped future and keep running as orphans.
processkit puts every child inside the operating system's own containment
primitive. Teardown is one operation over the whole process tree, not a
best-effort signal to one PID:
- Nothing escapes silently. Dropping a run or group reaps every descendant;
the active
Mechanismtells you which OS guarantee is in effect. - Terminal when you need one. The opt-in
ptyfeature gives terminal-only tools a real controlling terminal while keeping the same containment and kill-on-drop lifecycle. - Honest outcomes. A non-zero exit remains inspectable data until you ask for success; timeouts, cancellation, spawn failures, and resource limits stay distinct in the typed error model.
- Async and testable. Streaming, pipelines, readiness, and supervision are
tokio-native, while the
ProcessRunnerseam replaces real processes with scripted or record/replay doubles in tests.
3.0 highlights
- PTY support is here. With the opt-in
ptyfeature,Command::use_pty()gives terminal-only tools a real controlling terminal:openptyon Unix andCreatePseudoConsole(ConPTY) on Windows. The terminal has one merged output stream, supports interactive input plus initial/live window sizing, and stays inside the same kill-on-drop containment as a piped run. Start with the PTY dialog and window-size sections, then check the platform matrix. - Errors are split by responsibility.
Erroris now a pointer-sized wrapper aroundBox<ErrorReason>; its existing accessors remain the convenient path,reason()exposes the structured variant, andErrorKindis the coarse total classification for routing. See Errors for the model and Upgrading for the 2.x migration. - Lifecycle streaming is end to end. The renamed
events()stream carriesProcessEvent::Started→ ordered output →ProcessEvent::Exited, whilePipeline::start()andSupervisor::start()expose live sessions for observing and stopping multi-stage or restarted workloads. Continue with lifecycle events, live pipelines, and live supervision. - Tree shutdown and ownership are explicit.
ProcessGroup::stop()returns aShutdownReport,update_limits()changes resource caps on a live group, andspawn_detached()is a separate opt-in for the exceptional child that must outlive its launcher. See observable teardown, live limits, and detached children. - Batch streaming and capture are production-ready. Completion-order
output_streamfan-out no longer holds fast results behind slow work; byte-accurate raw tees expose the undecoded stream; andCapturePolicyredacts or reshapes retained lines before they enter a result. See results as they finish, raw output, and redaction at capture. - The host is inspectable before and after launch.
host_containment()reports the effective containment mechanism without spawning;process_info()/process_is_alive()provide reuse-safe PID identity checks; and the newmetricsfeature publishes secret-safe counters and histograms. See Platform support, process identity, and Observability.
The complete release record is in the
v3.0.0 release.
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 |
| Building typed CLI clients | CliClient and cli_client!: wrapper structure, defaults and precedence, typed parsing/JSON, errors, scripted tests, and record/replay fixtures |
| Running many at once | Bounded output_all / output_all_bytes fan-out, shared versus independent containment, and wait_any / wait_all races and joins |
| Comparative benchmarks | End-to-end processkit vs. plain Tokio and standard-library baselines across capture, streaming, and concurrent fan-out |
| 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, PTY dialogs/window resize/output hygiene, 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 | The Error wrapper, every ErrorReason variant, ErrorKind, subtle look-alikes (Timeout vs Cancelled, NotReady vs Timeout, NotFound vs Spawn, CassetteMiss), classifiers, and how retries/supervision use them |
| Troubleshooting | Symptom-first diagnosis for surviving children, missing tools, unavailable limits, silent or ANSI-heavy output, stuck waits, Windows graceful stop, and readiness-vs-run deadlines |
| Supervision | Keeping a child alive: restart policies, backoff & jitter math, the failure-storm guard, stop conditions, outcomes, supervising inside a shared group |
| Observability | The tracing event seam and the metrics counters/histograms over data the crate already computes — what's measured, the secret-hygiene guarantee (never argv/env), label cardinality, and wiring a backend exporter |
| 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 |
| Running untrusted children | A hardening checklist for launching a program you don't trust: containment, resource limits, privilege drop order, environment hygiene, output/wall-time bounds — what the crate guarantees, what it doesn't, and where to go for real isolation |
| Upgrading | Per-version consumer upgrade notes, including the 2.x → 3.0 ErrorReason and lifecycle-event migrations |
| 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, adopt_external} | — |
limits | off | Whole-tree resource caps on ProcessGroupOptions (max_memory / max_processes / cpu_quota), ErrorReason::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, per-transition graceful teardown (soft_signal → grace_started → drained/escalated/spared), retries, supervisor storms, teardown anomalies (never argv/env) | tracing |
metrics | off | Counters/histograms over already-computed run data: run/spawn counters, run-duration histograms, an exit-code/timeout/cancel tally, retry/restart/storm events — into any metrics recorder you install (never argv/env). See Observability | metrics |
record | off | RecordReplayRunner JSON cassettes over the runner seam | serde, serde_json |
json | off | Typed whole-output JSON verbs (Command::output_json, …) and line-wise NDJSON streaming (RunningProcess::stdout_json_lines), with bounded, location-rich parse diagnostics | serde, serde_json |
report-serde | off | serde::Serialize for the report types (ProcessResult, RunProfile, ProcessGroupStats, ShutdownReport, MemberInfo, LimitEvidence, supervision events/outcome/status), every enum tagged by its stable name() identifier. Serialize only, and never captured output/argv/env — see Errors | serde |
pty | off | Real pseudo-terminal launch mode (openpty on Unix, ConPTY on Windows), interactive master input, merged output, initial/live window sizing | windows-sys/Win32_System_Pipes (Windows) |
[dependencies]
processkit = { version = "3", 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.
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
- Run a tool that requires a terminal
- Answer an unterminated PTY prompt
- Read in-place progress from an agent CLI
- Avoid PTY full-duplex deadlocks
- Test PTY behavior without a terminal
- Driving ssh
- 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 ProcessKit attempts whole-tree teardown. Once terminal state is
confirmed, capture verbs keep the timeout as data (timed_out(), partial output
kept) and success-checking verbs (run, exit_code) surface
ErrorReason::Timeout. A rejected kill/escalation/reap that leaves the tree
potentially live is ErrorReason::Teardown instead.
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
ErrorReason::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 ErrorReason::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.
Forward a child's exact bytes (binary passthrough)
use processkit::Command; use tokio::fs::File; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let exact = File::create("archive.tar").await?; let outcome = Command::new("git") .args(["archive", "HEAD"]) // binary tar on stdout .stdout_raw_tee(exact) // exact bytes, before any decoding .start() .await? .drain() // stream through, retain nothing .await?; println!("done: {outcome:?}"); Ok(()) }
stdout_raw_tee / stderr_raw_tee write each chunk exactly as read from the
pipe — no decoding, no CRLF rewrite, no line splitting, no lost tail — so
non-UTF-8 output (git archive, tar -cz -, ffmpeg … -) survives byte for
byte. It runs alongside the decoded sinks (stdout_lines, on_stdout_line,
stdout_tee), so you can forward the raw stream and react to decoded lines at
once. For the whole output in memory instead of streamed through, output_bytes()
returns the exact bytes directly.
Fine print: Streaming → byte-accurate raw output.
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::ErrorReason::Io)? in a processkit::Result function, or use
Box<dyn std::error::Error>.
Fine print: Streaming & interactive I/O → interactive stdin.
Run a tool that requires a terminal
Some tools change behavior behind isatty() or refuse to start without a
controlling terminal. Enable the pty feature, opt this run into PTY mode, and
read its output through the usual capture verbs:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("terminal-only-tool") .arg("--status") .use_pty() .pty_size(120, 30) .output_string() .await?; // A PTY has one merged output stream. Everything the child wrote to its // terminal is captured as stdout; the separate stderr result is empty. println!("{}", result.stdout()); assert!(result.stderr().is_empty()); Ok(()) }
use_pty() changes the transport, not the lifecycle: timeout, cancellation,
containment, and kill-on-drop behave as for a piped child. It is a minimal
terminal session rather than a terminal emulator; set pty_size when wrapping
and layout matter.
Fine print: Streaming → PTY window size and live resize · Platform support → PTY mode.
Answer an unterminated PTY prompt
Terminal prompts commonly end in ": " rather than a newline. Use
wait_for_output, not wait_for_line, to match that live partial tail; then
answer over the same PTY master:
use processkit::Command; use std::time::Duration; async fn authenticate(secret: &str) -> Result<(), Box<dyn std::error::Error>> { let mut run = Command::new("sudo") .args(["-k", "-v"]) .use_pty() .keep_stdin_open() .start() .await?; run.wait_for_output( |tail| tail.contains("Password:"), Duration::from_secs(10), ) .await?; let mut stdin = run.take_stdin().expect("PTY stdin was kept open"); stdin.write_line(secret).await?; run.finish().await?; Ok(()) } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { authenticate("secret supplied by a protected source").await }
On Unix, processkit disables terminal echo for its PTY slave, so the secret is
not copied into merged output. That echo-off guarantee is Unix-only:
ConPTY has no portable per-session equivalent, so do not retain or log the
merged transcript when sending a secret on Windows. wait_for_output is
byte-driven and works for ordinary prompts on both platforms; it does not need
a newline or a signal from the child.
Fine print: Streaming → prompt-aware waiting · Running commands → interactive auth / TTY.
Read in-place progress from an agent CLI
Agent CLIs often require a terminal, redraw progress with bare carriage returns, and decorate it with VT/ANSI control sequences. Frame each redraw as a line and sanitize the captured text before inspecting it:
use processkit::prelude::StreamExt; use processkit::{Command, LineTerminator}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut run = Command::new("agent") .arg("run") .use_pty() // PTY output is the logical stdout stream. `line_terminator(...)` // is the equivalent whole-command spelling. .stdout_line_terminator(LineTerminator::CarriageReturn) .stdout_sanitize_vt() .start() .await?; let mut frames = run.stdout_lines()?; while let Some(frame) = frames.next().await { println!("agent: {frame}"); } run.finish().await?; Ok(()) }
PTY mode already defaults to carriage-return-aware framing; spelling it out is
useful when this behavior is part of your wrapper's contract. Use
line_terminator and sanitize_vt to configure both logical streams when the
same builder may also run without PTY. VT sanitization is destructive and
affects captured text only; exact-byte output and raw tees remain untouched.
Fine print: Streaming → PTY output hygiene · Untrusted children → content transforms.
Avoid PTY full-duplex deadlocks
A PTY master, like a pair of pipes, has finite kernel buffers. Do not await a large write while leaving output unread: the child may fill its output buffer, stop reading input, and leave both sides parked. Drain and write concurrently:
use processkit::prelude::StreamExt; use processkit::Command; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let payload = vec![b'x'; 4 * 1024 * 1024]; let mut run = Command::new("terminal-filter") .use_pty() .keep_stdin_open() .start() .await?; let mut stdin = run.take_stdin().expect("PTY stdin was kept open"); let mut output = run.stdout_lines()?; let write = async move { stdin.write(&payload).await?; stdin.finish().await }; let drain = async move { while let Some(line) = output.next().await { println!("{line}"); } Ok::<(), std::io::Error>(()) }; tokio::try_join!(write, drain)?; run.finish().await?; Ok(()) }
Also remember that a PTY has no independent stderr channel. Both child file
descriptors arrive through stdout_lines/on_stdout_line;
on_stderr_line is not delivered, stderr_tee has nothing to write, and the
finished result's stderr is empty. Keep both destinations StdioMode::Piped:
PTY mode rejects Inherit, Null, and file redirects for either output
descriptor before spawn. If stream identity or another destination matters, use
ordinary pipes instead of PTY.
Fine print: Streaming → interactive stdin · Running commands → interactive auth / TTY.
Test PTY behavior without a terminal
The ScriptedRunner PTY variant is selected by putting use_pty() on the
scripted command. It hermetically models the merged master and PTY line framing,
so CI needs neither a real terminal nor a platform-specific helper:
use processkit::prelude::StreamExt; use processkit::testing::{Reply, ScriptedRunner}; use processkit::{Command, LineTerminator, ProcessRunner}; #[tokio::main] async fn main() -> processkit::Result<()> { let runner = ScriptedRunner::new().fallback( Reply::ok("working 10%\rworking 100%\r") .with_stderr("final diagnostic"), ); let command = Command::new("agent") .use_pty() .line_terminator(LineTerminator::CarriageReturn); let mut run = runner.start(&command).await?; let mut merged = run.stdout_lines()?; let mut seen = Vec::new(); while let Some(frame) = merged.next().await { seen.push(frame); } let finished = run.finish().await?; assert!(seen.iter().any(|line| line == "working 100%")); assert!(seen.iter().any(|line| line == "final diagnostic")); assert!(finished.stderr.is_empty()); Ok(()) }
For dialogs, script Reply::dialog("Password: ", "accepted\n"); it emits the
unterminated prompt, waits for take_stdin() to receive an answer, then emits
the continuation. This exercises wait_for_output without subprocess timing.
Fine print: Testing → scripted streaming · Streaming → prompt-aware waiting.
Driving ssh
ssh is the one tool worth its own recipe. It often demands a terminal
(password/passphrase prompts), and — alone among the tools you'll launch — it
spawns work on another host, past anything kill-on-drop can reach. Two
things to get right: how you authenticate, and what "contained" does and
doesn't cover.
Prefer the non-interactive path — key auth, BatchMode=yes, ordinary
pipes. With key-based auth and BatchMode=yes, ssh never prompts, so a plain
run captures the remote command's output like any local one:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("ssh") .args(["-o", "BatchMode=yes", "deploy@host", "systemctl is-active app"]) .output_string() .await?; match result.code() { // 255 is ssh's OWN failure (host unreachable, auth/BatchMode rejected) — // NOT the remote command's exit; don't read it as the remote result. Some(255) => eprintln!("ssh could not connect: {}", result.stderr()), // Any other code came straight through from the remote command. Some(code) => println!("remote `systemctl is-active` exited {code}"), None => eprintln!("ssh was signalled or timed out on this side"), } Ok(()) }
Exit code 255 is ssh's own "connection/auth failed", not the remote
command's code. ssh forwards the remote command's real exit status as its own
— except 255, which it reserves for its own failures (host unreachable, auth
rejected, BatchMode with no usable key). A caller that cares about the remote
side must special-case 255 before trusting the code, as above. The one residual
ambiguity ssh can't resolve for you: a remote command that itself exits 255
looks identical to a connection failure.
The remote command line is parsed by the remote shell — the crate's
shell-free guarantee stops at the local process. processkit runs the local
ssh with no shell: its argv is passed literally, with no interpolation and
no injection surface (the crate's shell-free property). But ssh host "some command" hands that command string to a login shell on the far end, which
word-splits and expands it like any shell line. So any value you interpolate
into a remote command needs manual quoting/escaping (or build it so no untrusted
value reaches the remote shell at all) — the injection surface the local API
removes reappears on the remote side.
Password / passphrase / host-key prompts need a PTY. When key-auth isn't an
option, use_pty() (the pty feature) gives ssh the terminal it insists on;
drive the prompt over the merged master with keep_stdin_open + take_stdin:
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut run = Command::new("ssh") .args(["deploy@host", "systemctl status app"]) .use_pty() // real terminal (needs the `pty` feature) so ssh prompts .keep_stdin_open() .start() .await?; // Match the live, unterminated prompt tail; no newline is required. run.wait_for_output( |tail| tail.contains("passphrase"), Duration::from_secs(10), ) .await?; let mut stdin = run.take_stdin().expect("stdin kept open"); stdin.write_line("s3cr3t-passphrase").await?; // stdout and stderr are MERGED onto the one master in PTY mode — read the // rest of the exchange from run.stdout_lines(). Ok(()) }
wait_for_output sees the un-terminated prompt tail that wait_for_line
deliberately cannot. The same platform echo caveat as the general
PTY prompt recipe applies: echo is
disabled by processkit on Unix, but not portably controllable through ConPTY.
The containment boundary stops at the local ssh client. kill-on-drop,
timeout, and cancellation reap the local process tree — here, the ssh
client itself. They do not reach across the connection: dropping the handle
(or a timeout, or a panic) severs the local ssh client, but the command it
started on the remote host can keep running, orphaned. The crate's
whole-tree guarantee is about your machine's process tree; an ssh hop is a
boundary it cannot cross. Contain the remote side on the remote side:
ssh -ttforces a remote pseudo-terminal, so when the connection drops the remote session receivesSIGHUP— which ends many (not all) remote programs.- A server-side deadline —
ssh host 'timeout 300 long-job', or the service's own timeout/idle limit — bounds the remote work regardless of the local client's fate. - For anything that must be torn down reliably, make the remote command own its own lifecycle (a unit/scope with a deadline, a job the remote scheduler can kill), not the ssh client's liveness.
Fine print: Running commands → interactive auth · Running untrusted children → what not to rely on.
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_http("127.0.0.1:8080".parse().unwrap(), "/healthz", |s| (200..300).contains(&s), Duration::from_secs(10)).await?; // server.wait_for_socket("/tmp/my-server.sock", Duration::from_secs(10)).await?; // Unix only // server.wait_for_pipe("my-server", Duration::from_secs(10)).await?; // Windows only // server.wait_for(|| async { https_or_body_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 ErrorReason::NotReady and never kills
the child — you decide what happens next. No more sleep(2) and hoping.
wait_for_http is intentionally plain HTTP and status-only; use the generic
predicate with your own client when TLS, redirects, or response bodies matter.
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 ErrorReason::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::ErrorReason; 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.reason(), ErrorReason::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 ErrorReason::Cancelled let outcome = job.await; // Err(ErrorReason::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, ErrorReason, 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(|e| Error::from(ErrorReason::Io(e)))?; 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.
Only a pid to go on? When the process is not yours to hold a Child for — an
outside supervisor started it, the number came from a pidfile, or the caller is
an FFI binding that cannot build a Child at all — use adopt_external(pid):
use processkit::ProcessGroup; fn main() -> processkit::Result<()> { let pid: u32 = std::env::var("HELPER_PID").unwrap().parse().unwrap(); let group = ProcessGroup::new()?; group.adopt_external(pid)?; // the crate takes its own identity anchor here // The group's teardown now covers it — but nothing here will ever reap it, // and no exit status for it appears in this API. Ok(()) }
The number is an address, not a handle: the crate anchors on the process the pid
names at that moment (the process object on Windows, cgroup membership on
Linux, the start-time token on the POSIX process-group backends), so a later
recycle of the number is not signalled. It cannot check the window before the
call, so look the pid up as late as you can. FreeBSD and the other BSDs have no
start-time reader and return Unsupported rather than tracking a bare number.
Adopting a process another supervisor already contains has a side effect that outlives the group, in opposite directions per platform: on Windows this group's job is nested under the job the process was already in (so that outer job then reaches this group's members, later-started ones included — and an adoption into a group that already has its own members may simply be refused, so adopt before you start); on Linux cgroup v2 the process is taken out of its previous cgroup, which disables that supervisor's teardown and limits for it.
Fine print: Process groups → adopt · Adopting by pid · 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.
Deep guide: Building typed CLI clients · Testing doubles and cassettes.
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
- Redirecting output directly to a file
- 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, ErrorReason::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 ErrorReason::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. The dedicated
typed CLI clients guide covers wrapper structure, shared
defaults, parsing, and hermetic tests end to end.
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?; // Unix: choose a multicall mode without changing executable resolution. Command::new("busybox").arg0("httpd").arg("-f").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 ErrorReason::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. This covers the common non-tty-negotiating
interactive cases without the crate having to pump bytes; a tool that truly
demands a tty (not just inherited stdin) instead wants
use_pty (the pty feature). 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 ErrorReason::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.
Redirecting output directly to a file
stdout_file(path) and stderr_file(path) give the child a file descriptor at
spawn (Stdio::from(File)). They do not tee through a parent task: no output
is line-pumped, decoded, or retained in memory, and the child can keep writing if
the parent exits suddenly.
The plain builder creates the file when needed and truncates it for that
spawn. Use the explicit *_file_truncate spelling when choosing a mode in a
conditional expression, or *_file_append to preserve existing contents and
append each new child incarnation:
use processkit::{Command, Supervisor}; #[tokio::main] async fn main() -> processkit::Result<()> { let service = Command::new("my-service") .stdout_file_append("service.log") // every Supervisor incarnation shares this log .stderr_file_append("service.log"); Supervisor::new(service).run().await?; Ok(()) }
This is the low-overhead choice for a service under Supervisor that writes its
own log: append mode accumulates every restart in one file, while truncate mode
starts a fresh log for each spawn. The command's stdout(StdioMode::…) /
stderr(StdioMode::…) setters are last-wins and clear a prior file destination.
A redirected stdout is deliberately not piped. output_string,
output_bytes, stdout_lines, and events therefore reject it just as
they reject Inherit/Null; call start().await?.wait().await? (or supervise
the command) when only its exit outcome matters. Stderr may be redirected
independently; it does not prevent stdout capture.
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 — a contiguous prefix of the output: the first line that doesn't fit
seals the head, so every later line is dropped too (even a shorter one that
would still fit), never leaving a set that skipped a dropped line and kept a
later one. 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
ErrorReason::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, output_json — refuse
silently-truncated output (B12): if the policy dropped lines they fail with
ErrorReason::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, ErrorReason}; 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.reason(), ErrorReason::Timeout { .. }) // …but only retry timeouts }) .run() .await?; Ok(()) }
timeoutattempts whole-tree teardown at the deadline. After terminal state is confirmed, capturing verbs keep the expiry as data (ProcessResult::timed_out) and success-checking verbs raiseErrorReason::Timeout; an unconfirmed kill/escalation/reap isErrorReason::Teardowninstead. The full decision table lives in Timeouts, retries & cancellation.retryapplies to the success-checking verbs only —run,run_unit,exit_code,probe,checked,parse,try_parse, and (withjson)output_json(each runs through the retry loop). The classifier sees the typed error and decides.CancelledandTeardownare terminal even when that classifier accepts them. 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 / arg0 are POSIX-only — on Windows the run
fails with ErrorReason::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.
arg0 changes only the value delivered as the child's first argument: lookup,
preflight, containment, and spawn errors still use Command::program().
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. When the owner dies abruptly the kernel has no portable Unix way to take
the whole tree down, so Command::kill_on_parent_death_scope() reports the
honest reach as a ParentDeathCleanup — WholeTree (Windows),
DirectChildOnly (Linux), or Unsupported (macOS/BSD). A wrapper can surface
that scope instead of overpromising a whole-tree cleanup:
#![allow(unused)] fn main() { use processkit::{Command, ParentDeathCleanup}; match Command::kill_on_parent_death_scope() { ParentDeathCleanup::WholeTree => { /* the whole tree dies with the owner */ } ParentDeathCleanup::DirectChildOnly => { /* only the direct child; grandchildren survive */ } ParentDeathCleanup::Unsupported => { /* no abrupt-death cleanup on this platform */ } _ => {} } }
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, umask, and per-process rlimits
Spawn-time knobs reuse the same seams as the builders above — Unix pre_exec
and Windows' suspended-child configuration — for background/batch children
that shouldn't starve the foreground, and for controlling the permissions of
files a child creates:
use processkit::{Command, IoPriority, Priority, RlimitResource}; #[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?; // Linux + Windows: keep a noisy worker on logical CPUs 2 and 3. Command::new("compiler") .cpu_affinity([2, 3]) .run().await?; // Linux only: yield disk time to foreground users. Command::new("indexer") .io_priority(IoPriority::BestEffort(7)) .run().await?; // Unix only: files this child creates get 0644/0755 instead of 0666/0777. Command::new("worker").umask(0o022).run().await?; // Unix only: disable core dumps and cap this child's open descriptors. Command::new("secret-worker") .rlimit(RlimitResource::Core, 0, 0) .rlimit(RlimitResource::NoFile, 256, 256) .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 ErrorReason::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 (ErrorReason::Spawn), never silently
downgrading to a lower priority.
cpu_affinity accepts logical CPU indices. Linux applies a cpu_set_t with
sched_setaffinity before exec; Windows calls SetProcessAffinityMask after
race-free Job assignment while the child is still suspended (the ConPTY launch
does the same), then resumes it. Descendants inherit the mask, though a child
with sufficient rights may later change its own. Empty or unrepresentable sets
fail before user code runs; an OS-rejected processor fails the spawn. macOS/BSD
return ErrorReason::Unsupported. The Windows API is one processor-group mask,
so indices are limited to the native mask width. spawn_detached refuses the
knob; on Windows to_tokio_command does too because a raw command cannot carry
the required post-spawn configuration seam.
io_priority is Linux-only: it calls ioprio_set(2) in pre_exec before the
program starts. BestEffort(7) is the lowest normal Linux I/O priority; smaller
data values are more urgent, while Idle runs only when the device is otherwise
idle. RealTime can starve other users and normally needs CAP_SYS_ADMIN; a
rejected request fails as ErrorReason::Spawn. On Windows, macOS/BSD, and other
Unix targets, requesting I/O priority fails with ErrorReason::Unsupported rather
than silently inheriting the caller's I/O priority. It is also refused by
spawn_detached, whose owner-independent launch contract cannot honor it.
umask is Unix-only — like setsid/groups, requesting it on Windows fails
with ErrorReason::Unsupported rather than being silently ignored.
Interactive auth / TTY. By default processkit wires pipes, not a
pseudo-terminal, so a tool that demands a tty — an ssh/sudo password
prompt, some credential helpers, an isatty()-gated agentic CLI — won't get one.
Two ways to satisfy them:
-
Non-interactive (no PTY needed). Prefer this when possible: 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 work today viakeep_stdin_open+stdout_lines. -
PTY mode (
use_pty, theptyfeature). For a tool that truly requires a controlling terminal,Command::use_pty()launches it under a real pseudo-terminal —openptyon Unix,CreatePseudoConsole(ConPTY) on Windows — soisatty()reports a terminal. This is a minimal single-master-fd mode, not a terminal emulator, with four things to know:- stdout and stderr are merged onto the one master, so in this mode the
on_stderr_line/stderr_teesplit collapses andProcessResult::stderris empty — the whole output arrives through piped logical stdout. Both destinations must remainStdioMode::Piped: stdout or stderrInherit/Nulland*_file*redirects are rejected before a file is opened or a child is spawned, because the merged terminal cannot honor a separate per-descriptor destination. - Interactive input runs over the same master
(
keep_stdin_open+take_stdin); on Unix terminal echo is disabled so a written password is not echoed back into the merged output (the ConPTY has no portable per-write echo control, so that is Unix-only). - The child receives
COLUMNS/LINESmatching the initial PTY size (80×24, orpty_size(cols, rows)). A zero axis is rejected before spawn on every platform; Windows also rejects either axis abovei16::MAXrather than clamping ConPTY's signedCOORD, while Unix accepts the full remainingu16range. Unix also defaultsTERM=xterm-256color; Windows relies on ConPTY's console/VT APIs and does not synthesizeTERM. Explicitenv(...)orenv_remove(...)calls for any of these names win. - Containment is unchanged — the PTY child lives in the same job/cgroup/process group, so whole-tree kill-on-drop, timeouts, and cancellation behave exactly as for a piped run.
Scenario recipes live in the cookbook: run an
isatty()-requiring tool, wait for an unterminated prompt and answer, consume agent-CLI progress, avoid full-duplex deadlock, and test the PTY contract hermetically. The streaming guide owns the detailed PTY I/O mechanics and output-hygiene knobs. For the end-to-endsshcase specifically — including the boundary where kill-on-drop stops at the local client — see Driving ssh.The historical defer/design is recorded in
decisions/permissions-privileges-pty-network.md§4. - stdout and stderr are merged onto the one master, so in this mode the
Consuming verbs
Typed JSON and NDJSON
With the additive json feature, output_json::<T>() runs to an accepted exit
and deserializes the complete stdout. It is available on Command,
ProcessRunnerExt, and CliClient, so a typed wrapper keeps the same verb when
its runner changes from a real process to ScriptedRunner:
#![allow(unused)] fn main() { use processkit::Command; use serde::Deserialize; #[derive(Deserialize)] struct Release { tag_name: String, } async fn example() -> processkit::Result<()> { let release: Release = Command::new("gh") .args(["release", "view", "--json", "tagName"]) .output_json() .await?; println!("{}", release.tag_name); Ok(()) } }
For NDJSON, start the process and take a typed line stream. Each item is its own
Result<T>: a malformed line is reported with its one-based line/column and
zero-based byte offset, then the stream continues. Empty lines are errors rather than
being silently skipped.
#![allow(unused)] fn main() { use processkit::prelude::StreamExt; use processkit::Command; use serde::Deserialize; #[derive(Deserialize)] struct Message { reason: String, } async fn example() -> processkit::Result<()> { let mut process = Command::new("cargo") .args(["check", "--message-format=json"]) .start() .await?; let mut messages = process.stdout_json_lines::<Message>()?; while let Some(message) = messages.next().await { println!("{}", message?.reason); } let finished = process.finish().await?; assert_eq!(finished.outcome.code(), Some(0)); Ok(()) } }
Both verbs reject incomplete data instead of pretending it is valid:
output_json fails before parsing when a bounded capture was truncated, while
the NDJSON stream inherits stdout_lines' fail-loud overflow and timeout
contracts. JSON parse errors use ErrorReason::Parse; their child-controlled
fragment is capped to 160 input bytes and control-escaped even in the public
message field. NDJSON offsets refer to ProcessKit's decoded, \n-normalized
stdout.
| 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 | ErrorReason::Exit | ErrorReason::Timeout | "Give me the answer or fail" |
exit_code() | i32 | the code, Ok | ErrorReason::Timeout | The code is the answer |
probe() | bool | 0→true, 1→false, else ErrorReason::Exit | ErrorReason::Timeout | Predicate commands: git diff --quiet, grep -q |
output_json::<T>() | T | ErrorReason::Exit | ErrorReason::Timeout | Deserialize one complete JSON document (json feature) |
first_line(pred) | Option<String> | — (stream-based) | ErrorReason::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 ErrorReason::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(); // an absolute or inactivity timeout expired result.inactivity_timed_out(); // specifically: stdout/stderr went quiet result.outcome(); // the explicit disposition 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!) result.configured_timeout(); // Option<Duration> — the timeout this run was launched with result.ok_codes(); // &[i32] — the accepted exit codes ({0} by default) // Opt into erroring whenever you're ready: let ok = result.ensure_success()?; // Exit / Timeout / Signalled (signal-kill) as typed errors Ok(()) }
When the exact disposition 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"), Outcome::InactivityTimedOut => println!("stopped producing output"), _ => {} // 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() / inactivity_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 |
|---|---|
ErrorReason::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() |
ErrorReason::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 |
ErrorReason::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]) |
ErrorReason::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 |
ErrorReason::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes } | A fail_loud buffer's line or byte ceiling was exceeded |
ErrorReason::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 |
ErrorReason::NotReady { program, timeout } | A readiness probe gave up |
ErrorReason::Parse { program, message } | A try_parse parser (on Command, ProcessRunnerExt, CliClient, or Pipeline) or a typed JSON/NDJSON verb rejected the output. Generic callers own the full message; JSON helpers cap and control-escape their child-controlled detail/fragment before storing it, while Display/Debug additionally use a 200-byte preview. |
ErrorReason::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 |
ErrorReason::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 |
ErrorReason::Unsupported { operation } | The platform can't do what was asked (and silently skipping would be wrong) |
ErrorReason::Cancelled { program } | the run's token was cancelled |
ErrorReason::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]) |
ErrorReason::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.
Escape hatch: a platform knob the crate doesn't model
Command exposes typed builders for the OS knobs that carry their weight —
priority, cpu_affinity, io_priority, umask, create_no_window, windows_graceful_ctrl_break,
run_as (uid/gid), parent_death, and so on. New real needs are added the
same way: as a typed verb, so the command stays inspectable (its Debug, its
Clone, and — with the record feature — the cassette it serialises to all
stay truthful). There is deliberately no before_spawn-style hook that
stores an opaque closure to mutate the raw command per launch: a stored mutator
is invisible to Debug/Clone and would make a recorded cassette lie — it
would replay the command as written while a different, mutated command actually
ran. (The full reasoning is in decisions/before-spawn-hook-2026-07.md.)
When you genuinely hit a platform knob the crate has no verb for — a niche
creation flag, your own pre_exec — the honest escape is to lower the builder
to a raw tokio::process::Command and spawn it into a
ProcessGroup:
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Build the OS command exactly as ProcessKit would: program resolution, // environment, working directory, scheduling/umask knobs, capture-wired stdio. let raw = Command::new("odd-tool") .args(["--serve"]) .to_tokio_command()?; // `raw` is a plain `tokio::process::Command`. Set the platform knob the // builder has no typed verb for here — e.g. a raw creation flag on Windows // or your own `pre_exec` on Unix. // Spawn INTO a group so containment still holds (see below). let group = ProcessGroup::new()?; let mut child = group.spawn(raw)?; let status = child.wait().await?; // the bare tokio Child is yours to drive let _ = status; Ok(()) }
What survives, and what you give up. to_tokio_command() carries over
everything the builder resolves at the OS level (program/args/cwd, the layered
environment, the Unix priority/umask/privilege-drop/setsid pre_exec
hooks (including Linux-only io_priority and cpu_affinity), Windows creation
flags, and stdio wired for capture). Windows cpu_affinity is the deliberate
exception: it needs a live, still-suspended process handle, so lowering such a
command fails with Unsupported instead of dropping the request. Spawning the result
through ProcessGroup::spawn still enrolls the child in the
group's Job/cgroup/process-group, so containment is preserved — kill-on-drop
and the group-level teardown verbs still reach it. What you leave behind is the
high-level machinery that lives above the OS command: the async output pump and
capture, the ProcessResult/RunningProcess verbs, and the per-run
timeout/cancel_on/timeout_grace/windows_graceful_ctrl_break wiring. You
own the bare tokio::process::Child — draining its pipes and reaping it are
your job. (On Windows, spawn re-sets creation flags for a race-free
job assignment, so a creation flag left on the raw command is overwritten there;
prefer the typed create_no_window on a high-level launch path — see
Process groups for the full raw-spawn contract.)
Next: Streaming & interactive I/O · Timeouts, retries & cancellation · Process groups
Building typed CLI clients
CliClient is the reusable middle layer between a raw Command
and a domain-specific wrapper such as Git, Jj, or Gh. It owns the program,
an injectable ProcessRunner, and defaults shared by every invocation. Your
wrapper owns the public vocabulary and parsing rules.
Use CliClient when several operations target the same executable, share
timeout/environment/retry policy, or need hermetic tests. For one or two direct
calls, Command is simpler and loses no capability.
Scaffold a wrapper
cli_client! creates a generic wrapper whose production runner is JobRunner
and whose tests can inject any ProcessRunner. The generated core field is
module-private: implement the tool-specific methods beside the macro invocation.
#![allow(unused)] fn main() { use processkit::{cli_client, Error, ProcessRunner, Result}; use std::path::Path; cli_client!( /// A small typed wrapper around Git. pub struct Git => "git" ); impl<R: ProcessRunner> Git<R> { pub async fn head(&self, repo: &Path) -> Result<String> { self.core .run(self.core.command_in(repo, ["rev-parse", "HEAD"])) .await } pub async fn is_clean(&self, repo: &Path) -> Result<bool> { self.core .probe(self.core.command_in(repo, ["diff", "--quiet"])) .await } pub async fn branches(&self, repo: &Path) -> Result<Vec<String>> { self.core .try_parse( self.core .command_in(repo, ["branch", "--format=%(refname:short)"]), |stdout| { let branches: Vec<String> = stdout.lines().map(str::to_owned).collect(); if branches.is_empty() { Err(Error::parse("git", "no branches returned")) } else { Ok(branches) } }, ) .await } pub async fn version_json(&self) -> Result<serde_json::Value> { self.core.output_json(["version", "--json"]).await } } }
The macro supplies Git::new(), Default, Git::with_runner(runner), and
builders for the client defaults. A hand-written struct containing
CliClient<R> is equally supported when you need a different layout or want to
expose the core deliberately.
Build commands and choose verbs
command(args) builds program <args>; command_in(dir, args) adds a working
directory. Most verbs also accept an argument list directly, so build a
Command only for a per-call override:
#![allow(unused)] fn main() { use processkit::CliClient; use std::time::Duration; async fn example() -> processkit::Result<()> { let git = CliClient::new("git").default_timeout(Duration::from_secs(30)); let version = git.run(["--version"]).await?; let status = git .output_string(git.command(["status", "--short"]).timeout(Duration::from_secs(5))) .await?; assert!(status.is_success()); let _ = version; Ok(()) } }
The verbs are the same vocabulary used by Command and ProcessRunnerExt:
| Need | Verb | Result contract |
|---|---|---|
| trimmed stdout, accepted exit required | run | String |
| accepted exit, no value | run_unit | () |
| full accepted result | checked | ProcessResult<String> |
| inspect any exit | output_string / output_bytes | full result; non-zero is data |
| exit code is the answer | exit_code / probe | i32 or 0 → true, 1 → false |
| typed parsing | parse / try_parse | infallible or fallible parser |
typed JSON (json) | output_json | deserialized T with bounded diagnostics |
parse and try_parse require an accepted exit and reject truncated capture
before invoking the parser. try_parse should return Error::parse (or another
typed processkit error) when output is malformed. With the json feature,
output_json supplies that policy for complete JSON documents; its parse
failure is ErrorReason::Parse with a bounded fragment and location.
Client defaults and precedence
Defaults are gap-filled into every command. An explicit per-command value wins.
#![allow(unused)] fn main() { use processkit::{CliClient, RetryPolicy}; use std::time::Duration; let gh = CliClient::new("gh") .default_timeout(Duration::from_secs(30)) .default_env("GH_PAGER", "cat") .default_env_remove("GH_PROMPT_DISABLED") .default_env_fn("REQUEST_ID", || "request-42") .default_retry( RetryPolicy::new().max_retries(2), |error| error.is_transient(), ); // The explicit 5 s timeout wins; the env and retry defaults are still filled in. let command = gh.command(["api", "user"]).timeout(Duration::from_secs(5)); let _ = command; }
default_timeout,default_cancel_on, anddefault_retryapply wherever the command did not set its own value.- Static
default_env/default_env_removevalues override a dynamic resolver for the same key. Per-commandenv/env_removeoverride both. default_env_fnruns synchronously once when a command is built. A retry or second run of that already-built command reuses the baked value. Use it for cheap per-operation values, not a token that must change for every retry.- Retry only operations safe to replay, and classify typed errors instead of retrying every failure. Non-erroring capture verbs do not retry.
Defaults also apply when a verb receives a ready-made Command, but that
command keeps its own program. Prefer argument lists or client.command(...)
unless deliberately grafting one client's defaults onto another executable.
Error boundaries
The wrapper should preserve processkit's typed taxonomy:
- launch and resolution failures remain
NotFound,Spawn,Unsupported, orIo; - a checking verb maps an unaccepted exit to
Exitand a run deadline toTimeout; - parser failures belong in
Parse, never a fabricated exit code; checkedis intentionally lenient about truncated capture, whilerun,parse,try_parse, andoutput_jsonfail loud withOutputTooLarge.
This keeps callers able to branch on error.reason(), is_not_found(),
is_timeout(), or is_transient() without parsing strings. See
Errors for the complete taxonomy and Timeouts, retries &
cancellation for replay safety.
Test the wrapper without subprocesses
ScriptedRunner matches program plus argv and returns deterministic replies.
The wrapper code and parsers are the production code; only process execution is
replaced.
#![allow(unused)] fn main() { use processkit::testing::{Reply, ScriptedRunner}; 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> { self.core.run(["branch", "--show-current"]).await } } #[tokio::test] async fn current_branch_is_trimmed() { let git = Git::with_runner( ScriptedRunner::new() .on(["git", "branch", "--show-current"], Reply::ok("main\n")), ); assert_eq!(git.current_branch().await.unwrap(), "main"); } }
For a response too awkward to maintain by hand, record it once and replay the
cassette in tests. RecordReplayRunner still implements ProcessRunner, so the
wrapper needs no alternate code path:
#![allow(unused)] fn main() { use processkit::testing::RecordReplayRunner; use processkit::CliClient; async fn replay() -> processkit::Result<String> { let runner = RecordReplayRunner::replay("fixtures/git.json")?; let git = CliClient::with_runner("git", runner); git.run(["rev-parse", "HEAD"]).await } }
Use replay in normal tests; keep recording as an explicit fixture-refresh step. The full cassette security and matching contract is in Testing your code.
Worked wrappers
- The .NET version shows a larger typed surface and error translation around a real CLI.
- The Python wrapper shows how the same runner/client seam maps across a language boundary.
- Cookbook: wrap a CLI tool is the short recipe; this chapter is the design and testing guide behind it.
Running many at once
The batch helpers cover two distinct shapes of concurrent process work:
output_all/output_all_bytesstart a collection ofCommands while retaining a fixed number of live runs, then hand back every result in input order once the whole batch finishes.output_stream/output_stream_bytesrun the same bounded fan-out but yield each result the moment it finishes.wait_any/wait_allobserve a fixed collection ofRunningProcesshandles that you started earlier.
They provide bounded fan-out and joins, not a scheduler or worker-pool API.
Timeouts, retries, streaming, cancellation, and containment remain the normal
Command and ProcessGroup primitives.
- Bounded fan-out
- Results as they finish
- Containment scope
- Racing and joining handles
- Timeouts and output
- Common batch shapes
Bounded fan-out
output_all(commands, concurrency, runner) consumes any iterator of commands
and returns one result per input command, in input order. At most concurrency
runs are live at once; 0 is treated as 1, and an empty input returns an
empty Vec.
It is collect-all: a launch or I/O failure fills that command's Err slot,
and the remaining commands still run. A non-zero exit is not an Err; it is an
Ok(ProcessResult) whose code() or is_success() you inspect. Fold completed
results after the batch rather than expecting the first failed command to stop
it:
use processkit::{Command, JobRunner, output_all}; #[tokio::main] async fn main() -> processkit::Result<()> { let conversions = (0..200) .map(|n| Command::new("convert").args([format!("input-{n}.png"), format!("output-{n}.webp")])); let results = output_all(conversions, 8, &JobRunner).await; let failed = results .iter() .filter(|result| !matches!(result, Ok(output) if output.is_success())) .count(); println!("{failed} conversions failed"); Ok(()) }
output_all_bytes has identical scheduling and error semantics, but each
ProcessResult contains Vec<u8> stdout. Use it when stdout is an artifact,
such as an archive, image, or git cat-file object:
use processkit::{Command, JobRunner, output_all_bytes}; #[tokio::main] async fn main() -> processkit::Result<()> { let commands = [ Command::new("git").args(["cat-file", "blob", "HEAD:logo.png"]), Command::new("git").args(["cat-file", "blob", "HEAD:icon.png"]), ]; for blob in output_all_bytes(commands, 2, &JobRunner).await { println!("captured {} bytes", blob?.stdout().len()); } Ok(()) }
Like one-shot capture verbs, output_all* waits for and captures every
command's output. The vector appears only after the entire batch finishes;
dropping its future returns no partial vector. See Timeouts and
cancellation when cancellation is part of the
design.
Results as they finish
output_stream(commands, concurrency, runner) is the streaming sibling of
output_all: the same bounded fan-out — the same concurrency cap, the same
per-command error semantics, the same containment — but instead of one Vec at
the very end it returns a Stream that yields each result the instant that
command finishes. Each item is an (input index, result) pair, so a result is
still traceable to the command that produced it even though items arrive in
completion order rather than input order. Drive it with StreamExt::next (the
crate re-exports StreamExt from processkit::prelude):
use processkit::{Command, JobRunner, output_stream}; use processkit::prelude::StreamExt; #[tokio::main] async fn main() -> processkit::Result<()> { let conversions = (0..200) .map(|n| Command::new("convert").args([format!("input-{n}.png"), format!("output-{n}.webp")])); let runner = JobRunner; let mut results = output_stream(conversions, 8, &runner); while let Some((index, result)) = results.next().await { // Handle each conversion as soon as it lands, without waiting for the // slowest one in the batch. match result { Ok(output) if output.is_success() => {} _ => eprintln!("conversion #{index} failed"), } } Ok(()) }
examples/batch_stream.rs
is a self-contained completion-order demo that launches child copies of itself.
Reach for output_stream over output_all when either of these matters:
- First result early. A fast command is handed back immediately instead of waiting behind the slowest in the batch — useful for progress reporting, or to start downstream work on the first artifact.
- Partial results survive cancellation. Every result you have already pulled
from the stream is yours; dropping the stream mid-fan-out keeps them. This is
the gap
output_allcannot cover — itsVecmaterializes only at the end, so dropping its future discards even the commands that had already completed.
Cancellation and containment work exactly as for output_all. Dropping the
stream drops the in-flight command futures: with &JobRunner (an own group per
run) that kills every still-live process tree with no orphans; with a shared
&group those children live until you tear the group down. Commands still
waiting for a concurrency slot are dropped without ever being spawned — a
queued command runs nothing until it is scheduled, so cancelling the fan-out
cancels them for free.
output_stream_bytes is the raw-bytes twin (each ProcessResult carries
Vec<u8> stdout), with identical scheduling, ordering, and cancellation — the
streaming counterpart of output_all_bytes. In fact output_all is exactly
output_stream collected back into input order: both are the same fan-out
engine, so their concurrency and no-short-circuit guarantees cannot drift apart.
Containment scope
The runner argument controls containment, not only testability.
Pass as runner | Use it when | What dropping a live run affects |
|---|---|---|
&JobRunner | Each command should be independent. | Its own fresh private ProcessGroup; batch siblings are unaffected. |
&group where group: ProcessGroup | The batch is one unit of fate. | The shared group controls every member, so dropping or shutting it down tears down the whole batch. |
Use &JobRunner for independent conversions. Use a shared group when every
process must disappear together:
use processkit::{Command, ProcessGroup, output_all}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let checks = [ Command::new("check-shard").arg("a"), Command::new("check-shard").arg("b"), ]; let results = output_all(checks, 2, &group).await; assert_eq!(results.len(), 2); group.shutdown().await?; Ok(()) }
Do not choose a shared group merely because the commands began together. It
couples their fate: cancelling output_all leaves shared-group children under
the group's lifetime, while each JobRunner run owns a private group. See
Process groups for the teardown model.
Racing and joining handles
Use wait_any and wait_all when you need live handles — for readiness,
incremental stdin, or a decision before workers finish — rather than only final
captured output. Both functions borrow their handles, so those handles remain
usable afterwards.
wait_any returns the index and outcome of the first child to exit. A mirror
race can then stop its loser by shutting down the shared group:
use processkit::{Command, ProcessGroup, wait_any}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let mut primary = group.start(&Command::new("fetch-from").arg("primary")).await?; let mut mirror = group.start(&Command::new("fetch-from").arg("mirror")).await?; let (winner, outcome) = wait_any(&mut [&mut primary, &mut mirror]).await?; println!("mirror #{winner} finished with {outcome:?}"); group.shutdown().await?; Ok(()) }
wait_all joins a fixed worker set and returns every outcome in slice order:
use processkit::{Command, ProcessGroup, wait_all}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let mut workers = Vec::new(); for shard in ["a", "b", "c", "d"] { workers.push(group.start(&Command::new("quiet-worker").arg(shard)).await?); } let mut borrowed: Vec<_> = workers.iter_mut().collect(); let outcomes = wait_all(&mut borrowed).await?; println!("joined {} workers: {outcomes:?}", outcomes.len()); Ok(()) }
wait_any rejects an empty slice because no child can win; wait_all accepts
one and returns an empty vector. A non-zero exit or signal is an Outcome, not
an error. wait_all can return the first cancellation, stdin, or reaping error;
its remaining handles are still waitable.
Timeouts and output
wait_any and wait_all intentionally add neither of these features:
- Per-process timeout. Put
Command::timeouton each command beforestart, or wrap the complete wait intokio::time::timeoutwhen one deadline belongs to the collection. - Output pumping. A chatty child can fill stdout or stderr and block before
exit. Drain it with
stdout_lines/stderr_lines, a line handler, or a tee; use capture-orientedoutput_all*when incremental output is unnecessary.
An individual deadline belongs to its command:
#![allow(unused)] fn main() { use processkit::Command; use std::time::Duration; let worker = Command::new("worker").timeout(Duration::from_secs(30)); }
One collection deadline is explicit instead:
#![allow(unused)] fn main() { use processkit::{Command, ProcessGroup, wait_all}; use std::time::Duration; async fn example() -> processkit::Result<()> { let group = ProcessGroup::new()?; let mut worker = group.start(&Command::new("quiet-worker")).await?; let outcome = tokio::time::timeout( Duration::from_secs(60), wait_all(&mut [&mut worker]), ).await; let _ = outcome; Ok(()) } }
The outer timeout only stops waiting. Choose containment deliberately, then explicitly shut down or retain children according to your policy. For streaming patterns, see Streaming & interactive I/O.
Common batch shapes
- Bounded file conversion: generate one
Commandper file and pass the iterator tooutput_allwith a cap chosen for CPU, memory, file descriptors, and the external tool's limits. Fold all results to report the full failure set. - Mirror race: start one
RunningProcessper endpoint in a sharedProcessGroup, callwait_any, preserve the winner as needed, then shut down the group to stop losers. - Fixed worker join: start known quiet or independently-drained workers,
retain their handles in a
Vec, and pass mutable references towait_allfor ordered outcomes.
For single-command recipes, return to the Cookbook.
Comparative benchmarks
processkit adds process-tree containment, decoded line streaming, bounded
capture, and a consistent async API around child processes. Those guarantees
have a cost, so this repository keeps an end-to-end comparison against the
plain Tokio and standard-library APIs. The benchmark is intended to answer
"what does the convenience and safety layer cost for this workload?", not to
declare one API universally faster.
On Windows there is a second question the end-to-end comparison cannot answer:
creating a process costs tens of milliseconds all by itself on a machine with
real-time antivirus, so an absolute "start took N ms" number attributes nothing.
benches/win_spawn_phases.rs answers it by splitting the fixed start cost into
phases; see Where a Windows start goes.
What is measured
benches/compare.rs runs the same real passthrough child (cat on Unix,
cmd /c findstr on Windows) for every contender. Payload construction is
outside the timed sections where possible. Each sample includes child spawn,
pipe setup, stdin/stdout transfer, output handling, and process wait.
The children are intentionally trivial: they only echo stdin to stdout, so the
measurement is about process handling rather than application work.
| Scenario | Children | Input / output | ProcessKit path | Plain baselines |
|---|---|---|---|---|
| Small capture | 1 | 32 bytes, 3 lines | Command::output_string, plus processkit_resolved_program (absolute program path) and stdout(StdioMode::Null) + start().wait() to isolate program lookup and capture cost | tokio::process::Command and std::process::Command |
| Large streaming | 1 | 1,032,000 bytes, 8,000 lines of 128 bytes | start + stdout_lines | async Tokio line reader and synchronous BufRead::lines |
| Concurrent fan-out | 16 | 32 bytes per child | 16 concurrent output_string runs | 16 Tokio tasks or 16 standard threads |
The recorded run used an Intel Core i9-12900H CPU with 20 logical processors,
Windows 11 Enterprise (build 10.0.26200) with Defender real-time protection
enabled, and Rust 1.93.0. The machine had 514 live processes and 7,761 live
threads while the numbers were taken — a figure that matters for the start-cost
attribution below, and that a quiet CI runner will not reproduce. Criterion was
configured in benches/compare.rs::configure with sample_size=20,
warm_up_time=10s, and measurement_time=5s for each series. The table reports
the Criterion mean and standard deviation; throughput is derived from the fixed
workload size (it is not a separately configured Criterion throughput
measurement). Deltas use the std_process result in the same scenario as the
baseline. Absolute values are machine-dependent: CPU, OS process-spawn cost,
antivirus, scheduler load, filesystem state, Rust toolchain, and available Tokio
runtime threads can all change them. Compare contenders only within the same
invocation and retain the command and environment with any published result.
One Windows-specific measurement hazard is handled inside the benchmark rather
than left to the reader. A host with behavioural monitoring throttles a process
that abruptly starts creating children and only settles tens of seconds later,
which is longer than Criterion's per-series warm-up; without intervention that
whole penalty lands on whichever series runs first, and it was observed making
the first contender read two to four times its value from a later run while
every other series stayed stable. benches/compare.rs::prime_process_creation
therefore spawns real children, recording nothing, until batch times stop
falling, and only then lets the first series start. On a host that does not
throttle it costs a few seconds.
Measured results
These measurements were produced by cargo bench --bench compare on the host
described above. Times are end-to-end and shown as mean +/- standard deviation.
The throughput units are runs/s for small capture, MiB/s for the 1,032,000-byte
stream, and child processes/s for the 16-child fan-out.
| Scenario | Contender | Time | Derived throughput | Delta vs std_process |
|---|---|---|---|---|
| Small capture | processkit | 57.161 +/- 5.283 ms | 17.49 runs/s | +3.7% |
| Small capture | processkit_resolved_program | 54.513 +/- 7.712 ms | 18.34 runs/s | -1.1% |
| Small capture | processkit_discard_stdout | 58.810 +/- 6.221 ms | 17.00 runs/s | +6.7% |
| Small capture | tokio_process | 52.413 +/- 6.716 ms | 19.08 runs/s | -4.9% |
| Small capture | std_process | 55.108 +/- 8.267 ms | 18.15 runs/s | baseline |
| Large streaming | processkit | 66.876 +/- 5.329 ms | 14.72 MiB/s | +16.9% |
| Large streaming | tokio_process | 57.782 +/- 4.756 ms | 17.03 MiB/s | +1.0% |
| Large streaming | std_process | 57.226 +/- 8.170 ms | 17.20 MiB/s | baseline |
| Concurrent fan-out | processkit | 209.388 +/- 32.835 ms | 76.41 children/s | +9.2% |
| Concurrent fan-out | tokio_process | 192.012 +/- 24.948 ms | 83.33 children/s | +0.2% |
| Concurrent fan-out | std_process | 191.690 +/- 28.314 ms | 83.47 children/s | baseline |
Where a Windows start goes
The small-capture delta above is a few milliseconds on a host where creating the
child itself costs about 50. Interpreting that requires knowing which part of a
start belongs to ProcessKit and which to the operating system, so
benches/win_spawn_phases.rs measures the phases directly. It uses a fixed
absolute-path child (cmd /c exit) so no series pays for a program lookup, and
it is configured with sample_size=50, warm_up_time=15s, and
measurement_time=20s.
os_spawn_plain is the floor: CreateProcess and wait, nothing else. The
os_spawn_suspended_resume_* series add ProcessKit's CREATE_SUSPENDED and
resume cycle to it, and the containment_sequence_* series add Job Object
creation and assignment on top of that, mirroring sys::windows::Job::{new, spawn} step for step. Within each pair, ..._snapshot and ..._direct differ
only in how the launcher finds the primary thread of the suspended child it must
release: the documented system-wide TH32CS_SNAPTHREAD ToolHelp snapshot, or the
per-process ntdll!NtGetNextThread walk ProcessKit uses (falling back to the
snapshot when that entry point is unavailable).
| Phase series | Time | Delta vs os_spawn_plain |
|---|---|---|
os_spawn_plain | 22.527 +/- 2.002 ms | baseline |
os_spawn_suspended_resume_direct | 22.643 +/- 2.653 ms | +0.5% |
containment_sequence_direct | 22.661 +/- 1.661 ms | +0.6% |
os_spawn_suspended_resume_snapshot | 96.378 +/- 4.053 ms | +327.8% |
containment_sequence_snapshot | 97.695 +/- 4.043 ms | +333.7% |
The primitives behind those phases, measured with no child process involved so that a spawn outlier cannot hide them:
| Primitive | Time |
|---|---|
job_object_create | 4.457 +/- 0.156 us |
direct_thread_walk | 8.634 +/- 0.934 us |
program_lookup_absolute_path | 37.255 +/- 2.355 us |
program_lookup_bare_name | 5.402 +/- 0.428 ms |
thread_snapshot_walk | 73.990 +/- 1.601 ms |
The two tables reconcile. os_spawn_plain (22.527 ms) plus thread_snapshot_walk
(73.990 ms) plus job_object_create (0.004 ms) is 96.521 ms against a measured
containment_sequence_snapshot of 97.695 ms, leaving 1.2 ms for the assignment,
the per-thread open and resume, and the suspended spawn itself. The same sum with
direct_thread_walk instead is 22.540 ms against a measured 22.661 ms.
Read together:
- Creating the Job Object is free. At 4.5 microseconds it is four orders of
magnitude below the
CreateProcessit contains. Whole-tree containment on Windows is not what makes a start expensive, and no amount of deferring or pooling the container could pay for itself. - Finding the suspended child's primary thread was the whole cost. The only
documented pid-to-thread mapping on Windows is a snapshot that is
system-wide — the process-id argument is ignored for thread lists — so it
materialises all 7,761 threads on the machine to locate the one thread that was
just created. At 74 ms that was 3.3x the cost of the
CreateProcessit followed, and it dominated everything else in the start path put together. Asking the same question per-process instead answers it in 8.6 microseconds, about 8,600 times faster, and collapses the whole containment sequence to within noise of a plain unguarded spawn (+0.6%). - Program lookup is the remaining ProcessKit-specific cost, and it is under
the caller's control. Resolving a bare name across
PATHx PATHEXT — which ProcessKit does so that a launch spawns exactly what the spawn-freeCommand::resolve_programpreflight reports, including a.cmd/.batthe OS's own.exe-only search would never find — cost 5.4 ms against 37 microseconds for a program named by an absolute path, on aPATHwith 77 entries and a PATHEXT with 14 extensions. That is theprocesskitversusprocesskit_resolved_programgap in the table above. A caller starting the same program repeatedly can resolve it once withCommand::resolve_programand pass the resulting path. - Serialising the spawn call itself is not the bottleneck. ProcessKit holds one process-global lock across every child creation, so an ordinary spawn cannot observe the ConPTY path's temporary process-global standard handles. Running the same 16 spawns with and without that lock measured 131.893 +/- 24.799 ms parallel against 150.864 +/- 62.608 ms serialised. That +14.4% sits inside the serialised series' own standard deviation, and a repeat run put the same pair at -1.2%; the two runs disagree in sign, so no reliable penalty is resolved at this noise level. Windows serialises process creation internally in any case. What it does rule out is the lock as an explanation for a slow fan-out — that was the same per-spawn thread snapshot, paid once per child.
Running the comparison
Run the complete local suite with:
just bench-compare
The equivalent Cargo command is:
cargo bench --bench compare
The Windows start-cost attribution is a separate bench:
just bench-win-phases
cargo bench --bench win_spawn_phases
Criterion's normal filtering and measurement options remain available, for
example cargo bench --bench compare -- stream_large_stdout. Both benchmarks
spawn real children and are deliberately not part of the ordinary CI test
gate; use them when comparing a change, a platform, or a toolchain.
win_spawn_phases is Windows-specific and prints an explanatory message
elsewhere.
Reading the result
The comparison is meaningful only when the workloads and semantics match:
processkitmeasures its normal private process-group setup. This is the price of its unconditional kill-on-drop tree guarantee, and is not present in the plain baselines. On Windows the phase table above shows what that actually costs.- The small-capture group includes a
processkit_resolved_programseries using an absolute program path and aprocesskit_discard_stdoutseries usingstdout(StdioMode::Null)+start().wait(). Compare each withprocesskitin the same group to see the crate's program-lookup and capture/pump costs separately;StdioMode::Nullis a discard path, so it is not an output-equivalent replacement for capture. - The capture case compares a decoded
ProcessResult<String>with byte output from the plain APIs. The benchmark uses ASCII payloads so decoding does not change the transferred content; the APIs still have deliberately different result and failure semantics. - The streaming case consumes every line before waiting for the child. This prevents a full stdout pipe from turning the benchmark into a deadlock and measures the live line-delivery path rather than only a bulk read.
- The fan-out case gives each API its normal concurrency primitive: Tokio tasks
for async contenders and scoped standard threads for
std. It measures the whole batch, not a synthetic per-child loop.
Conclusions
Using a 10% delta as a practical negligible-overhead threshold, the small
capture (+3.7%) and concurrent fan-out (+9.2%) were negligible relative to the
direct std_process API in this run, and large streaming (+16.9%) was not. The
plain Tokio contender stayed within 5% of the standard-library baseline in all
three scenarios. Small capture with an absolute program path (-1.1%) was
indistinguishable from the baseline, which places the crate's remaining
short-run overhead in program resolution rather than in containment.
For a short-lived Windows child, expect ProcessKit's own fixed start cost to be
a few milliseconds against a CreateProcess that costs tens of them on an
antivirus-equipped host — dominated by the PATH/PATHEXT lookup when the
program is named by a bare name, and essentially nothing when it is not. If you
are diagnosing a slow start, measure the difference against a plain spawn of the
same command on the same machine, as spawn_capture_small does, before
attributing it to this crate: the absolute number is mostly the operating
system's.
The remaining streaming cost is the trade-off for ProcessKit's decoded,
bounded-capture line delivery. Choose ProcessKit when containment, streaming,
bounded capture, and typed outcomes matter; choose a plain process API when
minimum overhead on a bulk transfer is the priority and those guarantees are not
required. These are single-machine measurements, so they describe this workload
and environment, not a universal performance ranking. For this Windows run,
std_process is the direct CreateProcess-based reference; Unix users should
rerun the benchmark before treating it as a comparison with their platform's
fork/exec path.
If a change improves one scenario and regresses another, report the scenario and its Criterion output rather than collapsing the results into one score. Containment, streaming, and typed outcome handling are features; a faster plain spawn is not automatically a better substitute for them.
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
- Asking whether a soft stop is available
- 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
Four 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, while you // still hold its Child handle (and stay responsible for reaping it). let external = tokio::process::Command::new("legacy-launcher").spawn()?; group.adopt(&external)?; // 4. adopt_external(): contain a process you have only a pid for — one an // outside supervisor started, or one you forked but never handed over. // Nothing here will ever reap it; the group can only signal it. let pid_from_elsewhere: u32 = 4321; let _ = group.adopt_external(pid_from_elsewhere); let _ = (server, child); Ok(()) }
Both adoption doors move 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.
Adopting by pid (adopt_external)
adopt needs a live tokio::process::Child, which a non-Rust consumer cannot
construct at all. adopt_external(pid) takes the one identifier such a caller
does have — and treats it as an address, not as a handle, because the OS may
give a reaped process's number to an unrelated one:
- the crate captures an identity anchor of its own for the process the number
currently names, during the call: the process object behind an
OpenProcesson Windows, kernel cgroup membership (plus a/procstart-time read on either side of the write) on Linux cgroup v2, the start-time token on the POSIX process-group backends. From then on the group's probes, signals and teardown are bound to that, so a process that recycles the number afterwards is not a member and is not signalled; - a number recycled during the call itself is detected by that closing read and
reported as an error — but the two unix mechanisms are left in different states,
which the error message spells out. The process-group backends have nothing to
undo (the entry they made is identity-gated and is pruned unsignalled). Linux
cgroup v2 has already migrated a task, so the call tries to move the number back
out into the cgroup this group's own directory lives in; where a host refuses that
move, the process holding the number stays a member of this group and this group's
teardown will kill it. Windows cannot reach this state — it uses the number once,
for
OpenProcess; - adoption is not neutral for containment the process already has, and the direction differs per mechanism: on Windows the process keeps its existing job and this group's job becomes a child of it (so the outer job's terminate/close then reaches this group's members, including ones started later, and its limits bind them — and whether the assign succeeds at all depends on this group's own state, so adopt before you start); on Linux cgroup v2 the process loses its previous cgroup, since v2 membership is exclusive and nothing can restore it; on the process-group backends nothing is taken away. See platform support for the full note;
- what no crate can check is the window before the call — whether the pid still
named the process you meant when you looked it up. Look it up as late as you
can, and use
process_is_alive(pid, start_time)to re-check an instance later; - nothing here ever reaps it. No exit status for an adopted-by-pid process
appears anywhere in this API; the group can signal it and list it, and that is
all. Reaping stays with whoever is its actual parent — this process, an outside
supervisor, or
init; - FreeBSD and the other BSDs return
Unsupported. No start-time reader is wired up there, so there is no anchor to capture, and the crate refuses rather than tracking a bare number it would laterSIGKILL.adoptis unaffected — theChildyou hold un-reaped is what keeps its number from being recycled.
pid == 0 and this process's own pid are refused everywhere: both would point
the group's own teardown at the caller.
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; on success the group stays usable (cgroup-kill / Job Object / process-group backends). Where the per-pid SIGKILL fallback runs instead — a pre-5.14 Linux kernel lacking cgroup.kill, or a refused cgroup.kill write — it returns Err if the tree doesn't drain (a fork bomb still out-spawning, or D-state zombies), and also when the tree did drain but the freeze guarding the sweep could not be cleared and the cgroup reads frozen: the tree is dead, the group is left unusable for further spawns (see Upgrading) | 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) — unless a child opted into windows_graceful_ctrl_break (see below), which gives Windows a real CTRL_BREAK → wait → kill tier. Consumes the group (shutdown_ref(&self) is the same teardown, borrowing — for a group held behind an Arc/supervisor) | Graceful service stop |
group.stop(grace, escalate).await | The observable graceful stop (needs process-control): the same teardown as shutdown_ref, with an explicit grace/escalate, returning a ShutdownReport — the attempted soft signal (and whether it landed), member counts before/after, whether the tree drained within the grace or was hard-killed, and the actual elapsed. Borrows the group (usable afterwards) | You own the end-of-run race and want the observed facts — or a "kill and wait" via stop(Duration::ZERO, true) |
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.
Windows: the graceful soft tier (WM_CLOSE, opt-in CTRL_BREAK)
A Windows shutdown has no POSIX SIGTERM, but it still tries to trigger a
clean exit before the atomic Job Object kill. For a windowed child (Electron
app, desktop tool, windowed service) this is automatic: WM_CLOSE is posted
(never sent, so a hung window can't block us) to every top-level window a live
member owns, then the same signal → wait → escalate ladder runs — the child
gets the shutdown_timeout to flush and exit, else TerminateJobObject. A
console child has no window, so opt in per child with
Command::windows_graceful_ctrl_break():
the direct child is spawned in its own console process group
(CREATE_NEW_PROCESS_GROUP), and shutdown then sends it
GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid), waits the shutdown_timeout,
and TerminateJobObjects any survivor — the very same signal → wait → escalate
ladder as Unix, so a console child that handles CTRL_BREAK shuts down softly.
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)), )?; // CTRL_BREAK is sent on shutdown; a console child gets 5s to exit, else kill. let _service = group .start(&Command::new("my-service").windows_graceful_ctrl_break()) .await?; group.shutdown().await?; Ok(()) }
The CTRL_BREAK opt-in is console-only: a child spawned
create_no_window
or DETACHED_PROCESS does not share this process's console, so it never receives
the event and rides the grace to the TerminateJobObject fallback. Only the
direct child is addressed by CTRL_BREAK — an
adopted
child is not — and the event is CTRL_BREAK, not CTRL_C (a new process group
disables CTRL_C). The automatic WM_CLOSE path is the complement: it reaches
any live member that owns a top-level window (including forked descendants and
adopted children), needs no console and no opt-in, but only a member that actually
has a window. A member with neither a window nor the console opt-in is hard-killed
promptly at the deadline. Off Windows the builder is a no-op.
Observing the teardown: stop and ShutdownReport
Requires the default-on
process-controlfeature (the report carries aSignal).
shutdown/shutdown_ref are fire-and-forget — they report only success or an
error. When you own your own end-of-run race (a timeout ⨯ Ctrl-C ⨯
control-socket race, not a Command::timeout) you usually want to stop the instant
the tree is empty rather than always spend the whole grace, and to report the tier
the kernel observed rather than what you tried. ProcessGroup::stop(grace, escalate) is that verb: the same SIGTERM / CTRL_BREAK / WM_CLOSE → wait →
escalate ladder, taking grace/escalate explicitly and returning a
ShutdownReport.
use processkit::{Command, ProcessGroup}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _service = group.start(&Command::new("my-service")).await?; // SIGTERM, up to 5s to drain, then SIGKILL survivors — and report what happened. let report = group.stop(Duration::from_secs(5), true).await?; if report.drained_within_grace() { println!("clean exit in {:?}", report.elapsed()); } else if report.escalated() { let survivors = report.members_after().unwrap_or(0); eprintln!("hard-killed {survivors} survivor(s) after the grace"); } if let Some(sig) = report.attempted_signal() { println!("attempted soft signal: {sig:?}"); } Ok(()) }
The report is honest per platform. members_before/members_after count the same
member set as members — the whole
tree on the Job Object / cgroup mechanisms, the tracked group leaders on the
process-group fallback (where an unreaped zombie still counts, so reap your handles
for a true members_after). The soft_signal() verdict is three-way —
SoftSignal::Sent(sig), SoftSignal::Failed(sig), or SoftSignal::Unsupported;
the last arises only on a windowless Windows Job Object with no console-CTRL
leader (every Unix mechanism always has a real SIGTERM tier). stop(Duration::ZERO, true) is the "kill and wait" path: it hard-kills at once (a zero grace waits
not at all) and reports what was still live — where bare kill_all returns as soon
as the kill is issued. shutdown/shutdown_ref are unchanged; stop is purely
additive.
ShutdownReport is the teardown facts as a typed value after the teardown
returns. For the same transitions live — stamped the instant each one happens —
enable the tracing feature: the teardown driver narrates soft_signal → grace_started → drained | escalated | spared (each in a stable phase field) on
the processkit target, for every graceful path (stop, shutdown, a run-level
timeout_grace, a supervisor's graceful stop). The two are one seam read two ways
(both derive from the same driver outcome); neither can influence the teardown, and
neither carries argv/env.
Deliberately detaching a child (spawn_detached)
This inverts the crate's headline guarantee — on purpose. Everything above is
about keeping a tree contained so nothing escapes. Command::spawn_detached is
the crate's one deliberate escape hatch for the opposite need: a child that
must outlive its launcher — daemonizing, a nohup-style long-lived helper, a
handoff to a process you want to keep running after this one exits.
use processkit::Command; fn main() -> processkit::Result<()> { // Launch a helper that survives this process. Its stdout goes to a file — never // a pipe, which would deadlock the child once nothing is left to drain it. let child = Command::new("my-daemon") .arg("--serve") .stdout_file("/var/log/my-daemon.log") .spawn_detached()?; println!("detached daemon pid = {}", child.pid()); // Dropping `child` does NOT kill the daemon — the crate is done with it. Ok(()) }
For a safe runnable demonstration whose detached child exits by itself, see
examples/detached.rs.
What it does, and what it deliberately does not:
- Detach at birth. Unix — a new session (
setsid), no controlling terminal. Windows — the child is not assigned to this crate's Job Object. It is not made to break away from a Job Object / cgroup the host already put your process in (a CI runner, asystemdscope, this crate's own supervisor): that would be hostile to whoever set up the host containment. So a detached child escapes this crate's per-run containment, not a broader host one it inherits. - A separate, non-interchangeable type. You get a
DetachedChildcarrying only thepid— no public kill, wait, timeout, capture, or control/teardown APIs — because it is no longer contained. Dropping it does nothing to the child. On Unix, a private background reaper owns the detachedChildand collects its exit status so it does not become a zombie; that bookkeeping is not a public wait or control API. Windows and other non-Unix targets use their normal process-handle cleanup instead. (Left as a bare code span, not adocs.rslink: this type ships in the next release, so adocs.rsURL would 404 until then.) - stdio is null, or a file — never a pipe. With no owner left to drain it, a
pipe would deadlock the child the moment its buffer fills. stdout/stderr are
null by default; the only alternative is a file redirect (
stdout_file/stderr_file). stdin is always null. - Incompatible knobs are refused loudly. A
Commandcarrying a timeout, capture wiring (on_stdout_line/tees/capture_policy), an interactive stdin (keep_stdin_open/inherit_stdin/astdinsource),retry,cancel_on,kill_on_parent_death(its exact opposite),windows_graceful_ctrl_break,cpu_affinity, or Linuxio_priorityis rejected with a typedErrorReason::Unsupportednaming it — never silently ignored. Program/args/env/working-directory and the privilege-drop knobs (uid/gid/groups/umask/priority) are honored.
Reach for spawn_detached only when you truly want a child to outlive its
launcher. For everything else, start/run/output_* keep the child contained.
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), FreeBSD reaper, macOS/other BSD | Any — Term, Kill, Int, Hup, Quit, Usr1, Usr2, Other(n) |
| Windows | Kill (Job Object terminate); Int/Term as a best-effort soft close (CTRL_BREAK to console leaders + WM_CLOSE to windowed members) — ErrorReason::Unsupported only when neither exists; every other signal → ErrorReason::Unsupported |
Signal::Kill always takes the same atomic whole-tree kill path as kill_all
(cgroup.kill / PROC_REAP_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. On Windows,
Signal::Int/Signal::Term do not wait or escalate (they only trigger a soft
close — contrast the graceful shutdown, which then waits the grace and
escalates). An empty group accepts any deliverable signal trivially — except
Windows Int/Term, which report Unsupported on an empty group (no member,
hence no console or windowed target to soft-close). On every Unix mechanism a
real send failure is surfaced as an Err rather than swallowed — an EINVAL (an
out-of-range Other(n)) always, and an EPERM against a live, non-zombie
member (a sudo/setuid child that rejects the signal, or a seccomp/container
restriction). The process-group mechanism (macOS/the other BSDs,
Linux-without-cgroup) reaches the same verdict as the cgroup one by checking the
target's run state after an EPERM, so a harmless zombie-only EPERM — and, on
the bare BSDs where no state reader exists, every EPERM — stays swallowed. The
FreeBSD reaper makes that discrimination too, from the kernel's own zombie flag
on the member PROC_REAP_KILL names as the failing one — so unlike the bare-BSD
process-group path it does surface a live member's EPERM. An ESRCH race (the
member already exited) is still success. Signal::Other(0) is the POSIX
existence probe: it returns Ok having delivered nothing (a live target was
reached, not signalled) — and because that probe never takes a delivery path
(FreeBSD routes it back through the process group, which has no state reader on
any BSD but macOS), the EPERM rule above does not reach it everywhere: on
FreeBSD and the bare BSDs a live target that rejects even the null signal still
answers Ok, where Linux and macOS surface the EPERM. suspend/resume on
the process-group mechanism now use the same honest delivery verdict for
SIGSTOP/SIGCONT: a live-member EPERM surfaces as an Err, while ESRCH,
zombie-only EPERM, an empty group, and BSD-without-state-reader EPERM
remain Ok. The FreeBSD reaper applies that verdict to freezing and thawing
too — SIGSTOP/SIGCONT ride the same PROC_REAP_KILL classification as any
other signal there. The older-kernel cgroup per-process fallback reports its
SIGSTOP/SIGCONT failures the same way.
Asking whether a soft stop is available
Before you fire a soft stop (signal(Signal::Term) / Signal::Int), you can ask
the group whether one will actually reach anything — soft_stop_scope() returns a
SoftStopScope capability report, so a caller cancelling a run on its own
schedule (a UI Cancel, a control-socket command, a timeout it owns) can decide up
front whether to attempt a graceful stop and can tell its user the real reach,
instead of firing a signal, catching ErrorReason::Unsupported, and reverse-engineering
the scope. It is the group-axis sibling of
Command::kill_on_parent_death_scope() -> ParentDeathCleanup, but read from the
group's live membership (not fixed per platform) and side-effect-free — it
delivers no signal, posts no WM_CLOSE, spawns nothing, and does not mutate the
group.
use processkit::{Command, ProcessGroup, Signal, SoftStopScope}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _server = group .start(&Command::new("my-server").windows_graceful_ctrl_break()) .await?; // Decide BEFORE attempting — no ErrorReason::Unsupported to parse back. match group.soft_stop_scope() { SoftStopScope::WholeTree | SoftStopScope::OptInMembers => { group.signal(Signal::Term)?; // a soft stop will reach a member } SoftStopScope::Unsupported => { group.kill_all()?; // no soft tier here — go straight to the hard kill } other => eprintln!("unknown soft-stop scope: {}", other.name()), } Ok(()) }
| Mechanism | soft_stop_scope() | Why |
|---|---|---|
| Linux cgroup v2, macOS/other BSD, Linux pgroup fallback | WholeTree | signal(Int/Term) reaches every member of the tree (the cgroup, or every tracked process group via killpg); never Unsupported |
| FreeBSD reaper | WholeTree | PROC_REAP_KILL delivers to every descendant the reaper sees — including one that setsided out of its process group; never Unsupported |
Windows, with a live console-CTRL leader (windows_graceful_ctrl_break) or a windowed member | OptInMembers | a soft close reaches only members it can trigger — a curated subset, not the whole tree |
| Windows, with neither | Unsupported | a Job Object has no POSIX signal and there is nothing to soft-close, so signal(Int/Term) would return ErrorReason::Unsupported |
Consistent with signal by construction: it reads the very same live-membership
primitives signal(Int/Term) acts on, so what it reports matches what a real soft
stop would then reach. It describes the soft tier only — the unconditional hard
kill (Signal::Kill, kill_all, dropping the group) always tears the whole tree
down regardless. SoftStopScope is #[non_exhaustive] and carries a stable
name() / from_name() machine identifier (whole_tree / opt_in_members /
none), like the other reporting enums.
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/other BSD | SIGSTOP / SIGCONT broadcast | Idempotent (level-triggered) |
| FreeBSD reaper | SIGSTOP / SIGCONT through PROC_REAP_KILL | Idempotent; covers the whole subtree, a setsid escapee included; a refused delivery surfaces as an Err |
| 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.
Enriched snapshot: members_info
When bare pids aren't enough — a diagnostic members_snapshot event, a
process-tree view — members_info returns the same member set as members,
but each pid comes wrapped in a MemberInfo carrying best-effort parent
pid, image name, and start time:
use processkit::{Command, ProcessGroup}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; let _a = group.start(&Command::new("worker-a")).await?; for m in group.members_info()? { println!( "pid={} ppid={:?} exe={:?} start={:?}", m.pid(), m.ppid(), m.exe_name(), m.start_time(), ); } Ok(()) }
The fields are read where the platform can report them and are None
otherwise — never a fabricated value. Windows and Linux (both the cgroup and
/proc fallback paths) and macOS fill all four; on the bare BSDs only the pid
is reported and the rest are None. start_time is an opaque identity
anchor (its unit and epoch differ per platform), not a wall-clock timestamp —
its use is pairing with the pid to tell a recycled number apart from the
original process, not display. The raw command line is deliberately never
included on any platform: it routinely carries secrets, and redaction is the
consumer's policy to own.
Same point-in-time contract as members, with one addition: if a member exits
between its pid being enumerated and its metadata being read, that pid is
skipped rather than reported with fabricated fields — a single vanished
member never fails the whole call.
Identifying a process by pid (outside a group)
Sometimes the pid you care about is not a member of any group you own — a pid
saved to disk between runs, a launch registry checking whether the owner of a
crash-surviving entry is still alive, an e2e probe watching a process from outside
its container. For that, the crate publishes the same identity query as a
free-standing function (needs process-control):
fn main() -> processkit::Result<()> { let pid = 4321; // Look up an arbitrary pid — the standalone twin of `members_info`, returning the // same best-effort `MemberInfo` (parent pid, image name, start time). match processkit::process_info(pid)? { Some(info) => println!( "pid={} ppid={:?} exe={:?} start={:?}", info.pid(), info.ppid(), info.exe_name(), info.start_time(), ), None => println!("pid {pid} is not running"), } Ok(()) }
process_info returns three distinct outcomes, and the distinction is the
point:
Ok(Some(info))— the process exists; fields are best-effortOptionexactly as inmembers_info.Ok(None)— the pid names no process: an honest negative, the "it's gone" answer a liveness check wants.Err— the process may well exist, but you couldn't inspect it (no permission — a Windows protected/Systemprocess, a Linuxhidepidmount, a macOS restricted process — or an OS read error). Never read this as "dead." That is the whole reason it is an error rather thanOk(None).
It reads no argv/environment, on any platform — the same "never argv/env" stance
MemberInfo documents.
Reuse-safe liveness: process_is_alive
Because the OS reuses pid numbers, "is pid N still alive?" is the wrong question for a saved pid: a stranger may have recycled the number after your process exited. Pair the pid with the start-time token and ask instead "is the same process still running?":
fn main() -> processkit::Result<()> { // Earlier: record identity. let pid = 4321; let saved_start = processkit::process_info(pid)?.and_then(|i| i.start_time()); // Later (perhaps after a restart): is that same process still alive? if processkit::process_is_alive(pid, saved_start)? { println!("the original process {pid} is still alive"); } else { println!("process {pid} is gone (exited, or its number was recycled)"); } Ok(()) }
process_is_alive is Ok(true) only when the process exists and its current
start time matches the saved one; a different start time on the same number
(the number was recycled) reads as Ok(false), and so does a nonexistent pid. A
permission Err propagates just like process_info — again, never "dead". The
start time is an opaque identity anchor (unit/epoch differ per platform), used only
for this pairing, never displayed. Where the platform reports no start time
(the bare BSDs, where start_time() is None), the check degrades to bare-pid
liveness — exactly the number-only check you'd otherwise write by hand, no weaker,
and never a false "dead".
Resource limits
Requires the limits feature. Caps are a property of the group, set at
creation (and adjustable later — see Updating a live
group) 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 / other BSD | FreeBSD reaper |
|---|---|---|---|---|
| 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 ErrorReason::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, no mechanism with whole-tree resource accounting exists here
(Unsupported — the pgroup mechanisms, which have no whole-tree container at all,
and the FreeBSD reaper, which contains a tree without accounting for it), 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.
Updating a live group
ProcessGroup::update_limits(ResourceLimits) re-applies a fresh set of caps to
an already-running group — without recreating the container or restarting
its children — for adaptive resource management (tighten a slumping batch's
memory, widen a long-lived worker pool's CPU quota):
use processkit::{ProcessGroup, ResourceLimits}; fn main() -> processkit::Result<()> { let mut group = ProcessGroup::new()?; // Later, adapt the caps on the already-running group: let mut limits = ResourceLimits::default(); limits.max_memory = Some(256 * 1024 * 1024); // tighten to 256 MiB limits.cpu_quota = Some(2.0); // widen CPU to two cores group.update_limits(limits)?; // max_processes left None → that cap is lifted Ok(()) }
The new value is a full replacement, not a merge: an axis left None is
lifted back to unbounded — it does not keep its previous cap — so always
describe the complete desired state (start from ResourceLimits::default() and
set the axes you want capped). On Windows the live Job Object's caps are
reissued; on Linux cgroup v2 the memory.max / pids.max / cpu.max files are
rewritten (a removed axis written back to max). It routes through the same
live container the tree-control verbs use, so the same platform matrix and
ErrorReason::ResourceLimit { kind, reason, detail } classification apply — a
process-group mechanism (macOS/the other BSDs, the Linux fallback) and the FreeBSD
reaper refuse any requested cap with Unsupported rather than silently dropping
it, while lifting all caps there is a trivial success.
A failure is not a rollback. The caps are written axis by axis — on Windows
one Job Object call for the memory and process caps and a second for the CPU cap,
on cgroup v2 memory.max, then pids.max, then cpu.max — and nothing about
that is transactional. A call that fails part-way can leave the container carrying
a mix of old and new caps, including an axis the request meant to lift that has
already been lifted; only the group's reflected options (what Debug shows) stay
on the previous set, and the error doesn't say how far the write got. Re-issue the
complete desired set (a full replacement, so retrying is idempotent) or tear the
group down. An Invalid value is the one case guaranteed to change nothing: it is
rejected before the OS is touched. Evidence stays honest across all of this —
every axis a request names joins the group's sticky cap record whether the call
succeeds or fails, so an axis that did land before the failure is still read from
the kernel's counters by limit_evidence() rather than reported NotTripped with
nothing behind it.
Did the cap actually fire? (limit_evidence)
The caps above answer "may this tree use more?". They don't, by themselves, tell
you afterwards whether one of them stopped something — and a plain exit status
can't either: a child OOM-killed under max_memory and a child that crashed on
its own both surface as an ordinary non-zero exit (a SIGKILL on Unix). stats
reports peak and cumulative samples, which is a measurement, not a verdict.
ProcessGroup::limit_evidence() closes that gap. It returns a LimitEvidence
report carrying one LimitVerdict per axis, read from the kernel/OS container
the crate owns:
use processkit::{Command, LimitVerdict, ProcessGroup, ProcessGroupOptions}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = ProcessGroup::with_options( ProcessGroupOptions::default().max_memory(512 * 1024 * 1024), )?; let outcome = group.start(&Command::new("untrusted-tool")).await? .output_string().await?; if !outcome.is_success() { match group.limit_evidence().memory() { LimitVerdict::Tripped => eprintln!("killed by its memory cap"), LimitVerdict::NotTripped => eprintln!("the tool failed on its own"), // `LimitVerdict` is `#[non_exhaustive]`; treat anything new the way // you treat `Unknown` — as "no answer", never as a "no". _ => eprintln!("this platform can't say"), } } Ok(()) }
Two different questions. ErrorReason::ResourceLimit is admission: "the
cap you asked for could not be applied" (Invalid / Unsupported /
Unenforceable). with_options returns it instead of running anything at all —
it hands back no group, so there is nothing left to ask. update_limits returns
it against an already-running tree, where it undoes nothing that already landed
(see A failure is not a rollback above).
limit_evidence is the other side: did a cap on this axis then fire?
Nothing about the error's behaviour changes — but on a live group the two can
meet on the same axis: after a failed update_limits the error says the
requested set could not be applied whole, while the evidence still answers what
actually fired, read from the counters rather than assumed away.
Three-valued on purpose, and never a guess. Tripped is returned only on
authoritative kernel/OS evidence recorded by this group's own container.
NotTripped means the evidence says it did not fire — or that the axis never
carried a cap, so nothing could. Unknown means no evidence is available,
and is deliberately not folded into a "no". Exit codes and signals are never
consulted: they cannot separate a cap-driven kill from a self-inflicted one.
| Mechanism | Memory | Processes | CPU | Evidence |
|---|---|---|---|---|
| Linux cgroup v2 | ✅ | ✅ | ✅ | memory.events' oom, pids.events' max, cpu.stat's nr_throttled |
| Windows Job Object | ❓ Unknown | ❓ Unknown | ❓ Unknown | the mechanism keeps no post-mortem record — see below |
| pgroup / macOS / other BSD | ❓ Unknown | ❓ Unknown | ❓ Unknown | no whole-tree resource accounting exists at all |
| FreeBSD reaper | ❓ Unknown | ❓ Unknown | ❓ Unknown | contains the tree, accounts for nothing in it |
What "fired" means differs by axis, because the OS's own behaviour does: memory
means the container hit its own cap and the kernel had to OOM inside it;
processes means a fork was refused; CPU means the quota throttled the tree at
least once — a CPU cap slows work rather than stopping it, so a Tripped CPU
verdict reads as "the quota bound this workload", not "the quota broke it".
The memory axis keys on cgroup v2's oom counter and deliberately not on
oom_kill, which the kernel documents as processes of this cgroup killed by
any OOM killer — a host-wide out-of-memory kill would otherwise be reported as
"your cap killed it".
A NotTripped memory verdict on a host with swap deserves one caveat, and it
is the kernel's, not the report's: memory.max caps memory, not memory + swap.
Where swap is available to the tree (memory.swap.max defaults to max, and the
crate sets no swap cap), the kernel may page a hog out instead of OOM-killing it —
the cap engages, nothing dies, and NotTripped is the truthful answer. If you
need "over the cap means death", take swap off the tree externally
(memory.swap.max, or a swapless host/container — which is what most containers
already are).
Windows is a measured negative, not an oversight. A Job Object enforces all
three caps but preserves nothing about them afterwards: the active-process cap
refuses the offending process without ever counting it as a member (the job
accounting's "terminated because of a limit violation" tally is measurably
unmoved by a real violation), the memory cap fails a commit rather than
killing and is surfaced only as a live IO-completion-port notification, and the
CPU hard cap throttles with no counter at all. Reading any of them would mean
attaching a completion port and a drain thread to every group — new machinery on
the containment object itself, purely for reporting — which this crate does not
do. Inferring from PeakJobMemoryUsed or an exit code is refused as a guess, so
a capped axis reports Unknown there. (On the process-group mechanism the
answer is Unknown too, for a different reason: it has no accounting to read —
and it refuses to carry a cap in the first place.)
Read it before the group goes away. The evidence lives in the container, so
call limit_evidence() while the group is still alive; dropping it (or the
consuming shutdown()) removes the cgroup / closes the job handle and takes the
counters with it. Reading is free of side effects and repeatable: it sends no
signal, kills nothing, writes nothing, and cannot perturb teardown or
kill-on-drop whenever you call it. The counters are cumulative and are not reset
by reading, by a teardown, or by update_limits — an axis whose cap was later
lifted still reports that it fired while the cap was in force, and so does an
axis named by an update_limits call that failed (see above: that call is not
a rollback, so the axis may have been applied before the failure). An axis that
never carried a cap is answered without touching the OS at all, so a group
created without caps performs no evidence I/O whatsoever.
(LimitEvidence and LimitVerdict are left as bare code spans, not docs.rs
links: they ship in the next release, so a docs.rs URL would 404 until then.)
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, ); println!( "io_read={:?} io_write={:?} peak_procs={:?}", snap.io_read_bytes, snap.io_write_bytes, snap.peak_process_count, ); // …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.
What each measurement is, and where it comes from
None never means zero anywhere in this snapshot: it means this mechanism does
not account for that. Which fields carry a number depends on the mechanism, and
on two different kinds of source — a counter the container itself keeps
(whole-tree and cumulative, exited members included), or a sum over the members
that are live right now (a member that exits takes its share out of the next
snapshot):
| Field | Windows Job Object | Linux cgroup v2 | Process group / FreeBSD reaper |
|---|---|---|---|
active_process_count | job's ActiveProcesses | live cgroup.procs members | tracked entries (reaper: whole tree) |
total_cpu_time | job counter, cumulative | sum over live members (/proc) | None |
peak_memory_bytes | job counter (commit charge) | sum of live members' VmHWM | None |
io_read_bytes / io_write_bytes | job IO_COUNTERS, cumulative | io.stat rbytes/wbytes, cumulative — needs the io controller | None |
peak_process_count | None — no such counter | pids.peak — needs the pids controller | None |
Three caveats are worth reading before comparing numbers across hosts:
- The I/O counters measure different traffic per platform. Windows counts the
bytes the job's read/write operations moved against any target — file, pipe,
device. A cgroup's
io.statcounts what reached the block layer, so a read served from the page cache or traffic over a pipe or socket is not in it, and a write is counted when the kernel writes the page back — possibly after the member that dirtied it exited, and not yet at all in a snapshot taken before that. A short write-and-exit run can therefore report fewer bytes than it handed towrite(2). - A cgroup reports these only where the controller is enabled for the group's
cgroup (in its parent's
cgroup.subtree_control). processkit enables exactly the controllers a requested resource cap needs —memory,pids,cpu— and neverio, so on a host that has not enabledioitself the byte counters are honestlyNonerather than a zero. peak_process_countis a peak of what the pids controller counts, which is tasks: every thread of a multi-threaded member counts towards it, so it equals a process count only while the members are single-threaded. Windows reportsNonehere rather than a substitute — a job'sActiveProcessesis how many are in it now andTotalProcesseshow many ever were, and neither is a peak.
An owning, 'static sampler
Because sample_stats borrows the group, its StatsSampler is tied to that
borrow — it can't be moved into a [tokio::spawn]ed task or handed across an FFI
boundary, both of which need a 'static value. When the group already lives
behind a shared [Arc] (a long-lived service, a supervisor, an FFI wrapper),
reach instead for OwnedStatsSampler, the owning twin — the sampling analogue of
how shutdown_ref is the non-consuming
twin of shutdown:
use std::sync::Arc; use std::time::Duration; use processkit::prelude::StreamExt; use processkit::{Command, OwnedStatsSampler, ProcessGroup}; #[tokio::main] async fn main() -> processkit::Result<()> { let group = Arc::new(ProcessGroup::new()?); let _worker = group.start(&Command::new("worker")).await?; // `Send + 'static`: build it from `&Arc<…>` (the caller keeps the `Arc`) and // move it into a task. Same cadence as `sample_stats` — first sample // immediate, then one per interval, missed ticks skipped. let mut samples = OwnedStatsSampler::new(&group, Duration::from_millis(250)); tokio::spawn(async move { while let Some(s) = samples.next().await { println!("rss now: {:?}", s.peak_memory_bytes); } // The series ended: either the container can no longer report, or every // `Arc` to the group was dropped and the tree is gone. }); Ok(()) }
It holds the group only weakly, so — exactly like the borrowing
StatsSampler — it never keeps the group or its kill-on-drop guarantee alive; a
sampler left running in a detached task can't pin a tree that should have been
torn down. That makes its behaviour when the group goes away well-defined: the
stream ends — yields None, and stays ended (it is fused) — on the first tick
that can't produce a snapshot, whether because the container was torn down (a
failed stats(), same as the borrowing sampler) or because every strong
Arc to the group was released while the sampler ran (the weak handle no longer
upgrades). It never silently repeats the last snapshot and never leaves the task
awaiting a tick that will never come.
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.
The same live-handle model extends to multi-stage chains:
Pipeline::start() returns a PipelineSession — the multi-stage analogue of
RunningProcess — that streams the last stage's stdout, waits for a readiness
line, and folds the pipefail outcome at finish(), all while the whole chain is
bounded and torn down as a unit. See
Pipelines → streaming a live chain.
- Lifecycle
- Streaming stdout
- The full lifecycle as one stream (
events()) - Interactive stdin
- Readiness probes
- Prompt-aware waiting (
wait_for_output) - 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 // drain() → like wait(), but honors output_buffer's byte cap; feeds tees, retains nothing // 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(()) }
wait() vs drain()
Both wait for exit while draining stdout/stderr so the child never blocks on a
full pipe, and both return the same Outcome. They differ only in the in-flight
memory bound:
wait()ignoresoutput_bufferand pins a large fixed internal cap. Reach for it when you just want the exit outcome.drain()honors the configuredoutput_bufferbyte cap (with_max_bytes) for the in-flight bound, retaining nothing. Reach for it when the output is already going where you want it — astdout_tee/stderr_teewriting to a file, or anon_stdout_line/on_stderr_linehandler — and you want held memory bounded by your configured limit rather than the child's output size. Those sinks still see every line that fits the cap; a line longer than the byte cap is skipped for every sink alike (counted only via the truncation signal), and an unboundedoutput_bufferfalls back to the same fixed floorwaituses.
use processkit::{Command, OutputBufferPolicy}; use tokio::fs::File; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Tee a noisy build to a file, keep only ~1 MiB in flight, capture nothing. let log = File::create("build.log").await?; let outcome = Command::new("cargo") .args(["build", "--release"]) .output_buffer(OutputBufferPolicy::unbounded().with_max_bytes(1 << 20)) .stdout_tee(log) .start() .await? .drain() .await?; println!("build finished: {outcome:?}"); 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/eventscall (stdout is consumed once), or a non-piped stdout (StdioMode::Inherit/NullorCommand::stdout_file*), 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 reportsErrorReason::Cancelled. Details in Timeouts & cancellation. - Line counters tick live:
run.stdout_line_count()/stderr_line_count()are cheap progress gauges even while you stream. - Byte counters tick live:
run.stdout_bytes_seen()/stderr_bytes_seen()count raw bytes read from each pipe before decoding or line splitting. They are monotonic and include bytes discarded by the buffer policy, including oversized lines, and remain stable after the pump completes. A stream that is not pumped (a file redirect orStdioMode::Null/Inherit) reports0honestly. - 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 `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.
Byte-accurate raw output (stdout_raw_tee)
Every path above hands you decoded text: bytes go through encoding_rs,
lines are split on the terminator, and CRLF is normalized. That is exactly right
for text logic — but a transparent wrapper needs the child's bytes unaltered.
Decoding mangles four things a passthrough must preserve: non-UTF-8 stdout (binary
from git archive, tar -cz -, ffmpeg … -) becomes U+FFFD; CRLF is rewritten
and a missing final newline is fabricated; an unterminated prompt (Password: )
sits in the decode buffer until EOF and reads as a hang; and a line past the byte
cap vanishes from the transcript entirely.
stdout_raw_tee(writer) / stderr_raw_tee(writer) are the byte plane, orthogonal
to stdout_tee. Each chunk is written to writer exactly as read from the
pipe — before decoding, before line splitting — so it is byte-for-byte the
child's output, in order: non-UTF-8 bytes survive, CRLF is untouched, the tail is
never padded, an unterminated chunk arrives the instant it is read, and even a
policy-dropped line is teed whole. It is strictly additive — the decoded line
path (capture, on_*_line, stdout_tee, the buffer policy) is unchanged, and
both tees can run at once, each seeing its own view. The write is awaited on the
capture pump, so a slow raw sink applies the same backpressure as the line tee (no
unbounded in-flight buffer), and it is flushed at stream end.
use processkit::Command; use tokio::fs::File; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Transparent passthrough: forward the child's *exact* stdout bytes to a file // (binary-safe — no decoding, no CRLF rewrite, no lost tail), retaining // nothing in memory. `drain` streams the bytes through to the tee and returns // just the classified exit outcome. let exact = File::create("archive.tar").await?; let outcome = Command::new("git") .args(["archive", "HEAD"]) // writes a binary tar to stdout .stdout_raw_tee(exact) .start() .await? .drain() .await?; println!("archive written: {outcome:?}"); Ok(()) }
The raw tee fires from the line/streaming verbs (output_string, start +
stdout_lines / events, wait / drain). It is a no-op under
stdout(Inherit) / stdout(Null) / a stdout_file redirect — none run a capture
pump — and under output_bytes, whose own return value already is the exact raw
stdout. Reach for it when you need the raw bytes alongside decoded lines.
Redaction at capture (capture_policy)
Every plane above either observes a line (on_stdout_line, stdout_tee) or
returns it verbatim (stdout_raw_tee, output_bytes). None can change what is
retained: a secret a child echoes — a passphrase prompt from an agent CLI, a
--token re-printed in a diagnostic — lands in the captured ProcessResult
word for word. capture_policy is the seam that shapes the backlog before
the line settles:
use std::borrow::Cow; use processkit::{Command, CapturePolicy, OutputStream}; // A typed, named policy — introspectable in `Command`'s `Debug`, not an opaque // closure. It rewrites secrets as each line is captured. struct RedactTokens; impl CapturePolicy for RedactTokens { fn name(&self) -> &str { "redact-tokens" } fn on_capture<'a>(&self, _stream: OutputStream, line: &'a str) -> Cow<'a, str> { if let Some(i) = line.find("token=") { Cow::Owned(format!("{}token=[REDACTED]", &line[..i])) } else { Cow::Borrowed(line) // unchanged: retained verbatim, no allocation } } } #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let out = Command::new("agent-cli") .arg("login") .capture_policy(RedactTokens) .output_string() .await?; // `out.stdout()` — and a streamed `stdout_lines` / `events` — carry // the redacted text; the raw secret never reached the buffer. assert!(!out.stdout().contains("token=hunter2")); Ok(()) }
Return Cow::Borrowed(line) to keep a line unchanged (no allocation), a
Cow::Owned to rewrite it, or an empty string to blank the content while
keeping the line's slot and the exact line counter. The policy is handed the
OutputStream each line came from, so one implementation can treat stdout and
stderr differently. This completes the crate's secret-hygiene story — a cassette
stores env names only, Debug redacts env values, and capture_policy
scrubs secrets a child prints — see
Running untrusted children.
Scope. The seam shapes the capture backlog only — output_string /
ProcessResult and the streaming verbs. The observing on_*_line handlers, the
decoded stdout_tee / stderr_tee, the byte-plane stdout_raw_tee, and the raw
stdout of output_bytes are independent and see the line un-redacted; if you
also tee to a log, redact in that sink too. A line past an OutputBufferPolicy
byte cap is never assembled, so — like the handlers/tee — it never reaches the
policy. How much is retained stays OutputBufferPolicy's job (the two compose:
the policy shapes each retained line's content, the buffer policy bounds how many
survive). A policy that panics fails closed — the offending line is blanked,
never leaked raw.
The full lifecycle as one stream (events())
stdout_lines() gives you stdout; events() gives you the child's whole
lifecycle as one ordered, typed stream — the single asynchronous source a TUI,
dashboard, or supervisor wants, instead of stitching together separate channels
for "started", output, and "exited":
Started { pid } → interleaved Stdout / Stderr lines → Exited(outcome)
ProcessEvent::Started { pid }leads the stream, emitted as soon as the pid is known (before any output).pidisNonefor a scripted double.ProcessEvent::Stdout(line)/Stderr(line)carry each decodedOutputLine(readline.text()), the two streams polled fairly so neither starves the other.ProcessEvent::Exited(outcome)ends the stream, carrying the run'sOutcome— the same valuefinish()reports.
The enum is #[non_exhaustive]: a match needs a _ arm, and future kinds
(e.g. the graceful-teardown transitions) can be added without a breaking change.
ProcessEvent::name() gives a stable "started" / "stdout" / "stderr" /
"exited" tag for logging or serialization.
Drive the stream and the finisher together. The terminal Exited is
delivered when the run is reaped — which is what finish() (or wait()) does.
So poll the stream and that finisher concurrently (e.g. with tokio::join!),
rather than draining the stream to completion first and only then calling
finish() — the stream would park waiting for an Exited no one is yet
producing. After the stream ends, finish()'s stderr is empty because stderr
was delivered to you as Stderr events.
use processkit::prelude::StreamExt; use processkit::{Command, ProcessEvent}; #[tokio::main] async fn main() -> processkit::Result<()> { let mut run = Command::new("deploy").start().await?; let mut events = run.events()?; let render = async { while let Some(ev) = events.next().await { match ev { ProcessEvent::Started { pid } => eprintln!("started {pid:?}"), ProcessEvent::Stdout(line) => println!("{}", line.text()), ProcessEvent::Stderr(line) => eprintln!("! {}", line.text()), ProcessEvent::Exited(outcome) => eprintln!("exited {outcome:?}"), _ => {} // non_exhaustive: future event kinds } } }; // Poll the event stream and the reaping `finish()` together. let (_, finished) = tokio::join!(render, run.finish()); println!("run finished: {:?}", finished?.outcome); Ok(()) }
Run the same pattern without external dependencies in
examples/lifecycle_events.rs.
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::ErrorReason::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?; // sends a line + Enter, 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) (terminal Enter + flush),
flush(), and finish() (EOF). Dropping the writer requests the same EOF: while
the PTY session and its async runtime remain live, the backend retains that
request through temporary input backpressure and delivers it after the child
resumes reading. finish() makes delivery explicit and awaitable, including its
I/O error; dropping has no error channel. Dropping the whole RunningProcess
tears the child tree down, so neither EOF form promises delivery after the
session/runtime is already irreversibly ending. write_line sends \n to a pipe
or Unix PTY and \r to a Windows ConPTY, where a lone LF is Ctrl-J rather than
the Enter key; use write when you need byte-exact input.
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 same rule applies to a PTY's
single, full-duplex master: awaiting one large write while no sink reads merged
output can deadlock. (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.
PTY dialog: wait for a prompt, then answer
A terminal prompt is usually an un-terminated tail (Password: ), not a line.
Combine use_pty, keep_stdin_open, and wait_for_output to drive an
expect-style exchange without guessing when the child is ready:
use processkit::Command; use std::time::Duration; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let mut run = Command::new("terminal-login") .use_pty() .pty_size(100, 30) .keep_stdin_open() .start() .await?; run.wait_for_output( |tail| tail.ends_with("Password: "), Duration::from_secs(10), ) .await?; run.take_stdin() .expect("PTY stdin was kept open") .write_line("secret supplied by a protected source") .await?; run.finish().await?; Ok(()) }
The self-contained
examples/pty_dialog.rs
also demonstrates live resize; run it with --features pty.
On Unix, the PTY slave has echo disabled so the answer is not copied into the
merged capture. That guarantee is Unix-only; ConPTY offers no portable
equivalent, so a Windows caller sending secrets should avoid retaining or
logging the transcript. The prompt wait itself is byte-driven on both
platforms. Signal-driven terminal behavior is separate: on Unix, use_pty
creates a controlling terminal so terminal signals such as the SIGWINCH
raised by resize_pty reach the child as described below.
PTY output destination
A PTY has one merged output master, exposed as piped logical stdout. Keep
both output destinations at their default StdioMode::Piped: capture,
stdout_lines, stdout handlers, and stdout tees then receive the merged bytes,
while ProcessResult::stderr stays empty. There is no independent stderr
destination after the terminal has merged the child descriptors.
Accordingly, use_pty rejects stdout Inherit, Null,
stdout_file/stdout_file_append, and any separate stderr Inherit, Null,
stderr_file/stderr_file_append destination with
ErrorReason::Unsupported. The check runs before opening, creating, or
truncating a redirect file and before spawning the child on both Unix and
Windows. This makes the unsupported combinations fail visibly instead of
creating an unused file or secretly draining the real PTY master to a discard
sink. Use ordinary pipe mode when those per-descriptor destinations are needed.
PTY window size and live resize
Under use_pty the child runs on a real pseudo-terminal, and terminal-aware
tools care about its size: it drives line wrapping, TUI/progress layout, and
pager behavior. Set the initial geometry with pty_size(cols, rows) (default
80×24), and change it on a running session with
RunningProcess::resize_pty(cols, rows) — the way you propagate a host window
resize down to the child. At spawn, COLUMNS/LINES match that initial
geometry (and Unix defaults TERM=xterm-256color); explicit env/env_remove
operations override those values:
use processkit::prelude::StreamExt; use processkit::Command; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // Open the terminal at 120×40 instead of the 80×24 default. let mut run = Command::new("htop").use_pty().pty_size(120, 40).start().await?; let mut screen = run.stdout_lines()?; // …later, the host window grows — tell the child so it re-renders. run.resize_pty(160, 50)?; while let Some(line) = screen.next().await { // render `line`… let _ = line; break; } Ok(()) }
Dimensions must be non-zero. Windows ConPTY represents each axis in a signed
16-bit COORD, so 1..=32767 is accepted there and larger values fail with
ErrorReason::Io (InvalidInput) before a child is spawned or a live terminal
is resized. Unix winsize uses u16 fields and accepts the full non-zero range
through 65535. Values are never clamped: a successful spawn therefore keeps
the applied terminal geometry and synthesized COLUMNS/LINES aligned, while
explicit environment operations retain their documented precedence.
resize_pty works while you still hold the handle — typically interleaved with
driving an owned stdout_lines()/events() stream and the take_stdin() writer
of a live session. It returns ErrorReason::Unsupported
— never a panic or a silent no-op — on a run that is not use_pty (there is
no terminal to size) or once the child has exited. Platform delivery differs:
on Unix the resize (TIOCSWINSZ) raises SIGWINCH on the child immediately;
on Windows ResizePseudoConsole has no signal, so a console client observes
the new size on its next console query and conhost may reflow a little later. On a
non-use_pty command pty_size is a documented no-op (nothing to size). See
platform support.
An invalid resize is checked before the backend call and leaves the live PTY
usable for a later valid resize.
Because a running process's environment is immutable, live resize does not
rewrite COLUMNS/LINES; applications learn the new size through the platform
resize mechanism.
PTY output hygiene: line framing and VT sanitization
A PTY child writes like a terminal, which two things about use_pty handle so a
line-oriented consumer gets sensible output — one automatic, one opt-in:
- Framing is
\r-aware by default underuse_pty. Terminal tools draw progress by redrawing a line in place with a bare\r(no\nuntil the end). Under the defaultNewlineframing that whole progress stream is one ever-growing line that only surfaces at EOF; souse_ptymakes the effective defaultline_terminatorCarriageReturn, and each redraw becomes its own frame/line live. This only changes where lines split (a\r\nstill counts as one terminator), so it is the safe default for the mode. An explicitline_terminator(...)— evenNewline— always wins, so you can pin the framing if you need to. - Escape sanitization is opt-in. Agentic CLIs spray VT/ANSI escapes (colors,
cursor moves, alternate-screen switches, OSC window-title/hyperlink codes) into
their merged output, so
output_string,wait_for_line/first_line, and the streaming verbs otherwise carry\x1b[31m…-mucked strings. Turn onCommand::sanitize_vt()to strip those sequences (and lone control codes, keeping tabs) from the capture backlog. It is opt-in because it is destructive — it removes bytes from the captured output — unlike the non-destructive framing default.
use processkit::prelude::StreamExt; use processkit::{Command, LineTerminator}; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // A PTY agent CLI: make the progress-frame contract explicit and opt into // de-escaping, so every captured line is readable text. let mut run = Command::new("agent") .use_pty() .pty_size(120, 30) .line_terminator(LineTerminator::CarriageReturn) .sanitize_vt() .start() .await?; let mut lines = run.stdout_lines()?; while let Some(line) = lines.next().await { // `line` is a clean, de-escaped frame — safe for a `contains(...)` probe. let _ = line; break; } Ok(()) }
Because a PTY merges both child descriptors into its logical stdout, the whole-command setters above are the clearest spelling. For a wrapper that can also run over ordinary pipes, the per-stream variants let each real pipe keep its own policy:
use processkit::{Command, LineTerminator}; fn command_for_pipes() -> Command { Command::new("agent") .stdout_line_terminator(LineTerminator::CarriageReturn) .stderr_line_terminator(LineTerminator::CarriageReturn) .stdout_sanitize_vt() .stderr_sanitize_vt() } fn command_for_pty() -> Command { Command::new("agent") .use_pty() .pty_size(120, 30) .line_terminator(LineTerminator::CarriageReturn) .sanitize_vt() } fn main() { let _ = (command_for_pipes(), command_for_pty()); }
In PTY mode, stderr_line_terminator and stderr_sanitize_vt have no separate
stream to act on: the child stderr bytes have already joined stdout on the
master. Likewise, on_stderr_line is not delivered and the final stderr
capture is empty. Use the stdout/whole-command settings for PTY output, or use
pipes when preserving stream identity is required.
Sanitization shapes only the capture backlog — exactly the boundary
capture_policy
draws. The per-line handlers (on_stdout_line), the decoded stdout_tee, the
byte-plane stdout_raw_tee, and output_bytes are independent and keep seeing the
raw, escape-laden bytes; if you also tee to a log and want it clean, sanitize in
that sink. When combined with capture_policy, sanitization runs first, so a
secret-scrubbing policy matches on already-cleaned text rather than a token a color
escape could split mid-word. Set it per stream with stdout_sanitize_vt() /
stderr_sanitize_vt() when only one stream needs it. See
platform support for the
per-platform PTY table.
Readiness probes
"Start a server, then use it" needs ready, not merely started. Readiness 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 pidfile or readiness sentinel appearing: run.wait_for_path("run/my-server.ready", Duration::from_secs(10)) .await?; // 3. A TCP port accepting connections: run.wait_for_port("127.0.0.1:8080".parse().unwrap(), Duration::from_secs(10)) .await?; // 4. A plain HTTP endpoint returning an expected status: run.wait_for_http( "127.0.0.1:8080".parse().unwrap(), "/healthz", |status| (200..300).contains(&status), Duration::from_secs(10), ) .await?; // 5. A Unix domain socket accepting connections (Unix only): run.wait_for_socket("/tmp/my-server.sock", Duration::from_secs(10)) .await?; // 6. A Windows named pipe accepting clients (Windows only): run.wait_for_pipe("my-service", Duration::from_secs(10)).await?; // 7. Any async predicate (an HTTPS/body or metadata check, …): run.wait_for(|| async { health_check().await }, Duration::from_secs(10)) .await?; // ready — use the server… let _ = banner; Ok(()) }
If a tool writes its banner to stderr, use the matching counterpart instead:
let banner = run
.wait_for_stderr_line(|line| line.contains("backend ready"), Duration::from_secs(10))
.await?;
Choose one consuming line probe for a handle: while it hands you the selected stream, the other stream is already being drained in the background for liveness and later capture.
Probe semantics, deliberately uniform:
- A probe that can't pass within its deadline fails with
ErrorReason::NotReady— distinct fromErrorReason::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 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_lineandwait_for_stderr_lineconsume the selected stream up to (and including) the match — continue withfinish(whose stderr omits lines already consumed bywait_for_stderr_line).wait_for_path/wait_for_port/wait_for_http/wait_for_socket/wait_for_pipe/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/eventscall do not compose with any readiness probe that started a line pump (same as callingwait_for_linefirst). wait_for_socketuses a real connection attempt, not just a socket-file existence check, and returnsErrorReason::Unsupportedimmediately on platforms without AF_UNIX (including Windows).wait_for_pathis deliberately existence-only: a file or directory counts. Usewait_forwithtokio::fs::metadatawhen readiness also depends on type, size, permissions, or other metadata.wait_for_pipeaccepts a bare name or a fully-qualified\\.\pipe\...path. It returnsErrorReason::Unsupportedoff Windows. A busy pipe counts as ready: all server instances being occupied still proves the endpoint is serving.wait_for_httpsends a minimal HTTP/1.1 GET and reads only a bounded status line. It is plain HTTP only: no TLS, redirect following, response bodies, or authentication policy. Redirects count only if your status predicate accepts them; usewait_forwith your own HTTP client for HTTPS or richer checks.
Prompt-aware waiting (wait_for_output)
wait_for_line only ever sees complete lines — text with its terminator. An
interactive prompt is the opposite: Password: , passphrase: , (y/N) , a
REPL >>> are all written without a trailing newline and then blocked on,
waiting for you to answer. Such a prompt never becomes a line, so wait_for_line
(and stdout_lines) cannot see it until the stream ends — the "wait for the
prompt, then answer it" dialog can't be expressed line by line.
wait_for_output(predicate, within) closes that gap. Its stderr counterpart is
wait_for_stderr_output; both are expect-style
primitive (in the spirit of rexpect): it matches the child's current
un-terminated output tail — the partial line the pump has decoded but not yet
split — and hands it back so you can answer over take_stdin(). PTY is the
motivating case (a merged terminal stream is full of un-terminated prompts), but
it is not PTY-specific — a plain piped run benefits too (e.g. a progress
meter that rewrites one line without a newline).
Unlike the line probes, these methods do not consume their selected stream
and are repeatable: a whole session can be a sequence of
wait_for_output → answer turns, one per prompt.
use processkit::{Command, ProcessRunner}; use processkit::testing::{Reply, ScriptedRunner}; use std::time::Duration; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { // A hermetic stand-in for a tool that prompts, reads a secret, then // continues — no real subprocess. `Reply::dialog` emits the prompt, waits // for the answer over stdin, then emits the continuation. let runner = ScriptedRunner::new() .fallback(Reply::dialog("Password: ", "granted, welcome> ")); let mut run = runner .start(&Command::new("login").keep_stdin_open()) .await?; // Wait for the un-terminated `Password: ` prompt (a whole line never arrives). let prompt = run .wait_for_output(|tail| tail.ends_with("Password: "), Duration::from_secs(5)) .await?; assert!(prompt.contains("Password:")); // Answer it, then wait for the next (also un-terminated) prompt. run.take_stdin().expect("interactive stdin").write_line("s3cret").await?; let cont = run .wait_for_output(|tail| tail.contains("welcome>"), Duration::from_secs(5)) .await?; assert!(cont.contains("welcome>")); run.finish().await?; Ok(()) }
Semantics, and how it differs from wait_for_line:
- Un-terminated tail, not lines.
wait_for_outputmatches the live partial line; once the child terminates it with a newline it becomes a complete line (seen bywait_for_line/stdout_lines, not here). So answer a prompt before waiting for the next, and reach forwait_for_linewhen you want whole lines. - Non-consuming and repeatable. It only peeks while the background pump keeps
draining stdout under your
OutputBufferPolicy; the handle stays usable fortake_stdin, furtherwait_for_outputcalls, andfinish.wait_for_linetakes the stdout stream and so is one-shot. - Raw, not redacted. The predicate (and the returned fragment) see the tail
before any
capture_policyredaction (see Redaction at capture) — the same observation category ashandler/tee/raw_tee/output_bytes, not the redacted backlogwait_for_lineandProcessResultdraw from. A partial line can't be run through a per-line redactor, and a prompt is a synchronization token you match verbatim. The retained/finished output stays redacted independently — just match on prompts, not on secret-bearing partial text. - Same probe deadline. It fails with
ErrorReason::NotReadywhenwithinelapses (or stdout closes) with no match, never killing the child or arming the run'stimeoutwatchdog — exactly like the readiness probes above. - Choose the actual pipe. Use
wait_for_stderr_line/wait_for_stderr_outputwhen the tool reports readiness on stderr. Both keep stdout draining in the background. A PTY exposes one merged stream, so its stdout methods observe output originally written to either child stream.
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.
That boundary is why RunProfile carries no whole-tree counters — no I/O bytes,
no peak process count — even though ProcessGroupStats does. Those come from the
containment object, and a run has no whole-tree scope of its own to report them
under: started into a shared ProcessGroup, it shares that container with every
other run in it, and a container's counters cannot be split into per-run shares.
When you want a run's tree, give it a group of its own and read
ProcessGroup::stats — the same numbers,
named as the group's.
Lifecycle narration (tracing)
With the opt-in tracing feature, a run narrates its lifecycle on the
processkit target — spawn (pid, mechanism), the graceful-teardown transitions
(soft_signal → grace_started → drained / escalated / spared, each in a
stable phase field), and exited — so a subscriber can stamp each transition the
instant the layer that observed it crossed it, and map it to an event of your own
protocol. The teardown transitions are narrated the same way for a streaming
handle's own shutdown(grace) as for a whole-group ProcessGroup::stop, so you get
one uniform timeline. It is observation only (never a control surface) and never
carries argv/env; for the same teardown facts as a typed value returned after the
fact, see
ShutdownReport.
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
- Merging a stage's stderr into the pipe
- Unchecked stages
- Timeouts
- Streaming a live chain
- 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 ErrorReason::Exit; fails loud on a truncated capture |
checked() | full ProcessResult<String> | …raised as ErrorReason::Exit (untrimmed stdout) |
run_unit() | () | …raised as ErrorReason::Exit (output discarded) |
exit_code() | i32 | …its attributed code (no code → ErrorReason::Timeout/Signalled) |
probe() | bool | 0 → true, 1 → false, else Err |
parse(|s| …) / try_parse(|s| …) | T | …raised as ErrorReason::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 first_line probe is deliberately not a buffering pipeline verb: those
verbs consume the 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. To instead read a chain that keeps
running — wait for a banner line, then stream the rest — use
Pipeline::start(), which gives a chain the same live
streaming surface a single Command::start() gives a process.
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 likewise write to the pipe, full stop — see below.
- The last stage's stdout is the chain's own output and is honored as configured
when that destination is supported (
stdout(Null),stdout_file(..)and friends behave as they do on a standalone non-PTY command). - Inner stages' stderr is captured per-stage for pipefail diagnostics;
only the last stage's stdout reaches you. A stage explicitly marked with
merge_stderr_in_pipe()is the exception described below.
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(()) }
A non-final stage's stdout belongs to the pipe
Because the link between two stages is unconditional, the chain — not the stage —
owns a non-final stage's stdout. If such a stage carries stdout(StdioMode::Null),
stdout(StdioMode::Inherit), or a
file redirect,
the pipe wins and that configuration goes inert for the run:
- the stage's bytes go to the next stage and nowhere else — not to
/dev/null, not to the parent's terminal, not to the file; - a configured redirect file is neither created nor truncated, so a log a previous run left there is left intact;
- the stage's stdout observers (
on_stdout_line,stdout_tee,stdout_raw_tee) do not fire — as they do not on any non-final stage, whose stdout goes to the next stage rather than through a pump that could run them; - stderr is untouched: it keeps its own configuration and stays available for pipefail diagnostics.
This is the same precedence inner-stage stdin has had all along, applied to the
other end of the same relay, and it is what keeps a Command that carries a
redirect for standalone use reusable as a pipeline stage. The alternative — treating
the combination as a configuration error — would reject such a stage before spawn;
what neither does is quietly drop the producer's output and start the next stage on
an empty stdin.
The one non-final stdout configuration that is rejected rather than overridden is
use_pty(): a PTY master carries a merged terminal stream that cannot feed a later
stage at all, so the chain fails before spawn with ErrorReason::Unsupported.
The final stage may use a PTY only with its supported merged Piped stdout and
Piped stderr destination; Inherit, Null, and file redirects are rejected.
The pipeline checks every PTY stage's destination before it starts any stage, so
an invalid final-stage destination cannot run an upstream command or open a
redirect file first. A non-final PTY retains the more specific pipeline-topology
error because it cannot provide the next stage's stdin pipe at all.
Merging a stage's stderr into the pipe
Mark a non-final stage with merge_stderr_in_pipe() for the shell-free
equivalent of command 2>&1 | next:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // Feed both compiler diagnostics and ordinary output to the filter. let matches = Command::new("cargo").args(["check", "--message-format=short"]) .merge_stderr_in_pipe() .pipe(Command::new("grep").arg("warning")) .output_string() .await?; println!("{}", matches.stdout()); Ok(()) }
The child receives two cloned handles to the same anonymous-pipe writer for stdout and stderr. Their bytes therefore enter one OS pipe in the order the child writes them; processkit does not merge two independently-read streams in userspace. The normal pipeline boundary still relays that one reader into the next stage's stdin, as described at the top of this page.
The marker is opt-in per stage and only applies when that stage has a downstream
neighbor. It is a no-op on a standalone command and on the final pipeline stage.
For an affected stage, the downstream pipe overrides its configured stdout and
stderr destinations. The shared anonymous-pipe wiring is supported on Unix and
Windows; activating it on another target fails before spawn with
ErrorReason::Unsupported.
This changes pipefail diagnostics deliberately: the merged stage no longer has
a separate stderr capture. If it is blamed for a failure, the result's stderr
is empty; its diagnostic bytes may instead be present in the final stdout after
flowing through the remaining stages. Leave the marker off when retaining the
culprit stage's dedicated stderr is more important than filtering the combined
stream.
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 teardown is attempted for every stage sub-group and, when confirmed, the result reportstimed_out. The result keeps best-effort stdout and stderr already captured by the final stage before teardown, subject to the same buffer/truncation policy as a normal capture. A successful fan-out kill is followed by a bounded wait for every stage-owned handle to observe and reap its child. If any group kill is rejected or a stage does not reach that terminal disposition within the teardown bound, the call fails closed withErrorReason::Teardown(TeardownCause::Timeout) and retains that prefix instead of claiming a timed-out result over a potentially-live stage.- 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'sErrorReason::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 ErrorReason::Cancelled
after confirmed teardown; a refused terminal step is ErrorReason::Teardown. (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.
Cancellation finalization collects the stages' bounded terminal dispositions,
including siblings that settle after the first Cancelled error. If any sibling
reports an unconfirmed kill/escalation/reap, its ErrorReason::Teardown
deterministically outranks ordinary cancellation, stdin, and output-pump errors;
the first teardown error observed in completion order supplies the retained OS
source. A failed fallback group kill also keeps the cause latched when proactive
teardown first fired: stage-local or chain cancellation reports
TeardownCause::Cancellation, while an earlier stage failure remains
TeardownCause::PipelineFailure even if a token fires during the drain grace.
The same terminal-confirmation bound follows a successful fallback fan-out kill,
so buffered collection cannot wait forever for an unreaped sibling.
Streaming a live chain
The verbs above buffer the whole run. For a long-lived chain you read from
rather than wait out — journalctl -f | grep ERROR, tail -F access.log | jq
— Pipeline::start() returns a PipelineSession: the multi-stage analogue of
the RunningProcess a single Command::start() gives you. It
streams the last stage's stdout as it arrives while every inner stage drains
in the background, then folds the same pipefail outcome at finish().
use processkit::prelude::StreamExt; use processkit::{Command, Finished, Outcome}; #[tokio::main] async fn main() -> processkit::Result<()> { // journalctl -f | grep --line-buffered ERROR let mut session = Command::new("journalctl").arg("-f") .pipe(Command::new("grep").args(["--line-buffered", "ERROR"])) .start() .await?; let mut lines = session.stdout_lines()?; // the *last* stage's stdout, live while let Some(line) = lines.next().await { println!("error: {line}"); // …break out when you've seen enough, then tear the chain down… break; } drop(lines); // Fold the pipefail outcome. The last stage's stdout was already streamed, // so — like RunningProcess::finish — none is re-bundled; `outcome` and // `stderr` come from the pipefail-attributed (culprit) stage. session.start_kill()?; // stop the whole chain now let Finished { outcome, stderr, .. } = session.finish().await?; if outcome != Outcome::Exited(0) { eprintln!("chain ended {outcome:?}: {stderr}"); } Ok(()) }
The session mirrors RunningProcess:
stdout_lines()/events()— the last stage's stdout (lines) or its full lifecycle (Started→ interleaved stdout+stderr →Exited), each consume-once (a second take is a loudErr, never a silently-empty stream).wait_for_line(pred, within)— wait for a readiness banner on that stream without tearing the chain down (anErrorReason::NotReadyon timeout, like the single-process probe).finish()— the streaming analogue ofoutput_string(): the pipefail-attributed stage'soutcomeand its ownstderrin aFinished(no stdout — you already streamed it). A chain-widePipeline::timeoutthat elapsed reportsOutcome::TimedOut; aPipeline::cancel_onthat fired while the chain was still running surfaces asErrorReason::Cancelled— exactly as in the buffering verbs. After a timeout/cancellation/proactive fan-out kill,finish()boundedly waits for every stage-owned handle to confirm its child was reaped. A rejected kill or expired confirmation isErrorReason::Teardownand takes priority over the initiating disposition. As for a singleCommand::cancel_on, that disposition is first-observation-wins: every stage is observed while the session is live (each inner stage by its background drain, the last stage by the session's own watcher), so a token fired after the chain had already ended does not rewrite its real outcome intoCancelled, even whenfinish()is called afterwards.start_kill()and kill-on-drop — stop the whole chain now, or drop the session and every stage's tree dies.start_kill()attempts every stage group and returnsErrorReason::TeardownwithTeardownCause::ExplicitKillwhen any group rejects the operation; kill-on-drop remains the backstop.
Whole-chain teardown still applies while streaming: any stage's checked
failure proactively tears the chain down (so a quiet upstream can't hold a failed
live chain open), and the chain-wide timeout / cancellation bounds
the session regardless of which stage is slow. That includes the last stage,
and without waiting for finish() — a standing watcher observes it for a terminal
outcome, so holding an unfinished session is not a way for a failed chain's
upstream to keep running. The teardown is prompt rather than instantaneous: it
lands within one probe interval of the last stage's failure plus the short drain
grace. A last stage that exits cleanly deliberately fires no teardown — a chain
whose stages all succeed is not a failed chain.
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 ErrorReason::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. (The abandonment is about the outcome, not the manner: the goodbye can be made soft with
cancel_grace, and it is still always an error.)
Timeouts
Command::timeout(d) kills the whole process tree at the absolute 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.reason(), processkit::ErrorReason::Timeout { .. })); Ok(()) }
Where each verb lands:
Every row below assumes the terminal kill/escalation/reap was confirmed. If the
OS rejects that teardown, the consuming path fails closed with
ErrorReason::Teardown { cause, operation, source, .. } instead of reporting a
successful timeout disposition over a potentially-live child/tree. Captured
stdout/stderr remains attached as a bounded best-effort prefix.
| Verb | Deadline expiry becomes |
|---|---|
output_string() / output_bytes() | Ok result with timed_out() == true, code() == None, partial output kept |
run() / exit_code() / probe() / checked() | ErrorReason::Timeout { program, timeout, inactivity, stdout, stderr, .. } — the partial output captured before the kill is attached (err.diagnostic() surfaces a hung tool's last words) |
first_line(pred) | ErrorReason::Timeout (the line never arrived in time) |
start() + streaming | after confirmed teardown the stream ends at the deadline (tree killed, pipes closed); finish reports outcome == Outcome::TimedOut; an unconfirmed teardown is ErrorReason::Teardown |
ensure_success() on a captured result | ErrorReason::Timeout, checked before the exit code |
Pipeline | chain deadline → timed_out result; per-stage deadlines fold into pipefail |
Output-inactivity watchdog
Command::inactivity_timeout(d) kills a single run when neither stdout nor
stderr has produced bytes for d. Its clock starts at spawn and resets on every
successful read from either stream, including merged PTY output:
use processkit::{Command, Outcome}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let result = Command::new("build-tool") .timeout(Duration::from_secs(30 * 60)) .inactivity_timeout(Duration::from_secs(5 * 60)) .output_string() .await?; if result.outcome() == Outcome::InactivityTimedOut { eprintln!("build produced no output for five minutes"); } Ok(()) }
The first watchdog to fire wins. Both use the same whole-tree teardown and honor
timeout_grace / timeout_signal, but their results remain distinct:
Outcome::TimedOut means the absolute runtime expired;
Outcome::InactivityTimedOut means the output went quiet. timed_out() is true
for either; inactivity_timed_out() identifies the latter. Checking verbs turn
both into ErrorReason::Timeout after confirmed teardown; its inactivity field
carries the distinction and its timeout field is the window that fired. A
failed kill/escalation/reap is ErrorReason::Teardown with
TeardownCause::InactivityTimeout or TeardownCause::Timeout instead.
This first iteration applies to individual command runs, including capture, streaming, first-line consumption, and PTY mode. Readiness probes drain output and therefore refresh the activity clock, but do not arm either command watchdog themselves. Pipeline-wide and supervisor-wide inactivity policies are separate concerns; commands they launch still keep their own configured watchdog.
Two distinct deadline families to keep apart:
Command::timeout/Command::inactivity_timeout— the run's own contracts, this section.- The readiness probes'
withinparameter — givesErrorReason::NotReadyand never kills the child.
Graceful timeout
By default a watchdog hard-kills at once. Add timeout_grace(d) to give the
tree a chance to clean up: when it fires the tree 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 by default: timeout_grace is accepted but the deadline kills the job
atomically. To get a real soft-shutdown window there, add
windows_graceful_ctrl_break():
the direct child is spawned in its own console process group and, at the deadline,
sent a console CTRL_BREAK before the grace window, then TerminateJobObject'd if
it hasn't exited — the same timeout_grace window now actually meaning something on
Windows. It works only for a child that shares this process's console (a
create_no_window / DETACHED_PROCESS child never receives the event and rides
the grace to the hard kill), and sends CTRL_BREAK rather than the Unix
timeout_signal. See Process groups → Windows opt-in.
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.
Observing the grace window
With the tracing feature on, the teardown driver narrates each grace-window
transition live on the processkit target — soft_signal (the SIGTERM /
CTRL_BREAK was issued), grace_started (the wait began, with grace_ms), and then
one of drained (the tree exited in time), escalated (the grace elapsed and the
tree was hard-killed), or spared (a non-escalating stop left survivors) — each
carried in a stable phase field and stamped by your subscriber at the instant it
happens. This is the same soft-signal → grace → drain/kill ladder every graceful
path drives (timeout_grace, cancel_grace,
RunningProcess::shutdown, ProcessGroup::stop), so
you get one uniform timeline whichever verb fired it. For the same facts after the
teardown returns — as a typed value rather than log events — reach for
ProcessGroup::stop's ShutdownReport.
Neither is a control surface: both are observation only, and never carry argv/env.
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, ErrorReason}; 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.reason(), ErrorReason::Timeout { .. }) || matches!(e.reason(), ErrorReason::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 anErrorReason::Io(InvalidInput) at launch.) CancelledandTeardownerrors are never retried, classifier or not. A cancelled token stays cancelled; an unconfirmed teardown may have left the failed attempt live, so another attempt could duplicate it.
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 tears the run's tree down and makes every consuming path
report ErrorReason::Cancelled once terminal teardown is confirmed. If the OS
rejects a required kill/escalation/reap, the path reports
ErrorReason::Teardown with TeardownCause::Cancellation instead:
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().map_err(|e| e.into_reason()), Err(processkit::ErrorReason::Cancelled { .. }) )); Ok(()) }
The contract, path by path:
| Situation | Behavior |
|---|---|
Cancel during run / output_string / output_bytes / wait / profile / exit_code / probe | confirmed tree teardown → ErrorReason::Cancelled { program }; refused/unconfirmed teardown → ErrorReason::Teardown |
Cancel during streaming (stdout_lines) | after confirmed teardown the stream ends and finish reports ErrorReason::Cancelled; an unconfirmed teardown is ErrorReason::Teardown |
| 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 |
| A token fires after an exit that was already observed | not retroactive — the cancel disposition is latched at the first observation of the exit and never moved, so the real outcome stands. Observers are: a terminal verb's own wait (it observes the exit inside the call), a liveness-polling readiness probe on a handle you hold (wait_for / wait_for_path / wait_for_port / …, but not the line-oriented wait_for_line, which only reads output), and — in a PipelineSession — every inner stage's background drain plus the session's watcher for the last stage, so for a chain this holds even when finish() is called after the token fired |
A token fires when nothing has observed the exit yet (a started run you never probed) | ErrorReason::Cancelled — finish is itself the first observation, and a token already cancelled when the exit is observed wins there by design, however long ago the child actually exited. This is the same rule, not an exception to it: probe the handle (or use a terminal verb) if you need the true outcome of a child that may already be gone |
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 ErrorReason::Cancelled once the token fires — a cancelled stream that closes without a match is reported as cancellation, not Ok(None) |
| Teardown manner | hard kill by default; SIGTERM → grace → SIGKILL with cancel_grace (the ordinary outcome is Cancelled either way; an unconfirmed terminal step is Teardown) |
Graceful cancellation
By default a cancellation hard-kills at once — the mirror image of a bare
watchdog. Add cancel_grace(d) to give the tree a chance to clean up: when the
token fires the tree is sent SIGTERM (or the signal chosen with cancel_signal,
which needs the process-control feature), allowed up to the grace window to
exit, then SIGKILLed. This is the exact same SIGTERM → wait → SIGKILL ladder as
timeout_grace — the same driver, the same phase
observability — just fired by the token instead of the deadline. A
signal-handling child that exits ends the grace early.
use processkit::{CancellationToken, Command}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let shutdown = CancellationToken::new(); let job = tokio::spawn({ let token = shutdown.child_token(); async move { Command::new("long-export") .cancel_on(token) // SIGTERM, wait up to 5s for a clean shutdown, then SIGKILL .cancel_grace(Duration::from_secs(5)) .run() .await } }); shutdown.cancel(); // Ctrl-C, sibling failure, … // Still an error — only the manner of the teardown changed. assert!(matches!( job.await.unwrap().map_err(|e| e.into_reason()), Err(processkit::ErrorReason::Cancelled { .. }) )); Ok(()) }
This is the knob for the recommended "one shared token for the whole app"
shutdown pattern: without it, a Ctrl-C on the parent SIGKILLs every child
outright, with no chance to flush state, finish a transaction, or remove a
pidfile. RunningProcess::shutdown(grace) already covered that for handles you
started by hand; cancel_grace extends it to the bulk verbs, the streamed runs,
and the Supervisor — everything the token reaches.
Ground rules:
- Opt-in, and inert by default. Without
cancel_graceevery cancellation path behaves exactly as it always has (immediate hard kill). It also does nothing without a token. - The confirmed outcome never changes. Whether the child exited on the soft
signal or was killed after the grace, every consuming path reports
ErrorReason::Cancelled. A refused escalation/reap is not confirmation and reportsErrorReason::Teardowninstead. Both are terminal under retry. - Independent of the timeout knobs.
cancel_grace/cancel_signaldo not read (and are not filled in by)timeout_grace/timeout_signal, so a command can say farewell differently for "the caller changed its mind" than for "the deadline expired".cancel_signaldefaults toSIGTERM, liketimeout_signal. - Same teardown scope as any cancellation. An own-group run tears down the
whole tree; a shared-
ProcessGrouprun reaches only its direct child (its grandchildren remain the documented shared-group gap). - Windows has no POSIX signal tier — exactly as for
timeout_grace, the soft tier is the best-effortWM_CLOSEpost plus the opt-inwindows_graceful_ctrl_break()console event; a tree with neither is killed atomically andgracegoes unused. - Cancel racing the deadline. Cancellation still wins the race (the outcome is
Cancelled, neverTimedOut). Which teardown runs follows the cancellation policy: withoutcancel_gracethat tie hard-kills, as it always did; withcancel_graceit takes the graceful ladder, because that is what the caller asked cancellation to do.
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 ErrorReason::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.reason(), processkit::ErrorReason::Cancelled { .. })); Ok(()) }
Teardown failure vs. every initiating disposition. The timeout/cancellation
ordering above applies only after terminal teardown is confirmed. A non-routine
kill, graceful escalation, or reap failure takes priority over timeout,
inactivity timeout, cancellation, pipeline failure, stdin failure, and pump
failure. ErrorReason::Teardown preserves the initiating TeardownCause, the
original io::Error, and any prefix the bounded output drain already captured;
callers must assume the child/tree may still be live.
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 |
| "…and let them shut down cleanly first" | cancel_grace alongside it |
| "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 structured type covers every failure mode — spawn, exit, timeout,
cancellation, IO — so a caller pattern-matches on a typed variant instead of
parsing strings. Since 3.0, Error is a pointer-sized wrapper — a
Box<ErrorReason> — that keeps Result<T, Error> small on the pervasive
run path; the variants live on the re-exported [ErrorReason] enum, reached
through err.reason() (or moved out with
err.into_reason()). The read accessors (code(), is_timeout(),
diagnostic(), …) and Display/Debug work on Error directly, unchanged.
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.
Upgrading from 2.x: a direct
match err { Error::Exit { .. } => … }becomesmatch err.reason() { ErrorReason::Exit { .. } => … }. See Upgrading.
- Variant reference
- Variants that look alike but aren't
- Classifiers
- Total classification:
kind() - Stable machine identifiers
- Matching under
#[non_exhaustive] - Errors and retries
- Errors and supervision
Variant reference
| Variant | Where it comes from | Recommended reaction |
|---|---|---|
ErrorReason::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. |
ErrorReason::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. |
ErrorReason::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". |
ErrorReason::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). |
ErrorReason::Timeout { program, timeout, stdout, stderr, stdout_bytes } | Command::timeout elapsed and terminal teardown was confirmed, on a checking verb | is_timeout(). Whatever was captured before teardown is attached — diagnostic() often explains the hang. Consider composing into a retry classifier: e.is_timeout() || e.is_transient(). |
ErrorReason::Teardown { program, cause, operation, source, stdout, stderr, stdout_bytes } | A timeout, inactivity window, cancellation, explicit pipeline kill, or pipeline-stage failure required terminal teardown, but a kill/escalation/reap was rejected or bounded terminal confirmation expired and the crate could not confirm that the child or tree is gone | is_teardown(). Treat the process as potentially live. Inspect cause and operation; source retains the OS error when one was returned, or is a synthetic io::ErrorKind::TimedOut when confirmation expired. Use diagnostic() / stdout_bytes() for the bounded prefix captured before the failure. This is terminal under retry even when a classifier accepts it. |
ErrorReason::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. |
ErrorReason::Parse { program, message } | The run succeeded but try_parse, a typed JSON/NDJSON verb, or a caller's own parser feeding Error::parse could not make sense of the output | Generic message values are caller-built and retained in full; JSON helpers store only bounded, control-escaped error detail and raw fragments. Display/Debug apply an additional preview cap. |
ErrorReason::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. |
ErrorReason::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. Admission-time only: whether a cap that was applied later fired is a separate, post-run question — see below. |
ErrorReason::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. |
ErrorReason::Cancelled { program } | The run's CancellationToken fired and terminal teardown was confirmed | 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. A refused teardown is Teardown, not a false Cancelled. |
ErrorReason::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. |
ErrorReason::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. |
ErrorReason::Io(source) | A low-level IO error from the crate's own machinery — driving a child, controlling a process group, validating PTY geometry, 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. PTY geometry validation also uses InvalidInput for a zero axis on every platform or a Windows ConPTY axis above i16::MAX; other input-validation failures use the same error kind, so it does not uniquely identify PTY geometry. |
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(ErrorReason::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.Teardownoutranks the disposition that requested it.Timeout, inactivity timeout, andCancelledare reported only after terminal teardown is confirmed (or an already-exited race is confirmed by reap). If a kill/escalation/reap fails,Teardowncarries the initiatingTeardownCause, originalio::Error, and the bounded output prefix instead of claiming a potentially-live tree was handled. It also outranks stdin and pump errors from the same finalization window.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.ResourceLimitmeans "the cap couldn't be applied", never "the cap fired". It is an admission error: the requested value was nonsensical (Invalid), the platform has no mechanism with whole-tree resource accounting (Unsupported), or a capable mechanism rejected this request (Unenforceable).ProcessGroup::with_optionsraises it before anything runs and hands back no group.ProcessGroup::update_limitsraises it against an already-running group, where it does not mean "nothing changed": the caps are written axis by axis, so a part-way failure can leave a mix of old and new in force — see updating a live group. A cap that was applied and then actually stopped the tree produces no error at all — the child just exits non-zero (or dies bySIGKILL), exactly like a self-inflicted crash. For that question ask the group afterwards:ProcessGroup::limit_evidence()returns a per-axisLimitVerdict—Tripped(authoritative kernel/OS evidence that this cap fired),NotTripped, orUnknown(no evidence available on this mechanism — deliberately not folded into a "no"). Exit codes and signals are never consulted for it, precisely because they cannot tell a cap-driven kill from an ordinary failure. So:ResourceLimiton the error path,limit_evidenceon the result path — two different questions, and this error's semantics are unchanged by it. They can land on the same axis on a live group, though: an axis named by a failedupdate_limitsis still read from the counters, because that failure is not a rollback. (Left as a bare code span, not adocs.rslink:LimitVerdictships in the next release, so adocs.rsURL would 404 until then.)
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_teardown() | Teardown only | Fail closed: the child/tree may still be live. Never retry automatically. |
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 / Teardown / Signalled (Some) | Stderr if it carries text, else stdout (git/jj put decisive output there), else None. |
timeout_duration() | Timeout (Some(dur)) | The run deadline that elapsed. None everywhere else — including NotReady, whose probe deadline is a separate clock (matching is_timeout()'s scoping). |
output_overflow() | OutputTooLarge (Some(OutputOverflow)) | The overflow counters as one snapshot — total_lines() / total_bytes() / max_lines() / max_bytes() — instead of destructuring the #[non_exhaustive] variant. None for every other error. |
unsupported_operation() | Unsupported (Some(&str)) | The operation description ("signal(Hup)", "suspend"). None for every other variant. |
kind() | Every error (total) | The one coarse routing bucket — see Total classification. Never None; every error has a kind. |
Total classification: kind()
The classifiers above answer one question each. When you route every
failure onto your own shape — a CLI folding each disposition into a distinct
process exit code, a cross-language binding raising a matching exception class, a
router picking a retry policy — you want one total classification instead of
a chain of is_* checks ending in "everything else". err.kind() is that: a
compact [ErrorKind]
with one bucket per operational disposition, derived from each variant's
existing semantics (not invented), and covering every variant — present and
future — through an exhaustive match inside the crate.
ErrorKind | Derived from ErrorReason | Machine name |
|---|---|---|
NotFound | NotFound | not_found |
Spawn | Spawn whose source is not a permission denial | spawn |
PermissionDenied | the PermissionDenied subset of Spawn / Io | permission_denied |
ResourceLimit (limits feature) | ResourceLimit | resource_limit |
Unsupported | Unsupported | unsupported |
Timeout | Timeout | timeout |
Cancelled | Cancelled | cancelled |
Teardown | Teardown | teardown |
Exit | Exit | exit |
Signalled | Signalled | signalled |
Other | CassetteMiss, Parse, NotReady, OutputTooLarge, Stdin, and a non-PermissionDenied Io | other |
kind() is a routing answer, deliberately coarser than the variant — it is
not a replacement for matching reason() when
you need the details (the exit code, the captured streams, the timeout duration,
which limit failed). It stays consistent with the point classifiers:
is_not_found() ⇔ kind() == NotFound, is_permission_denied() ⇔
PermissionDenied, is_timeout() ⇔ Timeout, is_teardown() ⇔ Teardown,
and so on.
ErrorKind mirrors std::io::ErrorKind: it is #[non_exhaustive] and carries
an Other bucket, so a downstream match needs a catch-all arm — which is
exactly what makes it forward-compatible.
#![allow(unused)] fn main() { use processkit::{Error, ErrorKind}; // Fold every failure onto your own exit codes — one arm per kind you care // about, a catch-all for the rest (kinds are `#[non_exhaustive]`). fn exit_code(err: &Error) -> i32 { match err.kind() { ErrorKind::NotFound => 127, ErrorKind::PermissionDenied => 126, ErrorKind::Timeout => 124, ErrorKind::Exit => 1, // A future kind (or one behind a feature this build doesn't enable, // e.g. ResourceLimit) routes here instead of breaking the build. _ => 70, } } let err = Error::parse("jq", "unexpected token"); assert_eq!(err.kind(), ErrorKind::Other); assert_eq!(err.kind().name(), "other"); assert_eq!(exit_code(&err), 70); }
Stable machine identifiers
When you publish a machine-readable contract over this crate's types — a CLI's JSONL schema, a cross-language binding, a structured log field — you need one canonical string per enum variant, not a table you hand-maintain (and that silently mislabels a new variant as "unknown"). The reporting and configuration enums carry that table for you:
The language-neutral, canonical dictionary is
spec/identifiers.json. The table below explains
the Rust API that produces it; both shapes describe the same contract and must
change together.
| Method | On | Direction |
|---|---|---|
name() -> &'static str | Mechanism, Outcome, ErrorKind, TeardownCause, ParentDeathCleanup, SoftStopScope, SoftSignal, StopReason, LimitKind, LimitReason, LimitVerdict, StdioMode, LineTerminator, OverflowMode, OutputStream, Priority, RestartPolicy, RlimitResource, ProcessEvent, SupervisionEvent | A short, lowercase snake_case identifier for the variant. |
name() -> Option<&'static str> | Signal | Some(id) for a curated signal; None for the raw-number Signal::Other (render its i32 instead). |
from_name(&str) -> Option<Self> | every enum above except Outcome, ErrorKind, ProcessEvent, SupervisionEvent, and SoftSignal — LimitVerdict included, so a recorded tripped / not_tripped / unknown parses back | Parse an identifier back into the value; None — not a default — for an unrecognized name. |
The identifiers are a compatibility surface, held stable like the rest of
the public API: a new variant gets a new identifier, and an existing
identifier is never renamed without a major release. They are diagnostic
names — a stable vocabulary, not a frozen record schema — and the opt-in
report-serde feature puts that very vocabulary on the wire rather than
minting a second one: it implements serde::Serialize for the crate's report
types, and every enum in them serializes as its name()
({"kind": "exited", "code": 0, "signal_number": null}, never serde's derived
{"Exited": 0}), so a consumer that adopted the dictionary and a consumer that
serializes a report read the same strings. The single exception is the one the
table above already records: Signal::Other has no curated identifier, so a
Signal travels as its identifier string when curated and as a bare i32 when
not — confined to the signal key, while the raw exit-signal number an
Outcome carries is the separate key signal_number. That feature is
Serialize-only
(these values are reported, never supplied back — see the report-only enums
listed below), it never puts captured output, argv or environment values on the
wire, and it promises the spelling of what it emits, not a frozen field set:
every report type is #[non_exhaustive], keeps its fields private, or both —
grown, not frozen — so a minor release may add a key, never rename one, and a
consumer must ignore unknown keys. See the crate-root report-serde section
for the full rules.
Mechanism and ParentDeathCleanup use the spellings downstream tools
already publish (job_object/cgroup_v2/process_group,
whole_tree/direct_child_only/none), so adopting them needs no migration;
the FreeBSD reaper mechanism adds process_reaper in the same shape.
SoftStopScope (the group-axis soft-stop reach, process-control) reuses the
same whole_tree and none spellings for its shared cases, adding
opt_in_members for the Windows partial-reach case. LimitVerdict (limits)
is the one entry that is not an error classification at all: it is the
post-run answer to "did this cap fire?" (tripped / not_tripped /
unknown), while LimitKind / LimitReason describe the admission-time
ResourceLimit failure — two different questions, never two spellings of one
answer (see Variants that look alike
but aren't), and both carry the same
stability promise.
#![allow(unused)] fn main() { use processkit::{Mechanism, Priority}; // Forward — a stable identifier for machine-readable output: assert_eq!(Mechanism::CgroupV2.name(), "cgroup_v2"); assert_eq!(Priority::BelowNormal.name(), "below_normal"); // Inverse (a config value, a CLI flag, a call from another language) — an // honest `None` on an unrecognized name, never a silent default: assert_eq!(Priority::from_name("below_normal"), Some(Priority::BelowNormal)); assert_eq!(Priority::from_name("turbo"), None); }
The enums the table above lists as from_name exceptions report a name() but
take no inverse, because they are classifications, events or fates the crate
reports and never accepts back. Outcome::name()
reports the disposition only (exited / signalled / timed_out) — the name
alone can't carry the exit code or signal number (read those from
code()
/ signal()), and an Outcome is always reported, never supplied. ErrorKind
is the same: a total failure classification the crate hands you via
Error::kind(), never one you supply back in. Read
the fuller payload from the [ErrorReason] variant when a name isn't enough.
ProcessEvent and SupervisionEvent similarly identify lifecycle event kinds;
their payloads carry the process output, outcome, timing, or supervision detail.
SoftSignal (process-control) names which fate a graceful stop's best-effort
soft-signal tier met — sent / unsupported / failed — an observation made
after a teardown, never a request; the Signal it concerns travels separately
(ShutdownReport::attempted_signal).
Signal::name() returns Option because the Other(i32) escape hatch has no
curated name (it still has a from_name).
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, ErrorReason}; #[tokio::main] async fn main() -> processkit::Result<()> { let err = Command::new("maybe-missing-tool").run().await.unwrap_err(); match err.reason() { ErrorReason::NotFound { .. } => eprintln!("is it installed?"), ErrorReason::Timeout { .. } => eprintln!("hit its deadline"), ErrorReason::Cancelled { .. } => { /* caller-initiated, nothing to log */ } ErrorReason::Teardown { cause, operation, .. } => { eprintln!("{cause:?} teardown failed during {operation}") } ErrorReason::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(()) }
ErrorReason::Cancelled and ErrorReason::Teardown are never retried,
whatever the classifier says. A cancelled token stays cancelled; an unconfirmed
teardown may have left the failed attempt live, so launching another copy would
compound it. 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. ErrorReason::Cancelled is
terminal here too — supervision returns Err(Cancelled) instead of
restarting into a still-cancelled token, give_up_when or not.
ErrorReason::ResourceLimit and ErrorReason::Unsupported specifically are how a
sandboxing request fails loud instead of silently doing less than asked —
see Running untrusted children for the hardening
checklist that relies on exactly that honesty.
Next: Running commands · Timeouts, retries & cancellation · Supervision · Running untrusted children
Troubleshooting
Start here when you have a symptom but do not yet know which processkit subsystem owns it. The sections below give a short diagnosis and route to the guide that owns the full contract.
Before changing code, preserve the structured evidence:
- inspect
Error::reason()/Error::kind()andError::diagnostic()instead of parsingDisplaytext; - record
ProcessGroup::mechanism()(or usehost_containment()before any group exists); - note whether the run owns a private group or uses a shared group —
RunningProcess::kills_tree_on_drop()answers that directly; - distinguish the command's lifetime deadline from a readiness probe's
withindeadline.
| Symptom | First distinction |
|---|---|
| A child survived drop | Private owner, shared group, deliberate detach, external/remote process, or POSIX setsid escape? |
NotFound, but the tool is installed | Bare-name lookup or an explicit path? Which effective PATH / PATHEXT was used? |
ResourceLimit while creating a group | Invalid value, unsupported mechanism, or an existing but undelegated cgroup? |
| The process runs, but no output appears | End-of-run capture, incomplete line, child-side pipe buffering, or non-piped output? |
| PTY output contains ANSI/VT garbage | Retained text that can be sanitized, or an exact/raw sink that intentionally stays byte-accurate? |
| PTY window size is rejected | Zero axis, or a Windows ConPTY dimension outside signed COORD range? |
wait or a stream never finishes | Open stdin, undrained full-duplex output, missing line terminator, or a live process/descendant? |
| Graceful shutdown does not work on Windows | Windowed WM_CLOSE, opted-in console CTRL_BREAK, or hard-kill-only member? |
Timeout instead of NotReady — or vice versa | Run contract versus non-killing readiness observation? |
A child survived drop
First identify what was dropped and who owns containment:
Command::start()creates a private group. Its handle reportskills_tree_on_drop() == true; dropping it hard-kills the local tree.ProcessGroup::start()creates a shared-group handle. It reportsfalse: the separately heldProcessGroupcontrols the tree's lifetime, so stop or drop that group (or callstart_kill()when only the direct child should stop). Keeping anArc<ProcessGroup>clone alive keeps the owner alive too.spawn_detached()is the deliberate exception. DroppingDetachedChilddoes nothing because the child was explicitly launched to outlive this process.- A process spawned outside processkit is outside the boundary until
adopt()(with itsChildhandle) oradopt_external()(with only its pid). Adoption moves the named process, not descendants it already created; the POSIX process-group backend can only track an already-executed adopted child individually.adopt_externaladditionally reportsUnsupportedon FreeBSD and the other BSDs, where the crate has no start-time reader to anchor the pid on — a refusal, so a process left running there was never contained. - A group whose members die to a teardown it never ran, or whose
startsuddenly fails "job is full" (Windows). Adopting a pid that already belonged to another Job Object nests this crate's job under that one, so the outer job's terminate or close reaches these members (later-started ones included) and its limits bind them. The mirror image on Linux cgroup v2: adopting a process takes it out of its previous cgroup, so an outside supervisor's teardown and limits stop applying to the process it thought it held. Neither is reverted on drop — see platform support. adopt_externalreturned "pid … was recycled while it was being adopted". The number changed hands inside the call, so nothing was adopted. Read the rest of that message before retrying: on Linux cgroup v2 it also says whether the number could be moved back out of this group's cgroup, and in the case where it could not, the process now holding the number is a member of this group and will be killed by its teardown — drop or tear down that group promptly if that is not acceptable.- Nothing reaps a process adopted by pid. No exit status for it appears anywhere
in this API, so a
wait-shaped answer for it has to come from whoever is its actual parent; on the POSIX process-group backends an exited one that nobody reaps stays a zombie, keeps probing alive through a graceful shutdown's whole grace, and cannot be cleared byescalate_to_kill. - A command reached through
sshis local containment only: the localsshclient dies, but the remote command needs remote-side containment.
Then inspect the mechanism. JobObject and CgroupV2 are real tree
containers. ProcessGroup is intentionally reported as the weaker fallback:
a descendant that calls setsid can escape it. Also remember that a process
abort or SIGKILL can skip Rust Drop. Windows still closes the Job Object and
reaps the whole tree; Linux's opt-in kill_on_parent_death() reaches only the
direct child, and macOS/BSD have no equivalent owner-death hook.
If an ordinary private-group child survives a normal Rust drop without one of those boundaries, capture the mechanism, platform, program shape, and a minimal reproducer — that is not an expected lifecycle outcome.
Full contracts: process ownership and adoption, deliberate detachment, containment mechanisms, and the remote-execution boundary.
NotFound, but the tool is installed
NotFound means processkit could not resolve the program under the command's
actual launch rules. Check these in order:
- A bare name is searched on the command's effective child
PATH(and on Windows withPATHEXT).env_clear(),inherit_env(...), an explicitPATH, or a missing extension can make that different from the interactive shell where you verified the installation. - A program containing a path separator is not searched on
PATH.current_dirdoes not portably re-anchor a relative program path; combine a child working directory with an absolute program path. prefer_local(dir)affects only bare names. A relative preferred directory is resolved against the parent's real current directory, not the command'scurrent_dir.- Run
Command::resolve_program()(orprocesskit::whichfor a bare name). It is a spawn-free preflight using the samePATH/PATHEXT, executable-bit, environment, andprefer_locallogic as the real launch.
If preflight finds the file but launch still fails, inspect whether the error is
actually Spawn: permissions, a bad cwd, or a Windows .cmd/.bat that
needs cmd.exe are launch failures, not missing programs.
Full contracts: program and working-directory resolution,
prefer_local,
spawn-free preflight,
and NotFound versus Spawn.
ResourceLimit while creating a group
Do not parse the English detail. Read limit_kind() and limit_reason():
Invalidmeans the requested value itself is invalid;Unsupportedmeans there is no whole-tree resource accounting for that limit (no container at all, or — on FreeBSD — a reaper that contains but does not account);Unenforceablemeans a suitable mechanism exists but this process cannot apply the cap.
On Linux, processkit's per-tree limits require the process to own the real
cgroup v2 hierarchy root so it can enable controllers. A container cgroup
namespace root is not that root, and a normal systemd session, scope, or service
is not delegated this way. Both ordinary and privileged containers therefore
commonly return Unenforceable; requesting a cap fails loud instead of silently
creating an unbounded group.
Inside Docker/Kubernetes, use the orchestrator's memory/CPU limits as the outer
boundary. Use processkit's limits on a host where the process genuinely owns
the cgroup root (typically a minimal non-systemd init on bare metal or a VM).
Kill-on-drop can still fall back to a POSIX process group when no per-tree cap
was requested; it is the unenforceable protection request that must fail.
Full contracts: container limits versus processkit limits, resource-limit semantics, and cgroup prerequisites.
The process runs, but no output appears
Separate transport from the child's own buffering:
- One-shot capture (
output_string,run) returns output when the child exits. For live output, usestart()plusstdout_lines(), a line handler, or a tee. - A line stream emits only after a terminator. An interactive
Password:or REPL prompt without\nneedswait_for_output, notwait_for_line. - A child may block-buffer stdout when it sees a pipe. Prefer the tool's own
unbuffered/line-buffered switch when available. If its behavior is genuinely
isatty()-gated, enable theptyfeature and useuse_pty(). StdioMode::Inherit,Null, andstdout_file*intentionally bypass the capture pump in ordinary pipe mode. A PTY instead exposes its merged stdout and stderr only as piped logical stdout, so combininguse_ptywith those destinations (or a separate stderr destination) is rejected before spawn; its separate stderr capture is empty on a supported PTY run.
PTY is a semantic change, not just a flushing switch: it merges streams, uses terminal line framing, and may add control sequences. Keep pipes when stream identity or exact bytes matter.
Full contracts: streaming stdout, prompt-aware waiting, interactive/TTY launch, and the PTY platform matrix.
PTY output contains ANSI/VT garbage
Terminal applications emit color, cursor movement, alternate-screen, OSC
title/hyperlink, and other VT sequences. use_pty() does not emulate a screen.
Opt into sanitize_vt() (or the per-stream stdout_sanitize_vt() /
stderr_sanitize_vt() variants for pipe-capable wrappers) when retained text
should be plain.
Sanitization is deliberately scoped to the capture backlog. Handlers, decoded
tees, raw tees, and output_bytes() still see exact input; sanitize inside
those sinks if they also need clean text. PTY framing is separately \r-aware
by default; an explicit LineTerminator::Newline can make redraw-style progress
look delayed even after escapes are removed.
Full contract: PTY output hygiene.
PTY window size is rejected
pty_size and resize_pty accept u16 arguments, but zero is not a usable PTY
axis and is rejected as ErrorReason::Io (InvalidInput) on every platform.
Windows ConPTY has the narrower 1..=32767 range because COORD stores signed
16-bit fields; Unix accepts every non-zero u16 value. ProcessKit rejects an
out-of-range ConPTY request rather than silently clamping it, so retry with a
representable size. A refused launch starts no child, and a refused live resize
does not mutate or close the existing terminal.
Full contract: PTY window size and live resize.
wait or a stream never finishes
Work through the resources that can still be open:
- Stdin is closed by default. If you opted into
inherit_stdin(), the child can wait for EOF from the parent's terminal or pipe. If you calledkeep_stdin_open()and took the writer, callProcessStdin::finish()or drop it when the conversation ends. - Do not perform a large interactive write while nothing drains output. A full stdout/PTY buffer can stop the child reading stdin while the parent's stdin write is also blocked. Read and write concurrently.
stdout_lines()andwait_for_line()need a complete line. For an unterminated prompt, usewait_for_output().wait_any()does not pump output. Drain chatty children first (or concurrently) before racing them.- A still-running descendant can hold an inherited pipe open after its direct
parent exits. Bound the run with
Command::timeout()orinactivity_timeout(). On a shared-group handle those watchdogs stop the direct child; stop the owning group when the whole shared tree must end.
A readiness within bound is not a run bound: NotReady leaves the child
alive. If giving up on readiness should end the run, explicitly shutdown() an
own-group handle, start_kill() a direct child, or stop the shared group.
Full contracts: interactive stdin and full-duplex deadlock,
wait versus drain,
stream deadlines, and
timeouts and inactivity.
Graceful shutdown does not work on Windows
Dropping a handle/group is always the hard safety net; it never waits for
application cleanup. Use shutdown, stop, or timeout_grace for a graceful
tier, then identify which Windows soft-close path the child can receive:
| Child shape | Soft-close path |
|---|---|
| Owns a top-level window | WM_CLOSE is posted automatically; the child has the grace window to exit. |
Console child spawned with windows_graceful_ctrl_break() | The direct child receives CTRL_BREAK, then survivors are terminated after the grace. |
create_no_window, DETACHED_PROCESS, or console child without the opt-in | No console event can land; teardown reaches the hard Job Object kill. |
| Adopted console child | Not a registered CTRL leader; only an owned top-level window can receive the automatic WM_CLOSE path. |
soft_stop_scope() is the side-effect-free check for the live group:
OptInMembers means at least a console/window target is reachable;
Unsupported means no soft target exists. The console event is CTRL_BREAK,
not CTRL_C, and the child must install the corresponding handler and exit
within the grace.
Full contract: Windows graceful teardown and graceful timeouts.
Timeout instead of NotReady — or vice versa
They answer different questions:
| Result | Clock | Side effect |
|---|---|---|
ErrorReason::NotReady | A readiness method's within argument | Observation stops; the child remains alive. |
Outcome::TimedOut / ErrorReason::Timeout | Command::timeout() or inactivity_timeout() | The applicable process tree/direct child is torn down. |
Readiness probes deliberately do not arm the command watchdog while they
poll. A probe can therefore return NotReady even if the command's absolute
deadline has passed; the following consuming verb (finish, wait,
output_string, and so on) enforces that run deadline and can then report
Timeout. Conversely, a probe whose output stream closes before a match returns
NotReady immediately because readiness can no longer happen — it does not wait
out the rest of within.
After NotReady, choose explicitly: keep waiting, stop the handle/group, or
surface startup failure. For checking code, branch on err.reason() /
is_timeout(); is_timeout() is intentionally false for NotReady.
Full contracts: readiness probes, deadline families, and look-alike errors.
Next: Errors · Streaming & interactive I/O · Platform support · Running in containers
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
- Liveness health checks
- Stopping
- Giving up on permanent failures
- Outcomes
- Live sessions:
start() - 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.
Liveness health checks
A RestartPolicy only ever reacts to a child that exits. It is blind to a
child that is still alive but wedged — a deadlocked server, a stuck event
loop, a process that stopped serving but never dies. From the policy's view that
child is "running", so it is never restarted. The opt-in health check adds
the missing dimension — the analogue of systemd's WatchdogSec and a container
liveness probe:
use processkit::{Command, Supervisor}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("my-server").args(["--port", "8080"])) .health_check( // Any async predicate: `true` = healthy. A port connect here; an // HTTP `/healthz` request, a heartbeat file, a custom check elsewhere. || async { tokio::net::TcpStream::connect("127.0.0.1:8080").await.is_ok() }, Duration::from_secs(5), // probe cadence ) .health_check_failures(3) // consecutive misses tolerated (default 3) .max_restarts(10) .run() .await?; println!("liveness kills: {}", outcome.liveness_kills); Ok(()) }
The probe re-runs every interval for the life of each incarnation, in the same
shape as the readiness wait_for check (it
takes no handle — it observes the child out-of-band, over a port/endpoint/file).
The first probe fires one interval after the incarnation starts, so a booting
child gets that long before liveness is judged; a healthy child then loops here
untouched for its whole lifetime. A zero (or otherwise degenerate) interval is
clamped to a small safe minimum rather than passed through as-is — it neither
panics nor turns the probe loop into a busy-spin, and the startup-grace promise
above still holds.
When the probe fails health_check_failures times in a row (any healthy
probe resets the streak, so a single blip is forgiven), the wedged incarnation is
force-killed — dropped, which kills it on drop under the default JobRunner
(the shared-group caveat from Errors and cancellation
applies) — and treated as a crash: it flows through the RestartPolicy,
backoff, the storm guard and max_restarts exactly like a real crash, and its
synthetic final result carries Outcome::Signalled. It does not consult
stop_when (there is no cleanly-completed run to evaluate). The effective grace
before a kill is roughly interval × health_check_failures.
A liveness kill counts toward the backoff escalation using how long the
incarnation actually stayed up before wedging — so a service that runs healthy
for a long stretch and only occasionally wedges restarts near base, while one
that wedges promptly after each restart self-throttles (the same uptime floor as
Backoff and jitter). Force-kills are counted in
SupervisionOutcome::liveness_kills. Under RestartPolicy::Never — a single
"run once, but kill it if it goes unresponsive" bound — the force-killed run ends
supervision with StopReason::Unhealthy. Left unset (the default), a supervisor
behaves exactly as it did before health-checking existed.
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.
A liveness kill is not a completed run, so it skips
gate 1 (stop_when) but still runs through the policy and gates 3–4 as a crash;
under RestartPolicy::Never it ends supervision with StopReason::Unhealthy.
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.
Fallible control predicates
The control predicates above are infallible — stop_when and give_up_when
return bool, health_check resolves to bool. When your predicate can itself
fail (an I/O probe, a classification that parses something, or — the driving
case — a callback in a wrapper over a language where any callback can throw),
each has a try_* twin that returns Result<bool, E> for any
E: Into<Box<dyn Error + Send + Sync>>:
| infallible | fallible twin | predicate returns |
|---|---|---|
stop_when | try_stop_when | Result<bool, E> |
give_up_when | try_give_up_when | Result<bool, E> |
health_check | try_health_check | impl Future<Output = Result<bool, E>> |
Ok(true) / Ok(false) behave exactly as the infallible twin's true /
false. An Err, however, aborts supervision and surfaces to the caller as
run()'s Err — an ErrorReason::Predicate (kind ErrorKind::Predicate)
carrying your predicate's own error verbatim as its source:
use processkit::{Command, Supervisor}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("my-server")) // The probe may itself fail — e.g. a health endpoint whose body your // parser rejects. An `Err` aborts supervision (surfaced as an // `ErrorReason::Predicate`) instead of voting "unhealthy". .try_health_check(|| async { probe_health().await }, Duration::from_secs(5)) .run() .await?; println!("stopped: {:?}", outcome.stopped); Ok(()) } #[derive(Debug)] struct ProbeError; impl std::fmt::Display for ProbeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "probe error") } } impl std::error::Error for ProbeError {} async fn probe_health() -> Result<bool, ProbeError> { Ok(true) }
The distinction is deliberate: a predicate error is never a fabricated
verdict. A normal stop_when/try_stop_when match (Ok(true)) ends supervision
with a benign SupervisionOutcome (StopReason::Predicate); a predicate Err
ends it with a failure the caller can tell apart by err.kind() == ErrorKind::Predicate and recover the original error from
err.reason()'s Predicate { source, .. }. A failing try_health_check probe
still tears the live incarnation down by the same kill a liveness failure uses —
so aborting on a probe error leaks no child.
Each twin is purely additive: setting try_stop_when (etc.) replaces its
infallible sibling on the same slot, and the infallible builders are unchanged.
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, Unhealthy, Stopped} outcome.storm_pauses; // failure-storm pauses taken (0 unless storm_pause is set) outcome.liveness_kills; // incarnations force-killed by a health check (0 unless health_check 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.
Live sessions: start()
run() owns supervision from start to finish and only hands back a
SupervisionOutcome at the very end — there is no handle to watch or steer it
while it runs. For a daemon / process-manager that needs a live view, call
start() instead of run(). It returns a SupervisionSession, a Send handle
to supervision running on a background task:
status()— a consistent snapshot (SupervisionStatus): whether supervision is still active, the live restart count, whether a failure-storm pause is in effect right now, and the current incarnation'spid/ start time (bothNonebetween incarnations, during a backoff, or for a capture-only test double). Poll it any time; it never races the loop.events()— take the session's typed, one-consumerSupervisionEventsstream. It reports incarnation starts and outcomes, launch-error classes, scheduled backoffs, storm pauses, health-check failures, give-up decisions, and the terminal reason. Each variant has a stablename()for structured logs and metrics. The history is bounded at 128 events; a slow consumer gets an explicitSupervisionEvent::Lagged { skipped }marker and resumes from the oldest retained event instead of growing the supervisor without limit.stop(grace)— stop the current child gracefully (its normalSIGTERM→ waitgrace→SIGKILLpath, under the default own-group runner) and end supervision withStopReason::Stopped— a deliberate, honest reason distinct from a crash, an exhausted budget, a cancellation, or astop_whenmatch. A stop taken during a backoff (no child alive right now) cuts the sleep short and ends at once, launching no further incarnation. An incarnation with no own group to shut down (a shared-ProcessGroupor a capture-only child) is stopped by firing its cancel token instead — so give the supervised command acancel_graceif that child, too, should get a soft signal and a window rather than an outright kill.wait()— await the finalSupervisionOutcome, exactly whatrun()would have returned.
use processkit::prelude::StreamExt; use processkit::{Command, RestartPolicy, SupervisionEvent, Supervisor}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let mut session = Supervisor::new(Command::new("my-server")) .restart(RestartPolicy::Always) .start(); // supervision runs in the background from here // Take the stream once and drain it independently of the session handle. let mut events = session.events()?; tokio::spawn(async move { while let Some(event) = events.next().await { match event { SupervisionEvent::RestartScheduled { restart, delay } => { println!("restart {restart} after {delay:?}"); } other => println!("supervision event: {}", other.name()), } } }); // ... elsewhere: observe it live ... let status = session.status(); println!("active={}, restarts={}", status.is_active(), status.restarts()); // ... on shutdown: stop gracefully and read the final outcome ... let outcome = session.stop(Duration::from_secs(5)).await?; println!("stopped: {:?}", outcome.stopped); // StopReason::Stopped Ok(()) }
Live status and events are additions to the exit-driven policy and callbacks,
not replacements — supervision behaves identically to run(). Only a session stop() produces
StopReason::Stopped; run() never does.
Two handle contracts round it out: dropping a SupervisionSession without
wait()/stop() aborts the background task (no orphaned supervision task, and
under the default JobRunner the in-flight incarnation is killed on drop), and
start() needs a 'static runner because supervision moves onto a spawned task
— the borrowed shared-group form (with_runner(&group)) is available through
run(); own the group (by value, or an Arc<ProcessGroup>) to drive a shared
group from a session.
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(ErrorReason::Cancelled) immediately. The token never un-cancels, so a restart
could only produce another instantly-cancelled run — the supervisor refuses
the futile loop.
An ErrorReason::Teardown incarnation is terminal too: its child or descendants
may still be alive because the required kill/escalation/reap was not confirmed,
so starting a replacement would violate the supervisor's one-incarnation-at-a-time
contract. The original teardown error is returned without consulting restart
policy or give_up_when.
A fallible control predicate (try_stop_when /
try_give_up_when / try_health_check) that returns Err is likewise terminal:
run() returns Err(ErrorReason::Predicate) — kind ErrorKind::Predicate,
carrying your predicate's own error verbatim as its source — rather than a
fabricated stop/unhealthy verdict.
Next: Testing your code · Timeouts, retries & cancellation · Process groups
Observability
The crate can narrate what it does without you instrumenting a single call site, through two independent, additive, off-by-default seams:
tracing— one structured event per lifecycle transition (spawn, exit, timeout/cancel firing, per-phase graceful teardown, retries, supervisor restarts). Good for a human reading a live log or a distributed trace.metrics— aggregate counters and histograms over the same moments (how many runs, how long they took, how they ended, how often you retried or restarted). Good for a dashboard, an SLO alert, or a fleet-wide latency percentile.
Both derive everything from data the crate already computes on the run's existing path — they add no extra work, no second wall-clock read, and no new public API. With a feature off, its call sites compile out entirely; the kill-on-drop tree guarantee and every run's behavior are byte-identical either way.
The motivating consumer is an orchestrator supervising many agentic-CLI subprocesses: SLO/latency signals across a production fleet, without hand-wiring a timer around every spawn.
metrics
Enable the metrics feature and the crate emits into whatever global
metrics recorder your process installs. The crate
ships no exporter of its own — you pick the backend (Prometheus, OpenTelemetry,
StatsD, a test recorder …) exactly as you would for your own application metrics,
and the crate's series simply appear alongside yours.
# Cargo.toml
[dependencies]
processkit = { version = "…", features = ["metrics"] }
metrics = "0.24"
# …plus any metrics-compatible exporter, e.g. metrics-exporter-prometheus.
Install a recorder once at startup — before you run any commands — and that is the entire wiring:
For a dependency-free exporter example, the runnable
examples/metrics.rs
installs a tiny in-process recorder, launches two self-exec children, and prints
the counters and histogram summaries it captured:
cargo run --example metrics --features metrics
// Any `metrics::Recorder` works; a Prometheus exporter is shown for concreteness.
use metrics_exporter_prometheus::PrometheusBuilder;
PrometheusBuilder::new()
.install() // registers the global recorder + scrape endpoint
.expect("install Prometheus recorder");
// From here on, every processkit run feeds the counters/histograms below.
With the feature on and a recorder installed, an ordinary run needs no metrics code at all:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // Emits `processkit.spawns.total`, then on completion `processkit.runs.total` // + `processkit.run.duration_seconds` (+ `processkit.exit_code.total` on a // clean exit) — no per-call-site instrumentation. let _ = Command::new("git") .args(["rev-parse", "HEAD"]) .output_string() .await?; Ok(()) }
What is measured
| Metric | Type | Labels | Meaning |
|---|---|---|---|
processkit.spawns.total | counter | program, mechanism | A child process was launched, under the given containment mechanism (job_object / cgroup_v2 / process_group / process_reaper). |
processkit.runs.total | counter | program, outcome | A run reached a terminal outcome. outcome is one of exited / signalled / timed_out / cancelled — the timeout/cancel/signal tally. |
processkit.run.duration_seconds | histogram | program | Wall-clock duration of a completed run, taken from the run's own already-measured elapsed time (no extra clock read). |
processkit.exit_code.total | counter | program, code | Per-exit-code tally, recorded only for a genuine self-exit (a signalled/timed_out/cancelled run has no exit code). |
processkit.retries.total | counter | program | A retryable failure was about to be retried (see retries). |
processkit.restarts.total | counter | — | A Supervisor was about to restart its child. |
processkit.storm_pauses.total | counter | — | A supervisor tripped its failure-storm guard and paused restarts. |
processkit.teardown.total | counter | phase | A graceful teardown reached a terminal phase: drained (exited within the grace), escalated (grace elapsed → hard kill), or spared (grace elapsed, a non-escalating stop left survivors). |
processkit.teardown.duration_seconds | histogram | phase | Grace-window duration of a completed whole-tree teardown, keyed by terminal phase. |
The restarts/storm_pauses counters carry no program label: a supervisor
drives a runner, not one fixed command, so it has no single program name.
Secret hygiene
The label values are exactly the same secret-safe facts the tracing seam logs:
a program name, a containment mechanism, an outcome class, an exit code, a
teardown phase. Command argv and environment values — which routinely
carry tokens, passwords, and connection strings — are never emitted, in a
metric name or a label. This is a structural guarantee, not a review convention:
the emission layer is handed only the already-derived program name and outcome,
never the Command, so there is no code path through which argv or env could
reach a series. The crate carries a test that runs a real child whose argv and
environment hold sentinel secrets and asserts neither ever surfaces in an emitted
name or label.
Cardinality
Labels are kept to bounded dimensions so a time-series backend is not flooded
with unique series. In particular the process pid — harmless on the one-shot
tracing seam but effectively unbounded as an aggregation key — is deliberately
not a metric label; nor is any file path or argv fragment. The one numeric
label, the exit code, is bounded in practice (a process exit code is a small
integer). If your programs are themselves unbounded in number (say, a distinct
binary per tenant), pre-aggregate or drop the program label in your exporter's
relabeling — the same discipline you would apply to any high-cardinality
dimension.
tracing
The tracing feature emits one event per lifecycle transition on the
processkit target — spawn/exit (with program, pid, mechanism), timeout and
cancellation firing, the per-phase graceful teardown timeline
(soft_signal → grace_started → drained/escalated/spared), retry
attempts, and supervisor restarts and storm pauses. It follows the same
secret-hygiene rule: argv and environment values are never logged. Enable it with
features = ["tracing"] and subscribe with any tracing subscriber; the two
seams are independent and can be enabled together or separately.
See also
- Supervision — the restart/backoff/storm machinery the
restarts/storm_pausescounters instrument. - Timeouts, retries & cancellation — where the
retriescounter and thetimed_out/cancelledoutcomes come from. - Process groups — the containment mechanisms behind the
mechanismlabel and the graceful-teardown phases.
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 - How the crate tests itself: the stress tier
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), parse/try_parse (feed stdout to a closure),
and — with json — output_json (typed deserialization). These are all callable
on a &dyn ProcessRunner; being generic over the closure or output type,
parse/try_parse/output_json/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 ErrorReason::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 raiseErrorReason::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 withErrorReason::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. whenhas a fallible twin,try_when— the predicate returnsResult<bool, E>(anyE: Into<Box<dyn Error + Send + Sync>>).Ok(true)/Ok(false)match exactly aswhen'strue/false, but anErraborts the run: the verb fails with anErrorReason::Predicate(kindErrorKind::Predicate) carrying the predicate's own error verbatim, and no later rule is consulted. TheScriptedRunnercounterpart of theSupervisortry_*predicate twins — for exercising a wrapper whose match callback can throw.- No match and no fallback is a loud error (
ErrorReason::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:
The runnable
examples/record_replay.rs
does the complete round trip with a self-exec child and a temporary cassette,
including a scrub hook and a guard proving replay never spawns:
cargo run --example record_replay --features record
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 for visibility. Opt in to a stricter key with match_on_cwd / match_on_env (below). A scrub_with hook transforms args symmetrically on record and replay (and cwd too when matched), so redacted keys still hit |
| 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 ErrorReason::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 ErrorReason::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 ErrorReason::Io(InvalidData), a missing file keeps NotFound |
| Err results | recorded and replayed faithfully (ErrorReason::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 ErrorReason::CassetteMiss. ErrorReason::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 (ErrorReason::Unsupported) in both modes — a lossy-UTF-8 text fixture can't reproduce exact raw bytes; capture bytes from a real or scripted runner |
By default only env values are excluded. program, args, cwd, stdout,
and stderr are stored verbatim and can carry secrets (a --password=…
flag, a token echoed to output). For a fixture that can contain one, opt into a
field-aware scrub hook:
#![allow(unused)] fn main() { use processkit::{JobRunner, ProcessRunnerExt}; use processkit::testing::{CassetteField, RecordReplayRunner}; let scrub = |field: CassetteField, text: &str| match field { CassetteField::Argument | CassetteField::Stdout | CassetteField::Stderr => text.replace("s3cret", "<redacted>"), CassetteField::Cwd => text.replace("/home/alice", "<workspace>"), _ => text.to_owned(), }; let recorder = RecordReplayRunner::record("fixtures/tool.json", JobRunner::new()) .scrub_with(scrub); // run commands, then recorder.save()?; // Apply the same deterministic hook before replay lookup: a live secret argv // is transformed to the same redacted key stored in the fixture. let replayer = RecordReplayRunner::replay("fixtures/tool.json")? .scrub_with(scrub); let _ = (recorder, replayer); Ok::<(), processkit::Error>(()) }
The hook changes only the persisted entry. Record-mode callers still receive
the real unsanitized stdout/stderr; replay naturally returns the scrubbed text
that exists in the fixture. Use the same deterministic hook on both runners.
Still 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:
For the full design guide — default precedence, dynamic env resolution, retry/error policy, typed JSON, and worked wrappers — see Building typed CLI clients. This section focuses on the test seam.
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 `ErrorReason::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 → ErrorReason::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.
How the crate tests itself: the stress tier
The doubles above are for testing your code. The crate's own suite has an
extra, non-functional layer you'll only meet if you hack on processkit
itself: an opt-in stress tier (tests/stress/) that exercises behaviour at
scale and under multiplicity — spawn bursts, cancel storms, mass teardown, and
buffer floods. It is gated behind the PROCESSKIT_STRESS env var (so the normal
PR matrix compiles it for free but pays nothing) and runs on its own scheduled /
on-demand CI leg (.github/workflows/stress.yml), never in the fast PR run.
Its most searching scenario is the seeded randomized-interleaving harness
(tests/stress/interleave.rs). A seed deterministically generates a random
combination of the public lifecycle operations — wait / start_kill /
take_stdin / the streaming verbs / group signal / suspend / shutdown —
over a shared ProcessGroup and children of three shapes
(short-lived, long-lived, stdout-flooding), then runs them concurrently so real
thread scheduling explores the orderings. After every combination it checks the
invariants that must hold under any interleaving:
- no operation panics or returns an impossible
Error(only the documented, operational variants); - no live descendants or zombies survive (reusing the tier's reap helpers);
- no background pump/drain/supervision task faulted unnoticed;
- every descriptor and group is released (Job handle closed, cgroup directory removed).
The seed fixes the plan, not the scheduling: a re-run with the same seed
replays the identical sequence of operations, so a failure is reproducible. The
offending seed is printed on failure — re-run just that plan in a loop with
PROCESSKIT_STRESS_SEED=<seed> while you debug. Run the tier locally with:
PROCESSKIT_STRESS=1 cargo test --all-features --test stress -- --test-threads=1
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
ErrorReason::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 x64
(windows-latest), Windows ARM64 (windows-11-arm), 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.
Both Windows architectures run the same three feature configurations for clippy and the same real-subprocess test suite. The ARM64 leg therefore executes the crate's most platform-specific surface — Job Objects, CTRL delivery, per-thread suspend/resume, and ConPTY — under the ARM64 Windows ABI instead of only proving that x64 code compiles.
FreeBSD has two explicit tiers rather than inheriting confidence from macOS:
check-freebsd cross-compiles the library and binaries for
x86_64-unknown-freebsd, while test-freebsd boots a real FreeBSD 14.4 VM.
The VM runs the hermetic library suite plus representative real-subprocess
checks for mechanism selection, kill-on-drop, Unix signal delivery, graceful
TERM shutdown, and — since FreeBSD has a containment backend of its own — the
procctl reaper's resistance to a setsid escapee. This directly exercises the
FreeBSD reaper backend and the process-group machinery underneath it against
FreeBSD's kernel and userland without making the already broad full-suite matrix
depend on an emulated VM. It is also the only place that backend runs at all:
the procctl code cannot execute on any other host, so a change to it that
passes check-freebsd is compiled, not tested, until this job says otherwise.
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 |
ProcessReaper | FreeBSD | The procctl(2) process reaper (PROC_REAP_ACQUIRE); every descendant stays in the reaper's subtree from fork on, listed by PROC_REAP_GETPIDS and torn down by PROC_REAP_KILL — a child that setsids away does not escape |
ProcessGroup | macOS, the other BSDs, Linux fallback | POSIX process groups (setpgid); teardown is killpg; tracked per started/adopted child |
FreeBSD is not "macOS with a different name". It is the only non-Linux unix with a real whole-tree containment primitive, so it gets its own mechanism rather than the process-group fallback the other BSDs use. Three consequences are worth knowing up front:
- A
setsidescapee stays contained. The classic escape — a descendant that starts its own session, including the daemonising double-fork — leaves the process group but not the reaper's subtree.members(),signal,shutdownand kill-on-drop all still reach it. members()is the whole tree, one entry per process, not one per contained child (see the inspection matrix).- Orphans re-parent to this process, not to
init. That is inherent to being the reaper, and this crate takes on the duty that comes with it: a re-parented descendant that exits iswaited for by the crate itself, so it does not accumulate as a zombie. Children this process forked itself are never touched by that sweep — their exit status belongs to whoever spawned them, processkit or not. Reaper status is acquired lazily (on the firstProcessGroup), never released while the process lives, and is shared harmlessly with an application that acquired it first.
To learn which mechanism you would get without creating a group — for a
spawn-free preflight / host-check that must have no side effects — call
host_containment(),
which returns a HostContainment (mechanism(), plus soft_stop_scope(),
parent_death_cleanup(), and crate_version()) from read-only checks that create
no container and spawn no process (on Linux: no cgroup directory). The predicted
mechanism matches a real ProcessGroup::new on the same host; two answers are
best-effort, both because the query must create nothing. The Linux cgroup answer
probes whether a cgroup could be created rather than creating one, and the
FreeBSD answer reports ProcessReaper without acquiring reaper status (acquiring
it is a real, permanent side effect on the process — available unprivileged on
every supported kernel, so the prediction holds in practice). Either way a live
ProcessGroup::mechanism() remains the final word. See Running in
containers → Which containment mechanism you get.
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 (ErrorReason::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 | FreeBSD reaper | macOS/other BSD |
|---|---|---|---|---|---|
| Kill-on-drop, whole tree | ✅ | ✅ | ✅ groups-based | ✅ subtree-based | ✅ groups-based |
Survives a descendant's setsid / double-fork | ✅ | ✅ | ❌ escapes | ✅ | ❌ escapes |
Graceful shutdown (TERM → grace → KILL) | 🟡 auto WM_CLOSE soft tier for windowed children; opt-in CTRL_BREAK for console children; else atomic kill | ✅ | ✅ | ✅ | ✅ |
adopt an external child | ✅ (future forks contained) | ✅ (future forks contained) | 🟡 exec'd child tracked individually | ✅ (future forks contained) | 🟡 exec'd child tracked individually |
adopt_external a process by pid | ✅ (anchored on the process object; future forks contained) · ⚠️ a target already in another job nests this group under that job | ✅ (anchored on cgroup membership; future forks contained) · ⚠️ takes the process out of its previous cgroup | 🟡 anchored on the start-time token, tracked individually | ❌ Unsupported — no start-time reader to anchor on | 🟡 macOS: anchored, tracked individually · ❌ other BSDs: Unsupported |
Adopting by pid is not neutral for containment the process already has. The two ⚠️ cells above point opposite ways, and neither is undone when the group is dropped:
- Windows — a process may belong to several nested jobs (Windows 8+), so a
target already in an orchestrator's or CI agent's job is not refused for that
reason alone; the assign that succeeds makes this crate's job a child of
that outer job. Membership of a child job is membership of every job above it, so
from then on the outer job's terminate/close reaches this group's members —
including ones started after the adoption — and the outer job's limits bound them.
Whether the assign succeeds also depends on this group's own state: observed on
Windows 11, an empty group takes such a process in, while a group that already
holds a member outside that outer job's hierarchy is refused
ERROR_ACCESS_DENIED. Adopt first, then start. - Linux cgroup v2 — membership is exclusive, so the write that moves the process into this group's cgroup takes it out of the cgroup it was in: the teardown and limits of whoever contained it before stop applying. The kernel does not report what a task left behind, so nothing restores it.
- Process-group backends — nothing is taken away: containment there is tracking,
not moving. The
setpgidthe call attempts is permitted only for a not-yet-exec'd child of this process; where it does apply, the process becomes a group leader of its own.
Windows has no POSIX signal tier, so for a windowless child with no opt-in 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. That promise covers what
this group does; it cannot bind a job above it. A group that adopted a process
already belonging to another Job Object is nested under that job (see the
adopt_external note above), and the outer job's own terminate or close still
reaches these survivors.
Automatic soft tier for windowed children (Windows). Before the atomic kill,
a graceful shutdown posts WM_CLOSE to every top-level window owned by a live
member and then drives the same signal → wait → escalate loop the unix backends
use. A windowed child (Electron app, desktop tool, windowed service) that handles
WM_CLOSE can flush and exit within the grace; any survivor is then
TerminateJobObject'd, the same hard fallback as before. This is automatic — no
opt-in — and WM_CLOSE is posted, never sent, so a hung window can never
block teardown. A windowless tree with no console opt-in still hard-kills promptly
at the deadline (no grace wait is introduced for it), so its timings are unchanged.
Opt-in soft tier for console children (Windows).
Command::windows_graceful_ctrl_break()
gives Windows a real soft-shutdown trigger: the direct child is spawned in its
own console process group (CREATE_NEW_PROCESS_GROUP), and at graceful teardown
it is sent GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid) before the grace
window — driven through the same signal → wait → escalate loop the unix
backends use. A console child that handles CTRL_BREAK (many CLIs, Node,
Python, Go services do) can flush and exit within the grace; any survivor is then
TerminateJobObject'd, the same hard fallback as before, so containment is never
weakened. Boundaries: it works only for children that share this process's
console — a child spawned create_no_window / DETACHED_PROCESS (or a GUI /
service parent with no console) never receives the event and simply rides the
grace to the fallback kill; the event is CTRL_BREAK (not CTRL_C, which a new
process group disables); and only the direct child is addressed (an adopted
child is not). Off Windows the builder is a no-op — the graceful ladder already
sends a real signal.
Signals & freezing
| Capability | Windows | Linux cgroup | Linux pgroup | FreeBSD reaper | macOS/other BSD |
|---|---|---|---|---|---|
Arbitrary signal (Hup, Usr1, Other(n), …) | 🟡 Kill, plus Int/Term as a best-effort soft close (CTRL_BREAK + WM_CLOSE); others unsupported | ✅ | ✅ | ✅ PROC_REAP_KILL | ✅ |
Signal reaches a setsid escapee | n/a | ✅ | ❌ | ✅ | ❌ |
soft_stop_scope() (soft Int/Term reach) | 🟡 OptInMembers with a console/windowed member, else Unsupported | WholeTree | WholeTree | WholeTree | WholeTree |
suspend / resume | 🟡 per-thread counts | ✅ cgroup.freeze | ✅ SIGSTOP/CONT, honest verdict | ✅ SIGSTOP/CONT, whole subtree, honest verdict | ✅ SIGSTOP/CONT, honest verdict |
On every Unix mechanism, a signal broadcast surfaces a real send failure as
an Err rather than swallowing it — an EINVAL (an out-of-range Other(n)) and
an EPERM against a live, non-zombie member (a uid-changed child, or a
seccomp/container restriction) — consistent with the "never silently skipped"
philosophy. The process-group backend (macOS/BSD, Linux fallback) matches the
cgroup verdict by checking the target's run state after an EPERM, so a harmless
zombie-only EPERM — and every EPERM on the bare BSDs (no state reader) —
stays swallowed; the FreeBSD reaper makes the same discrimination from the
kernel's own zombie flag on the failing member, which PROC_REAP_KILL names
explicitly; an ESRCH race (the member already exited) is still success,
and Signal::Other(0) returns Ok having delivered nothing (the POSIX existence
probe). That probe never takes a delivery path — FreeBSD routes it back through
the process group, which has no state reader on any BSD but macOS — so the
EPERM rule above does not extend to it there: on FreeBSD and the bare BSDs a
live target that rejects even the null signal is still an Ok, where Linux and
macOS surface it. suspend/resume on that process-group mechanism now apply
the same honest verdict to SIGSTOP/SIGCONT: a live-member EPERM is an
Err, while ESRCH, zombie-only EPERM, an empty group, and
BSD-without-state-reader EPERM remain Ok. The FreeBSD reaper freezes and
thaws with the same honesty — its SIGSTOP/SIGCONT go out through the very
PROC_REAP_KILL classification described above, so a live member's EPERM (and
an EINVAL/ECAPMODE that means the request never ran) surfaces, while a
drained subtree and a zombie-only refusal stay Ok. On the cgroup mechanism the
SIGSTOP/SIGCONT fallback used on pre-5.2 kernels (no cgroup.freeze)
surfaces failures the same way.
soft_stop_scope() answers, before you attempt a soft Int/Term, which
members it would reach — a side-effect-free SoftStopScope capability report read
from the group's live membership, so a caller cancelling on its own schedule can
decide up front instead of firing a signal and parsing an ErrorReason::Unsupported
back. The Unix backends always reach the whole tree (WholeTree, never
Unsupported); Windows reports OptInMembers when a live console-CTRL leader
(windows_graceful_ctrl_break) or a windowed member exists, and Unsupported
otherwise — exactly the split where signal(Int/Term) returns Ok versus
ErrorReason::Unsupported. It is the group-axis sibling of
kill_on_parent_death_scope() below, but read at runtime rather than fixed per
platform. Gated on the process-control feature, like signal.
Inspection & accounting
| Capability | Windows | Linux cgroup | Linux pgroup | FreeBSD reaper | macOS/other BSD |
|---|---|---|---|---|---|
members() | ✅ whole tree | ✅ whole tree | 🟡 leaders only | ✅ whole tree | 🟡 leaders only |
members_info() ppid / image / start time | ✅ | ✅ | ✅ (/proc) | ❌ all None | 🟡 macOS ✅, other BSDs all None |
| Group CPU / peak memory | ✅ | ✅ | ❌ count only | ❌ count only | ❌ count only |
Group io_read_bytes / io_write_bytes | ✅ job IO_COUNTERS | 🟡 io.stat, if the io controller is enabled | ❌ None | ❌ None | ❌ None |
Group peak_process_count | ❌ None (no such counter) | 🟡 pids.peak, if the pids controller is enabled | ❌ None | ❌ None | ❌ None |
Per-run cpu_time / peak_memory_bytes / profile | ✅ | ✅ | ✅ (/proc) | ❌ None | ❌ None |
members() is gated on the process-control feature; the CPU / memory / I/O
/ profile rows are gated on the stats feature.
The two whole-tree counter rows read a counter the container keeps, so they are
cumulative — a member that already exited is still in the number — where the group
CPU / peak-memory row is a per-live-member sum on the Linux cgroup backend. They
are also not directly comparable across platforms: a Job Object counts bytes moved
against any target (file, pipe, device), a cgroup's io.stat only what reached the
block layer. The 🟡 cells are honest None on a host that has not enabled the
controller for the group's cgroup — processkit enables only the controllers a
requested resource cap needs (memory/pids/cpu), never io. The per-run
profile row deliberately does not gain these: they are group-level facts, and
RunProfile says so rather than reporting a shared group's tree under a per-run
name.
Resource limits (limits feature)
| Capability | Windows | Linux cgroup | Linux pgroup | FreeBSD reaper | macOS/other BSD |
|---|---|---|---|---|---|
max_memory (whole tree) | ✅ | ✅ | ❌ | ❌ | ❌ |
max_processes | ✅ | ✅ | ❌ | ❌ | ❌ |
cpu_quota | 🟡 approximate | ✅ | ❌ | ❌ | ❌ |
Readiness probes
| Capability | Windows | Unix |
|---|---|---|
wait_for_line, wait_for_port, wait_for_http, wait_for | ✅ | ✅ |
wait_for_socket (AF_UNIX) | ❌ Unsupported | ✅ |
wait_for_pipe (named pipe) | ✅ | ❌ Unsupported |
wait_for_socket attempts a real Unix domain socket connection, so an orphaned
socket file does not count as ready. On Windows and any target without AF_UNIX,
it returns ErrorReason::Unsupported immediately.
wait_for_pipe provides the symmetric Windows local-IPC probe: it attempts a
real client open, treats ERROR_PIPE_BUSY as ready, and is unsupported on Unix.
Spawn-time controls
| Capability | Windows | Unix (all) |
|---|---|---|
inherit_env allow-list | ✅ | ✅ |
uid / gid drop | ❌ Unsupported | ✅ |
arg0 override | ❌ Unsupported | ✅ |
setsid | ❌ Unsupported | ✅ |
create_no_window | ✅ | no-op |
kill_on_parent_death | ✅ always on (kernel) | Linux: direct child; macOS/BSD (incl. FreeBSD): no-op |
kill_on_parent_death_scope() (abrupt-death reach) | WholeTree | Linux: DirectChildOnly; macOS/BSD (incl. FreeBSD): Unsupported |
priority | ✅ (priority class) | ✅ (nice/setpriority) |
cpu_affinity | ✅ (SetProcessAffinityMask) | Linux: ✅ (sched_setaffinity); macOS/BSD: ❌ Unsupported |
io_priority | ❌ Unsupported | Linux: ✅ (ioprio_set); macOS/BSD: ❌ Unsupported |
umask | ❌ Unsupported | ✅ |
rlimit (per process) | ❌ Unsupported | ✅ (setrlimit) |
Everything not listed — capture, streaming, interactive stdin, encodings, buffer policies, timeouts, retry, pipelines, supervision, the non-socket readiness probes, the test doubles, cassettes, cancellation — is platform-agnostic and behaves identically everywhere.
PTY mode (use_pty, the pty feature)
Command::use_pty
launches the child under a real pseudo-terminal instead of three pipes, so an
isatty()-gated tool works. Per platform:
| Unix | Windows | |
|---|---|---|
| Mechanism | openpty — the pty slave becomes the child's stdio, spawned through the same cgroup/process-group containment path as any other run | CreatePseudoConsole (ConPTY) — the child is created suspended, AssignProcessToJobObject'd to the same Job Object, then resumed |
| stdout/stderr | merged onto one piped logical-stdout master (ProcessResult::stderr is empty); Inherit/Null/file destinations on either descriptor are rejected before file open or spawn | merged, same destination contract and pre-spawn rejection |
| Echo control | terminal echo disabled (termios) so a written secret is not echoed back into the merged output | ConPTY has no portable per-write echo control — echo behavior is host-managed (not disabled) |
| Window size | winsize passed to openpty, default 80×24; set with Command::pty_size(cols, rows); non-zero u16 axes (1..=65535) | COORD passed to CreatePseudoConsole, default 80×24; same builder, but signed fields limit each axis to 1..=32767 |
| Terminal environment | TERM=xterm-256color; COLUMNS/LINES match the initial window size | COLUMNS/LINES match the initial window size; no synthetic TERM because ConPTY exposes VT handling through Windows console APIs |
| Live resize | RunningProcess::resize_pty(cols, rows) → TIOCSWINSZ on the master, which delivers SIGWINCH to the child's foreground process group | resize_pty → ResizePseudoConsole; no SIGWINCH — a console client learns of the new geometry on its next console query, and conhost may reflow asynchronously (delivery is best-effort, not synchronously observable) |
| Line framing | effective LineTerminator defaults to CarriageReturn (bare-\r progress frames stream as lines; an explicit line_terminator(...) wins), platform-agnostic | same |
| Output hygiene | opt-in Command::sanitize_vt() strips VT/ANSI escapes + lone control codes from the captured lines (backlog only), platform-agnostic | same |
| Containment | unchanged — cgroup/pgroup kill-on-drop reaps the whole tree | unchanged — Job Object kill-on-close reaps the whole tree |
Off by default and additive: with the pty feature off (or on but use_pty
unset) the three-pipe behavior is byte-identical. It is a minimal
single-master-fd mode, not a terminal emulator. The Windows master I/O runs
over the ConPTY pipes bridged by dedicated blocking threads (acceptable for the
low-volume interactive use case). Whether a Windows ConPTY child's standard
handles bind to the pseudoconsole (rather than a launcher-inherited console) can
depend on the host's console state; the containment and spawn are validated on CI.
Window size and live resize. The pseudo-terminal opens at 80×24 unless
Command::pty_size(cols, rows)
requests otherwise (a documented no-op on a non-use_pty command — the
three-pipe launch has no terminal to size). At spawn, COLUMNS and LINES
default to those exact initial dimensions. On Unix, TERM also defaults to
xterm-256color; Windows deliberately adds no TERM because native ConPTY
clients discover terminal/VT capabilities through the console APIs. An inherited
Windows TERM, if any, remains subject to the normal environment layering.
Explicit env("TERM", ...), env("COLUMNS", ...), env("LINES", ...) or
matching env_remove(...) calls always override these defaults, including when
the command also uses env_clear() or inherit_env(...).
Zero dimensions are rejected before launch on both backends. Windows also
rejects either axis above i16::MAX before creating ConPTY resources instead of
clamping it to a geometry that disagrees with the requested value and synthesized
environment; Unix has no corresponding signed limit and accepts every non-zero
u16 value.
Change a running session's size —
e.g. propagating a host window resize — with
RunningProcess::resize_pty(cols, rows).
It returns ErrorReason::Unsupported
(never a panic or a silent no-op) on a non-PTY run or once the child has exited.
On a live PTY, zero dimensions — and, on Windows, axes above i16::MAX — return
ErrorReason::Io with InvalidInput before the terminal is mutated.
The platform delivery differs: Unix TIOCSWINSZ raises SIGWINCH on the child
synchronously, whereas Windows ResizePseudoConsole has no signal — conhost
reflows and the client observes the new geometry on its next console query,
possibly a little later. Neither platform can rewrite the environment of an
already-running child, so COLUMNS/LINES describe the spawn-time size; terminal
applications should use their normal resize notification/query path after that.
Line framing and output hygiene (platform-agnostic). A PTY child writes CRLF,
draws progress with bare \r, and emits VT/ANSI escapes. Two decisions make the
merged output line-consumable, both identical on every platform: (1) use_pty
defaults the effective line terminator to
CarriageReturn
so \r progress frames stream as individual lines instead of one growing blob (a
non-destructive reframing; an explicit
line_terminator(...)
— even Newline — overrides it); and (2) the opt-in
Command::sanitize_vt()
strips escape sequences and lone control codes (keeping tabs) from the captured
lines — kept opt-in because it is destructive. Sanitization scopes to the capture
backlog only (the handlers, tees, and output_bytes still see the raw bytes),
mirroring
capture_policy;
see the streaming guide.
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 / ErrorReason::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.
Per-process rlimit vs. whole-tree limits. Command::rlimit is applied
before the uid/gid drop and inherited across exec/fork, so it works with every
Unix containment mechanism, including the FreeBSD reaper, macOS/BSD and the Linux
pgroup fallback. It
is not an aggregate tree counter: descendants share no single byte/file budget,
and each may lower its own limits or raise its soft value up to the inherited
hard value. Use the limits feature where a cgroup/Job Object can enforce a
genuine whole-tree cap; use rlimits for per-process hardening such as
RLIMIT_CORE=0, RLIMIT_NOFILE, or RLIMIT_FSIZE.
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 a child started with Command::setsid() keeps the
kill-on-drop guarantee instead of breaking out of it. What that coordination
cannot cover is a descendant calling setsid on its own — the crate never sees
it happen, and on the process-group mechanism the escapee is then outside every
group being tracked. That gap is real on macOS and the other BSDs (and on Linux
without a usable cgroup); it does not exist under a Job Object, a cgroup, or
FreeBSD's ProcessReaper, all of which track descent rather than group
membership.
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).
The reach of kill_on_parent_death() on abrupt owner death is reported
honestly, not overpromised. There is no portable Unix primitive that kills a
whole process tree when its creator dies — only Windows Job Objects give it for
free. So Command::kill_on_parent_death_scope() returns a ParentDeathCleanup
capability report — WholeTree on Windows, DirectChildOnly on Linux,
Unsupported on macOS/BSD — letting a wrapper (e.g. a CLI) state the actual
scope instead of guaranteeing a whole-tree cleanup the kernel cannot deliver.
This describes only the abrupt-death path (a SIGKILL of the owner, where Drop
never runs); ordinary graceful teardown still kills the whole tree everywhere.
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.
Launching a program you don't trust? Running untrusted children assembles the containment/limits/privilege-drop caveats above into a threat-aware checklist.
Next: Process groups · Running in containers · Running untrusted children · 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.
Preflight without spawning anything. A container entrypoint often runs a
host-check / doctor step before it starts any real work, whose whole point is
to have no side effects — no child spawned, no container created. Such a step
can't build a ProcessGroup just to read mechanism(), so use
host_containment() instead: it reports the mechanism a group would get here (plus the reach of a
soft stop and abrupt-owner-death cleanup, and the crate version) from cheap
read-only checks, creating nothing — on Linux specifically, no new cgroup
directory. The mechanism it predicts is the same one a real ProcessGroup::new
selects; the Linux cgroup answer is best-effort (it checks whether a cgroup
could be created rather than creating one), so in the rare window where a
writable-looking cgroup then rejects creation the live mechanism() is the final
word.
fn main() { let host = processkit::host_containment(); // Log the containment story a run *would* get — no container created, no // process spawned (on Linux: no cgroup directory left behind): println!( "mechanism={:?} parent_death={:?} processkit={}", host.mechanism(), host.parent_death_cleanup(), host.crate_version(), ); }
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. - If your own process dies abruptly (SIGKILL), its
Dropnever runs, so the cgroup/pgroup teardown that normally reaps the tree does not fire. The opt-inkill_on_parent_death()hardens this case, but its reach is platform-limited and reported honestly byCommand::kill_on_parent_death_scope()—WholeTreeon Windows,DirectChildOnlyon Linux (the direct child dies; a surviving cgroup keeps grandchildren alive),Unsupportedon macOS/BSD. There is no portable Unix kernel primitive that tears a whole tree down on its creator's abrupt death; when that guarantee matters, keep an outer container/cgroup (or a subreaper) that owns the tree independently of your process.
Why there's no "reattach to the leaked tree by its id" escape hatch. On Linux
the surviving cgroup could in principle be reaped by a later process that knew
its path (cgroup.kill acts on the whole subtree) — so it's natural to wish for a
stable container identifier you could record and later reattach to. The crate
deliberately does not expose one, because such an identifier cannot be made
identity-safe across reuse: a cgroup path (or a Windows Job Object name) is
freed when its container is torn down and can be handed to an unrelated later
container, so acting on a recorded id risks killing an innocent tree. Nothing
the crate can stamp on the container fixes this — a cgroup directory has nowhere to
hold a crate-chosen marker, its pseudo-filesystem inode/birth-time are not a
contractual identity, and giving a second process a live Windows job handle would
itself defeat the KILL_ON_JOB_CLOSE kill-on-owner-death that makes the abrupt-death
case already clean on Windows (the kernel reaps the whole tree there, so there is
no leaked tree to reattach to in the first place). The honest remedy is the same one
above: have an external owner — a subreaper, or a delegated systemd scope — own
the tree independently of your process, so its death, not a fragile recorded id, is
what triggers cleanup.
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.)
To observe that teardown — log whether the tree drained within your grace or had
to be hard-killed, export the elapsed as a shutdown metric, or drive your own race
between the orchestrator's SIGTERM and a control-socket "drain" command — use the
process-control ProcessGroup::stop(grace, escalate)
in place of shutdown(): the same teardown, returning a ShutdownReport (the
attempted soft signal, member counts before/after, drained-within-grace vs
escalated, and the actual elapsed). stop(Duration::ZERO, true) additionally gives
a "kill and wait for the tree to actually empty" that bare kill_all — which
returns as soon as the kill is issued — does not.
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: ErrorReason::ResourceLimit { kind: Memory, reason: Unenforceable,
detail: "…Read-only file system…" }
docker run --privileged: ErrorReason::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 —
ErrorReason::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.
On a host where the crate's limits do apply, the caps are not frozen at
creation: ProcessGroup::update_limits
re-applies a fresh set to the live tree (a full replacement) for adaptive
tightening or relaxing, and refuses — rather than silently drops — a cap on a
mechanism that can't enforce it, exactly as creation does.
Reading the tree's usage, even where limits can't be enforced. Reporting
isn't gated on a cap being enforceable: ProcessGroup::stats
and the sample_stats time-series (the opt-in stats feature) still answer
inside a container — the live process count on every backend, plus total CPU
time and peak memory wherever a real cgroup or Job Object is active (a plain
unprivileged container's process-group fallback leaves those two None, mirroring
its unavailable limits). A long-lived containerized service typically holds its
group behind an Arc (shared with a supervisor, or a metrics task) — for that
shape reach for
OwnedStatsSampler, the
Send + 'static owning sampler: it takes an &Arc<ProcessGroup>, so it can be
moved into a spawned telemetry task without being tied to the borrow's lifetime,
holds the group only weakly (never pinning a tree that should be torn down), and
ends its series honestly if the group is ever released.
"The cap couldn't be applied" and "the cap fired" are different questions.
Everything above is about applying a cap: ErrorReason::ResourceLimit says a
requested cap could not be put in force. with_options raises it before
anything runs at all; update_limits raises it against an already-running tree,
where it does not mean "nothing changed" — that call is
not a rollback, so part of the
requested set may already be in force. The separate question — did a cap
actually stop this tree? — is answered post-run by
ProcessGroup::limit_evidence(),
which returns a three-valued verdict per axis (Tripped / NotTripped /
Unknown) read from the container's own kernel counters. Neither one changes
the other's meaning: an admission failure is ResourceLimit, a cap that fired
is a Tripped verdict, and where no authoritative evidence exists the answer is
an explicit Unknown rather than a "no". They stay separable even where they
meet — every axis a failed update_limits named is still read from the
counters, never quietly reported as NotTripped.
This matters most in exactly the situation this page is about, because in
a container the interesting kill usually comes from the outer limit: a verdict
is Tripped only when this crate's own cgroup recorded hitting its own
cap (memory.events' oom), so an orchestrator-level OOM kill of the whole
container is never dressed up as "your max_memory killed it". On a Windows Job
Object and on the process-group fallback every capped axis reports Unknown —
those mechanisms preserve no post-mortem record — which is why the guide states
the limitation rather than inventing a verdict.
Running something you don't trust inside that container? See Running untrusted children for the full hardening checklist — containment, resource limits, privilege drop, env hygiene, output/wall-time bounds — with the platform caveats from this page and Platform support folded in.
Next: Platform support · Running untrusted children · Process groups · docs index
Running untrusted children
Sometimes the program you launch isn't yours: a CI plugin, a user-submitted
build script, a tool discovered on PATH at runtime. The hardening knobs for
that case already exist across this guide set — containment, resource limits,
privilege drop, environment hygiene, output/wall-time bounds — but they're
scattered one guide per topic. This page pulls them into one threat-aware
checklist, states plainly what processkit actually guarantees, and links
back to the deep guide for each item.
- What this crate is — and isn't
- 1. Containment: pick a mechanism, then check it
- 2. Resource limits and their platform caveats
- 3. Drop privileges in the right order
- 4. Keep the environment hermetic
- 5. Bound output, wall time, and give yourself an exit
- What not to rely on
What this crate is — and isn't
processkit guarantees the things a process-management library can
guarantee:
- Whole-tree containment that can't leak. Every
Command::run()-style call spawns into a fresh, private kill-on-drop group; an early return, a panic, a dropped future — the tree still dies. See Process groups. - Honest failure instead of silent degradation. A resource cap that can't
be enforced fails the call with a typed
ErrorReason::ResourceLimitrather than handing back a group that looks capped but isn't; a privilege-drop knob unsupported on the current platform fails withErrorReason::Unsupportedrather than being quietly skipped. Nothing you asked for is ever dropped on the floor without telling you. - No secrets in diagnostics by default. Argv and environment values
never appear in
Debug, intracingoutput, or in aMemberInfosnapshot — see §4 for the one deliberate, opt-in exception.
It does not guarantee the things only the OS (or a purpose-built sandbox)
can guarantee. processkit is not a sandbox, and using it does not replace
seccomp, an AppContainer/restricted token, Linux namespaces
(mount/user/net), SELinux/AppArmor profiles, or a real OS-level
container/VM (gVisor, Firecracker, docker run with a security profile, …).
A child that fully controls its own process can still do anything the
credentials you handed it allow: read/write files it has permission to
touch, open sockets, fork and exec other binaries. This crate's job is to
make sure you — the parent — can always find and kill the whole tree,
apply OS-level caps and a privilege drop cleanly, and never leak secrets
into your own logs — not to build a security boundary the OS itself doesn't
provide. Reach for real isolation when "untrusted"
means "actively adversarial", not just "third-party".
1. Containment: pick a mechanism, then check it
You don't have to opt in to containment — every one-shot verb already runs the child inside a private group. What you do need to check, for an untrusted child, is which mechanism you actually got, because the strength of "contained" varies:
use processkit::{Mechanism, ProcessGroup}; fn main() -> processkit::Result<()> { let group = ProcessGroup::new()?; match group.mechanism() { Mechanism::JobObject | Mechanism::CgroupV2 => { // Whole-tree kill AND whole-tree resource accounting/limits. } Mechanism::ProcessReaper => { // FreeBSD's procctl reaper: whole-tree kill and whole-tree // `members()` — a child that `setsid`s away stays contained and // visible, unlike the process-group fallback below. No whole-tree // resource accounting, so `limits` is refused (see §2). } Mechanism::ProcessGroup => { // Kill-on-drop still holds for the whole tree — but `members()` // only reports tracked leaders, a child that calls `setsid` escapes, // and `limits` is refused outright (see §2) rather than silently // doing nothing. } _ => {} // non_exhaustive: a future mechanism } Ok(()) }
Kill-on-drop containment is unconditional in every case — a ProcessGroup
(POSIX process-group) fallback still reaps the whole tree on drop/shutdown.
What narrows on the fallback is accounting and limits: no whole-tree
memory/CPU totals, and (§2) resource caps refuse to apply rather than
pretend to — plus the one containment gap worth knowing for an untrusted
child, a descendant that calls setsid and leaves the process group. Only the
process-group fallback has that gap: Job Objects, cgroups and FreeBSD's
ProcessReaper all follow the escapee. See Platform support → containment mechanisms
for exactly which platform/privilege combination gets which mechanism, and
Running in containers
for the concrete docker run case (an unprivileged container almost always
lands on the pgroup fallback).
2. Resource limits and their platform caveats
The limits feature caps a group's memory, process count, and CPU quota as
a whole-tree property, set once at creation:
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(()) }
Creation fails fast when a cap can't be enforced. with_options (and
ProcessGroup::new if any limit was requested) returns
ErrorReason::ResourceLimit { kind, reason, detail } instead of handing back a
group that looks capped but isn't — an unenforced limit is no protection for
an untrusted child. reason tells you why: Unsupported means no mechanism
with whole-tree resource accounting exists here — macOS/the other BSDs and a
Linux host with no cgroup v2 mounted have no whole-tree container at all, and
FreeBSD's process reaper contains a tree without accounting for it;
Unenforceable means a mechanism exists but this
particular request was rejected (most commonly: this process isn't at the
real cgroup-v2 hierarchy root — true of essentially every ordinary
container, privileged or not, and every systemd session/scope/service). Only
rely on limits for hosts where this process genuinely owns the cgroup
root; inside a container, rely on the orchestrator's own memory/CPU limits
instead. Full detail: Process groups → resource limits,
Platform support → containment mechanisms,
Running in containers → container limits.
max_processes enforces differently on Windows vs. Linux — read this
before relying on it as an admission control. On Windows the Job
Object's ActiveProcessLimit rejects the *(n+1)*th process assigned to the
job, so it caps repeated start() calls into the same group too. On
Linux the kernel only checks pids.max when a process forks inside the
cgroup; this crate's children fork in the parent cgroup and migrate in
during pre-exec, so the cap reliably bounds the descendants a contained
child forks (the fork-bomb case), but does not reject additional
top-level start() calls each placing one more child into an already-full
group. If you need Linux to reject the n+1th start() too, count starts
yourself. Detail: ResourceLimits::max_processes.
3. Drop privileges in the right order
On Unix, uid/gid/groups drop the child's identity before it ever
execs; the crate handles the ordering that privilege drop demands:
use processkit::{Command, RlimitResource}; #[tokio::main] async fn main() -> processkit::Result<()> { Command::new("untrusted-tool") .groups([1000]) // replace the inherited (often root's) supplementary groups .gid(1000) // applied before uid — a gid change needs privilege .uid(1000) // dropped last .setsid() // new session: detaches from the controlling terminal .umask(0o022) // files the child creates get 0644/0755, not 0666/0777 .rlimit(RlimitResource::Core, 0, 0) // never write a secret-bearing core dump .rlimit(RlimitResource::NoFile, 256, 256) // cap this process's open fds .run().await?; Ok(()) }
The OS syscall order is fixed internally regardless of builder call order:
setgroups → setgid → setuid. All three matter — dropping uid
alone leaves the child holding the parent's (often root's) supplementary
groups, which can still grant access you meant to remove; always pair
uid/gid with an explicit groups() (or groups([]) to drop every
extra). uid/gid/groups/setsid/umask/rlimit are Unix-only: on Windows the
run fails with ErrorReason::Unsupported rather than silently proceeding without
a drop — a wrapper that must run cross-platform should branch on the
platform ahead of time rather than assume the drop happened. None of this
needs an external helper (setpriv/su-exec/gosu) or a shell — the crate
calls the raw syscalls itself inside the child's pre-exec hook and always
execs the target directly — so a minimal/no-shell final image (FROM scratch-style) still drops privileges correctly. Detail: Running commands
→ privileges and spawn flags,
Running in containers → minimal images,
platform fine print (the cgroup × uid ordering interaction, setsid ×
process-group coordination) in Platform support → caveats.
rlimit is per process, not an aggregate substitute for cgroup/Job Object
limits. It is useful even where whole-tree caps are unavailable: disable core
dumps, bound descriptors or generated file size, and set a hard ceiling the
child cannot raise. Descendants inherit the cap but each has its own accounting;
for a shared tree-wide memory/CPU/process budget, keep using ResourceLimits
where the active containment mechanism can enforce them.
4. Keep the environment hermetic
By default the child inherits your process's full environment — often not
what you want for something untrusted. env_clear()/inherit_env() narrow
that to an explicit allow-list:
use processkit::Command; #[tokio::main] async fn main() -> processkit::Result<()> { // Allow-list: clear everything, copy only the named parent variables // (re-read from the parent on every retry), then layer explicit overrides. Command::new("untrusted-tool") .inherit_env(["PATH", "LANG"]) .env("MODE", "sandboxed") .run().await?; // Scorched earth: the child starts with an empty environment. Command::new("hermetic-tool").env_clear().run().await?; Ok(()) }
Argv and environment values are never rendered in diagnostics by
default. Command's manual Debug impl reduces argv to a count and env
overrides to variable names only — never values, never argv contents; the
tracing feature's own event stream follows the identical rule. Reach for
command_line() only when you deliberately need the full, human-readable
line (a dry-run echo, a log line you control) — it's an explicit, opt-in
escape hatch specifically because the default elsewhere is silence.
The one deliberate exception: record-feature cassettes. A cassette
(RecordReplayRunner) redacts env values the same way — only variable
names are persisted — but it stores argv, the working directory, and
both captured streams verbatim, by design (a cassette has to reproduce the
exact invocation on replay). Any of those can carry a secret a hostile or
merely careless child's stdout/stderr echoed back, or a --token=… argument
you passed it. The cassette file is therefore created owner-only (0600 on
Unix) rather than inheriting a world-readable umask — but on Windows the
file inherits the containing directory's ACL, so restrict the directory
(or use a per-user temp path, never a shared/world-writable one) if you
record cassettes for anything that can carry secrets. Don't assume "the
crate never logs argv" extends to cassette recording — it's the one
opt-in feature where argv reaches disk on purpose. Detail: Testing your
code → record/replay cassettes.
Redacting what a child prints — capture_policy. The rules above cover
what the crate logs (argv/env) and stores (cassettes); the remaining leak is a
secret the child itself echoes to stdout/stderr — a passphrase prompt from an
agent CLI under a pseudo-terminal, a token re-printed in a diagnostic — which
otherwise lands in the captured ProcessResult verbatim. capture_policy
installs a typed CapturePolicy that shapes each decoded line before it is
retained, so you can scrub the secret out of the capture (and the streaming
verbs) at the source:
#![allow(unused)] fn main() { use std::borrow::Cow; use processkit::{Command, CapturePolicy, OutputStream}; struct Scrub; impl CapturePolicy for Scrub { fn name(&self) -> &str { "scrub" } fn on_capture<'a>(&self, _s: OutputStream, line: &'a str) -> Cow<'a, str> { if line.contains("passphrase") { Cow::Borrowed("<redacted>") } else { Cow::Borrowed(line) } } } async fn f() -> Result<(), Box<dyn std::error::Error>> { let out = Command::new("agent").capture_policy(Scrub).output_string().await?; let _ = out; Ok(()) } }
It is a narrow, capture-scoped seam: it shapes the backlog only. The
on_*_line handlers, a stdout_tee/stderr_tee, and stdout_raw_tee are
independent and still see the un-redacted line — if you also tee a child's output
to a log, scrub in that sink too, or don't tee a stream that can carry secrets. A
policy that panics fails closed (the line is blanked, never leaked). Full contract
and examples: Streaming → redaction at capture.
5. Bound output, wall time, and give yourself an exit
An untrusted child can flood its own stdout/stderr, hang forever, or need to be abandoned mid-run for reasons that have nothing to do with its exit code.
use processkit::{Command, OutputBufferPolicy}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("untrusted-tool") // Fail loud instead of growing an unbounded in-memory buffer; the // pipe is still fully drained either way, so the child never blocks. .output_buffer(OutputBufferPolicy::fail_loud(10_000).with_max_bytes(8 << 20)) // Bound wall-time — kills the whole tree at the deadline. .timeout(Duration::from_secs(30)) // Take the direct child down even if THIS process is SIGKILLed. .kill_on_parent_death() .output_string() .await?; Ok(()) }
output_buffer(...fail_loud(...))bounds memory, not wall time — an unbounded flood with notimeoutkeeps the child running (its pipe is drained into nothing) until it exits on its own, so pair the two. Detail: Running commands → buffer policies.timeoutkills the whole tree (not just the direct child) at the deadline, on every consuming verb — captured as data on the capturing verbs, raised asErrorReason::Timeouton the checking ones. A caller-initiated abandonment (rather than a fixed deadline) iscancel_on(token)instead — it always errors, on every path. Detail: Timeouts, retries & cancellation.kill_on_parent_death()hardens the case where your own process is SIGKILLed before it can runDrop(which is what normally tears the child tree down). Its reach is honest, not overpromised — guaranteed whole-tree on Windows (Job Object kill-on-close), direct-child-only on Linux (PR_SET_PDEATHSIG; a surviving cgroup/pgroup leaves grandchildren running), a documented no-op on macOS/the BSDs. QueryCommand::kill_on_parent_death_scope()(aParentDeathCleanup— noCommandinstance needed) to report the actual reach to your own caller instead of promising a whole-tree cleanup the kernel can't deliver on that platform. There is no portable Unix "kill the tree when its creator dies" primitive; when that guarantee genuinely matters, keep an outer container/cgroup or subreaper that owns the tree independently of your process. Detail: Running commands → privileges and spawn flags, Platform support → caveats.
What not to rely on
- A non-tty child is not a sandboxed child. processkit wires pipes,
never a pseudo-terminal, to a spawned child's stdio — but that's a
plumbing choice for capture/streaming, not a security boundary. A child
with no controlling terminal can still fork, exec, open sockets, and
read/write anything its credentials allow; don't reason "it can't get a
tty, so it can't do X" for any
Xbeyond "prompt interactively on a terminal it doesn't have". If a tool demands a tty (anssh/sudopassword prompt), that's a different, unrelated limitation — see Running commands → interactive auth. - Kill-on-drop stops at your machine's process tree — it does not cross an
sshhop (or any remote-execution client). The whole-tree guarantee reaps the local tree, including a localsshclient; the command that client runs on a remote host is beyond its reach, so a dropped handle, atimeout, or a panic that kills the client can leave the remote command running, orphaned. Contain the remote side on the remote side —ssh -tt(a connection drop then deliversSIGHUPto the remote session), a server-sidetimeout(1)or unit deadline. See the cookbook's Driving ssh recipe for the full boundary and mitigations. - Stdin closed by default is hygiene, not confinement. It stops a child from hanging on unexpected input; it says nothing about what the child can do once running.
- A resource limit or privilege drop this crate refused to apply
(
ErrorReason::ResourceLimit/ErrorReason::Unsupported) is not "close enough". Treat either as a hard stop for the launch, not a degraded-but-acceptable mode — see §2 and §3. - When the child is genuinely adversarial, go get real isolation.
processkitcomposes cleanly underneath any of these, but doesn't replace them: an OS container with a seccomp/AppArmor/SELinux profile, a Windows AppContainer or a restricted access token, a microVM (Firecracker, gVisor), or — the low-tech option that's often enough — a dedicated, minimally-privileged service account with no access to anything the child shouldn't reach even if it does everything an ordinary process running as that account is allowed to do.
For the error types this section's failure modes actually raise, see
Errors; for the crate's security-relevance and how to report a
vulnerability, see SECURITY.md.
Next: Running commands · Running in containers · Platform support · 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. The current line is 3.x:processkit = "3"accepts compatible3.*upgrades but not a future4.0. A consumer still declaringprocesskit = "2"remains on 2.x until it deliberately changes the requirement and applies the migration below. (Themockfeature'smockall-generatedexpect_*surface stays semver-exempt — it tracks themockallversion.)
3.3.1 (from 3.3.0)
Each subsection below is a change a consumer has to make a decision about; a 3.3.1
fix that only reports a failure previously swallowed — the Windows ConPTY wait
that no longer masks a failed WaitForSingleObject as a live or exit-code-less
process — leaves nothing to migrate and has no subsection here.
A non-final Pipeline stage's own stdout destination is now overridden by the pipe (not compiler-caught)
This change is not compiler-caught: every builder keeps its signature and existing code compiles unchanged. What moved is where a non-final stage's bytes go.
Who it affects: anyone that configured stdout(StdioMode::Null),
stdout(StdioMode::Inherit), stdout_file(..), or stdout_file_append(..) on
any stage of a chain other than the last — through the buffering verbs
(output_string / output_bytes / run / checked) or the streaming
Pipeline::start session alike. A standalone Command is untouched, and so is
the last stage of a chain: its stdout is the chain's own output and is still
honored exactly as configured.
What changes. Such a stage's stdout now goes to the next stage and nowhere else:
- the next stage receives the producer's bytes — before, it started on an immediate EOF (or on whatever stdin it had been configured with);
- a configured redirect file is neither created nor truncated: a log left by a previous run stays intact, and a file that did not exist is not created;
stdout(StdioMode::Inherit)no longer prints that stage's output to the parent's terminal, andstdout(StdioMode::Null)no longer discards it;- that stage's stdout observers (
on_stdout_line,stdout_tee,stdout_raw_tee) do not fire — as they already did not on any other non-final stage; - stderr is untouched: it keeps its own configuration and stays available for pipefail diagnostics.
The old behavior was not something to depend on. It was silent data loss, not a supported redirect: the producer's output left the chain, the next stage read an immediate EOF, and the chain reported success over the missing data.
#![allow(unused)] fn main() { use processkit::Command; async fn count() -> processkit::Result<()> { // Before: `commits.log` was written, `wc` counted an empty stream, and the chain // still reported success. Now: `wc` receives the log, and `commits.log` is // neither created nor truncated. let lines = Command::new("git") .args(["log", "--oneline"]) .stdout_file("commits.log") .pipe(Command::new("wc").arg("-l")) .run() .await?; let _ = lines; Ok(()) } }
Pick the behavior you actually wanted:
You wanted the chain. Drop the redirect from the non-final stage — there is nothing else to change, the chain now does what it always claimed to do.
You wanted the file and the chain. Make the copy an explicit stage, so the split is visible in the chain itself:
#![allow(unused)] fn main() { use processkit::Command; async fn count() -> processkit::Result<()> { let lines = Command::new("git") .args(["log", "--oneline"]) .pipe(Command::new("tee").arg("commits.log")) .pipe(Command::new("wc").arg("-l")) .run() .await?; let _ = lines; Ok(()) } }
You wanted the file, not the chain. Run that stage as its own Command,
where every stdout setting is honored, and start a separate run from the file it
wrote:
#![allow(unused)] fn main() { use processkit::Command; async fn count() -> processkit::Result<()> { Command::new("git") .args(["log", "--oneline"]) .stdout_file("commits.log") .run_unit() .await?; let lines = Command::new("wc").args(["-l", "commits.log"]).run().await?; let _ = lines; Ok(()) } }
The redirect was meant for the chain's output. Move it to the last stage, where it is honored exactly as configured.
The one non-final stdout configuration still rejected rather than overridden is
use_pty() (the pty feature): a PTY master carries a merged terminal stream
that cannot feed a later stage at all, so the chain fails before spawn with
ErrorReason::Unsupported — unchanged.
ProcessGroup::kill_all reports a group its own teardown left frozen (not compiler-caught)
This change is not compiler-caught: kill_all keeps its signature and existing
code compiles unchanged. What moved is one outcome that used to be Ok(()) and is
now an ErrorReason::Io — carrying the refused write's own ErrorKind, so an
EACCES classifies as PermissionDenied exactly as a refused suspend already
did.
Who it affects: Linux, and only where teardown runs the legacy per-pid
SIGKILL fallback instead of the atomic cgroup.kill — a kernel without the
cgroup.kill file (pre-5.14), or a host that refuses the cgroup.kill write (a
write-restricted delegated cgroup). Windows Job Objects, the FreeBSD reaper, the
POSIX process-group mechanism and any Linux where cgroup.kill works never reach
that path. Besides kill_all itself, the new error can come out of shutdown /
shutdown_ref / stop, which run that same hard kill when the grace elapses with
survivors and escalate_to_kill (escalate) is set.
What changes. That fallback freezes the subtree while it sweeps — so a fork
bomb cannot out-spawn its SIGKILLs — and thaws it afterwards. The thaw's result
used to be discarded: when the thaw was refused, the call still answered Ok(())
on the strength of an empty cgroup.procs, leaving the group frozen. That is
not a group you can spawn into: cgroup v2 freezes a task that joins a frozen
cgroup, and this backend joins the cgroup before exec, so the next child would
stop there instead of running (a start() into it may never return until the
freeze is cleared).
Now the thaw is retried once, and if it is still refused the freezer is read back
from cgroup.freeze: a group that reads frozen is reported as such, with the
refusal's errno and the remedy in the message. A refusal over a group that reads
unfrozen is not reported — the write-restricted host that refuses the thaw is
the one whose paired freeze never landed either, so nothing was left behind and the
per-pid sweep was the complete teardown it already was there. The verb stays
idempotent in the same direction: a repeat kill_all over a group still frozen
reports it again instead of answering cleanly, and a freeze an earlier suspend
left standing is covered the same way.
The error is about the group, not the tree. It is produced only after the tree drained, so the processes are dead; what is unusable is the cgroup they ran in.
Before, a failed thaw was invisible and the next spawn paid for it:
#![allow(unused)] fn main() { use processkit::{Command, ProcessGroup}; async fn teardown(group: &ProcessGroup) -> processkit::Result<()> { group.kill_all()?; // Used to be Ok(()) even over a group left frozen. // On the legacy fallback this could then stop before `exec` and never return. let _next = group.start(&Command::new("worker")).await?; Ok(()) } }
After, handle it as "torn down, do not reuse": clear the freeze before spawning into the group again, or drop it and start a fresh one.
#![allow(unused)] fn main() { use processkit::ProcessGroup; fn teardown(group: &ProcessGroup) -> processkit::Result<()> { if let Err(err) = group.kill_all() { // The tree is dead; what this reports is the group. Clearing the freeze is // the `cgroup.freeze` write `resume` makes (`process-control`, on by // default) — and if the host refuses that write too, drop the group rather // than spawning into it. group.resume()?; return Err(err); } Ok(()) } }
If you discard the result (let _ = group.kill_all()), two plausible backstops
do not cover this outcome:
- Drop is not a backstop for it. Kill-on-drop tears the group down through this
same call, and it has nothing to report with:
Dropreturns no result, and this backend discards the one the call hands it. What it backstops is the tree — which this error already reports as dead. - A member count does not detect it. This error is produced only after the tree
drained, so the group reads as empty either way, and
ProcessGroup::membersreadscgroup.procson this backend — which carries nothing about the freezer. A teardown status derived from a member count therefore calls the group clean exactly wherekill_allrefused to.
A caller that already treats kill_all as fallible and never reuses a group after
tearing it down needs no change: it drops the group, and the tree is dead in
either case.
3.2.0 (from 3.1.x)
ProcessGroup::suspend / resume report POSIX delivery failures (not compiler-caught)
This behavior change is not compiler-caught: both methods still return the same
Result<()>, but on the POSIX process-group mechanism (macOS/BSD and the Linux
process-group fallback) a real SIGSTOP / SIGCONT delivery failure now reaches the
caller as ErrorReason::Io. In particular, EPERM from a live, non-zombie member now
surfaces; ESRCH, harmless zombie-only EPERM, an empty group, and EPERM on a BSD
target without a process-state reader remain Ok.
On FreeBSD the same reporting applies through a different mechanism: 3.2.0 moves
that target off the shared process-group backend onto the new process reaper
(Mechanism::ProcessReaper — see
Platform support), where suspend / resume deliver through
PROC_REAP_KILL and surface its refusals — a live, non-zombie member's EPERM, and an
EINVAL / ECAPMODE meaning the request never ran — with the same ESRCH and
zombie-only-EPERM exemptions. Review FreeBSD call sites exactly as below; if the
reaper cannot be acquired the group falls back to the process-group mechanism and its
wording applies verbatim.
Before, code could treat a successful call as guaranteed because the backend swallowed every send failure:
#![allow(unused)] fn main() { use processkit::ProcessGroup; fn pause(group: &ProcessGroup) -> processkit::Result<()> { group.suspend()?; // POSIX pgroup used to return Ok even when SIGSTOP was rejected. // Work that assumes the whole tree is frozen. group.resume()?; Ok(()) } }
After, review those call sites and handle a delivery error explicitly before assuming the whole tree reached the requested state:
#![allow(unused)] fn main() { use processkit::ProcessGroup; fn pause(group: &ProcessGroup) -> processkit::Result<()> { if let Err(err) = group.suspend() { // The sweep still visited every member, so some members may be suspended. return Err(err); } // Work that requires the tree to be frozen. group.resume()?; // Review and handle a partial-resume error here as well. Ok(()) } }
This requires a code review, not just a rebuild, for callers that assumed Ok(())
meant every member was suspended or resumed. The sweep continues after a rejected
operation, so Err does not mean that nothing changed: the group can be partially
suspended or resumed.
3.0.0 (from 2.x)
Error is now a pointer-sized wrapper over ErrorReason
Error changed from an enum into a thin struct Error { .. } holding a
Box<ErrorReason>, so it is one pointer wide instead of ~100 bytes. This
shrinks every Result<T, Error> on the run path (and any enum that embeds one)
and silences the default result_large_err / large_enum_variant clippy lints.
The former enum — with all its variants and fields unchanged — is now the
re-exported [ErrorReason], reached through err.reason().
Who it affects: anyone that pattern-matches an Error by variant. The read
accessors (code(), program(), diagnostic(), is_timeout(),
stdout_bytes(), …), Display, Debug, and source() are unchanged and
still work on Error directly — only direct variant matches need a fix.
Fix: reach the variant through reason() (borrow) or into_reason() (own).
Before:
match err {
Error::Exit { code, .. } => eprintln!("exit {code}"),
Error::Timeout { .. } => eprintln!("timed out"),
_ => {}
}
After:
#![allow(unused)] fn main() { use processkit::{Error, ErrorReason}; fn handle(err: Error) { match err.reason() { ErrorReason::Exit { code, .. } => eprintln!("exit {code}"), ErrorReason::Timeout { .. } => eprintln!("timed out"), _ => {} } // To move a captured stream or the owned `io::Error` out of the reason, // consume the wrapper instead: `match err.into_reason() { .. }`. } }
The #[doc(hidden)] constructors (Error::exit/timeout/signalled/spawn/
not_found/stdin) and the public Error::parse(..) are unchanged and still
return an Error.
To construct an Error from a directly constructible ErrorReason variant,
wrap the variant literal with the From implementation:
#![allow(unused)] fn main() { use processkit::{Error, ErrorReason}; let err = Error::from(ErrorReason::Unsupported { operation: "custom soft stop".into(), }); let _ = err; }
Error, ErrorReason, and ErrorKind are three separate public names, all
re-exported from the crate root. If your crate previously re-exported only
Error, decide whether its public surface should now also expose ErrorReason
(and ErrorKind if it exposes classification), so your consumers can still
inspect failure reasons through your API.
The merged output stream is now a process-lifecycle stream (output_events() → events(), OutputEvent → ProcessEvent)
The merged output-event stream widened from "which output line" to "an event in the process's life", so the verb and its enum are renamed to match — a deliberate 3.0 break with no deprecated alias:
| Before | After |
|---|---|
RunningProcess::output_events() (verb) | RunningProcess::events() |
PipelineSession::output_events() (verb) | PipelineSession::events() |
OutputEvent (event enum) | ProcessEvent |
OutputEvents (stream type) | ProcessEvents |
Who it affects: anyone that calls output_events() or matches an OutputEvent.
The rename is compiler-caught — a build after the bump flags every site ("no
method named output_events" / "cannot find type OutputEvent").
ProcessEvent::Stdout/Stderr carry the same OutputLine payload with unchanged
semantics, and ProcessEvent::text() still returns Some for a line event and
None otherwise. The stream also gained two lifecycle variants —
ProcessEvent::Started { pid } (leads the stream) and ProcessEvent::Exited(Outcome)
(ends it) — so one stream now carries Started → Stdout/Stderr → Exited. The
enum stays #[non_exhaustive], so a _ arm covers them (and any future kind).
Fix — rename the verb and the type, and add a _ arm.
Before:
use processkit::OutputEvent;
let mut events = running.output_events()?;
while let Some(ev) = events.next().await {
match ev {
OutputEvent::Stdout(line) => println!("out: {}", line.text()),
OutputEvent::Stderr(line) => eprintln!("err: {}", line.text()),
_ => {}
}
}
After — the verb is events(), the enum is ProcessEvent, and the new lifecycle
variants are handled (or fall through the _ arm):
#![allow(unused)] fn main() { use processkit::ProcessEvent; fn handle(ev: ProcessEvent) { match ev { ProcessEvent::Stdout(line) => println!("out: {}", line.text()), ProcessEvent::Stderr(line) => eprintln!("err: {}", line.text()), ProcessEvent::Started { pid, .. } => eprintln!("started: {pid:?}"), ProcessEvent::Exited(_outcome) => eprintln!("exited"), _ => {} } } }
Behavior change — drive the stream concurrently with the finisher (not
compiler-caught). Because Exited is delivered when the run is reaped, the stream
now parks after both pipes close and yields its terminal Exited only once the run
is finished. So the old "drain the stream to its end, then call finish()/wait()"
shape deadlocks — the stream is waiting for the reap that finish() performs. Drive
the two together instead (e.g. tokio::join! the stream loop and finish()), or
wait()/finish() on a separate task while you consume the stream. If you only used
output_events() for its output lines and always finish()ed separately afterward,
switch to consuming both concurrently.
output_bytes and OutputTooLarge now count raw bytes read from the pipe
Not compiler-caught. The max_bytes ceiling (OverflowMode::Error and the drop
modes) and the total_bytes an OutputTooLarge failure reports now count the raw
bytes read off the output pipe — including line terminators and invalid-UTF-8 bytes —
rather than the decoded line-content bytes they counted before. For typical ASCII/UTF-8
line output the two are identical; they diverge for output with CRLF terminators or
non-UTF-8 bytes, where the raw count is slightly higher.
Who it affects: a caller that set a byte cap (with_max_bytes) and depends on the
exact threshold at which capture truncates/errors, or that reads
OutputOverflow::total_bytes() / the total_bytes field and compares it against a
precise expected value. It also affects a downstream crate that documented the prior
decoded-line-content meaning of total_bytes as part of its own public contract: for
that consumer, the documented contract exposed to its customers has changed, not just
an internal capture threshold. Fix: re-check those thresholds/assertions against the
raw-byte count. If you set no byte cap, nothing changes.
ProcessGroup::signal reports the soft-stop outcome more truthfully
Two behavior changes to ProcessGroup::signal(Signal::Int | Signal::Term), neither
compiler-caught (the signature is unchanged, Result<()>):
- Windows: it now best-effort soft-closes the tree (a console
CTRL_BREAKtowindows_graceful_ctrl_breakleaders plusWM_CLOSEto windowed members) and returnsOkwhen it had something to signal, instead of always returningErrorReason::Unsupported. It still returnsUnsupportedonly when the group has neither a console-CTRL leader nor a windowed member. A caller that treated the old blanketUnsupportedas "Windows never soft-stops" should stop assuming that. This change is confined toProcessGroup::signal(Signal::Int | Signal::Term); it does not changeProcessGroup::soft_stop_scope()/SoftStopScope, the separate side-effect-free capability probe. Nor does it affectProcessGroup::kill_all()or drop: those remain unconditional whole-tree hard kills (through the Job Object on Windows), unchanged on every platform. - POSIX process-group mechanism (macOS/BSD, and the Linux process-group fallback):
a genuinely failed send now surfaces as
ErrorReason::Ioinstead of being swallowed behind a falseOk— anEINVAL(an out-of-rangeSignal::Other(n)) or anEPERMfrom a live, non-zombie member now reaches the caller. An already-exited member (ESRCH), a harmless zombie-onlyEPERM, an empty group, and theSignal::Other(0)existence probe still reportOk. A caller that ignored the return value is unaffected; one that inspects it now sees these real failures.
PTY support is now available (additive)
3.0 adds an opt-in real pseudo-terminal backend for tools that require a
controlling terminal (isatty()-gated CLIs, password prompts, full-screen or
in-place terminal output):
[dependencies]
processkit = { version = "3", features = ["pty"] }
Command::use_pty() selects openpty on Unix or CreatePseudoConsole (ConPTY)
on Windows. It is additive: without the feature, or with the feature enabled but
use_pty() unset, the existing three-pipe launch path is unchanged. Once selected:
- stdout and stderr are merged onto the terminal master, so
ProcessResult::stderris empty; - interactive input uses
keep_stdin_open()+RunningProcess::take_stdin(); pty_size(cols, rows)sets the initial geometry andresize_pty(cols, rows)updates a live session;- the child stays in the same Job Object, cgroup, or process group, so timeout, cancellation, and kill-on-drop retain their whole-tree guarantee.
This is a terminal transport, not a terminal emulator. Unix and ConPTY differ in echo control, Enter/EOF handling, environment, and resize notification; read the PTY streaming guide and platform matrix before building an interactive protocol around it.
Also new in 3.0 (additive — nothing to migrate)
These are new capabilities, not migrations — no code changes are forced. Reach for them if they help:
Command::spawn_detached()→DetachedChild— the one deliberate, opt-in escape from kill-on-drop containment, for a child meant to outlive its launcher (daemonize, anohup-style helper). It inverts the crate's headline guarantee on purpose, so it is a separate, minimal type (just thepid) and loudly refuses every owner-dependent knob rather than dropping it silently. See its rustdoc before using it.Command::capture_policy(...)+ theCapturePolicytrait andOutputStreamenum — a typed redaction-at-capture seam: transform each captured line (e.g. scrub a secret) before it is retained in the backlog /ProcessResult. The handler/tee/output_bytespaths still see the unredacted text — only the retained capture is rewritten — and a panicking policy fails closed (the line is dropped, never leaked).Command::to_tokio_command()is no longer#[doc(hidden)]— it is now a documented, honest low-level escape hatch (pair it withProcessGroup::spawnto keep containment while dropping the high-level verbs/pump/capture). See the "Escape hatch" section in the commands guide.
Verify the upgrade
cargo update -p processkit
cargo build # the events()/ProcessEvent rename and the Error struct change are compiler-caught
cargo test # catches the events()-concurrency, output_bytes byte-count, and signal behavior changes if you rely on them
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, ErrorReason}; fn handle(err: Error) { // Since 3.0 the variants live on `ErrorReason`, reached via `err.reason()`. match err.reason() { ErrorReason::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:
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.