processkit 2.2.3

Async child-process management for tokio: whole-tree kill-on-drop (no orphans), plus streaming, pipelines, timeouts, and supervision
Documentation
# Errors

[‹ docs index](README.md)

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]#variant-reference
- [Variants that look alike but aren't]#variants-that-look-alike-but-arent
- [Classifiers]#classifiers
- [Matching under `#[non_exhaustive]`]#matching-under-non_exhaustive
- [Errors and retries]#errors-and-retries
- [Errors and supervision]#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]streaming.md#readiness-probes (`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]platform-support.md), 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]#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

- **`Timeout` is *captured*, `Cancelled` is *always an error*.** `output_string`/
  `output_bytes` return `Ok` with `timed_out() == true` on a deadline — the
  caller decides whether that counts as failure. A cancellation, by contrast,
  reports `Err(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]timeouts-and-cancellation.md#precedence-and-interactions.
- **`NotReady` is not `Timeout`.** `Command::timeout` is the run's own
  contract and kills the tree; a readiness probe's `within` deadline is a
  *separate* clock layered on top of an already-running child, and giving up
  on it never kills anything. `is_timeout()` is `false` for `NotReady`.
- **`NotFound` vs. `Spawn`.** `NotFound` is the single representation of
  "program not found" — bare name or path, any platform, one variant,
  `is_not_found() == true`. `Spawn` is every *other* OS-level launch failure
  once the program **was** located (permissions, a bad `cwd`, a `.cmd`/`.bat`
  needing `cmd.exe`) — `is_not_found()` is `false` there, so a "not
  installed?" hint never fires on the wrong condition (e.g. a bad working
  directory).
- **`CassetteMiss` is not `is_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.
- **`Exit` vs. `Signalled`.** Both carry captured streams and a `Display`
  diagnostic tail, but `Exit` has a `code()` and may or may not be a failure
  (`is_success()`/`ok_codes` decide); `Signalled` has no code at all —
  `code()` is `None` — 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.

```rust,no_run
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)`](timeouts-and-cancellation.md#retries)
replays a failed run while your classifier accepts the typed error — the
classifier is exactly this guide's variant table, read by the caller:

```rust,no_run
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](timeouts-and-cancellation.md#retries) for the full ground
rules (stdin re-use, which verbs retry at all).

## Errors and supervision

A [`Supervisor`](supervision.md) restarts a crashing service rather than
replaying one operation, but it faces the same "is this permanent?"
question — its
[`give_up_when(classifier)`](supervision.md#giving-up-on-permanent-failures)
gate is the same classifiers applied to a `GiveUpAttempt`:

```rust,no_run
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](commands.md) ·
[Timeouts, retries & cancellation](timeouts-and-cancellation.md) ·
[Supervision](supervision.md)