# Cookbook
[‹ docs index](README.md)
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](#run-a-command-and-get-its-output)
- [Inspect a failure instead of erroring](#inspect-a-failure-instead-of-erroring)
- [Ask a yes/no question](#ask-a-yesno-question)
- [Accept non-zero exit codes as success](#accept-non-zero-exit-codes-as-success)
- [Bound a run with a timeout](#bound-a-run-with-a-timeout)
- [Let a tool clean up on timeout](#let-a-tool-clean-up-on-timeout)
- [Show a useful error message](#show-a-useful-error-message)
- [Check a tool is installed without running it](#check-a-tool-is-installed-without-running-it)
- [Feed the child's stdin](#feed-the-childs-stdin)
- [Stream output as it arrives](#stream-output-as-it-arrives)
- [Talk to an interactive child](#talk-to-an-interactive-child)
- [Run a tool that requires a terminal](#run-a-tool-that-requires-a-terminal)
- [Answer an unterminated PTY prompt](#answer-an-unterminated-pty-prompt)
- [Read in-place progress from an agent CLI](#read-in-place-progress-from-an-agent-cli)
- [Avoid PTY full-duplex deadlocks](#avoid-pty-full-duplex-deadlocks)
- [Test PTY behavior without a terminal](#test-pty-behavior-without-a-terminal)
- [Driving ssh](#driving-ssh)
- [Pipe commands without a shell](#pipe-commands-without-a-shell)
- [Start a server and wait until it's ready](#start-a-server-and-wait-until-its-ready)
- [Tear down several children as a unit](#tear-down-several-children-as-a-unit)
- [React to whichever child exits first](#react-to-whichever-child-exits-first)
- [Sandbox an untrusted tool](#sandbox-an-untrusted-tool)
- [Keep a crash-prone service running](#keep-a-crash-prone-service-running)
- [Retry a flaky command](#retry-a-flaky-command)
- [Cancel runs on shutdown](#cancel-runs-on-shutdown)
- [Measure what a run cost](#measure-what-a-run-cost)
- [Contain a process you didn't spawn](#contain-a-process-you-didnt-spawn)
- [Test code that runs processes — without processes](#test-code-that-runs-processes--without-processes)
- [Test streaming code — without processes](#test-streaming-code--without-processes)
- [Wrap a CLI tool behind a typed API](#wrap-a-cli-tool-behind-a-typed-api)
## Run a command and get its output
```rust,no_run
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](commands.md).*
## Inspect a failure instead of erroring
```rust,no_run
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](commands.md).*
## Ask a yes/no question
```rust,no_run
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
```rust,no_run
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
```rust,no_run
use processkit::Command;
use std::time::Duration;
#[tokio::main]
async fn main() -> processkit::Result<()> {
let result = Command::new("slow-tool")
.timeout(Duration::from_secs(30))
.output_string()
.await?;
if result.timed_out() {
eprintln!("gave up after 30s; partial output: {}", result.stdout());
}
Ok(())
}
```
At the deadline the whole tree is killed. On the capture verbs the timeout is
*captured* (`timed_out()`, partial output kept); on the success-checking verbs
(`run`, `exit_code`) it surfaces as `ErrorReason::Timeout`.
## Let a tool clean up on timeout
```rust,no_run
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](timeouts-and-cancellation.md#graceful-timeout).*
*Fine print: [Timeouts, retries & cancellation](timeouts-and-cancellation.md).*
## Show a useful error message
```rust,no_run
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
```rust,no_run
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](commands.md).*
## Feed the child's stdin
```rust,no_run
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](commands.md).*
## Stream output as it arrives
```rust,no_run
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](streaming.md).*
## Forward a child's exact bytes (binary passthrough)
```rust,no_run
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](streaming.md).*
## Talk to an interactive child
```rust,no_run
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](streaming.md).*
## 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:
```rust,no_run
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](streaming.md#pty-window-size-and-live-resize) ·
[Platform support → PTY mode](platform-support.md#pty-mode-use_pty-the-pty-feature).*
## 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:
```rust,no_run
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](streaming.md#prompt-aware-waiting-wait_for_output) ·
[Running commands → interactive auth / TTY](commands.md#privileges-and-spawn-flags).*
## 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:
```rust,no_run
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](streaming.md#pty-output-hygiene-line-framing-and-vt-sanitization) ·
[Untrusted children → content transforms](untrusted-children.md).*
## 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:
```rust,no_run
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. If stream identity matters, use ordinary
pipes instead of PTY.
*Fine print: [Streaming → interactive stdin](streaming.md#interactive-stdin) ·
[Running commands → interactive auth / TTY](commands.md#privileges-and-spawn-flags).*
## 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:
```rust
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](testing.md#scripted-streaming) ·
[Streaming → prompt-aware waiting](streaming.md#prompt-aware-waiting-wait_for_output).*
## 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:
```rust,no_run
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`:
```rust,no_run
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](#answer-an-unterminated-pty-prompt) 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 -tt`** forces a remote pseudo-terminal, so when the connection drops
the remote session receives `SIGHUP` — 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](commands.md#privileges-and-spawn-flags) ·
[Running untrusted children → what not to rely on](untrusted-children.md#what-not-to-rely-on).*
## Pipe commands without a shell
```rust,no_run
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:
```rust,no_run
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](pipelines.md#unchecked-stages).*
## Start a server and wait until it's ready
```rust,no_run
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](streaming.md).*
## Tear down several children as a unit
```rust,no_run
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](process-groups.md).*
## React to whichever child exits first
```rust,no_run
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](streaming.md).*
## Sandbox an untrusted tool
```rust,no_run
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](process-groups.md) ·
[Running commands → privileges](commands.md).*
## Keep a crash-prone service running
```rust,no_run
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](supervision.md), [failure storms](supervision.md#failure-storms).*
## Retry a flaky command
```rust,no_run
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](timeouts-and-cancellation.md).*
## Cancel runs on shutdown
```rust,no_run
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:
```rust,no_run
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](timeouts-and-cancellation.md), [client-level default](timeouts-and-cancellation.md#client-level-default).*
## Measure what a run cost
```rust,no_run
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](process-groups.md) ·
[Streaming → profiling](streaming.md).*
## Contain a process you didn't spawn
```rust,no_run
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)`:
```rust,no_run
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](process-groups.md) ·
[Adopting by pid](process-groups.md#adopting-by-pid-adopt_external) ·
[Platform support](platform-support.md).*
## Test code that runs processes — without processes
```rust,no_run
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](testing.md).*
## Test streaming code — without processes
```rust,no_run
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](testing.md#scripted-streaming).*
## Wrap a CLI tool behind a typed API
```rust,no_run
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](cli-clients.md) · [Testing doubles and
cassettes](testing.md).*