processkit 2.2.4

Async child-process management for tokio: whole-tree kill-on-drop (no orphans), plus streaming, pipelines, timeouts, and supervision
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
# Running commands

[‹ docs index](README.md)

`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](process-groups.md), so an early return, panic, or dropped
future can never leak a process tree.

- [Program, arguments, working directory]#program-arguments-working-directory
- [Resolving a locally-installed tool: `prefer_local`]#resolving-a-locally-installed-tool-prefer_local
- [Environment]#environment
- [Standard input]#standard-input
- [Output handling]#output-handling
- [Timeouts and retries]#timeouts-and-retries
- [Privileges and spawn flags]#privileges-and-spawn-flags
- [Consuming verbs]#consuming-verbs
- [Results and errors]#results-and-errors

## Program, arguments, working directory

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    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?;
    Ok(())
}
```

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](pipelines.md), 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.

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

```rust,no_run
#[tokio::main]
async fn main() -> processkit::Result<()> {
    let version = processkit::run("cargo", ["--version"]).await?;       // trimmed stdout, success required
    let result  = processkit::output_string("git", ["status", "-s"]).await?;   // full ProcessResult
    Ok(())
}
```

## Resolving a locally-installed tool: `prefer_local`

`prefer_local` adds a directory to check **before** the system `PATH` when
resolving a bare-name `program` for this one run — for a project's own
`node_modules/.bin`, a `target/debug` build, or a vendored toolchain, without
hand-rolling a `PATH` override:

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let out = Command::new("eslint")
        .prefer_local("./node_modules/.bin")
        .arg("src/")
        .output_string()
        .await?;
    Ok(())
}
```

**Resolution order.** Repeated calls **accumulate**, in priority order: the
directory from the first call is probed first, then the second, and so on,
with the system `PATH` tried last as the final fallback:

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    Command::new("tool")
        .prefer_local("./vendor/bin")   // checked first
        .prefer_local("./target/debug") // checked second
        .run()                          // then the system PATH
        .await?;
    Ok(())
}
```

Resolution reuses the exact same PATHEXT-aware lookup as the `PATH` search
(the same internal `probe_dir` helper — not a separate implementation), so a
`.exe`/`.cmd`/`.bat` on Windows is found under a `prefer_local` directory
exactly as it would be on `PATH`.

**Only a bare name is affected.** If the `program` passed to `Command::new` is
a path — absolute, or relative with a separator (`"./tool"`, `"../bin/x"`) —
`prefer_local` has no effect at all: the existing contract that such a program
is never looked up on `PATH` (or here) is unchanged.

**Interaction with `PATH`/`inherit_env`/`env`.** `prefer_local` only changes
*where the parent looks* to resolve the program for this one launch. It does
**not** rewrite or extend the `PATH` the child sees in its own environment —
that is governed entirely by `env`/`inherit_env`/`env_clear`, as usual. When
the program is found under a `prefer_local` directory, the child is simply
spawned via that resolved absolute path instead of the bare name; a
grandchild the program itself spawns does not inherit this reach — only the
one program named in this `Command` benefits.

**Interaction with `current_dir`.** A relative `prefer_local` directory (as in
the examples above) is probed against the *process's* actual current
directory, never against whatever is set via `current_dir` on the same
`Command`. The resolved match is then always turned into an absolute path
before being handed to the OS, so it can't later be reinterpreted against the
child's working directory once `current_dir` is set — unlike a relative-path
`program` passed straight to `Command::new`, which *is* subject to that
footgun (see [Program, arguments, working directory](#program-arguments-working-directory)
above).

**Diagnostics.** If resolution fails everywhere, `Error::NotFound`'s
`searched` field includes the `prefer_local` directories too — first, in
priority order, ahead of the `PATH` directories — so the diagnostic never
hides that they were checked.

## Preflight: resolve a program without running it

Sometimes you want to know whether an external tool is *available* before you
run it — a **doctor** check at startup, a friendly "is `git` installed?" error
up front — with **no** side effects. `resolve_program` locates a command's
program and returns its absolute path **without spawning** anything:

```rust,no_run
use processkit::Command;

fn main() -> processkit::Result<()> {
    // `which` is the crate-level shortcut for a bare tool.
    let git = processkit::which("git")?;   // Ok(/usr/bin/git) or Err(NotFound)
    println!("git lives at {}", git.display());

    // On a builder it honors that command's own `prefer_local` and env, so it
    // resolves exactly what a real run of that command would launch.
    let eslint = Command::new("eslint")
        .prefer_local("./node_modules/.bin")
        .resolve_program()?;
    println!("eslint lives at {}", eslint.display());
    Ok(())
}
```

**No divergence from a real run.** Resolution reuses the crate's *own*
launch-path logic — the same `PATH`/PATHEXT/execute-bit resolution and
`prefer_local` handling a spawn performs, not a second copy — so a
`resolve_program` hit is exactly the executable a run would launch, and a miss
is exactly the `Error::NotFound` (with the same `searched` diagnostic and
`is_not_found()` classification) a run would raise. A command that relocates the
child's `PATH` (`env`/`env_remove` of `PATH`, `env_clear`, `inherit_env`) is
resolved against that *effective child* `PATH`, so preflight still matches the
spawn.

It is a **synchronous**, cheap filesystem probe (a few `stat`s) — no async
runtime is required, and no process is ever started. Contrast `probe()`, which
*runs* the tool to read its exit code; `resolve_program` only *locates* it.

```rust,no_run
fn main() {
    match processkit::which("definitely-not-installed") {
        Ok(path) => println!("found: {}", path.display()),
        Err(e) if e.is_not_found() => eprintln!("tool not installed"),
        Err(e) => eprintln!("resolution error: {e}"),
    }
}
```

For a tool wrapped behind a `CliClient`, `CliClient::resolve_program()` does the
same for the client's program, honoring its env defaults.

## Environment

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

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    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?;
    Ok(())
}
```

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

| Source | Reusable 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-shot | Any `AsyncRead` — a socket, a decompressor, … |
| `Stdin::from_lines(stream)` | ❌ one-shot | Any `Stream<Item = String>` — a channel, a tail, … |

```rust,no_run
use processkit::{Command, Stdin};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let sorted = Command::new("sort")
        .stdin(Stdin::from_iter_lines(["banana", "apple", "cherry"]))
        .run()
        .await?;
    assert_eq!(sorted, "apple\nbanana\ncherry");
    Ok(())
}
```

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](streaming.md#interactive-stdin).

### Inheriting the parent's stdin: `inherit_stdin()`

`inherit_stdin()` hands the child the parent's **own** standard input — it reads
directly from whatever this process's stdin is (a terminal, a file, a pipe)
rather than from a crate-managed pipe. It is the stdin counterpart of
`stdout(StdioMode::Inherit)` / `stderr(StdioMode::Inherit)`: the child *shares*
the parent stream instead of the crate mediating it.

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // `git commit` opens $EDITOR on the parent's terminal; the child talks to the
    // real tty directly. stdout/stderr are still captured as usual.
    Command::new("git").arg("commit").inherit_stdin().run().await?;
    Ok(())
}
```

Reach for it when a child must talk to the real terminal — `git commit` opening
`$EDITOR`, a tool prompting for a password or a yes/no — or to forward the
parent's piped stdin straight through. Until a pseudo-terminal exists (a future
direction, not yet provided) this covers the common non-tty-negotiating
interactive cases without the crate having to pump bytes. Because the child reads
the parent's stdin directly, the crate neither feeds nor captures that input, and
`take_stdin()` returns `None` (as for a non-`keep_stdin_open` run). Capturing and
streaming the child's *output* is unaffected.

**Why a dedicated verb rather than a `Stdin::Inherit` source or a mode enum.**
For stdout/stderr the three `StdioMode` variants map cleanly onto one setter, but
stdin's "piped" case is not modeless — it *needs a payload* (which source? what
bytes?), already expressed by `stdin(Stdin::…)`, and its "null" case is
`Stdin::empty()`. Folding inheritance into that same `stdin(Stdin)` field would
make "inherit **and** a source" collapse to silent last-write-wins, impossible to
flag. A separate `inherit_stdin()` keeps the two intents in distinct fields so an
incompatible pairing is a **detectable, rejectable** error instead.

Accordingly, `inherit_stdin()` is **mutually exclusive** with either way the crate
would otherwise drive stdin — a configured `stdin(Stdin::…)` source (including an
explicit `Stdin::empty()`) or `keep_stdin_open()`'s interactive pipe. Setting
`inherit_stdin()` together with one of those is a contradiction (feed the child a
source *and* let it read the terminal?), so it is refused at the launch boundary
with a typed `Error::Io` (`InvalidInput`) — the same failure mode as re-running a
consumed one-shot source — rather than silently letting one win. Drop the other
stdin knob to resolve it. The refusal is enforced on the same launch seam the
hermetic test doubles route through, so a `ScriptedRunner` rejects the conflict
exactly as a live run does.

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

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let out = Command::new("legacy-tool")
        .encoding(encoding_rs::SHIFT_JIS)          // both streams…
        // .stdout_encoding(…) / .stderr_encoding(…) // …or each its own
        .output_string()
        .await?;
    Ok(())
}
```

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

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

```rust,no_run
use processkit::{Command, OutputBufferPolicy, OverflowMode};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    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);
    Ok(())
}
```

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

```rust,no_run
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.

Even under a *drop* policy (`DropOldest`/`DropNewest`), the checking verbs that
hand back stdout as if complete — `run`, `parse`, `try_parse` — **refuse**
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:

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let result = Command::new("cargo")
        .args(["build", "--release"])
        .on_stderr_line(|line| eprintln!("[build] {line}"))
        .output_string()
        .await?;
    Ok(())
}
```

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]testing.md#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

```rust,no_run
use processkit::{Command, Error};
use std::time::Duration;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    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?;
    Ok(())
}
```

- **`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]timeouts-and-cancellation.md.
- **`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).

## Privileges and spawn flags

Spawn-time controls for sandboxing and service launch:

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // 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?;
    Ok(())
}
```

`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](platform-support.md#caveats).

### Scheduling: CPU priority and `umask`

Two more spawn-time knobs, reusing the same seams as the builders above —
Unix `pre_exec`, Windows `creation_flags` — for background/batch children
that shouldn't starve the foreground, and for controlling the permissions of
files a child creates:

```rust,no_run
use processkit::{Command, Priority};

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // Run at a lower CPU-scheduling priority — supported on BOTH platforms.
    Command::new("batch-job")
        .priority(Priority::BelowNormal)
        .run().await?;

    // Unix only: files this child creates get 0644/0755 instead of 0666/0777.
    Command::new("worker").umask(0o022).run().await?;
    Ok(())
}
```

`priority` maps onto `nice`/`setpriority` on Unix and a priority class on
Windows (`Idle`/`BelowNormal`/`Normal`/`AboveNormal`/`High`); unlike the
privilege builders, **every variant is supported on both platforms**, so this
knob never yields `Error::Unsupported`. One caveat: lowering `nice` below its
inherited value on Unix — raising priority via `Priority::AboveNormal`/`High`,
or even requesting `Priority::Normal` under a positively-niced parent (e.g. a
`nice`d CI/batch launcher) — needs `CAP_SYS_NICE`/root; without it the OS
rejects the change and the spawn fails loud (`Error::Spawn`), never silently
downgrading to a lower priority.

`umask` is Unix-only — like `setsid`/`groups`, requesting it on Windows fails
with `Error::Unsupported` rather than being silently ignored.

**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](streaming.md#interactive-stdin). Conversational
tools that read stdin without needing a tty already work today via
`keep_stdin_open` + `stdout_lines`.

## Consuming verbs

| Verb | Returns | Non-zero exit | Timeout | Use when |
|---|---|---|---|---|
| `output_string()` | `ProcessResult<String>` | captured | captured (`timed_out`) | You want to inspect the outcome yourself |
| `output_bytes()` | `ProcessResult<Vec<u8>>` | captured | captured | Binary stdout (images, archives, …) |
| `run()` | trimmed stdout `String` | `Error::Exit` | `Error::Timeout` | "Give me the answer or fail" |
| `exit_code()` | `i32` | the code, `Ok` | `Error::Timeout` | The code *is* the answer |
| `probe()` | `bool` | `0``true`, `1``false`, else `Error::Exit` | `Error::Timeout` | Predicate commands: `git diff --quiet`, `grep -q` |
| `first_line(pred)` | `Option<String>` | — (stream-based) | `Error::Timeout` | Grab one matching line, kill the rest |
| `start()` | live `RunningProcess` || bounds the stream | [Streaming, interactive I/O, probes]streaming.md |

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    // 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?;
    Ok(())
}
```

`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`](timeouts-and-cancellation.md) 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`:

```rust,no_run
use processkit::Command;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    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
    Ok(())
}
```

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

```rust,no_run
use processkit::Outcome;

#[tokio::main]
async fn main() -> processkit::Result<()> {
    let result = processkit::Command::new("git").args(["merge", "feature"]).output_string().await?;
    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
    }
    Ok(())
}
```

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]`:

| Variant | Meaning |
|---|---|
| `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` is `Some(exact bytes)` for a checking verb built over `output_bytes`, `None` on the text path — read via `Error::stdout_bytes()` (the variant is `#[non_exhaustive]`) |
| `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()`); `stdout_bytes` as above |
| `Error::OutputTooLarge { program, max_lines, max_bytes, total_lines, total_bytes }` | A `fail_loud` buffer's line or byte ceiling was exceeded |
| `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()`; `stdout_bytes` as above |
| `Error::NotReady { program, timeout }` | A [readiness probe]streaming.md#readiness-probes 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 cap couldn't be enforced — `kind` (`LimitKind::Memory`/`Processes`/`Cpu`) says *which* limit, `reason` (`LimitReason::Invalid`/`Unsupported`/`Unenforceable`) says *why*, without parsing `detail`'s English text; read via `Error::limit_kind()`/`limit_reason()` (the variant is `#[non_exhaustive]`) |
| `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) |

`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](streaming.md) ·
[Timeouts, retries & cancellation](timeouts-and-cancellation.md) ·
[Process groups](process-groups.md)