Running commands

‹ docs index

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

#![allow(unused)]
fn main() {
use processkit::Command;

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?;
}

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.

prefer_local(dir) steers bare-name resolution without touching any of that verbatim contract: it probes a directory before the system PATH for this one run — a project's node_modules/.bin, a target/debug, a vendored toolchain — so Command::new("eslint").prefer_local("node_modules/.bin") picks up the local binary ahead of any global one. Repeated calls accumulate in priority order, all ahead of the PATH fallback, and the lookup reuses the same PATHEXT-aware machinery, so a .exe/.cmd/.bat on Windows resolves exactly as it would on PATH. It affects only a bare name (a path-form program is untouched) and never rewrites the child's own PATH; when resolution still fails, these directories appear in Error::NotFound's searched alongside the PATH entries.

For quick one-liners the free functions skip the builder:

#![allow(unused)]
fn main() {
let version = processkit::run("cargo", ["--version"]).await?;       // trimmed stdout, success required
let result  = processkit::output_string("git", ["status", "-s"]).await?;   // full ProcessResult
}

Environment

Four builders compose, applied in a fixed order at spawn:

#![allow(unused)]
fn main() {
use processkit::Command;

Command::new("worker")
    .env("RUST_LOG", "debug")        // set one variable
    .env_remove("GIT_DIR")           // unset one inherited variable
    .run().await?;

// Allow-list mode: clear everything, copy only the named parent variables.
Command::new("sandboxed-tool")
    .inherit_env(["PATH", "HOME", "LANG"])
    .env("MODE", "ci")               // explicit env/env_remove still apply on top
    .run().await?;

// Scorched earth: the child starts with an empty environment.
Command::new("hermetic-tool").env_clear().run().await?;
}

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::…):

SourceReusable 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-shotAny AsyncRead — a socket, a decompressor, …
Stdin::from_lines(stream)❌ one-shotAny Stream<Item = String> — a channel, a tail, …
#![allow(unused)]
fn main() {
use processkit::{Command, Stdin};

let sorted = Command::new("sort")
    .stdin(Stdin::from_iter_lines(["banana", "apple", "cherry"]))
    .run()
    .await?;
assert_eq!(sorted, "apple\nbanana\ncherry");
}

The payload is written on a background task (so a large input can't deadlock against the child's output) and the pipe is dropped afterwards to signal EOF. The two one-shot sources are consumed by their first run: a retried or cloned command reusing them fails loud the second time — re-running a consumed from_reader/from_lines source is an Error::Io (InvalidInput) at launch (D10), not a silent empty stdin. Prefer the reusable sources when a command may run more than once.

For conversational, request/response stdin — write a line, read the answer, repeat — use keep_stdin_open() and the streaming API instead: see Streaming & interactive I/O.

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:

#![allow(unused)]
fn main() {
use processkit::Command;

let out = Command::new("legacy-tool")
    .encoding(encoding_rs::SHIFT_JIS)          // both streams…
    // .stdout_encoding(…) / .stderr_encoding(…) // …or each its own
    .output_string()
    .await?;
}

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

Line terminators

Output is split into lines on \n by default. Carriage-return progress output — curl/pip/apt redrawing \rProgress: 50%\rProgress: 100% over one visual line — otherwise accumulates as a single ever-growing unstreamed line (and can be dropped whole under a byte cap). Switch the boundary to \r per stream:

#![allow(unused)]
fn main() {
use processkit::{Command, LineTerminator};

let out = Command::new("pip")
    .args(["install", "big-package"])
    .line_terminator(LineTerminator::CarriageReturn)   // both streams…
    // .stdout_line_terminator(…) / .stderr_line_terminator(…) // …or each its own
    .on_stderr_line(|line| eprintln!("[pip] {line}"))
    .output_string()
    .await?;
}

CarriageReturn treats a bare \r as the boundary, while a \r\n pair still counts as one terminator (whole or split across reads). The choice threads through every sink that consumes "a line" — the streaming verbs, the stdout_tee/stderr_tee sinks, the on_stdout_line/on_stderr_line handlers, and output_string. The default (Newline, \n-only) is unchanged.

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):

#![allow(unused)]
fn main() {
use processkit::{Command, OutputBufferPolicy, OverflowMode};

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);
}

DropOldest (the default) keeps a rolling tail; DropNewest freezes the head. bounded(0) retains nothing — useful when a line handler (below) is the real consumer. Under a line cap, dropped or not, every line still feeds the handlers and the line counters.

The line cap alone does not bound memory — one enormous newline-free "line" (base64 -w0) is held whole. Add with_max_bytes to cap the retained bytes too (either ceiling, or both); the byte cap also bounds the pump's in-flight assembly buffer, so a never-terminated flood can't exhaust memory. One consequence: a line whose own length exceeds the byte cap can't be assembled, so it is dropped whole — counted, but not delivered to a per-line handler or stdout_tee (don't set a byte cap if a tee must see arbitrarily long lines):

#![allow(unused)]
fn main() {
use processkit::{Command, OutputBufferPolicy};
let policy = OutputBufferPolicy::unbounded().with_max_bytes(8 << 20); // 8 MiB ring
let strict = OutputBufferPolicy::fail_loud(10_000).with_max_bytes(8 << 20); // error on either
}

fail_loud makes the ceiling error instead of dropping: the run fails with Error::OutputTooLarge once the cumulative output (lines or bytes) crosses the cap — even when a streaming consumer is draining lines as they arrive. It bounds memory, not wall-time, so pair it with timeout against a flooding child.

The byte ceiling now reaches output_bytes' raw stdout capture too, not just the line-pumped streams: under OverflowMode::Error a raw capture past max_bytes fails with Error::OutputTooLarge (reported with max_lines: None — raw bytes carry no line count), and the drop modes bound the retained bytes to a head/tail and set ProcessResult::truncated. Without a byte cap output_bytes stays unbounded, exactly as before.

Even under a drop policy (DropOldest/DropNewest), the checking verbs that hand back stdout as if complete — run, parse, try_parserefuse silently-truncated output (B12): if the policy dropped lines they fail with Error::OutputTooLarge rather than feed a parser a truncated tail. The lenient capture verbs (output_string / output_bytes) are unaffected — they return the partial result with truncated() set for you to inspect.

Line handlers — tee output as it arrives

on_stdout_line / on_stderr_line run a callback on each decoded line in addition to capture or streaming — logging, progress bars, metrics:

#![allow(unused)]
fn main() {
use processkit::Command;

let result = Command::new("cargo")
    .args(["build", "--release"])
    .on_stderr_line(|line| eprintln!("[build] {line}"))
    .output_string()
    .await?;
}

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 tracing warn 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: ScriptedRunner replays 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

#![allow(unused)]
fn main() {
use processkit::{Command, Error};
use std::time::Duration;

let out = Command::new("flaky-network-tool")
    .timeout(Duration::from_secs(30))                 // kill the tree at the deadline
    .retry(3, Duration::from_millis(200), |e| {       // up to 3 attempts total
        matches!(e, Error::Timeout { .. })            // …but only retry timeouts
    })
    .run()
    .await?;
}
  • timeout kills the whole process tree at the deadline. On the capturing verbs the expiry is captured (ProcessResult::timed_out), on the success-checking verbs it raises Error::Timeout — the full decision table lives in Timeouts, retries & cancellation.
  • retry applies to the success-checking verbs only — run, run_unit, exit_code, probe, checked, parse, and try_parse (seven in all; each runs through the retry loop). The classifier sees the typed error and decides. The non-erroring output_string/output_bytes paths never retry, and neither does first_line (its stream search is single-attempt).
  • Config-driven variants. timeout_opt(Option<Duration>) folds the Some(d) => timeout(d) / None => no_timeout() match into one call for a config-sourced deadline, and retry_never() is an explicit per-command opt-out of a client default_retry (symmetric with no_timeout()). Both are covered in full on Timeouts, retries & cancellation.

Privileges and spawn flags

Spawn-time controls for sandboxing and service launch:

#![allow(unused)]
fn main() {
use processkit::Command;

// 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?;
}

uid / gid / groups / setsid are POSIX-only — on Windows the run fails with Error::Unsupported rather than silently skipping a privilege drop. A correct drop sets all three of uid/gid/groups: dropping the uid alone leaves the child holding the parent's (often root's) supplementary groups. create_no_window is a harmless no-op outside Windows. kill_on_parent_death is best-effort by design: guaranteed on Windows (regardless of the knob), direct-child-only on Linux, unavailable on macOS/BSD — the graceful-exit guarantee via Drop holds everywhere either way. Containment is preserved in every combination; the platform fine print (the Linux cgroup × uid interaction, setsid × process-group coordination, the pdeathsig thread caveat) is collected in Platform support.

Scheduling priority and umask. priority(Priority) launches the child at a lower — or higher — CPU-scheduling priority, for background/batch work that shouldn't starve the foreground (or a task that should win over it); umask sets its file-mode creation mask:

#![allow(unused)]
fn main() {
use processkit::{Command, Priority};

Command::new("ffmpeg")
    .args(["-i", "in.mov", "out.mp4"])
    .priority(Priority::BelowNormal)   // Idle · BelowNormal · Normal · AboveNormal · High
    .umask(0o077)                      // Unix: files this child creates are owner-only
    .run().await?;
}

Idle/BelowNormal/Normal/AboveNormal/High map to nice/setpriority on Unix and a priority-class flag on Windows. Unlike the privilege builders, priority never yields Error::Unsupported — every variant is covered on both platforms. The Unix catch is privilege, not support: lowering nice below the value the child inherits needs CAP_SYS_NICE/root, or the spawn fails as Error::Spawn. That bites both High and AboveNormal (nice(-5) is already a decrease), and — under a positively-niced parent (a niced CI/batch launcher) — even Priority::Normal: nice(0) there is itself a privileged decrease from the inherited value, not the no-op it is from an un-niced parent. Raising nice (Idle/BelowNormal) is always allowed.

umask(u32) sets the child's file-mode creation mask (umask(2)), governing the default permissions of the files it creates. Like uid/gid/setsid it is Unix-only (applied via pre_exec); on other targets the run fails with Error::Unsupported rather than silently ignoring the requested mask.

Interactive auth / TTY. processkit wires pipes, not a pseudo-terminal, so a tool that demands a tty — an ssh/sudo password prompt, some credential helpers — won't get one (PTY support is not implemented; the trade-off is recorded in decisions/permissions-privileges-pty-network.md). Drive such tools non-interactively instead: key-based auth, ssh -o BatchMode=yes, GIT_SSH_COMMAND / GIT_TERMINAL_PROMPT=0, or feed a known answer over interactive stdin. Conversational tools that read stdin without needing a tty already work today via keep_stdin_open + stdout_lines.

Consuming verbs

VerbReturnsNon-zero exitTimeoutUse when
output_string()ProcessResult<String>capturedcaptured (timed_out)You want to inspect the outcome yourself
output_bytes()ProcessResult<Vec<u8>>capturedcapturedBinary stdout (images, archives, …)
run()trimmed stdout StringError::ExitError::Timeout"Give me the answer or fail"
exit_code()i32the code, OkError::TimeoutThe code is the answer
probe()bool0true, 1false, else Error::ExitError::TimeoutPredicate commands: git diff --quiet, grep -q
first_line(pred)Option<String>— (stream-based)Error::TimeoutGrab one matching line, kill the rest
start()live RunningProcessbounds the streamStreaming, interactive I/O, probes
#![allow(unused)]
fn main() {
use processkit::Command;

// 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?;
}

first_line returns Ok(None) when stdout closes without a match, and kills the (private-group) child once it has its answer — you never wait out a long log for one line. A cancel_on token that fires while the search is still running surfaces as Error::Cancelled, so a readiness probe with a shutdown token can't misread token-driven teardown as "the line never appeared" — while a run that genuinely ends with no match still reports Ok(None), even if the token happens to fire an instant later.

Results and errors

The capturing verbs hand back a ProcessResult:

#![allow(unused)]
fn main() {
use processkit::Command;

let result = Command::new("git").args(["merge", "feature"]).output_string().await?;

result.code();         // Option<i32> — None = killed (timeout/signal), no code
result.signal();       // Option<i32> — the signal number (Unix), else None
result.is_success();   // code in ok_codes (default {0})
result.timed_out();    // the run's own deadline expired
result.outcome();      // the explicit three-way enum behind the accessors above
result.stdout();       // &str (or &[u8] from output_bytes)
result.stderr();       // &str
result.combined();     // stdout + stderr concatenated
result.diagnostic();   // stderr if non-empty, else stdout — the human-facing line
                       // (git/jj put "CONFLICT …" on stdout!)

// Opt into erroring whenever you're ready:
let ok = result.ensure_success()?; // Exit / Timeout / Signalled (signal-kill) as typed errors
}

When the three-way distinction matters, match on Outcome instead of mentally decoding the code()/timed_out() pair:

#![allow(unused)]
fn main() {
use processkit::Outcome;

match result.outcome() {
    Outcome::Exited(0) => println!("clean"),
    Outcome::Exited(code) => println!("failed with {code}"),
    Outcome::Signalled(signal) => println!("killed by signal {signal:?}"),
    Outcome::TimedOut => println!("hit its deadline"),
    _ => {} // non_exhaustive: future dispositions
}
}

For a single query you usually don't need the match (and its #[non_exhaustive] wildcard): Outcome carries the same code() / signal() / timed_out() accessors as ProcessResult, so a bare Outcome (from RunningProcess::wait or Finished::outcome) answers directly — outcome.code(), outcome.signal(), outcome.timed_out(). There is no Outcome::is_success (success is ok_codes-aware — use ProcessResult::is_success).

The error enum is structured and #[non_exhaustive]:

VariantMeaning
Error::Spawn { program, source }The program was located but the OS couldn't start it (permissions, a bad working directory, a Windows .cmd/.bat needing cmd.exe, …) — not is_not_found()
Error::NotFound { program, searched }The program couldn't be located (the single "not found" representation — is_not_found() is true); searched is Some(dirs) for a bare-name PATH lookup, None otherwise
Error::Exit { program, code, stdout, stderr, stdout_bytes }Non-zero exit, both streams attached in full (the Display message is bounded, but the fields carry the complete captured text for classification); stdout_bytes: Option<Vec<u8>> holds the raw stdout for an error built over output_bytes (None on the text path) — reach it via stdout_bytes()
Error::Signalled { program, signal, stdout, stderr, stdout_bytes }The process was killed by a signal (no exit code); signal carries the number on Unix, None elsewhere; the partial streams captured before the kill are attached (reach them via diagnostic()), with the raw stdout_bytes when built over output_bytes
Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes }A fail_loud buffer's line or byte ceiling was exceeded; max_lines/max_bytes are each Option<usize> — the configured cap that tripped, None on an axis with no cap (e.g. max_lines: None from a raw output_bytes capture) — mirroring the OutputBufferPolicy knobs, while total_lines/total_bytes report what was seen
Error::Timeout { program, timeout, stdout, stderr, stdout_bytes }The run's own deadline killed it; whatever the run captured before the kill is attached — a hung tool's last stderr line tails the Display and is reachable via diagnostic(), with the raw stdout_bytes when built over output_bytes
Error::NotReady { program, timeout }A readiness probe gave up
Error::Parse { program, message }A try_parse parser (on Command, ProcessRunnerExt, CliClient, or Pipeline) rejected the output (the Display/Debug of message is bounded to a 200-byte preview; the field carries the full text)
Error::Stdin { program, source }Feeding the child's stdin failed for a non-broken-pipe reason on an otherwise-successful run (a louder failure — exit/signal/timeout — wins instead); a routine broken pipe never surfaces
Error::CassetteMiss { program }(record feature) a cassette replay found no matching recording (stale/incomplete cassette) — kept distinct from a missing program, so is_not_found() is false
Error::Unsupported { operation }The platform can't do what was asked (and silently skipping would be wrong)
Error::Cancelled { program }the run's token was cancelled
Error::ResourceLimit { kind, reason, detail }(limits feature) a requested resource cap couldn't be enforced — kind: LimitKind (Memory/Processes/Cpu) is which limit, reason: LimitReason (Invalid/Unsupported/Unenforceable) is why, detail: String is the human-facing explanation; classify without parsing text via limit_kind()/limit_reason()
Error::Io(source)A low-level IO error from the crate's own machinery (driving a child, group control, cassette files) — never an arbitrary foreign io::Error (no blanket From, D13)

Beyond the enum itself, each data-carrying variant — Exit, Timeout, Signalled, Spawn, NotFound, Parse, OutputTooLarge, Stdin, and ResourceLimit — is individually #[non_exhaustive]: outside the crate you can neither build one with a struct literal nor destructure it field-exhaustively (a future release may add fields). Match with a trailing .. and read through the accessors instead — program(), stdout()/stderr()/combined(), code(), signal(), the is_*() predicates, stdout_bytes() for the raw stdout of a checking verb over output_bytes, and limit_kind()/limit_reason() for a ResourceLimit. Going the other way, building an Error::Parse from your own output parser (outside the crate's try_parse helpers) is a supported path: the constructor Error::parse(program, message) is on the public surface.

Error::diagnostic() returns the most useful human-facing line out of a failure that captured output — Exit, and (D12) Timeout / Signalled (the partial streams of a hung-then-killed or crashed tool). Each of those variants' one-line Display also appends a bounded excerpt of that diagnostic (the last non-empty line, capped at 200 bytes), so a bare eprintln!("{e}") reads `git` exited with code 2: fatal: boom — actionable in a log line without dumping multi-KiB streams into it.


Next: Streaming & interactive I/O · Timeouts, retries & cancellation · Process groups