procpilot
Production-grade subprocess runner for Rust — typed errors, retry with backoff, timeout, stdin piping, secret redaction.
Built for CLI tools that spawn external processes and need precise failure handling. Not intended for shell scripting (see xshell for that).
Why not std::process::Command?
Command::output() returns a status the caller must remember to check. Command::spawn() gives you a Child but no help with timeout, retry, or deadlock-safe pipe draining. Every production CLI ends up writing the same wrapping layer.
procpilot is that layer.
- Typed errors — [
RunError] distinguishesSpawn(couldn't start),NonZeroExit(command ran and failed, with captured stdout/stderr), andTimeout(killed after budget). - Retry with exponential backoff + jitter — [
Cmd::retry] / [Cmd::retry_when]. - Timeout + deadline — per-attempt timeout or overall wall-clock budget across retries.
- Stdin piping — owned bytes (reusable across retries) or a boxed
Read(one-shot streaming). - Stderr routing — capture / inherit / null / redirect-to-file via [
Redirection]. - Secret redaction — [
Cmd::secret] replaces args with<secret>in error output and logs. - Streaming / bidirectional — [
Cmd::spawn] returns a [SpawnedProcess] withtake_stdin/take_stdout,Readimpls,kill,wait,wait_timeout, andspawn_and_collect_linesfor line-by-line callbacks. - Pipelines — [
Cmd::pipe] or the|operator chains commands (a | b | c) with duct-style pipefail status precedence.
Usage
[]
= "0.5"
use Duration;
use ;
let output = new
.args
.in_dir
.env
.timeout
.run?;
# Ok::
Error handling
use ;
match new.args.run
# ; Ok::
RunError is #[non_exhaustive]; include a wildcard arm. All variants carry a [CmdDisplay] that renders the command shell-style (with secret redaction if .secret() was set). NonZeroExit and Timeout include up to the last 128 KiB of stdout/stderr.
Retry
use ;
new
.args
.in_dir
.retry
.retry_when
.run?;
# Ok::
Deadline across retries
.timeout() bounds a single attempt; .deadline() bounds the whole operation (including retry backoff sleeps). Combine them when you want "retry up to 3× but never exceed 10 seconds total".
use ;
use ;
new
.args
.in_dir
.timeout
.deadline
.retry
.run?;
# Ok::
Inheriting stderr (live progress)
When the child prompts the user or should stream progress to the terminal, route stderr with Redirection::Inherit instead of capturing it.
use ;
new
.args
.stderr
.run?;
# Ok::
Stdin
use Cmd;
let manifest = "apiVersion: v1\nkind: ConfigMap\n...";
new.args.stdin.run?;
# Ok::
Pipelines
Chain commands with [Cmd::pipe] or the | operator. Per-stage builders (arg, args, env, in_dir) target the rightmost stage; pipeline-level knobs (stdin, timeout, retry, stderr) apply to the whole thing.
use Cmd;
let out = new.args
.pipe
.pipe
.run?;
// Same, with `|`:
let out =
.run?;
# Ok::
Failure status follows duct's pipefail rule: any non-success trumps success; the rightmost non-success wins. All stages' stderr is captured and concatenated (capture mode) or routed identically (inherit/null/file).
Streaming (spawned processes)
For long-lived or bidirectional processes, use [Cmd::spawn] instead of .run(). The returned SpawnedProcess gives you ownership of stdin/stdout pipes and &self wait / kill so you can share the handle across threads.
use ;
use thread;
use Cmd;
// `git cat-file --batch` pattern: write requests on one thread, read
// responses on another.
let proc = new
.args
.in_dir
.spawn?;
let mut stdin = proc.take_stdin.expect;
let stdout = proc.take_stdout.expect;
spawn;
let mut reader = new;
let mut header = Stringnew;
reader.read_line?;
// ... parse headers + binary content ...
let _ = proc.wait;
# Ok::
For the common "read lines as they arrive" case:
use Cmd;
new
.args
.spawn_and_collect_lines?;
# Ok::
Secret redaction
use Cmd;
new.args.secret.run?;
// Error messages show `docker <secret>` instead of the token.
# Ok::
License
Licensed under either Apache-2.0 or MIT at your option.