Errors
One Error enum covers every failure mode — spawn, exit, timeout,
cancellation, IO — so a caller pattern-matches on a typed variant instead of
parsing strings. Several variants look alike at a glance but carry different
contracts; this guide is the one page that lays them all out side by side:
which variant fires from where, how to classify it, and what to do about it.
- Variant reference
- Variants that look alike but aren't
- Classifiers
- Matching under
#[non_exhaustive] - Errors and retries
- Errors and supervision
Variant reference
| Variant | Where it comes from | Recommended reaction |
|---|---|---|
Error::Spawn { program, source } | The program was located but the OS refused to start it — permission denied, a bad working directory, a Windows .cmd/.bat needing cmd.exe, ETXTBSY, … | Inspect source; is_permission_denied() for an ACL/executable-bit problem, is_transient() for a bare-retry-clears-it condition. Not is_not_found() — the program was found. |
Error::NotFound { program, searched } | The program could not be located at all — not installed, not on PATH, or a path that doesn't resolve | is_not_found() is true; surface a "is it installed?" hint. searched (Some(dirs) for a bare-name PATH lookup, None otherwise) is for a diagnostic only — never log it, it echoes the PATH value. |
Error::CassetteMiss { program } (record feature) | A cassette replay found no recording matching the invocation — a stale or incomplete cassette, not a missing program | Fix or re-record the cassette. Not is_not_found() — do not let an "optional dependency" wrapper swallow this as "tool not installed". |
Error::Exit { program, code, stdout, stderr, stdout_bytes } | The process ran to completion but exited non-zero | Branch on code(); diagnostic() for the best one-line human message (stderr, else stdout — git/jj put decisive text on stdout). |
Error::Timeout { program, timeout, stdout, stderr, stdout_bytes } | Command::timeout elapsed and the tree was killed, on a checking verb | is_timeout(). Whatever was captured before the kill is attached — diagnostic() often explains the hang. Consider composing into a retry classifier: e.is_timeout() || e.is_transient(). |
Error::NotReady { program, timeout } | A readiness probe (wait_for_line / wait_for_port / wait_for) did not pass within its own deadline | Not a run failure — the child is still running (a probe deadline never kills it). Decide whether to keep waiting, shutdown() the handle, or surface the failure. |
Error::Parse { program, message } | The run succeeded but try_parse (or a caller's own parser feeding Error::parse) could not make sense of the output | message is caller-built and carries the parse failure; bounded in Display/Debug, full text on the field. |
Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes } | A fail_loud capture ceiling (OutputBufferPolicy::max_lines/max_bytes) was exceeded; the run itself may have succeeded | Raise the ceiling, switch to a lossy/streaming policy, or treat as a genuine failure — the pipe was fully drained either way, so the child never blocked. |
Error::ResourceLimit { kind, reason, detail } (limits feature) | A requested cap on ProcessGroupOptions couldn't be enforced — no whole-tree container on this platform, or the OS rejected it | Read limit_kind() / limit_reason() rather than parsing detail; an unenforced limit is no protection, so treat this as a hard stop, not a warning. |
Error::Unsupported { operation } | An operation isn't supported by the active containment mechanism on this platform (e.g. any Signal but Kill on Windows Job Objects) | Branch on platform ahead of time (see Platform support), or catch and degrade. |
Error::Cancelled { program } | The run's CancellationToken fired and its tree was killed | is_cancelled(). This is an abandonment, not a failure to diagnose — the caller already knows why. Never retried (see Errors and retries); terminal under a Supervisor too. |
Error::Signalled { program, signal, stdout, stderr, stdout_bytes } | The process was killed by a signal (Unix only; a ScriptedRunner/cassette replay can also report Signalled(None)) | is_signalled(). No exit code to check — always a failure. diagnostic() surfaces whatever was captured before the crash. |
Error::Stdin { program, source } | Feeding the child's stdin failed for a reason other than a routine broken pipe, on an otherwise-successful run | A diagnostic of a silently-truncated input the child may have already acted on. The io-level classifiers (is_transient, is_not_found, is_permission_denied) deliberately return false here — the run already succeeded, so a blanket retry would just re-run a command that worked. |
Error::Io(source) | A low-level IO error from the crate's own machinery — driving a child, controlling a process group, reading/writing a cassette file | Never an arbitrary foreign io::Error (there is deliberately no blanket From<std::io::Error>); every Io here was raised at a known site inside the crate. |
Variants that look alike but aren't
Timeoutis captured,Cancelledis always an error.output_string/output_bytesreturnOkwithtimed_out() == trueon a deadline — the caller decides whether that counts as failure. A cancellation, by contrast, reportsErr(Error::Cancelled)on every consuming path, streaming included, because it's a deliberate caller action, not run data. When a run both hits its deadline and gets cancelled, cancellation wins (checked first). See Precedence and interactions.NotReadyis notTimeout.Command::timeoutis the run's own contract and kills the tree; a readiness probe'swithindeadline is a separate clock layered on top of an already-running child, and giving up on it never kills anything.is_timeout()isfalseforNotReady.NotFoundvs.Spawn.NotFoundis the single representation of "program not found" — bare name or path, any platform, one variant,is_not_found() == true.Spawnis every other OS-level launch failure once the program was located (permissions, a badcwd, a.cmd/.batneedingcmd.exe) —is_not_found()isfalsethere, so a "not installed?" hint never fires on the wrong condition (e.g. a bad working directory).CassetteMissis notis_not_found. A stale/incomplete cassette and a genuinely missing program are different failures a wrapper needs to tell apart — treating a cassette miss as "optional tool not installed" would silently hide a test-fixture bug.Exitvs.Signalled. Both carry captured streams and aDisplaydiagnostic tail, butExithas acode()and may or may not be a failure (is_success()/ok_codesdecide);Signalledhas no code at all —code()isNone— and is always terminal.
Classifiers
| Classifier | true for | Notes |
|---|---|---|
is_not_found() | NotFound only | The "is the program installed?" check. false for Spawn, CassetteMiss, and everything else. |
is_timeout() | Timeout only | The Error twin of ProcessResult::timed_out(). false for NotReady. |
is_cancelled() | Cancelled only | A caller that initiated the stop can swallow this rather than log/retry it as a real failure. |
is_signalled() | Signalled only | true even when the kernel reported no signal number (signal() is then None) — the reliable "died by a signal?" check. |
is_permission_denied() | Spawn / Io carrying PermissionDenied | IO/spawn-level only. |
is_transient() | Spawn / Io carrying an interrupted/would-block/busy/lock condition | IO/spawn-level only, never exit codes or timeouts by design; compose explicitly: e.is_transient() || e.is_timeout(). |
code() | Exit (Some(code)) | None for every other variant — a timeout or signal-kill carries no exit code. Same accessor name as ProcessResult::code() / Outcome::code(). |
signal() | Signalled with a known number | None for every other variant, and for a Signalled where the kernel reported no number. |
program() | Every variant that names one | None only for Unsupported, Io, and (limits feature) ResourceLimit — the ones with no single program to attribute. |
limit_kind() / limit_reason() (limits feature) | ResourceLimit only | Read structured fields instead of parsing detail's English text. |
diagnostic() | Exit / Timeout / Signalled (Some) | Stderr if it carries text, else stdout (git/jj put decisive output there), else None. |
Matching under #[non_exhaustive]
Error — and several of its struct-like variants — are #[non_exhaustive]:
a future release can add a variant or field without that being a breaking
change, but it also means every downstream match must carry a catch-all
arm.
use processkit::{Command, Error}; #[tokio::main] async fn main() -> processkit::Result<()> { let err = Command::new("maybe-missing-tool").run().await.unwrap_err(); match err { Error::NotFound { .. } => eprintln!("is it installed?"), Error::Timeout { .. } => eprintln!("hit its deadline"), Error::Cancelled { .. } => { /* caller-initiated, nothing to log */ } Error::Exit { code, .. } => eprintln!("exited with {code}"), // #[non_exhaustive]: a future variant (or a today's variant behind a // feature this build doesn't enable, e.g. ResourceLimit) falls here. other => eprintln!("run failed: {other}"), } Ok(()) }
Prefer the classifiers above (is_not_found(), is_timeout(), …) over
destructuring when all you need is a yes/no answer — they read the variant
without you having to keep the catch-all arm in sync as fields are added.
Errors and retries
Command::retry(max_attempts, backoff, classifier)
replays a failed run while your classifier accepts the typed error — the
classifier is exactly this guide's variant table, read by the caller:
use processkit::{Command, Error}; use std::time::Duration; #[tokio::main] async fn main() -> processkit::Result<()> { let out = Command::new("curl") .args(["-fsS", "https://example.com/api"]) .timeout(Duration::from_secs(10)) .retry(3, Duration::from_millis(250), |e: &Error| { // Transient: our own deadline, or a spawn/IO condition a bare retry clears. e.is_timeout() || e.is_transient() }) .run() .await?; Ok(()) }
Error::Cancelled is never retried, whatever the classifier says — the
token stays cancelled forever, so another attempt could only fail the same
way. See Retries for the full ground
rules (stdin re-use, which verbs retry at all).
Errors and supervision
A Supervisor restarts a crashing service rather than
replaying one operation, but it faces the same "is this permanent?"
question — its
give_up_when(classifier)
gate is the same classifiers applied to a GiveUpAttempt:
use processkit::{Command, GiveUpAttempt, Supervisor}; #[tokio::main] async fn main() -> processkit::Result<()> { let outcome = Supervisor::new(Command::new("maybe-typo'd-binary")) .give_up_when(|attempt| match attempt { // The child never started at all — e.g. is_not_found() for ENOENT. GiveUpAttempt::Failed(err) => err.is_not_found(), // A completed run your own domain knows is a permanent crash. GiveUpAttempt::Crashed(res) => res.code() == Some(78), _ => false, }) .run() .await?; println!("stopped: {:?}", outcome.stopped); Ok(()) }
GiveUpAttempt::Failed(&Error) is the spawn/IO path (no ProcessResult was
ever produced); GiveUpAttempt::Crashed(&ProcessResult<String>) is a
completed-but-failing run. A recognized-permanent failure reports
StopReason::GaveUp instead of restarting forever. Error::Cancelled is
terminal here too — supervision returns Err(Cancelled) instead of
restarting into a still-cancelled token, give_up_when or not.
Next: Running commands · Timeouts, retries & cancellation · Supervision