# processkit cookbook

[‹ docs index](./)

Task-oriented snippets — *"I want to … → do this."* Every example assumes
`from processkit import …`. Each recipe is a quick hit; for the full treatment of
any area — every knob, the error semantics, the platform fine print — follow the
links into the [guide set](./): [Running commands](commands.md),
[Process groups](process-groups.md),
[Streaming & interactive I/O](streaming.md), [Pipelines](pipelines.md),
[Timeouts & cancellation](timeouts-and-cancellation.md),
[Supervision](supervision.md), [Testing your code](testing.md), and
[Platform support](platforms.md).

The whole library has two parallel surfaces: a **synchronous** one (plain method
names) and an **asyncio** one (the same names with an `a` prefix). Use whichever
fits your code; they share the same types and the same no-orphan guarantee.

`ProcessStdin`'s write methods and the `stdout_lines()` / `output_events()`
iterators are **async-only**. A `RunningProcess`'s *consuming* methods —
`outcome`/`aoutcome`, `finish`/`afinish`, `output`/`aoutput`,
`output_bytes`/`aoutput_bytes`, `profile`/`aprofile`, `shutdown`/`ashutdown` —
each come in a sync/async pair like everywhere else in the library: the plain
name blocks the calling thread, the `a`-prefixed twin is a coroutine (see
[Streaming](streaming.md#lifecycle) for the full table). Its `stdout_lines()` /
`output_events()` / `take_stdin()` / `kill()` are *synchronous* setup calls
(it's the iterator/handle they return that you await). A `RunningProcess` is
still usable as a **sync or async context manager** for deterministic teardown.

---

## Run a command and capture its output

A non-zero exit is *data*, not an exception:

```python
from processkit import Command

result = Command("git", ["rev-parse", "HEAD"]).output()
print(result.stdout.strip())  # the commit hash
print(result.code)  # 0
print(result.is_success)  # True
```

Async:

```python
result = await Command("git", ["rev-parse", "HEAD"]).aoutput()
```

## Require success and just get stdout

`run()` returns trimmed stdout and raises on a non-zero exit, a timeout, or a
signal-kill:

```python
commit = Command("git", ["rev-parse", "HEAD"]).run()  # or: await ....arun()
```

## Check whether a command succeeds

```python
clean = Command("git", ["diff", "--quiet"]).probe()  # True if exit 0, False if 1
code = Command("mytool").exit_code()  # the raw exit code
```

## Accept non-zero exit codes

Some tools use non-zero as a normal result (`grep` 1 = no match, `diff` 1 =
differs). `success_codes` **replaces** the success set (default `{0}`) — list every
code you accept:

```python
differs = not Command("diff", ["a", "b"]).success_codes([0, 1]).probe()  # 0 same, 1 differs
Command("grep", ["needle", "file"]).success_codes([0, 1]).run()  # 1 (no match) is OK
```

`success_codes` affects `run()` and `result.is_success`; `exit_code()` (raw) and
`probe()` (0/1) are unchanged.

## Set a timeout

```python
result = Command("slow-tool").timeout(5.0).output()  # result.timed_out == True on expiry
Command("slow-tool").timeout(5.0).run()  # raises Timeout on expiry

# Graceful: signal, wait, then hard-kill.
Command("server").timeout(30.0).timeout_signal("term").timeout_grace(5.0).run()
```

## Pass input on stdin

```python
out = Command("tr", ["a-z", "A-Z"]).stdin_text("hello\n").run()  # "HELLO"
Command("sha256sum").stdin_bytes(b"\x00\x01\x02").run()
```

## Feed a large file to stdin without loading it into memory

```python
# Streams straight from disk to the child — no full read into Python bytes,
# so this works just as well for a multi-gigabyte dump/archive/log.
Command("psql", ["mydb"]).stdin_file("dump.sql").run()
Command("tar", ["-xf", "-"]).stdin_file("archive.tar").cwd("/tmp/extract").run()
```

## Let a child read the parent's real stdin

```python
# The child inherits *this* process's stdin — the real terminal, file, or pipe —
# instead of a crate-managed pipe. Its $EDITOR opens on the actual terminal.
Command("git", ["commit"]).inherit_stdin().run()

# Forward a shell pipeline's stdin straight through to the child:
#   cat notes.txt | python -m your_tool
Command("less").inherit_stdin().run()
```

`inherit_stdin()` is mutually exclusive with a mediated stdin source
(`stdin_bytes()` / `stdin_text()` / `stdin_file()`) or `keep_stdin_open()`;
combining them raises `ProcessError` at launch, not when you build the command.

## Set the working directory and environment

```python
Command("ls").cwd("/tmp").output()
Command("printenv", ["TOKEN"]).env("TOKEN", "secret").run()

# Set several at once, or drop an inherited one:
Command("worker").envs({"HOST": "127.0.0.1", "PORT": "8080"}).run()
Command("worker").env_remove("HTTP_PROXY").run()

# Start from an empty environment (reproducible / locked-down child), then add
# back only what you need:
Command("untrusted-tool").env_clear().env("PATH", "/usr/bin").run()
```

## Capture binary (non-UTF-8) output

`output_bytes()` returns a `BytesResult` whose `stdout` is `bytes` (stderr stays
decoded text):

```python
result = Command("convert", ["in.png", "out:-"]).output_bytes()  # or: await ....aoutput_bytes()
png = result.stdout  # bytes
print(result.code, result.is_success)
```

## Cap captured output (untrusted children)

Bound how much output is retained. To bound the parent's **memory**, cap
`max_bytes` — a `max_lines`-only cap doesn't, because one newline-free flood is a
single (unbounded) line:

```python
from processkit import Command, OutputTooLarge

# Keep only the most recent 1 MiB; older output is dropped (the default):
tail = Command("chatty-tool").output_limit(max_bytes=1024 * 1024).output()

# For an untrusted child, treat hitting the byte cap as a failure:
try:
    Command("untrusted-tool").output_limit(max_bytes=8 * 1024 * 1024, on_overflow="error").run()
except OutputTooLarge as e:
    print(e.total_bytes, e.max_bytes)
```

`on_overflow` is `"drop_oldest"` (keep most recent, the default), `"drop_newest"`
(keep earliest), or `"error"` (raise `OutputTooLarge`). A `max_lines` cap bounds
only line-captured output (raw bytes have no line count), but a `max_bytes` cap
also bounds the raw stdout of `output_bytes()` / `aoutput_bytes()` (since processkit
2.1.0): over the byte ceiling it either raises `OutputTooLarge` (`on_overflow="error"`)
or keeps a bounded head/tail with `BytesResult.truncated` set.

Under `on_overflow="error"` the ceiling (and the `total_bytes` an
`OutputTooLarge` reports) counts **raw bytes read from the pipe** — line
terminators and invalid-UTF-8 bytes included — not the bytes of the decoded text;
a drop-mode cap still bounds the retained decoded content. See
[Bounding captured output](commands.md#what-max_bytes-actually-counts).

## Stream output line by line (async)

```python
proc = await Command("my-build", ["--watch"]).astart()
async for line in proc.stdout_lines():
    print(line)
finished = await proc.afinish()  # outcome + captured stderr
```

Interleaved stdout + stderr:

```python
async for event in proc.output_events():
    print(event.stream, event.text)  # "stdout" / "stderr"
```

## Clean ANSI/VT escapes from PTY output

TTY-sensitive tools may emit colors, cursor movement, and OSC metadata into a
PTY's merged output. Sanitize the captured and streamed text before logging,
parsing, or asserting on it:

```python
from processkit import Command

result = Command("colorful-tool", ["status"]).pty().sanitize_vt().output()
print(result.stdout)  # plain text; terminal escape sequences removed
```

In pipe mode, use `stdout_sanitize_vt()` or `stderr_sanitize_vt()` to clean only
one captured stream. Sanitization runs after decoding and line splitting. A
simultaneous `stdout_tee()` still receives the original escape-laden decoded
lines, so it can remain a faithful terminal log while `result.stdout` is clean.

## Stream a log to a file and still get the captured result

`stdout_tee(path)` / `stderr_tee(path)` write the live stream to a file *and*
leave the full output in the captured result — no manual `stdout_lines()` loop,
and the one-shot verbs (`output()`, `run()`) still work:

```python
from processkit import Command

result = Command("cargo", ["build"]).stdout_tee("build.log").output()
# build.log has the live, line-by-line stream; result.stdout has the whole thing.
print(result.stdout)  # capture is untouched — the tee is a copy
```

The file is opened **when you call the builder** (a bad path raises `OSError`
there, not at run) and truncated by default — pass `append=True` to grow an
existing log. Separate files for each stream:

```python
Command("noisy-tool").stdout_tee("out.log").stderr_tee("err.log").run()
```

The sink can be a **file path** (as above) or a **Python writer** — any object
with a `write()` method (`io.StringIO`, `sys.stderr`, a text-mode file, a
logger wrapper) — to mirror the child's output straight into your own
console, buffer, or logger while still capturing it. See
[Streaming](streaming.md#tee-output-to-a-file) for backpressure, the no-op
conditions, and write-error isolation.

## Get live progress from a synchronous run

`stdout_lines()` / `output_events()` need an event loop; `on_stdout_line(callback)`
/ `on_stderr_line(callback)` give the plain, **blocking** `.output()` / `.run()`
call the same live view — `callback` fires on every decoded line as it streams
in, not just once the run finishes:

```python
from processkit import Command

result = (
    Command("cargo", ["build", "--release"])
    .on_stdout_line(lambda line: print("build:", line))
    .output()
)
# capture is untouched — result.stdout still has the whole output.
```

Works the same on the async verbs and on a streamed run — one callback, every
path. A raising callback never derails the run (it goes to
`sys.unraisablehook` instead). See
[Streaming](streaming.md#live-per-line-callbacks) for the no-op conditions and
the one-handler-per-stream rule.

## Tear a standalone process down deterministically

A `RunningProcess` is a context manager. Exiting the block kills the process —
for a standalone `astart()` / `start()` handle that means a hard kill of its
whole private tree — even if the block raises, without waiting on Python's GC:

```python
from processkit import Command

async with await Command("flaky-server").astart() as proc:
    async for line in proc.stdout_lines():
        if "ready" in line:
            break
# proc (and its children) are reaped here

# Sync handles work too — start() is the synchronous twin of astart():
with Command("worker").start() as proc:
    ...  # do other work
# proc torn down here
```

If you consume the handle inside the block (`proc.output()`/`.outcome()`/
`.finish()`/`.shutdown(...)`, or their `a`-prefixed async twins), exit is a
no-op.

## Talk to a process interactively (async)

```python
proc = await Command("python", ["-i"]).keep_stdin_open().astart()
stdin = proc.take_stdin()
await stdin.write_line("print(1 + 1)")
await stdin.close()  # EOF
async for line in proc.stdout_lines():
    print(line)
await proc.aoutcome()
```

## Contain a process tree (no orphans)

Everything started in the group — and everything those processes spawn — is
reaped when the block exits:

```python
from processkit import Command, ProcessGroup

with ProcessGroup() as group:
    group.start(Command("dev-server"))
    group.start(Command("worker"))
    # ... use them ...
# the whole tree, grandchildren included, is gone here
```

Async:

```python
async with ProcessGroup() as group:
    await group.astart(Command("dev-server"))
```

If another library already started the process, adopt it by pid instead of
recreating the launch:

```python
import subprocess

from processkit import ProcessGroup, Unsupported

external = subprocess.Popen(["dev-server"])
try:
    with ProcessGroup() as group:
        try:
            group.adopt_external(external.pid)
        except Unsupported:
            raise RuntimeError("pid-only adoption is unsupported on this platform")
        print(external.pid in group.members())
finally:
    if external.poll() is None:
        external.kill()
    external.wait()
```

The group now covers the adopted process for signalling and teardown, but it
does not reap it or expose an exit status. The original parent still calls
`wait()`. A process's future descendants are included by Windows Job Objects
and Linux cgroup v2; the POSIX process-group fallback normally tracks only the
adopted process. See [Existing processes and
containment](process-groups.md#existing-processes-and-containment) for pid
identity, platform errors, and the host-level alternative.

## Cancel a run and reap its tree (async)

Cancelling the awaiting task — directly, or via `asyncio.wait_for` /
`asyncio.timeout` — tears the whole tree down:

```python
task = asyncio.ensure_future(Command("long-job").aoutput())
task.cancel()  # the process tree is reaped; CancelledError propagates
```

## Wait for a server to be ready

```python
from processkit import (
    Command,
    ProcessGroup,
    wait_until,
    wait_for_named_pipe,
    wait_for_path,
    wait_for_port,
    wait_for_unix_socket,
    wait_for_http,
    wait_for_line,
)

async with ProcessGroup() as group:
    proc = await group.astart(Command("my-server"))
    await wait_for_port("127.0.0.1", 8080, timeout=10)  # poll the port
    # or probe an HTTP health endpoint (ready only on a 2xx, not merely an open
    # port — a warming-up server accepts the port while still replying 503):
    # await wait_for_http("127.0.0.1", 8080, "/health", timeout=10)
    # or wait for a log line (a plain string is a substring-match shorthand):
    # await wait_for_line(proc.stderr_lines(), "listening", timeout=10)
    # or wait for a Unix socket to accept connections (stronger than a path check):
    # await wait_for_unix_socket("/run/my-server.sock", timeout=10)
    # or wait for a Windows named pipe (a busy server is ready too):
    # await wait_for_named_pipe(r"\\.\pipe\my-server", timeout=10)
    # or wait for a pid file to appear:
    # await wait_for_path("/run/my-server.pid", timeout=10)
    # or poll any (sync or async) condition:
    # await wait_until(lambda: health_check_passes(), timeout=10, interval=0.1)
```

## Wait for a unix socket or pid file to appear

Some daemons (Docker, PostgreSQL, many others) announce readiness through a
Unix-domain socket or a pid file rather than a TCP connection or log line. A
socket's filesystem entry can appear before its daemon accepts connections, so
use `wait_for_unix_socket` for the socket case; keep `wait_for_path` for a pid
file or another marker that only needs to exist:

```python
from pathlib import Path
from processkit import Command, ProcessGroup, wait_for_path, wait_for_unix_socket

socket_path = Path("/run/my-daemon.sock")
pid_path = Path("/run/my-daemon.pid")

async with ProcessGroup() as group:
    await group.astart(Command("my-daemon", ["--socket", str(socket_path)]))
    await wait_for_unix_socket(socket_path, timeout=10, interval=0.05)
    # socket_path accepts connections; a pid-file-only daemon uses:
    # await wait_for_path(pid_path, timeout=10, interval=0.05)
```

A `WaitTimeout` (also a `TimeoutError`) is raised if the path never appears
within `timeout` seconds — it carries `.path` for diagnostics.

On Windows, use the symmetric named-pipe probe for services that publish a
pipe instead of a Unix socket:

```python
from processkit import wait_for_named_pipe

await wait_for_named_pipe(r"\\.\pipe\my-daemon", timeout=10, interval=0.05)
```

An occupied pipe counts as ready because its server is live; on non-Windows
platforms this probe raises `Unsupported`.

## Build a shell-free pipeline

```python
top = (Command("ps", ["aux"]) | Command("grep", ["python"])).run()
# or: Command(...).pipe(Command(...)).run() / .arun()

# Binary tail (e.g. `... | gzip`): capture raw bytes.
blob = (Command("cat", ["big.txt"]) | Command("gzip")).output_bytes().stdout
```

A pipeline is run-to-completion (no `astart()` streaming). If
`Pipeline.timeout()` fires, its capture verbs retain the best-effort stdout and
stderr already emitted by the last stage before the deadline. A pipeline has
no `output_limit` cap of its own — bound a flooding pipeline with `timeout()`.
That whole-chain timeout is distinct from a per-stage `Command.timeout()`.
Set per-stage `env`/`cwd` on each `Command` before piping.

## Run many commands at once

`output_all` runs a batch with bounded concurrency (default: the CPU count
available to the process, respecting affinity/cgroup quotas where the platform
reports them; fallback: `4`) and returns each result in input order. A command
that fails to *spawn* (or hits an I/O error) appears as a `ProcessError` in its
slot (a non-zero exit is still data on a `ProcessResult`):

```python
from processkit import Command, ProcessResult, output_all  # or: await aoutput_all(...)

results = output_all([Command("git", ["-C", d, "rev-parse", "HEAD"]) for d in repos], concurrency=8)
heads = [r.stdout.strip() for r in results if isinstance(r, ProcessResult) and r.is_success]
```

`concurrency` bounds how many run *at once*, but every result is retained until the
whole batch returns — peak memory is the sum of all captured outputs, not just
`concurrency` of them. For a large or untrusted batch, cap each command's output
(`.output_limit(max_bytes=…)`).

For raw-bytes output use `output_all_bytes` / `aoutput_all_bytes` — the same
batch, with each slot a `BytesResult` (or a `ProcessError`).

All four accept `runner=` too, driving the whole batch through a double (see
[Test code without spawning processes](#test-code-without-spawning-processes))
instead of the real runner — no real processes spawned in a batch test.

### Stream results as they finish

`output_all` and its twins are *collect-all* — nothing is visible until the whole
batch is done. For a large fan-out where you want progress, or to react to early
finishers instead of blocking on the slowest command, `aoutput_as_completed` is
an async iterator that yields each `(index, result)` pair the moment its command
completes — in completion order, not input order, with the `index` (the command's
position in the input) re-associating a result with the command that produced it:

```python
from processkit import Command, ProcessResult, aoutput_as_completed

commands = [Command("convert", [f"{i}.png", f"{i}.jpg"]) for i in range(200)]
async for index, result in aoutput_as_completed(commands, concurrency=8):
    if isinstance(result, ProcessResult) and result.is_success:
        print(f"page {index} converted")
```

Same hard concurrency cap (never more than `concurrency` children alive at once)
and the same per-slot-error contract as the collect-all verbs — a command that
fails to spawn yields its `ProcessError` in its own pair without aborting the
stream. Cancelling the consuming task, or `break`ing out of the loop early, tears
down every command still in flight, leaving no orphaned children. Use
`aoutput_as_completed_bytes` for undecoded `bytes` output.

Runnable version: [`examples/08_batch_as_completed.py`](https://github.com/ZelAnton/processkit-py/blob/main/examples/08_batch_as_completed.py).

## Wrap a CLI tool

`CliClient` binds a program to default timeout/env, so repeated calls pass only
their args:

```python
from processkit import CliClient

git = CliClient("git", default_timeout=30.0)
head = git.run(["rev-parse", "HEAD"])  # or: await git.arun([...])
clean = git.probe(["diff", "--quiet"])
```

Modern tools (`gh`, `kubectl`, `docker`, `az`, `jj`) emit machine-readable JSON.
For a one-off call, `Command.run_json()` / `arun_json()` decode it directly;
`CliClient.run_json()` / `arun_json()` add reusable program defaults. Both run
like `run` (requiring a zero exit) and hand back the already-parsed object, so
you skip the `run(...)` + `json.loads(...)` + error-mapping boilerplate:

```python
from processkit import CliClient, Command, InvalidJson

version = Command("tool", ["version", "--json"]).run_json()

gh = CliClient("gh")
try:
    pr = gh.run_json(["pr", "view", "42", "--json", "title,state"])
except InvalidJson as exc:
    # `run_json()`/`arun_json()` always attach `.stdout` (unlike the streaming
    # `RunningProcess.stdout_json_lines()` case, where it is `None`) — narrow the
    # type before slicing it.
    stdout = exc.stdout or ""
    raise SystemExit(f"{exc.program} did not return JSON: {stdout[:80]!r}")
print(pr["title"], pr["state"])  # or: await gh.arun_json([...])
```

A non-zero exit still raises `NonZeroExit` (exactly as `run` does); only a
zero-exit run whose stdout will not parse raises `InvalidJson` — a `ProcessError`
carrying the `program` and a bounded stdout fragment, never a bare
`json.JSONDecodeError`.

For testable code, pass `runner=` (a `ScriptedRunner` and friends from
`processkit.testing`) to drive every verb through a double instead of the real
runner — see [Testing your code](testing.md#wrapping-a-cli-tool-cliclient).

## Stream NDJSON output line by line

An agent/LLM tool or a build tool with a streaming `--json` mode that emits one
object per line — `stdout_json_lines()` is `stdout_lines()`'s typed twin: same
one-shot setup call, but each item is already the decoded object, no manual
`json.loads()` loop:

```python
from processkit import Command, InvalidJson

proc = await Command("agent-tool", ["--emit", "ndjson"]).astart()
async for event in proc.stdout_json_lines():
    print(event["type"])
finished = await proc.afinish()
```

A malformed line raises `InvalidJson` (its message already reports the NDJSON
line number and a bounded fragment of that line, plus the real column/byte
offset for a genuine JSON syntax error — see
[Streaming NDJSON output](streaming.md#streaming-ndjson-output) for the rare
non-syntax case that has no parser position to report) and the stream
continues with the next line rather than ending.

## Check a tool is installed before running it

Fail early with a friendly message instead of a spawn error deep in a workflow.
`resolve_program()` (or the module-level `which()`) locates the executable a run
*would* start — reusing the same `PATH`/`PATHEXT`/execute-bit lookup — without
starting any process:

```python
from processkit import CliClient, ProcessNotFound

git = CliClient("git")
try:
    git.resolve_program()
except ProcessNotFound as exc:
    raise SystemExit(f"git is required but was not found (searched: {exc.searched})")

head = git.run(["rev-parse", "HEAD"])
print(head)
```

`which("tool")` is the shorthand for a one-off check against the process `PATH`;
`Command(...).resolve_program()` and `CliClient(...).resolve_program()`
additionally honor a `prefer_local` directory and a relocated child `PATH`. All
three are synchronous and side-effect-free (a few `stat`s, no spawn), and raise
`ProcessNotFound` (also a `FileNotFoundError`) with a `searched` diagnostic on a
miss — the exact error a real run would raise.

## Run a tty-sensitive or pipe-buffered tool

Some CLIs block-buffer output when connected to a pipe, suppress interactive
features, or refuse to run without a tty. Give the child a managed PTY while
keeping process-tree teardown:

```python
from processkit import Command

command = Command("interactive-tool").pty(cols=120, rows=40).keep_stdin_open()
with command.start() as proc:
    stdin = proc.take_stdin()
    proc.resize_pty(160, 50)
```

Read `proc.stdout_lines()` for the merged terminal stream. In async code,
`await stdin.send_control("c")` delivers a real terminal Ctrl-C. Do not combine
PTY mode with inherited/null/file-redirected stdio; the builder rejects those
conflicts before launch.

## Keep a service alive (supervision)

```python
from processkit import Command, Supervisor

outcome = Supervisor(
    Command("flaky-worker"),
    restart="on_crash",  # "always" | "never" | "on_crash"
    max_restarts=10,
    backoff_initial=0.5,
    backoff_factor=2.0,
    max_backoff=30.0,
).run()  # or: await ....arun()
print(outcome.restarts, outcome.stopped)
```

The `stop_when=` predicate receives each run's `ProcessResult` and returns a
bool; inspect the passed result rather than calling a synchronous run verb inside
it (a nested sync call from within the supervisor's own loop is unsupported). A
predicate that raises aborts supervision and is re-raised to the caller; it is
never silently interpreted as "don't stop".

`Supervisor` also accepts `runner=` — pass a `ScriptedRunner` with
`.on_sequence(...)` (fail a few times, then succeed) to test a restart/backoff
policy hermetically, with no real flaky process behind it.

## Sandbox an untrusted tree with resource limits

Enforced by the Windows Job Object or a Linux **cgroup-v2 root**. Under a
container / systemd session / non-root cgroup the kernel forbids them and
`ResourceLimit` is raised:

```python
from processkit import Command, ProcessGroup

# Lock down the command too: empty env (allowlisting PATH), cap output, and tie
# its lifetime to ours. All cross-platform.
tool = (
    Command("untrusted-tool")
    .env_clear()
    .inherit_env(["PATH"])
    .kill_on_parent_death()  # die with us even without explicit teardown
    .output_limit(max_bytes=8 * 1024 * 1024)
)
with ProcessGroup(max_memory=512 * 1024 * 1024, max_processes=64, cpu_quota=1.0) as group:
    group.start(tool)
    stats = group.stats()
    print(stats.active_process_count, stats.peak_memory_bytes)
```

On POSIX you can also drop privileges to run as an unprivileged user — but set
**all three** of `gid` / `groups` / `uid` (builder order doesn't matter; the
crate applies them in the kernel-correct order, supplementary groups and gid
before uid):

```python
nobody = (
    Command("untrusted-tool").gid(65534).groups([65534]).uid(65534)  # run as nobody:nogroup
)
```

Setting `uid` (and `gid`) **without** `groups([...])` leaves the child holding the
*parent's* supplementary groups — often including privileged ones (`0`/root,
`docker`, `wheel`, `sudo`) when launched from root or in CI — which is a real
sandbox escape. Always clear/replace the supplementary groups with `groups([...])`
(pass the unprivileged group, or `groups([])` to drop them entirely). These
builders make the **run raise `Unsupported` on Windows** (a privilege drop is
never silently skipped), so apply them only when targeting POSIX.

## Watch a group's resource usage live (async)

`sample_stats(group, every)` turns `group.stats()` into a periodic series — no
self-rolled polling loop:

```python
from processkit import Command, ProcessGroup, sample_stats

async with ProcessGroup(max_memory=512 * 1024 * 1024) as group:
    await group.astart(Command("untrusted-tool"))
    async for snap in sample_stats(group, every=1.0):
        print(snap.active_process_count, snap.peak_memory_bytes)
```

The series is **fused**: the first failed sample (e.g. the group has since been
torn down) ends it for good, and that failure's own exception propagates out of
the `async for` rather than the series just quietly stopping — `break` out of
the loop yourself once you have what you need.

## Signal, suspend, or resume a tree

```python
with ProcessGroup() as group:
    group.start(Command("worker"))
    group.suspend()  # pause the whole tree
    group.resume()
    group.signal("term")  # term | kill | int | hup | quit | usr1 | usr2
    group.kill_all()  # immediate hard kill
```

## Handle errors

```python
from processkit import NonZeroExit, Timeout, ProcessNotFound

try:
    Command("git", ["push"]).run()
except NonZeroExit as e:
    print(e.code, e.stderr)  # structured fields, not just a message
except Timeout as e:
    print(e.timeout_seconds)
except ProcessNotFound as e:
    print("missing:", e.program)
```

Every exception derives from `ProcessError`. Three also derive from the builtin
the stdlib raises for the same condition, so familiar `except` clauses work:
`Timeout` is also a `TimeoutError` (as `asyncio.TimeoutError` is),
`ProcessNotFound` is also a `FileNotFoundError` (as `subprocess` raises), and
`PermissionDenied` is also a `PermissionError`. The async readiness helpers
(`wait_for_port` / `wait_for_http` / `wait_for_line` / `wait_for_path` /
`wait_for_unix_socket` / `wait_until`) raise
builtin `TimeoutError`, so `except TimeoutError` catches both run and
readiness timeouts.

## Test code without spawning processes

Write your code against a runner, then inject a `ScriptedRunner` in tests. The
doubles live in the `processkit.testing` submodule; `Runner` is top-level:

```python
from processkit import Command, Runner
from processkit.testing import Reply, ScriptedRunner


def latest_commit(runner):
    return runner.run(Command("git", ["rev-parse", "HEAD"]))


# production
latest_commit(Runner())

# test
scripted = ScriptedRunner()
scripted.on(["git", "rev-parse"], Reply.ok("deadbeef"))
assert latest_commit(scripted) == "deadbeef"
```

`Reply.ok` / `.fail` / `.timeout` / `.signalled` / `.lines` / `.pending` cover
the outcomes; `ScriptedRunner.start()` even returns a streamable scripted
`RunningProcess`. `.on_sequence(prefix, replies)` scripts a *sequence* of
replies for successive matching calls (fail once, then succeed — the shape a
retry/supervision test needs), repeating the last reply once exhausted.

`output_all`/`aoutput_all` (and their `_bytes` twins), `Supervisor`, and
`CliClient` all accept the same doubles via a `runner=` keyword, so batches,
supervised commands, and CLI wrappers are just as testable as raw `Command`
code — see [Testing your code](testing.md) for the full picture.

To capture *real* tool output once and replay it deterministically offline, use
`RecordReplayRunner` — both share the `Runner` verb surface:

```python
from processkit.testing import RecordReplayRunner

rec = RecordReplayRunner.record("cassette.json")  # records via the real runner
recorded = latest_commit(rec)  # spawns git once, captures it
rec.save()

rep = RecordReplayRunner.replay("cassette.json")  # offline; no process spawned
assert latest_commit(rep) == recorded
```

To assert on *what* your code ran (not just its output), inject a
`RecordingRunner` spy — it replies uniformly and records every call:

```python
from processkit import Command
from processkit.testing import RecordingRunner, Reply


def deploy(runner):
    runner.run(Command("git", ["push", "--tags"]))


spy = RecordingRunner.replying(Reply.ok(""))
deploy(spy)

inv = spy.only_call()  # the one call (raises unless exactly one)
assert inv.program == "git"
assert inv.args == ["push", "--tags"]
```

For a `--dry-run`/`--echo` mode — assert on (or print) the *rendered command
line*, with no reply to script and no output to replay — inject a
`DryRunRunner`. It never spawns, renders each command to its display-quoted
line, and returns a synthetic success:

```python
from processkit import Command
from processkit.testing import DryRunRunner


def prune(runner):
    runner.run(Command("rm", ["-rf", "build"]))


dry = DryRunRunner()
prune(dry)
assert dry.only_command() == "rm -rf build"  # nothing spawned
# dry.on_invocation(print) would echo each line live instead.
```

## Use the pytest fixtures

Installing processkit registers a pytest plugin (via a `pytest11` entry point) —
nothing to add to `conftest.py`. It hands you the doubles as fixtures, so
injecting one is a single parameter:

```python
from processkit import Command
from processkit.testing import Reply


def latest_commit(runner):
    return runner.run(Command("git", ["rev-parse", "HEAD"]))


def test_latest_commit(scripted_runner):  # fixture: a fresh ScriptedRunner
    scripted_runner.on(["git", "rev-parse"], Reply.ok("deadbeef"))
    assert latest_commit(scripted_runner) == "deadbeef"


def test_deploy_pushes_tags(recording_runner):  # fixture: a RecordingRunner spy
    recording_runner.run(Command("git", ["push", "--tags"]))
    assert recording_runner.only_call().args == ["push", "--tags"]
```

The `record_replay_runner` fixture serves a per-test cassette — replay by
default, record with `pytest --processkit-record` (or the `PROCESSKIT_RECORD`
env var / `processkit_record` ini). Point `processkit_cassette_dir` (ini) at a
committed fixtures directory to keep cassettes. Mark a test
`@pytest.mark.no_real_spawn` to make any real spawn inside it fail loudly. Full
details in [Testing your code](testing.md#the-pytest-plugin-ready-made-fixtures).

## See what processkit runs (logging)

Opt in once with `enable_logging()` and `processkit` forwards its internal run
events to Python's `logging`:

```python
import logging
from processkit import Command, enable_logging

logging.basicConfig(level=logging.DEBUG)
enable_logging()  # idempotent; returns False if another library already
# owns the process-global tracing subscriber

Command("git", ["rev-parse", "HEAD"]).run()
# DEBUG:processkit:child spawned program=git pid=Some(12345) mechanism=…
# DEBUG:processkit:process exited program=git outcome=Exited(0) elapsed_ms=7
```

(`mechanism` is the platform's containment — `JobObject` on Windows, a process
group / cgroup on POSIX. Fields are forwarded verbatim, so `pid` shows the core's
`Some(…)` rendering.)

Records land on the `processkit` logger (filter it like any other) — DEBUG for a
normal run, WARNING for an edge case. **argv and env are never logged** (the core
omits them — they routinely carry secrets). It's a deliberate **opt-in**: enabling
it installs a process-global subscriber and adds a little per-run overhead, so it's
a debugging/observability switch, off by default.

---

# Coming from `subprocess`

[‹ docs index](./)

You already know `subprocess` (or `asyncio.subprocess`). This guide maps the
patterns you write today onto their `processkit` equivalents, so porting existing
code is mechanical — and then shows the one thing the stdlib can't do that is the
reason to switch: **containing the whole process tree**.

Every snippet assumes `from processkit import ...`. For the full treatment of any
verb, follow the links into [Running commands](commands.md).

## The mental-model shift

`subprocess` couples *running* a command with *deciding whether it failed*:
`run(...)` gives you a `returncode` to inspect, `run(..., check=True)` raises. In
`processkit` those are two different verbs:

- `Command(...).output()` **captures** the result — a non-zero exit, a timeout, and
  a signal-kill are all **data** on a `ProcessResult`, never an exception.
- `Command(...).run()` **requires success** — it returns trimmed stdout and raises a
  typed exception on a non-zero exit, a timeout, or a signal-kill.

Pick the verb by what you want; you no longer thread a `check=` flag through.
See [Picking a verb](commands.md#picking-a-verb) for the full set.

## Running a command (sync)

| You wrote (`subprocess`) | Now write (`processkit`) |
|---|---|
| `run(cmd, capture_output=True, text=True)` → inspect `.returncode` / `.stdout` | `Command(prog, args).output()` → `ProcessResult` (`.code`, `.stdout`, `.is_success`, `.timed_out`) |
| `run(cmd, capture_output=True, text=True, check=True).stdout` | `Command(prog, args).run()` (returns **trimmed** stdout, raises on failure) |
| `run(cmd).returncode` | `Command(prog, args).exit_code()` (raw code) |
| `run(cmd).returncode == 0` | `Command(prog, args).output().is_success` (total); `.probe()` is a shortcut for `0`/`1`-exit predicate tools |
| `run(cmd, capture_output=True).stdout` (bytes) | `Command(prog, args).output_bytes()` → `BytesResult` (`.stdout` is `bytes`) |

```python
from processkit import Command

# subprocess: subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True)
result = Command("git", ["rev-parse", "HEAD"]).output()
print(result.stdout.strip(), result.code, result.is_success)

# subprocess: subprocess.run([...], check=True, capture_output=True, text=True).stdout
commit = Command("git", ["rev-parse", "HEAD"]).run()  # trimmed stdout, raises on failure
```

Note the two differences from `run()` in `subprocess`: `.output().stdout` is the
**full** captured text (not stripped — strip it yourself), while `.run()` returns
it **trimmed**; and a non-zero exit is only an error for `.run()`, never for
`.output()`.

One more divergence to know: unlike `subprocess`'s numeric `.returncode`, the
*checking* verbs (`exit_code`, `probe`, `run`) **raise** on a timeout or a
signal-kill instead of returning a code (and `probe()` also raises on any exit code
other than `0`/`1`). Reach for `.output()` when you want an abnormal exit as
inspectable data (`.timed_out`, `.signal`) rather than an exception.

## The common flags

| `subprocess` keyword | `processkit` builder |
|---|---|
| `timeout=5` | `.timeout(5.0)` — captured on `.output()` (`result.timed_out`), raised by `.run()` |
| `input="text"` / `input=b"..."` | `.stdin_text("text")` / `.stdin_bytes(b"...")` |
| `cwd="/path"` | `.cwd("/path")` |
| `env={...}` (**replaces** the whole environment) | `.env_clear().envs({...})` |
| add/override one variable on the inherited env | `.env("KEY", "value")` / `.envs({...})` |
| — (no equivalent) | `.success_codes([0, 1])` — **replaces** the success set with the listed codes (`grep`/`diff`) |

```python
# subprocess: subprocess.run(["slow"], timeout=5) -> raises TimeoutExpired
Command("slow").timeout(5.0).run()  # raises Timeout on expiry
result = Command("slow").timeout(5.0).output()  # result.timed_out is True instead

# subprocess: subprocess.run(["tr","a-z","A-Z"], input="hello\n", text=True)
Command("tr", ["a-z", "A-Z"]).stdin_text("hello\n").run()

# subprocess: subprocess.run(["grep","x","f"], check=True)  # exit 1 = "no match" -> would raise
Command("grep", ["x", "f"]).success_codes([0, 1]).run()  # 1 (no match) is not a failure
```

`env=` in `subprocess` **replaces** the entire environment; the direct equivalent
is `.env_clear().envs({...})`. To *add to* the inherited environment (the more
common intent), use `.env(...)` / `.envs(...)` without `env_clear()`. More in
[Environment and sandboxing](commands.md#environment-and-sandboxing).

## Shell pipelines, without the shell

`subprocess` pipelines usually mean `shell=True` (and a shell-injection footgun) or
hand-wiring two `Popen`s. `processkit` pipes are shell-free:

```python
# subprocess: subprocess.run("ps aux | grep python", shell=True)
from processkit import Command

out = (Command("ps", ["aux"]) | Command("grep", ["python"])).run()
```

See [Pipelines](pipelines.md) for pipefail attribution and binary tails.

## Async

If you reach for `asyncio.subprocess`, every verb has an `a`-prefixed twin that
shares the same types:

```python
# asyncio: proc = await asyncio.create_subprocess_exec("git","status", stdout=PIPE)
#          out, _ = await proc.communicate()
result = await Command("git", ["status", "--short"]).aoutput()

# Streaming stdout line by line (asyncio-native):
proc = await Command("my-build", ["--watch"]).astart()
async for line in proc.stdout_lines():
    print(line)
finished = await proc.afinish()
```

Streaming, interactive stdin, and readiness probes are covered in
[Streaming & interactive I/O](streaming.md).

## Exceptions

The exception hierarchy is independent, but the three that mirror a stdlib builtin
also *subclass* it — so your existing `except` clauses keep working:

| `subprocess` raises | `processkit` raises | Also a subclass of |
|---|---|---|
| `CalledProcessError` (from `check=True`) | `NonZeroExit` (`.code`, `.stderr`) | — |
| `TimeoutExpired` | `Timeout` (`.timeout_seconds`) | `TimeoutError` |
| `FileNotFoundError` (missing program) | `ProcessNotFound` (`.program`) | `FileNotFoundError` |
| `PermissionError` | `PermissionDenied` (`.program`, `str \| None` — `None` for a program-less permission-denied OS error, not just a spawn-time denial) | `PermissionError` |

```python
# This subprocess-style handler keeps working, because ProcessNotFound *is* a
# FileNotFoundError and Timeout *is* a TimeoutError:
from processkit import Command

try:
    Command("mytool").timeout(5.0).run()
except FileNotFoundError:
    print("not installed")
except TimeoutError:
    print("timed out")
```

Every exception derives from `ProcessError`; see [Errors](commands.md#errors).

## What you actually gain: containing the tree

Everything above is convenience — the *reason* to switch is that `subprocess` and
`asyncio.subprocess` reach only the **direct child**. The processes *it* spawns (a
build tool's compilers, the real payload behind a `sh -c` wrapper, a test's helper
servers) survive a timeout, an exception, or a cancelled task and keep running as
orphans. `processkit` spawns every child into the operating system's own
containment primitive, so teardown is one kernel operation over the whole tree:

```python
from processkit import Command, ProcessGroup

with ProcessGroup() as group:
    group.start(Command("dev-server"))
    group.start(Command("worker"))
    # ... use them ...
# leaving the block reaps the whole tree — grandchildren included
```

Even a single one-shot verb gets this for free: `Command(...).output()` runs inside
a private group that dies with the call, and cancelling an awaited `aoutput()`
reaps its tree. On top of the guarantee you also get whole-tree **resource limits**
(memory / process-count / CPU caps) for sandboxing untrusted children — something
`subprocess` cannot express at all. See [Process groups](process-groups.md) and
[Resource limits](process-groups.md#resource-limits-the-sandbox).

## When to stay with `subprocess`

`processkit` earns its place when you run process *trees*, need them reaped
reliably, or want resource-limited sandboxes. If you only ever run leaf commands
that never spawn children of their own, don't need async cancellation to be
leak-safe, and want zero third-party dependencies, the stdlib is a perfectly good
choice — `processkit` is deliberately **not** a general `subprocess`-convenience
replacement. The wedge is the no-orphan guarantee.

---

Next: [Running commands](commands.md) · [Cookbook](cookbook.md)

---

# Running commands

[‹ docs index](./)

`Command` is the entry point of the runner layer: a builder that describes *what*
to run and *how*, plus a family of verbs that decide *what you get back*. Every
one-shot verb spawns the child into a fresh, private, kill-on-exit process tree,
so an early return, an exception, or a cancelled task can never leak a child.

- [The two surfaces: sync and async](#the-two-surfaces-sync-and-async)
- [Picking a verb](#picking-a-verb)
- [Program, arguments, working directory](#program-arguments-working-directory)
- [Local program search](#local-program-search)
- [Environment and sandboxing](#environment-and-sandboxing)
- [Standard input](#standard-input)
- [Redirecting stdout and stderr](#redirecting-stdout-and-stderr)
- [Text decoding](#text-decoding)
- [Bounding captured output](#bounding-captured-output)
- [Timeouts](#timeouts)
- [Privileges and spawn flags](#privileges-and-spawn-flags)
- [CPU affinity](#cpu-affinity)
- [I/O scheduling priority](#io-scheduling-priority)
- [Detached launch: the deliberate containment opt-out](#detached-launch-the-deliberate-containment-opt-out)
- [Pseudo-terminal mode](#pseudo-terminal-mode)
- [Results](#results)
- [Errors](#errors)
- [Pipelines](#pipelines)

## The two surfaces: sync and async

The capture verbs come in two flavors: a **synchronous** one with a plain name,
and an **asyncio** one with the same name under an `a` prefix. They share the same
builder, the same result types, and the same no-orphan guarantee — pick whichever
fits the call site. (`start()` / `astart()` hand back a live `RunningProcess` for
streaming and interactive I/O — see [Streaming & interactive I/O](streaming.md).
That handle's *consuming* verbs (`outcome`/`aoutcome`, `finish`/`afinish`,
`output`/`aoutput`, …) come in sync/async pairs too, like everywhere else in
this library — use whichever matches your code, regardless of whether the
handle came from `start()` or `astart()`.)

```python
from processkit import Command

head = Command("git", ["rev-parse", "HEAD"]).run()  # sync
head = await Command("git", ["rev-parse", "HEAD"]).arun()  # asyncio
```

The rest of this guide shows the sync form and only repeats the async form where
the behavior differs. A blocked synchronous call **on the main thread** is
interruptible by Ctrl+C: it raises `KeyboardInterrupt` and reaps the process tree
on the way out (off the main thread CPython can't deliver the signal — use the
async API or a `timeout()` there). *Deeper:
[Timeouts & cancellation](timeouts-and-cancellation.md).*

## Picking a verb

| Verb | Returns | Non-zero exit | Timeout / signal-kill | Use when |
|---|---|---|---|---|
| `output()` | `ProcessResult` | captured (`.code`) | captured (`.timed_out` / `.signal`) | You want to inspect the outcome yourself |
| `output_bytes()` | `BytesResult` | captured | captured | stdout is binary (images, archives) |
| `run()` | trimmed stdout `str` | raises `NonZeroExit` | raises `Timeout` / `Signalled` | "Give me the answer, or fail" |
| `run_json()` | decoded JSON value | raises `NonZeroExit` | raises `Timeout` / `Signalled` | A one-off tool emits machine-readable JSON |
| `exit_code()` | `int` (raw) | returns the code | raises (no `-1` sentinel) | The exit code *is* the answer |
| `probe()` | `bool` | `0`→`True`, `1`→`False`, else raises | raises | Predicate tools: `git diff --quiet`, `grep -q` |
| `start()` / `astart()` | `RunningProcess` | — | — | Streaming / interactive I/O — see [Streaming](streaming.md) |

The capturing verbs (`output`, `output_bytes`) treat a non-zero exit, a timeout,
and a signal-kill as **data** — they never raise on the child's outcome. The
checking verbs (`run`, `exit_code`, `probe`) turn those into exceptions. Async
twins: `aoutput`, `aoutput_bytes`, `arun`, `arun_json`, `aexit_code`, `aprobe`,
`astart`.

```python
result = Command("git", ["merge", "feature"]).output()
print(result.code, result.is_success, result.stdout)  # nothing raised
```

`run_json()` is the checked JSON twin of `run()`: it requires a zero exit and
passes stdout through `json.loads`, returning ordinary Python dictionaries,
lists, scalars, booleans, or `None`. A successful command with malformed JSON
raises `InvalidJson` (a `ProcessError`) carrying the program and a bounded stdout
fragment; a process failure remains the same `NonZeroExit`/`Timeout`/`Signalled`
that `run()` would raise.

```python
metadata = Command("tool", ["metadata", "--json"]).run_json()
metadata = await Command("tool", ["metadata", "--json"]).arun_json()
```

## Program, arguments, working directory

Arguments are a list — there is **no shell** between you and the child, so no
quoting, no word-splitting, and no injection surface. Build them up one at a time
or in bulk; `cwd` sets the working directory. The program, the arguments, and
`cwd` accept a `str` or any `os.PathLike[str]` (e.g. `pathlib.Path`) — so a `Path`
argument needs no `str()`. (`bytes` paths are not accepted.)

```python
from pathlib import Path

out = (
    Command("git")
    .arg("log")  # one at a time...
    .args(["--oneline", "-n", "10"])  # ...or in bulk
    .cwd(Path("/srv/repo"))  # run there
    .run()
)
```

The program name reaches the OS verbatim: a bare name is resolved on `PATH` by
the OS, and `cwd` does **not** re-anchor a *relative* program path against the new
directory. Pass an absolute program path when you combine a relative tool with a
`cwd`.

Read back what you built with the `program` / `arguments` properties (`arguments`,
not `args` — that name is already the builder method that *appends* args), or
render the whole thing as a single shell-quoted line with `command_line()` — for
display only (logs, error messages, a dry-run echo): it never invokes a shell,
and the escaping targets human legibility, not any shell's actual parsing rules.
Unlike the redacted `repr()`, `command_line()` **does** include argv, so render it
only into a sink you control.

```python
cmd = Command("login", ["--password", "hunter2"])
cmd.program  # "login"
cmd.arguments  # ["--password", "hunter2"]
cmd.command_line()  # "login --password hunter2" — includes the secret!
repr(cmd)  # redacted: shows arg COUNT, never values
```

### Overriding `argv[0]`

`arg0(value)` overrides the child's `argv[0]` independently of `program` — for
a multicall binary (BusyBox/Toybox) or a login-shell convention (`-bash`):

```python
Command("busybox").arg0("ls").args(["-l"]).run()  # busybox dispatches on argv[0]
```

Program lookup, `prefer_local`, preflight, spawn diagnostics, and containment
all keep using `program`; only the argument vector delivered to the child
changes. Read back what's configured with `configured_arg0` (`None` if
unset); a repeat call is last-write-wins.

`arg0` is an argv value like any other, so the redaction above covers it too:
`repr()` renders it as `arg0: Some("<redacted>")`, never the configured string.
`configured_arg0` and `command_line()` (`busybox [argv0=ls] -l`) stay the opt-in
ways to read the real value.

**Unix only.** On a non-Unix platform a run raises `Unsupported` rather than
silently passing the executable name instead — `configured_arg0` stays
observable there even though a run can never use it.

## Local program search

Use `prefer_local(dir)` when a bare-name program should resolve from a project or
toolchain directory before falling back to the system `PATH`: for example
`node_modules/.bin`, `target/debug`, or a vendored tool directory. The directory
argument accepts `str` and `os.PathLike[str]`, like `cwd`.

```python
out = (
    Command("ruff")
    .prefer_local(Path(".venv/bin"))
    .prefer_local(Path("tools/bin"))
    .arg("--version")
    .run()
)
```

Repeated calls accumulate in priority order, so the first preferred directory is
searched first, then the next, then the normal `PATH`. The search reuses the same
platform behavior as `PATH` resolution, including `PATHEXT` on Windows.

`prefer_local` affects only bare-name programs such as `"ruff"` or `"cargo"`.
Path-form programs such as `"./ruff"`, `"tools/ruff"`, or an absolute path are
used as written. It also does not rewrite the child's own `PATH`; it only changes
how processkit finds the executable to spawn. If the program is not found, the
preferred directories are included in the failure diagnostics along with the
normal search locations.

Here is a self-contained example that creates two local tool directories and
prefers both before the system `PATH`:

```python
import os
import stat
import tempfile
from pathlib import Path

from processkit import Command

name = "demo-tool"


def write_tool(directory: Path, text: str) -> Path:
    directory.mkdir(parents=True)
    if os.name == "nt":
        tool = directory / f"{name}.cmd"
        tool.write_text(f"@echo off\necho {text}\n", encoding="utf-8")
    else:
        tool = directory / name
        tool.write_text(f"#!/bin/sh\necho {text}\n", encoding="utf-8")
        tool.chmod(tool.stat().st_mode | stat.S_IXUSR)
    return tool


with tempfile.TemporaryDirectory() as tmp:
    project = Path(tmp)
    first = write_tool(project / "node_modules" / ".bin", "node tool")
    second = write_tool(project / "target" / "debug", "debug tool")

    # Search order for the bare name "demo-tool":
    #   1. ./node_modules/.bin
    #   2. ./target/debug
    #   3. the parent process PATH
    out = Command(name).cwd(project).prefer_local(first.parent).prefer_local(second.parent).run()
    assert out == "node tool"

    # The child still receives the inherited PATH unless you change it with
    # env(...). prefer_local only affects processkit's spawn-time lookup.
    assert str(first.parent) not in os.environ.get("PATH", "")

    # Path-form programs bypass prefer_local and are used exactly as written.
    assert Command(second).prefer_local(first.parent).run() == "debug tool"
    old_cwd = Path.cwd()
    os.chdir(project)
    try:
        assert (
            Command(f"target{os.sep}debug{os.sep}{second.name}").prefer_local(first.parent).run()
            == "debug tool"
        )
    finally:
        os.chdir(old_cwd)

    print(out)
```

## Preflight: is a program installed?

Sometimes you want to check that a tool is present *before* you run it — a
"doctor" subcommand, or a friendlier error than a spawn failure surfacing deep
in a workflow. `resolve_program()` locates the executable a run *would* spawn,
**without starting any process**:

```python
from processkit import Command, ProcessNotFound

try:
    path = Command("ruff").resolve_program()
    print(f"ruff is installed at {path}")
except ProcessNotFound as exc:
    print(f"ruff is not installed (searched: {exc.searched})")
```

The lookup reuses the **same** resolution the real launch performs — a bare name
against any `prefer_local()` directories first, then `PATH`, honoring `PATHEXT`
on Windows and the execute bit on Unix; a path-form program (`"./tool"`, an
absolute path) probed directly. So a hit is exactly what a spawn of the same
command would run, and a miss is exactly the `ProcessNotFound` it would raise —
`searched` diagnostic included. It also honors a relocated child `PATH`
(`env()` / `env_clear()` / `inherit_env()`), so the preflight never disagrees
with the actual spawn. It is synchronous and cheap (a few `stat`s); there is no
`a`-prefixed async twin, because no runtime is involved.

For a one-off check against the process `PATH`, the module-level `which()` is
shorthand for `Command(program).resolve_program()`:

```python
import processkit

interpreter = processkit.which("python3")  # absolute path, or raises ProcessNotFound
print(interpreter)
```

A `CliClient` offers the same preflight for the tool it wraps, with the client's
defaults (including a `default_env` that relocates `PATH`) applied:

```python
from processkit import CliClient, ProcessNotFound

client = CliClient("git")
try:
    client.resolve_program()  # is git installed, per this client's config?
except ProcessNotFound:
    raise SystemExit("git is required but was not found")
```

## Environment and sandboxing

The environment builders compose, applied in a fixed order at spawn:

```python
# Mutate the inherited environment.
Command("worker").env("RUST_LOG", "debug").env_remove("HTTP_PROXY").run()
Command("worker").envs({"HOST": "127.0.0.1", "PORT": "8080"}).run()

# Allow-list: clear everything, then copy only the named parent variables.
Command("sandboxed-tool").inherit_env(["PATH", "HOME", "LANG"]).env("MODE", "ci").run()

# Scorched earth: the child starts with an empty environment.
Command("hermetic-tool").env_clear().env("PATH", "/usr/bin").run()
```

`inherit_env` is the sandboxing middle ground: it implies `env_clear`, then copies
the listed variables *from the parent at each spawn* (a re-run sees fresh values),
and repeated calls accumulate names. A name the parent doesn't have is skipped,
not set to empty. Explicit `env` / `env_remove` still apply on top.

## Standard input

By default stdin is **closed at spawn** — the child reads EOF immediately and can
never hang waiting for input. Feed a one-shot payload with `stdin_text` (a `str`)
or `stdin_bytes` (raw `bytes`):

```python
loud = Command("tr", ["a-z", "A-Z"]).stdin_text("hello\n").run()  # "HELLO"
Command("sha256sum").stdin_bytes(b"\x00\x01\x02").run()
```

The payload is written on a background task, so a large input can't deadlock
against the child's own output; the pipe is closed afterward to signal EOF.

For a large input already sitting in a file — a database dump piped into `psql`,
an archive fed to `tar`, a multi-gigabyte log run through a filter — use
`stdin_file(path)` instead of reading the file into Python `bytes` yourself. The
file streams straight to the child's stdin in chunks, so it never has to fit in
Python memory:

```python
Command("psql", ["mydb"]).stdin_file("dump.sql").run()
Command("tar", ["-xf", "-"]).stdin_file("archive.tar").cwd("/tmp/extract").run()
```

`stdin_file()` doesn't touch the filesystem when you call it — the path is
opened lazily when the command actually spawns, so a not-yet-existing path is
not an error there. If the file turns out to be missing or unreadable once the
command runs, that surfaces as a generic `ProcessError` from the run/output
verb (not `FileNotFoundError`), since the child process has, by then, already
spawned successfully.

For a conversational, request/response exchange — write a line, read the answer,
repeat — call `keep_stdin_open()` and drive the process through the streaming API
instead. *Deeper: [Streaming & interactive I/O](streaming.md).*

To let the child read the parent's **own** stdin directly — the real terminal, a
file, or a pipe this process was launched with — call `inherit_stdin()`. It is
the stdin counterpart of `stdout("inherit")`: the child *shares* the parent's
stream instead of the crate mediating it. Use it when the child must reach the
real terminal — `git commit` opening `$EDITOR`, a tool prompting for a password
or a yes/no confirmation, or forwarding a shell pipeline's stdin straight
through:

```python
# The editor opens on the real terminal; the crate doesn't touch stdin.
Command("git", ["commit"]).inherit_stdin().run()
```

The crate neither feeds nor captures that input, so there is no writer to
`take_stdin()` — but stdout/stderr are untouched, so `run()` / `output()` still
return the child's captured stdout as usual. `inherit_stdin()` is **mutually
exclusive** with any *mediated* stdin: a `stdin_bytes()` / `stdin_text()` /
`stdin_file()` source, or `keep_stdin_open()`. A child either reads the parent's
stdin or has its stdin driven by the crate, not both. Building the conflicting
combination does not raise; the contradiction is rejected as a `ProcessError`
from the run/output verb when the command actually **launches**, not when you
build the `Command` (the same guard fires on the test doubles too — see
[Interactive stdin](streaming.md#interactive-stdin)).

## Redirecting stdout and stderr

Each stream defaults to `"pipe"` (captured). You can also `"inherit"` the
parent's stream or send it to `"null"`:

```python
Command("long-build").stdout("inherit").stderr("inherit").start()
```

This matters: the one-shot capturing verbs (`output`, `output_bytes`, `run`,
`exit_code`, `probe`) need a piped stdout to do their job. If you set stdout to
`"inherit"` or `"null"`, those verbs **raise** — only `start()` / `astart()` plus
streaming work with a non-piped stdout, because there is nothing to capture. Redirect
streams only when you intend to stream or to discard.

### Redirecting a stream straight to a file

To send a stream *directly* to a file — the child writes to the file's own
descriptor, with no parent-side pump or capture in between — use `stdout_file()`
/ `stderr_file()`. This is the direct-redirect cousin of `stdout_tee()`: the tee
*also* captures and mirrors every decoded line, while these simply hand the child
the file (a `>` / `>>` shell redirect, minus the shell). `append=False` (the
default) creates or **truncates** the file on each spawn; `append=True` creates
or **appends** — the mode for a shared log across `Supervisor` incarnations or
`retry()` attempts, which write to one file with no separator.

```python
from processkit import Command, Supervisor

# Truncate a fresh file on each spawn (the default).
with Command("build", ["--all"]).stdout_file("build.log").start() as proc:
    proc.outcome()

# Append across restarts — one shared log for every Supervisor incarnation.
Supervisor(
    Command("worker").stdout_file("worker.log", append=True).stderr_file("worker.log", append=True)
).run()
```

Unlike `stdout_tee()`, the file is opened **at spawn time**, not when you build
the command — so a not-yet-existing path is not an error here, and each re-run or
retry reopens it. An unopenable path (a missing parent directory, a permission
denial) surfaces from the run verb when the command launches.

Because a file-redirected **stdout** has no pipe for the parent to read, the
verbs that actually read stdout back — `output()`, `run()`, `output_bytes()`
(and their `a`-twins), plus `start()` + `stdout_lines()` / `output_events()` —
**raise** the same "not piped" `ProcessError` as `stdout("null")`. `exit_code()`
and `probe()` are *not* capture verbs: they discard output entirely and never
touch the stdout pipe, so they work fine with a file-redirected stdout — they
(and their async twins `aexit_code()` / `aprobe()`) are the recommended way to
drive such a command to completion, alongside `start()` + `outcome()` /
`aoutcome()` (as the example above does). A file-redirected **stderr** leaves
stdout piped, so `output()` keeps working there; the child's stderr just lands
in the file and `result.stderr` comes back empty. A later `stdout(...)` /
`stderr(...)` call **clears** the redirect and restores the normal stdio mode,
so the builder chain stays composable.

## Text decoding

Output is decoded line by line, UTF-8 by default; invalid bytes become `U+FFFD`
rather than raising. Legacy-encoding tools can override per stream. Labels are
**WHATWG encoding labels** (as the web platform uses) — e.g. `"iso-8859-1"`,
`"windows-1252"`, `"windows-1251"`, `"shift_jis"`. Common **Python codec
aliases** are accepted too (`"latin_1"`, `"utf_8"`, `"euc_jp"`, …), normalized to
the WHATWG form. One caveat to know: WHATWG's `"iso-8859-1"` (and the Python
`"latin_1"` that maps to it) decodes as **windows-1252**, which differs from
strict ISO-8859-1 only in the `0x80`–`0x9F` range. The Windows ANSI code page
(`"mbcs"`/`"ansi"`) has no portable label — pass it explicitly (e.g.
`"windows-1251"`). An unmappable label raises `ValueError` naming the WHATWG form.

```python
out = Command("legacy-tool").encoding("shift_jis").output()  # both streams
out = Command("tool").stdout_encoding("iso-8859-1").output()  # ...or each its own
# .stderr_encoding(...) sets stderr independently
```

When stdout is genuinely binary, skip decoding entirely with `output_bytes()`
(below) instead of guessing an encoding.

## Bounding captured output

Captured lines are held in memory; a multi-gigabyte log would grow the buffer to
match. `output_limit` bounds *retention* — the pipe is always fully drained, so
the child never blocks on a full buffer.

```python
from processkit import Command, OutputTooLarge

# Keep only the most recent 1 MiB; older output is dropped (the default).
tail = Command("chatty-tool").output_limit(max_bytes=1024 * 1024).output()

# For an untrusted child, treat hitting the cap as a failure.
try:
    Command("untrusted-tool").output_limit(max_bytes=8 * 1024 * 1024, on_overflow="error").run()
except OutputTooLarge as e:
    print(e.total_bytes, e.max_bytes)
```

`on_overflow` is `"drop_oldest"` (keep the newest, the default), `"drop_newest"`
(freeze the head), or `"error"` (raise `OutputTooLarge`). To bound the parent's
**memory** against an untrusted child, cap `max_bytes`: a `max_lines`-only cap
does *not*, because one newline-free flood is a single, unbounded line. A
`max_lines` cap applies to line-captured output only — raw bytes have no line
count, so it never bounds the stdout of `output_bytes()`. A `max_bytes` cap
applies to *both* that line-captured output **and** the raw stdout of
`output_bytes()` / `aoutput_bytes()` (since processkit 2.1.0 — earlier the byte
ceiling bounded only the line-pumped stderr and raw stdout was always unbounded).
Over the byte cap an `output_bytes()` run either raises `OutputTooLarge` (with
`max_lines=None`) under `on_overflow="error"`, or keeps a bounded head/tail with
`BytesResult.truncated` set under a drop mode.

### What `max_bytes` actually counts

Which bytes the cap counts depends on `on_overflow` — the asymmetry is deliberate
upstream, so check which half applies to you before sizing a cap:

- **`on_overflow="error"`** — the ceiling counts the **raw bytes read from the
  child's output pipe**, before decoding and *cumulatively* over the whole run
  (a streaming consumer draining lines frees buffer space but does not reset it).
  The line terminator each line arrived with (`\n`, or *both* bytes of a `\r\n`)
  and bytes that are **not valid UTF-8** are charged against it, even though
  neither survives into `ProcessResult.stdout`. `OutputTooLarge.total_bytes`
  reports that same raw count, so it can exceed `len(result.stdout.encode())` for
  the same output. **Changed in processkit 3.0.0**: this ceiling used to count
  decoded line content, so a cap sized against decoded text now trips slightly
  sooner — by one byte per line for ordinary UTF-8 output, more for CRLF or
  binary-ish output.
- **`on_overflow="drop_oldest"` / `"drop_newest"`** — the cap bounds what is
  **retained**, measured in the bytes of the decoded line *content*, terminators
  excluded. Unchanged in 3.0.0: a drop-mode cap keeps exactly the head/tail it
  always did.

Either way `max_bytes` is a real bound on the parent's memory — the reason to
prefer it over `max_lines` for an untrusted child — the two modes just measure at
different points: what was *read* for the fail-loud ceiling, what is *kept* for
the ring buffers.

Raw stdout captured by `output_bytes()` is never decoded, so there is no
distinction to draw there: its byte cap counts the bytes as they were read, in
every mode, exactly as it did before 3.0.0.

## Timeouts

```python
result = Command("slow-tool").timeout(5.0).output()  # result.timed_out is True on expiry
Command("slow-tool").timeout(5.0).run()  # raises Timeout on expiry

# Graceful shutdown: send a signal, wait, then hard-kill.
Command("server").timeout(30.0).timeout_signal("term").timeout_grace(5.0).run()
```

Durations are floats of seconds — never a duration object. `timeout` kills the
whole process tree at the deadline; on the capturing verbs the expiry is captured
(`ProcessResult.timed_out`), on the checking verbs it raises `Timeout`. The
signal name in `timeout_signal` is one of `term | kill | int | hup | quit | usr1
| usr2`, or a raw platform signal number (an `int`, POSIX only — Windows raises
`Unsupported` for anything but a hard kill, same as the named variants).
*Deeper: [Timeouts & cancellation](timeouts-and-cancellation.md).*

`no_timeout()` runs without a deadline, and — unlike simply never calling
`timeout()` — also opts out of a `CliClient`'s `default_timeout` gap-fill
(useful for the one deliberately unbounded call — a `tail -f`, a watch loop —
against a client that otherwise imposes a deadline on every call). Whichever
of `timeout()` / `no_timeout()` you call **last** wins.

### `idle_timeout` — a silence watchdog

`idle_timeout(seconds)` bounds a *silent gap* rather than total runtime: it kills
the child if it emits no **watched output line** for that long — for a tool that
hangs silently while a healthy long job keeps printing. It **composes** with
`timeout()` (whichever threshold is reached first wins) and validates like it
(finite, `> 0`).

```python
from processkit import Command, IdleTimeout

proc = Command("./flaky-build").timeout(600.0).idle_timeout(30.0).start()
try:
    async with proc:
        async for event in proc.output_events():
            print(event.text)
except IdleTimeout as e:
    print(f"silent for {e.idle_timeout_seconds}s — killed")
```

It fires as a **distinct** `IdleTimeout` (a `ProcessError` sibling of `Timeout`,
carrying `idle_timeout_seconds`), never the wall-clock `timed_out`/`Timeout`, so
the two timeout classes stay tellable apart. **Boundaries:** idle monitoring
rides the per-line output channel, so it is enforced only on the
streaming/interactive surface (`start()`/`astart()` +
`stdout_lines()`/`stderr_lines()`/`output_events()`/`lifecycle_events()`). The
stdout-only iterator resets its window only for stdout lines; the other three
use the merged event stream, so either piped stream counts as activity. The
one-shot capture verbs, `Pipeline`, and
`Supervisor` do not enforce it (processkit's core has no native idle-timeout —
that awaits upstream support). A redirected stdout (`stdout_file`/`inherit`/
`null`) carries no line events, so the streaming verbs raise the usual "stdout is
not piped" `ProcessError` there — the combination is diagnosed, not silently
un-watched; redirect only stderr if you still want stdout watched.
*Deeper: [Timeouts & cancellation](timeouts-and-cancellation.md#idle-inactivity-timeout).*

## Retrying a run

```python
Command("flaky-fetch").retry(
    "transient_or_timeout",  # or "transient" — see below
    max_retries=3,  # up to 4 total attempts (default)
    initial_backoff=0.1,  # seconds before the first retry (default)
    multiplier=2.0,  # exponential growth per retry (default)
    max_backoff=30.0,  # cap on a single delay (default)
    jitter=True,  # spread the wait over [0, delay] (default)
).run()
```

Honored only by the success-checking verbs (`run`/`exit_code`/`probe`) — the
non-erroring `output()`/`output_bytes()` never retry, since they never raise
in the first place. `retry_if` is a named preset over the error-classification
accessors, not an arbitrary predicate: `"transient"` covers a bare-retry-clears
spawn/IO condition (interrupted, would-block, a busy resource);
`"transient_or_timeout"` also retries a `.timeout()` expiry. Each attempt
**re-executes the whole command from scratch** — only retry operations safe to
repeat (a `git push` that already reached the server, then dropped the
connection, will be replayed if retried). A one-shot `stdin_bytes()`/
`stdin_text()` source can't survive a retry, so a command built with one is
never retried at all. Ignored by `Supervisor` (its own restart policy governs
keep-alive restarts — a different concern), `output_all`, and `Pipeline`.

`CliClient` has the same knobs, prefixed `default_` (`default_retry_if=`,
`default_max_retries=`, …) — `default_retry_if` is the required opt-in gate;
setting a tuning knob without it raises `ValueError`.

## Privileges and spawn flags

Spawn-time controls for sandboxing and service launch:

```python
# POSIX: drop privileges (groups and gid before uid) and detach.
(
    Command("worker")
    .gid(1000)
    .groups([1000])
    .uid(1000)  # a correct drop sets all three
    .setsid()  # new session: survives the controlling terminal
    .run()
)

# Windows: don't flash a console window from a GUI app.
Command("helper").create_no_window().run()

# Windows: give a console child a CTRL_BREAK to shut down cleanly before the hard kill.
Command("service").windows_graceful_ctrl_break().timeout(30.0).timeout_grace(5.0).run()

# Take the direct child down even if THIS process is killed before teardown runs.
Command("worker").kill_on_parent_death().start()

# Ask what scope that hardening actually reaches on THIS platform (build-time
# fixed; no prior kill_on_parent_death() needed).
scope = Command.kill_on_parent_death_scope()  # "whole_tree" | "direct_child_only" | "unsupported"
```

Platform honesty, not silent no-ops:

- `uid` / `gid` / `groups` / `setsid` are **POSIX-only**. On Windows the run
  raises `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.
- `windows_graceful_ctrl_break` is a **Windows-only opt-in**: at a graceful
  timeout (`timeout_grace`) or a group shutdown it sends the direct console
  child a `CTRL_BREAK` before the grace window, so a child that handles it can
  exit cleanly before the hard `TerminateJobObject` fallback (Windows otherwise
  has no soft-signal tier). Console-only — inert under `create_no_window` /
  detached, and it delivers `CTRL_BREAK`, not `CTRL_C`. A harmless no-op outside
  Windows (Unix's graceful tier already sends a real signal), like
  `create_no_window` — not one of the POSIX-only knobs that raise `Unsupported`.
- `kill_on_parent_death` is best-effort by design: kernel-guaranteed on Windows,
  `PR_SET_PDEATHSIG` on the direct child on Linux, a documented no-op on
  macOS/BSD. The graceful `with`-block teardown holds everywhere regardless.
  `Command.kill_on_parent_death_scope()` reports that reach programmatically —
  `"whole_tree"` on Windows, `"direct_child_only"` on Linux, `"unsupported"` on
  macOS/BSD — so you can read the *actual* abrupt-death scope instead of trusting
  the prose caveat. It is a static capability query fixed at build time: it needs
  no prior `kill_on_parent_death()` call (read it off the class or any instance)
  and describes only abrupt owner death — graceful teardown still kills the whole
  tree everywhere.

## Per-process resource limits

`rlimit(resource, soft, hard)` sets a POSIX `setrlimit(2)` limit for the
child, installed after `fork` and before `exec` — before it has run any of
its own code:

```python
# Cap the child's open-file-descriptor count and CPU time, and disable core
# dumps for a process that may handle secrets.
(Command("worker").rlimit("no_file", 256, 256).rlimit("cpu", 30, 30).rlimit("core", 0, 0).run())
```

`resource` is one of the `RlimitResourceName` presets: `"cpu"` (seconds),
`"core"` (bytes), `"data"` (bytes), `"file_size"` (bytes), `"no_file"` (a
count), `"stack"` (bytes) — an unknown name raises `ValueError` immediately.
`soft`/`hard` use each resource's native unit; `soft` must not exceed `hard`
— an invalid pair is a predictable error before the child is ever spawned,
never a silent correction. Calls for different resources accumulate; a
repeated call for the same resource is last-write-wins. Descendants inherit
the values but may lower them further (and raise a lowered soft value back up
to `hard`).

`rlimit` is **POSIX-only**: on Windows the run raises `Unsupported`, the same
platform-honest contract as `uid` / `gid` / `groups` / `setsid` above. It
complements the whole-tree caps on `ProcessGroup` — `max_memory=...` /
`max_processes=...` / `cpu_quota=...`, see
[Sandboxing untrusted tools](sandboxing.md) — with a finer, per-command knob
that also works where a cgroup limit isn't available: a non-root cgroup, or
macOS/BSD, which have no cgroup equivalent at all.

## CPU affinity

`cpu_affinity(cpus)` pins the child to logical CPU indices; descendants inherit
the mask unless they later change their own affinity:

```python
worker = Command("indexer").cpu_affinity([0, 2])
```

Linux applies the set before `exec`; Windows applies it while the child is still
suspended, before it joins the Job Object and resumes. The sequence must be
non-empty and representable by the platform. Duplicates are removed, stored in
ascending order, and repeated calls are last-write-wins. macOS and BSD reject a
configured affinity at launch with `Unsupported` rather than silently ignoring
it. This is distinct from `cpu_quota`: affinity chooses *which* cores may run the
tree, while a quota caps total CPU time.

## I/O scheduling priority

On Linux, `io_priority` asks the kernel to lower or raise the child's disk-I/O
scheduling class independently of CPU `priority`:

```python
# Best-effort levels run from 0 (highest) to 7 (lowest).
background = Command("indexer").io_priority("best_effort", level=7)
idle_only = Command("cleanup").io_priority("idle")
```

`"best_effort"` and `"real_time"` require `level=0..7`; `"idle"` accepts no
level. Real-time I/O can require elevated privilege. The setting is Linux-only:
building the command remains portable, but launching it on Windows, macOS, or
BSD raises `Unsupported` rather than silently running with ordinary I/O
priority. Last write wins, like CPU `priority()`.

## Detached launch: the deliberate containment opt-out

`spawn_detached()` is the one API that intentionally inverts processkit's
no-orphan guarantee. It creates a child outside this library's per-run container
and returns a separate `DetachedChild` carrying only its spawn-time `pid`:

```python
child = (
    Command("self-updater", ["--apply"])
    .stdout_file("updater.log", append=True)
    .stderr_file("updater.log", append=True)
    .spawn_detached()
)
print(child.pid)
```

The module-level `process_info(child.pid)` and `process_is_alive(child.pid,
saved_start_time)` helpers can inspect whether that pid still names the same
process instance without taking ownership. Save `MemberInfo.start_time` when
available to reject pid reuse; neither helper adds `wait` or `kill` semantics.

Dropping `child` does **not** kill or reap the process. There is deliberately no
`kill`, `wait`, timeout, capture, or interactive-stdin method: after launch,
processkit no longer owns it. Stdio is null by default; file redirects are the
only supported output destination, because an ownerless pipe can fill and
deadlock the child.

Use this only for a daemon, updater, or handoff helper that must outlive its
launcher. Prefer `start()` or a one-shot verb everywhere else. Any setting that
needs an owner or output pump — timeouts (including `idle_timeout`), retries,
cancellation, PTY, open stdin, capture callbacks/limits/tees, inherited stdio,
or parent-death cleanup — is rejected with `Unsupported`, never ignored.
"Detached" means outside processkit's container, not outside a surrounding CI
job, Windows Job Object, cgroup, service, or container imposed by the host.
Runnable version: [`examples/09_spawn_detached.py`](https://github.com/ZelAnton/processkit-py/blob/main/examples/09_spawn_detached.py).

## Pseudo-terminal mode

Pipe mode remains the default. Opt into a real pseudo-terminal when a program
buffers output behind a pipe, requires a tty, or needs terminal control
semantics:

```python
from processkit import Command

command = Command("interactive-tool").pty(cols=120, rows=40).keep_stdin_open()
with command.start() as proc:
    proc.resize_pty(160, 50)
```

PTY mode is supported on Windows and POSIX. The terminal has one merged output
stream: both child stdout and stderr arrive through the existing stdout
capture/streaming APIs, while the stderr capture is empty. With
`keep_stdin_open()`, `take_stdin()` writes to the terminal master and
`send_control("c")` is interpreted by the terminal as Ctrl-C, rather than
merely writing byte `0x03` to a pipe.

Provide `cols` and `rows` together and use positive values. PTY mode owns the
child's stdio, so it cannot be combined with `inherit_stdin()`, inherited/null
stdout or stderr, or `stdout_file()` / `stderr_file()`; the conflicting builder
call raises before a child can spawn. Use non-interactive flags instead when a
PTY is unnecessary — for example `ssh -o BatchMode=yes` or
`GIT_TERMINAL_PROMPT=0`. *Deeper: [Streaming & interactive I/O](streaming.md).*
Runnable version: [`examples/06_interactive_pty.py`](https://github.com/ZelAnton/processkit-py/blob/main/examples/06_interactive_pty.py).

## Results

The capturing verbs hand back a `ProcessResult`:

```python
r = Command("git", ["merge", "feature"]).output()

r.stdout  # str (decoded)
r.stderr  # str
r.code  # int | None — None means killed (timeout / signal), no code
r.signal  # int | None — the signal number on Unix, else None
r.is_success  # code is in success_codes (default {0})
r.timed_out  # the run's own deadline expired
r.program  # the program name, for diagnostics
r.duration_seconds  # wall-clock duration
r.truncated  # an output_limit cap dropped output
r.combined  # stdout + stderr concatenated (property)
```

`output_bytes()` returns a `BytesResult` with the same fields (minus `combined`,
which can't join `bytes` stdout with `str` stderr), except `stdout` is raw `bytes`
(stderr stays decoded `str`). On a `BytesResult`, `truncated` is set when an
`output_limit` cap dropped output — the line-captured stderr under any cap, and
(since processkit 2.1.0) the raw bytes stdout too when a `max_bytes` ceiling bounds
it to a head/tail. A `max_lines` cap never truncates raw stdout (bytes have no line
count); only a `max_bytes` cap does.

```python
png = Command("convert", ["in.png", "png:-"]).output_bytes().stdout  # bytes
```

By default the success set is `{0}`. `success_codes([...])` **replaces** it — list
every code you accept. It affects `run()` and `is_success`, but **not**
`exit_code()` (always the raw int) or `probe()` (always 0/1). An empty sequence
raises `ValueError` (it would accept nothing).

```python
# diff exits 1 when files differ; treat that as success, not a failure.
differs = not Command("diff", ["a.txt", "b.txt"]).success_codes([0, 1]).probe()
Command("grep", ["needle", "log"]).success_codes([0, 1]).run()  # 1 (no match) is OK
```

## Errors

Every exception derives from `ProcessError`. The checking verbs raise these; the
capturing verbs do not (call `ProcessResult.ensure_success()` /
`BytesResult.ensure_success()` on an already-captured result to raise the same
exception after the fact — it returns `self` unchanged on success, so it
composes: `cmd.output().ensure_success().stdout`). Each carries **structured
fields**, not just a message:

| Exception | Raised when | Fields |
|---|---|---|
| `NonZeroExit` | a checking verb saw a non-success exit code | `program`, `code`, `stdout`, `stderr`, `stdout_bytes` (`bytes \| None`), `diagnostic` |
| `Timeout` | the run's deadline killed it | `program`, `timeout_seconds`, `stdout`, `stderr`, `stdout_bytes` (`bytes \| None`), `diagnostic` |
| `Signalled` | the process was killed by a signal | `program`, `signal`, `stdout`, `stderr`, `stdout_bytes` (`bytes \| None`), `diagnostic` |
| `ProcessNotFound` | the program couldn't be located / spawned | `program` |
| `PermissionDenied` | the program couldn't be spawned for lack of permission (e.g. a non-executable file), or a permission-denied OS error surfaced from elsewhere in the run (e.g. a group signal the OS refused) | `program` (`str \| None` — `None` for the broader "refused OS operation" case, where no program is being named) |
| `OutputTooLarge` | an `on_overflow="error"` cap was crossed | `program`, `max_lines`, `max_bytes`, `total_lines`, `total_bytes` |
| `ResourceLimit` | a memory / process / CPU cap was invalid or couldn't be enforced | — (reason is `str(exc)`) |
| `Unsupported` | the platform can't perform the requested operation | `operation` |
| `Cancelled` | a wired `CancellationToken` fired | `program` |

`diagnostic` (on the three stream-bearing exceptions) is the best human-facing
message — captured stderr if it carries text, otherwise captured stdout,
`None` if both streams are blank — so a generic `except ProcessError` handler
can log something useful without knowing which of the three it caught.

```python
from processkit import Command, NonZeroExit, Timeout, ProcessNotFound

try:
    Command("git", ["push"]).run()
except NonZeroExit as e:
    print(e.code, e.stderr)  # structured, not a parsed message
except Timeout as e:
    print(e.timeout_seconds)
except ProcessNotFound as e:
    print("missing:", e.program)
```

Three exceptions also derive from the builtin the stdlib raises for the same
condition, so familiar `except` clauses keep working: `Timeout` is also a
`TimeoutError` (as `asyncio.TimeoutError` is), `ProcessNotFound` is also a
`FileNotFoundError` (what `subprocess` raises), and `PermissionDenied` is also a
`PermissionError`. Cancelling an *awaited* run via asyncio (`task.cancel()`,
`asyncio.wait_for`, `asyncio.timeout`) surfaces as `asyncio.CancelledError`
instead of raising `Cancelled` (that's for an explicit `CancellationToken` wired
with `.cancel_on()`) — either way the tree is reaped.
*Deeper: [Timeouts & cancellation](timeouts-and-cancellation.md).*

### Secrets in diagnostics

`repr(Command(...))` is **redacted**: it shows the program, the argument *count*,
and env variable *names* — never argv values or env values. So a secret passed as
a flag or an `env(...)` value does **not** leak through a REPL echo, an `%r` log,
or a traceback frame.

The remaining channels carry raw values, so handle them with care:

- **Exception `stdout` / `stderr` fields carry the child's raw output verbatim**,
  and the exception *message* appends a **bounded last-line excerpt** of the
  captured output (stderr's last line, or stdout's when stderr is blank) — so if a
  tool echoes a token on failure, it can land in both. Don't forward exception
  text/fields to a low-trust log sink unredacted.
- **argv is visible to the OS** regardless of this library — any local user can
  read it via `ps` / `/proc/<pid>/cmdline` while the child runs. So for real
  secrets, prefer `env(...)` over a command-line flag: the env *value* is kept out
  of the `repr` and out of record/replay cassettes (only the variable name is
  recorded), and isn't exposed in the process listing.

## Pipelines

To connect stages `a | b | c` without a shell, use a `Pipeline` — either the `|`
operator or `.pipe()`. It runs to completion and exposes the same verbs:

```python
top = (Command("ps", ["aux"]) | Command("grep", ["python"])).run()
blob = (Command("cat", ["big.txt"]) | Command("gzip")).output_bytes().stdout
```

*Deeper: [Pipelines](pipelines.md).*

---

Next: [Streaming & interactive I/O](streaming.md) ·
[Process groups](process-groups.md) ·
[Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Supervision](supervision.md) · [Testing your code](testing.md) ·
[Cookbook](cookbook.md) · [Platform support](platforms.md)

---

# Process groups

[‹ docs index](./)

A `ProcessGroup` ties the lifetime of a whole child-process **tree** to a
context manager: every process you start in the group — and everything *those*
processes spawn — is killed when the block exits. A returning, raising, or
cancelled owner never leaks subprocesses, because the kernel object that
contains the tree (a Windows Job Object, a Linux cgroup, or a POSIX process
group) catches grandchildren you never knew about.

You rarely need an explicit group for one-shot runs: a standalone
`Command(...).astart()` / `Runner().start(...)` handle already owns a *private*
tree that its own context manager reaps (see [Running commands](commands.md)).
Reach for `ProcessGroup` when several children should **share one fate**, or
when you want the group verbs below — whole-tree signals, suspend/resume,
member listing, resource limits, and stats.

- [Creating a group and the mechanism](#creating-a-group-and-the-mechanism)
- [Spawning into the group](#spawning-into-the-group)
- [Existing processes and containment](#existing-processes-and-containment)
- [Tearing down](#tearing-down)
- [Signalling the whole tree](#signalling-the-whole-tree)
- [Suspending and resuming](#suspending-and-resuming)
- [Inspecting members](#inspecting-members)
- [Resource limits: the sandbox](#resource-limits-the-sandbox)
- [Stats](#stats)
- [Live monitoring](#live-monitoring)

## Creating a group and the mechanism

The constructor is keyword-only. With no arguments you get a plain container
with the default graceful-shutdown grace (a short window, then escalate to a
hard kill):

```python
from processkit import ProcessGroup, host_containment

host = host_containment()  # no group creation or process spawn
print(host.mechanism, host.soft_stop_scope, host.parent_death_cleanup)

with ProcessGroup() as group:
    print(group.mechanism)  # "job_object" | "cgroup_v2" | "process_group" | "unknown"
    print(group.soft_stop_scope)  # "whole_tree" | "opt_in_members" | "none"
```

`mechanism` reports what you actually got at runtime. On a Linux host without
cgroup-v2 delegation it quietly reads `"process_group"` instead of
`"cgroup_v2"` — the same fallback that decides which features below are
available. FreeBSD's `ProcessReaper` is currently reported as `"unknown"`
because the binding preserves unrecognized variants of the crate's
non-exhaustive mechanism enum. See [Platform support](platforms.md) for the
per-OS matrix; the short version is *Windows strongest, macOS weakest.*

`host_containment()` predicts the host-level mechanism and maximum graceful
stop reach before a group exists, plus abrupt parent-death cleanup and the
underlying Rust crate version. A real group's `soft_stop_scope` is more specific:
on Windows it can narrow from host-level `"opt_in_members"` to `"none"` when
the current membership has no console-CTRL or windowed process. On Unix it is
`"whole_tree"`.

Tune the teardown timing at construction:

```python
group = ProcessGroup(shutdown_grace=10.0, escalate_to_kill=True)
```

`shutdown_grace` is a float of **seconds**. The resource-limit keywords
(`max_memory`, `max_processes`, `cpu_quota`) are covered under
[Resource limits](#resource-limits-the-sandbox).

## Spawning into the group

`start()` (sync) and `astart()` (async) put a full `Command` — capture,
streaming, timeouts, all of it — into the **shared** group and hand back a
`RunningProcess`:

```python
from processkit import Command, ProcessGroup

with ProcessGroup() as group:
    server = group.start(Command("dev-server"))
    worker = group.start(Command("worker"))
    # ... use them ...
# both, and every grandchild they forked, are gone here
```

```python
async with ProcessGroup() as group:
    server = await group.astart(Command("dev-server"))
```

A child started into a shared group does **not** own a private tree: its
`owns_group` is `False`. That distinction matters for teardown. Exiting *that
child's* own context manager (or dropping it) kills only that one child; it is
the **group's** teardown that reaps the whole tree.

```python
with ProcessGroup() as group:
    proc = group.start(Command("worker"))
    assert proc.owns_group is False
    with proc:  # this block kills only `proc`...
        ...
    # ...but other group members keep running until the group exits
```

The streaming and consuming surface of the returned `RunningProcess`
(`stdout_lines()`, `take_stdin()`, `outcome()`/`aoutcome()`, `finish()`/
`afinish()`, …) is documented in [Streaming & interactive I/O](streaming.md).

Since a `ProcessGroup` is itself a runner, you can also run a one-shot command
as a shared member without ever getting a `RunningProcess` handle back — the
same verb surface `Runner`/`ScriptedRunner`/… expose:

```python
with ProcessGroup() as group:
    result = group.output(Command("check-something"))  # a non-zero exit is data
    version = group.run(Command("tool", ["--version"]))  # requires a zero exit
```

## Existing processes and containment

A `ProcessGroup` can establish containment in two ways: processkit can create a
root through the group's `start()` / `astart()` / runner verbs, or an already
running process can be enrolled with `adopt_external(pid)`. The latter is for a
process started by `subprocess`, `asyncio.create_subprocess_exec()` /
`asyncio.create_subprocess_shell()`, another library, an outside supervisor, or
a pidfile.

```python
import subprocess

from processkit import ProcessGroup, Unsupported

external = subprocess.Popen(["my-service"])
try:
    with ProcessGroup() as group:
        try:
            group.adopt_external(external.pid)
        except Unsupported:
            raise RuntimeError("pid-only adoption is unsupported on this platform")
        assert external.pid in group.members()
    # The group's teardown has killed the adopted process; its real parent
    # still owns completion observation and must reap it.
finally:
    if external.poll() is None:
        external.kill()
    external.wait()
```

`pid` is an address, not a process handle. During the call, the crate captures
its own identity anchor for the process currently named by that number. Later
pid reuse is therefore rejected by the group's probes, signals, and teardown.
The crate cannot check the earlier race between the caller reading the pid and
passing it to `adopt_external()`, so look the number up as late as possible.

Adoption is containment and teardown only. It never reaps the adopted process,
and this API exposes no completion handle or exit status for it. Use
`members()` / `members_info()` to list it and the group's signal or teardown
verbs to control it. The process's actual parent (the caller, an outside
supervisor, or `init` after re-parenting) remains responsible for `wait()` and
the exit status. On the `process_group` fallback, an adopted process that exits
without being reaped can remain a zombie during the configured shutdown grace;
only its parent can clear that state.

The containment boundary depends on `group.mechanism`:

- On Windows Job Objects and Linux cgroup v2, descendants spawned after the
  adoption inherit the job/cgroup. Descendants that were already spawned keep
  their original containment.
- On macOS and the Linux `process_group` fallback, a foreign process normally
  cannot be regrouped with `setpgid`, so adoption succeeds with individual
  tracking. Its future descendants are not included. This is `Ok`, not a
  silent failure.
- Linux cgroup-v2 membership is exclusive: adoption moves the process out of
  its previous cgroup, so that supervisor's limits and teardown no longer
  apply. Windows may nest a process already in another Job Object, but the
  kernel can reject the assignment depending on the existing jobs and call
  order; do not treat one host's result as a universal rule.
- FreeBSD and other BSDs return `Unsupported` because the crate cannot capture
  the identity anchor needed for safe pid-only tracking. The process is not
  tracked by a bare number.

`pid=0` and the current process's own pid are rejected as invalid input. A
number naming no process, including an already-reaped process, is rejected as a
not-found I/O error. Through this binding both cases surface as `ProcessError`;
`ProcessNotFound` remains reserved for a program that could not be located.

To observe a foreign process without taking ownership, use the module-level
`process_info()` and `process_is_alive()` lookup helpers documented in
[Commands](commands.md).

If adoption is unsupported or several independent launchers must live under one
operational umbrella, run the entire supervisor inside a host-managed
container, Job Object, or cgroup. That outer boundary belongs to the deployment
environment, not to this library.

## Tearing down

Prefer the context manager — its exit path is the no-orphan guarantee. For
explicit control you also have three verbs:

| Verb | What it does |
|---|---|
| `with` / `async with` exit | **Graceful** teardown of the whole tree — the same as `shutdown()` (signal → wait up to `shutdown_grace` → hard-kill survivors if `escalate_to_kill`). Always on, even if the block raises. |
| `group.kill_all()` | Immediate hard kill of the whole tree, mid-flight; idempotent. |
| `group.shutdown()` / `await group.ashutdown()` | **Graceful**: signal → wait up to `shutdown_grace` → hard-kill survivors if `escalate_to_kill`; closes the Python group handle. |
| `group.stop(grace, escalate=True)` / `await group.astop(...)` | Gracefully stop the current tree and return a `ShutdownReport`; the group remains open for later starts. |

```python
group = ProcessGroup(shutdown_grace=5.0, escalate_to_kill=True)
with group:
    group.start(Command("my-service"))
    ...
    group.shutdown()  # SIGTERM, give it 5s to flush, then SIGKILL stragglers
```

```python
async with ProcessGroup(shutdown_grace=5.0) as group:
    await group.astart(Command("my-service"))
    await group.ashutdown()
```

A child that handles `SIGTERM` and exits ends the grace **early** —
`shutdown` / `ashutdown` returns as soon as the tree is empty, not after the
full timeout. Use `kill_all()` when you want the tree gone *now* with no
grace at all.

Use `stop()` when teardown telemetry matters:

```python
with ProcessGroup() as group:
    group.start(Command("my-service"))
    report = group.stop(5.0, escalate=True)

print(report.soft_signal, report.attempted_signal)
print(report.members_before, report.members_after)
print(report.drained_within_grace, report.escalated, report.elapsed_seconds)
```

`soft_signal` is `"sent"`, `"unsupported"`, `"failed"`, or the forward-compatible
`"unknown"`; `attempted_signal` names the signal when one was attempted. Unlike
`shutdown()`, `stop()` does not close the group, so the same object can contain a
later child tree. Either member count is `None` when the platform membership
query itself failed. On the POSIX process-group fallback, `members_after` can
temporarily include a killed but not-yet-reaped zombie; atomic Job Object and
cgroup membership drop it at exit.

**The no-orphan guarantee and its platform asymmetry.** The `with` /
`async with` exit path reaps the tree on every platform, and so does cancelling
an awaited run (`task.cancel()`, `asyncio.wait_for`, `asyncio.timeout`).
**Surviving a hard kill of the Python parent itself** — `SIGKILL`,
`os._exit` — is a *Windows-only* property, enforced by the kernel's
`KILL_ON_JOB_CLOSE`; on Linux and macOS teardown runs from the normal exit
path, which a hard kill skips. There is **no** Python destructor guarantee:
`__del__` and `atexit` do not run under `SIGKILL` / `os._exit`, so never lean on
them. Lean on the context manager. Full matrix in
[Platform support](platforms.md).

**The `process_group` backend's `setsid()`/`setpgid()` escape.** On
macOS/BSD, and on Linux whenever the group falls back from `cgroup_v2` to
`process_group` (no cgroup-v2 delegation — see
[the mechanism](#creating-a-group-and-the-mechanism)), every teardown path
above — the graceful `with`-exit *and* `kill_all()` — reaches the tree via
`killpg` against the POSIX process group. A child that calls `setsid()` or
`setpgid()` to leave that group before teardown runs is no longer a member,
so `killpg` does not reach it: it survives even a normal, non-crashing
`with`-exit, not just a hard kill of the parent. This is the standard trick
hostile code uses to outlive a sandbox; an ordinary double-fork that never
calls `setsid()`/`setpgid()` stays in the group and is reaped normally. The
Windows Job Object and the Linux cgroup-v2 backend have no such escape —
membership there is kernel-tracked, not session-based, so a descendant
cannot opt itself out. If a child appears to have escaped, see
[Troubleshooting](troubleshooting.md#a-child-escaped-a-posix-process-group-with-setsid).

*Deeper: keeping a service alive across crashes is [Supervision](supervision.md).*

## Signalling the whole tree

`signal(name)` broadcasts a POSIX signal to every member. Accepted names are
`"term"`, `"kill"`, `"int"`, `"hup"`, `"quit"`, `"usr1"`, `"usr2"`:

```python
with ProcessGroup() as group:
    group.start(Command("my-server"))
    group.signal("hup")  # "reload your configuration"
    group.signal("usr1")  # whatever the tool defines
```

`signal("kill")` and `kill_all()` take the same *atomic* whole-tree kill
path, so they cannot miss a process forked mid-broadcast. Every other signal is
a best-effort per-member broadcast against a tree that may be forking at that
instant.

Signals are POSIX-real on Linux, macOS, and BSD. On **Windows** only `"kill"`
maps onto the Job Object terminate; **every other name, including `"term"`,
raises `Unsupported`.** Catch it if you target multiple platforms:

```python
from processkit import Unsupported

try:
    group.signal("hup")
except Unsupported:
    ...  # no SIGHUP on this platform — reload some other way
```

## Suspending and resuming

Freeze a tree (to snapshot it, to starve a runaway while you investigate, to
pause background work), then thaw it:

```python
with ProcessGroup() as group:
    group.start(Command("cpu-hog"))
    group.suspend()  # the whole tree stops consuming CPU
    # ... inspect, snapshot, wait for the user ...
    group.resume()
```

Suspend/resume work on every current backend (anywhere a container exists — all
supported platforms). Two gotchas bite in practice:

- **Resume before starting new work.** Under the cgroup mechanism a child
  spawned into a *frozen* group starts frozen, and `start()` may not return
  until you `resume()`.
- **Resume before a graceful shutdown.** `shutdown` opens with a signal a
  frozen tree can't act on, so it would wait out the whole `shutdown_grace`.
  An immediate hard kill (`kill_all()` or `signal("kill")`) works on a frozen
  tree regardless; the `with`-exit is itself a graceful shutdown, so it carries
  the same caveat — `resume()` first.

## Inspecting members

`members()` returns the live member pids as a point-in-time snapshot:

```python
with ProcessGroup() as group:
    group.start(Command("worker-a"))
    group.start(Command("worker-b"))
    print(group.members())  # e.g. [4123, 4124]
```

What "members" means depends on the mechanism. On Windows and the Linux cgroup
backend it is the **whole tree** — every descendant pid. On the POSIX
process-group backends (macOS/BSD, Linux without cgroup) it is the tracked
group *leaders*, one pid per started child; their descendants are contained but
not enumerated. A tree that is forking races the snapshot.

`members_info()` returns that **same** set of members — the same point-in-time
snapshot, the same mechanism-dependent matrix above — but carries each pid in a
`MemberInfo` alongside best-effort metadata (parent pid, image name, start time):

```python
with ProcessGroup() as group:
    group.start(Command("worker"))
    for member in group.members_info():
        print(member.pid, member.ppid, member.exe_name, member.start_time)
```

Every field beyond `pid` is `None` wherever the platform can't report it —
`ppid`/`exe_name`/`start_time` are populated on Windows, Linux, and macOS, and
are all `None` on the BSDs (no wired-up per-process reader). Values are never
fabricated: a member that exits mid-snapshot is simply omitted rather than
reported with invented fields.

`start_time` is **not** a wall-clock timestamp — it is an *opaque per-process
identity token* whose unit and epoch are platform-specific (a Windows creation
`FILETIME`, Linux clock ticks since boot, macOS microseconds since the Unix
epoch). Do not interpret it or compare it across platforms; its sole use is
pairing with `pid` — two snapshots whose `pid` *and* `start_time` both match name
the same process instance — to tell a recycled pid apart from the original. And,
like the crate's `tracing` output, `MemberInfo` deliberately never carries the
raw command line or environment on any platform: an argv routinely holds
secrets, and redaction is a policy the consumer must own.

## Resource limits: the sandbox

The three limit keywords turn the group into a sandbox. They are enforced by
the same kernel object that contains the tree:

```python
from processkit import Command, ProcessGroup

with ProcessGroup(
    max_memory=512 * 1024 * 1024,  # bytes, whole tree
    max_processes=64,  # fork-bomb ceiling
    cpu_quota=1.0,  # one core (0.5 = half, 2.0 = two)
) as group:
    group.start(Command("untrusted-tool"))
```

`update_limits(*, max_memory=None, max_processes=None, cpu_quota=None)` changes
those caps without recreating the group or restarting its children. It is a
**full replacement**, not a merge: every call describes all three axes, and an
omitted axis becomes unbounded. Reissuing the complete desired set is therefore
idempotent when the previous update was attempted. `update_limits()` can return
`ProcessError("busy")` while another operation on the same group is in flight
(including an incomplete `await group.arun(...)`). Wait for that operation to
complete, then retry the complete desired set:

```python
with ProcessGroup(max_memory=512 * 1024 * 1024) as group:
    group.start(Command("worker"))
    group.update_limits(
        max_memory=1024 * 1024 * 1024,
        max_processes=64,
        cpu_quota=1.0,
    )
    group.update_limits(max_processes=32)  # memory and CPU are lifted
```

The method is synchronous; the core update does no asynchronous work. Invalid
values and platform failures use the same typed `ResourceLimit` path as the
constructor: the message distinguishes an invalid value, a mechanism without
whole-tree accounting, and a capable mechanism that could not enforce the
request.

Applying several OS caps is not atomic. A failure does **not** roll back writes
that already succeeded, so the live container may hold a mix of old and new
caps; retry the complete desired set or tear the group down. Every axis named by
an update that reached the OS is nevertheless added to the sticky cap record,
whether the call succeeds or fails. That record remains conservative and never
supports a fabricated "not tripped" verdict for a possibly-applied cap.

`cpu_quota` is a fraction of a **single** core. On Windows it is converted
against the host CPU count and is approximate (a CPU-*rate* cap, not a hard
quota); on the Linux cgroup it is exact.

Limits need a **real container** — a Windows Job Object or a Linux **cgroup-v2
root**. If a requested cap can't be enforced, construction or `update_limits()` raises
`ResourceLimit` rather than handing you a silently-unbounded group:

```python
from processkit import ResourceLimit

try:
    group = ProcessGroup(max_memory=256 * 1024 * 1024)
except ResourceLimit:
    ...  # no Job Object / cgroup-v2 root here — limits unavailable
```

On Linux this requires the process to run at the real cgroup-v2 root. The
kernel's "no internal processes" rule forbids it under a container, a systemd
session/scope/service, or any non-root cgroup — so an ordinary container fails
too. macOS/BSD and the Linux process-group fallback have **no** whole-tree
limits at all. The prerequisites live in [Platform support](platforms.md); pair
limits with a locked-down `Command` (`env_clear().inherit_env(["PATH"])`,
`output_limit(...)`) per the [Cookbook](cookbook.md). For a quick diagnosis of a
`ResourceLimit` failure in those environments, see
[Troubleshooting](troubleshooting.md#resourcelimit-under-docker-systemd-or-a-non-root-cgroup).

## Stats

`stats()` returns a point-in-time `ProcessGroupStats` snapshot:

```python
with ProcessGroup() as group:
    group.start(Command("worker"))
    snap = group.stats()
    print(snap.active_process_count)  # int
    print(snap.peak_memory_bytes)  # int | None
    print(snap.total_cpu_time_seconds)  # float | None
    print(snap.io_read_bytes)  # int | None, cumulative
    print(snap.io_write_bytes)  # int | None, cumulative
    print(snap.peak_process_count)  # int | None, high-water mark
```

`active_process_count` is always available. `peak_memory_bytes` and
`total_cpu_time_seconds` are populated only where the kernel accounts for the
whole tree (Windows, Linux cgroup); on the process-group backends they stay
`None` and only the count is reported.

The three additional fields retain the upstream containment mechanism's
semantics rather than normalizing different operating systems into one
measurement:

| Field | Windows Job Object | Linux cgroup v2 | `process_group` fallback (macOS and non-FreeBSD BSDs; Linux without cgroup delegation) | FreeBSD `ProcessReaper` |
|---|---|---|---|---|
| `io_read_bytes` | Cumulative `IO_COUNTERS` read-transfer bytes for the whole tree; file, pipe, and device transfers count | `io.stat` block-layer read bytes, when an `io` controller is enabled; this binding does not enable that controller, so normally `None` | `None` | `None` |
| `io_write_bytes` | Cumulative `IO_COUNTERS` write-transfer bytes for the whole tree; file, pipe, and device transfers count | `io.stat` block-layer write bytes, when an `io` controller is enabled; this binding does not enable that controller, so normally `None` | `None` | `None` |
| `peak_process_count` | `None`; Job Objects expose neither a kernel peak nor a sampled substitute | `pids.peak` when the `pids` controller and file are available; it counts kernel **tasks**, including every thread | `None` | `None` |

The I/O counters are cumulative: a member that has already exited remains in
the total. They are not directly comparable between Windows and Linux. Windows
counts bytes moved by read/write operations against any target, while Linux
`io.stat` counts bytes that reached the block layer. Linux page-cache hits,
pipes, sockets, and tmpfs traffic therefore do not have a Windows-equivalent
meaning here; a write may also be accounted after the member that dirtied the
page exits. An accounted zero is a real zero, while `None` means that the
mechanism cannot provide that measurement — it is never substituted with `0`.

`peak_process_count` is a kernel high-water mark, not the largest
`active_process_count` observed by calls to `stats()`. On Linux it is a peak
task count, so a multithreaded member contributes all of its threads. It is
available only when the cgroup's `pids` controller is enabled (this binding
enables it for a requested `max_processes` cap) and the kernel exposes
`pids.peak`; otherwise it is `None`.

These are group counters, not per-run telemetry. `RunningProcess.profile()`
and `RunProfile` remain unchanged: they describe the process started by one
run, whereas group I/O counters and process peaks cannot be divided between
multiple runs sharing one containment object.

For a single run's end-to-end resource profile, use `RunningProcess.profile()`,
covered in [Streaming & interactive I/O](streaming.md).

## Live monitoring

`stats()` alone is a snapshot you poll yourself. `sample_stats(group, every)`
turns that into a periodic series — a pure-Python async generator (no
`ProcessGroup` verb of its own) built directly on `stats()`, for a dashboard,
adaptive throttling, or an alert as the tree approaches a resource cap:

```python
from processkit import Command, ProcessGroup, sample_stats

async with ProcessGroup(max_memory=512 * 1024 * 1024) as group:
    await group.astart(Command("untrusted-tool"))
    async for snap in sample_stats(group, every=1.0):
        print(snap.active_process_count, snap.peak_memory_bytes)
        if snap.active_process_count == 0:
            break
```

The first snapshot is taken immediately, then one every `every` seconds, for as
long as you keep consuming — there is no overall deadline; `break` out of the
loop (or otherwise stop iterating) when you're done.

**Fused, and louder than the crate's stream.** The crate's `StatsSampler`
swallows the error on the first failed sample and the series just ends
silently. This generator instead lets `stats()`'s own exception (e.g.
`ProcessError` — "ProcessGroup is already closed" — once the group has torn
down) propagate out of the `async for` untouched, so you learn *why* the
series stopped instead of just that it did. That failure still ends the
series for good: the exception is never retried, and — because it is an
ordinary Python async generator — a further iteration attempt afterwards
raises `StopAsyncIteration` rather than calling `stats()` again. If the group
is already closed/invalid before you ever start iterating, that same
exception surfaces on the very first `async for` step, not as a silently
empty series.

*Deeper: testing code that drives a group without spawning is
[Testing your code](testing.md).*

---

Next: [Streaming & interactive I/O](streaming.md) ·
[Supervision](supervision.md) · [Platform support](platforms.md) ·
[Cookbook](cookbook.md)

---

# Sandboxing untrusted tools

[‹ docs index](./)

Agent/LLM frameworks routinely hand a model the ability to run a "tool" it
picked itself, with arguments it generated itself — a shell command, a code
interpreter, a scraper. That tool is, by construction, less trusted than code
you wrote: it should never be able to outlive your process, exhaust the host,
or run forever. This guide is not a new capability — it is a **composition** of
pieces documented individually elsewhere: [Running commands](commands.md)
(environment, output caps), [Process groups](process-groups.md) (whole-tree
resource limits), and [Timeouts & cancellation](timeouts-and-cancellation.md)
(deadlines). It ties them into one recipe, a checklist, and — most
importantly — an honest statement of what this buys you and what it does not.

- [The threat model](#the-threat-model)
- [The recipe](#the-recipe)
  - [1. Locked-down environment](#1-locked-down-environment)
  - [2. Bounded output](#2-bounded-output)
  - [3. Whole-tree resource limits](#3-whole-tree-resource-limits)
  - [4. A timeout](#4-a-timeout)
  - [5. Teardown](#5-teardown)
- [Checklist: run an untrusted tool safely](#checklist-run-an-untrusted-tool-safely)
- [Never detach untrusted code](#never-detach-untrusted-code)
- [Full example](#full-example)

## The threat model

Be precise about what a `ProcessGroup` sandbox is — and is not — before
leaning on it for anything that matters.

**processkit protects against:**

- **Process-tree leakage — on Windows, and on Linux at a cgroup-v2 root.**
  Every process the tool spawns, and everything *that* spawns, dies when the
  sandbox exits — enforced by the kernel container (Job Object / cgroup v2),
  not a best-effort signal to one pid. The `process_group` backend — macOS/BSD
  always, and Linux whenever it falls back from cgroup v2 without delegation
  (see [the mechanism](process-groups.md#creating-a-group-and-the-mechanism))
  — is **not** in this category: its teardown is `killpg`, which cannot reach
  a child that called `setsid()`/`setpgid()` to leave the group before
  teardown runs — a standard daemonization trick, and exactly how hostile
  code escapes it. (An ordinary double-fork that never calls
  `setsid()`/`setpgid()` stays in the group and is still reaped.) See [the
  no-orphan guarantee and its escape](process-groups.md#tearing-down).
- **Resource exhaustion — only when the kernel container is real.**
  Whole-tree memory, process-count (fork bombs), and CPU caps are enforced by
  the kernel on Windows, and on Linux only when this process runs at a
  **cgroup-v2 root** (see
  [Resource limits](process-groups.md#resource-limits-the-sandbox)). A
  container, a systemd session/scope/service, or any non-root cgroup gets you
  nothing — the kernel's "no internal processes" rule forbids delegation
  there — same as macOS/BSD having no whole-tree limit primitive at all (see
  [Platform support](platforms.md)). The Python API fails closed:
  `ProcessGroup(...)` raises `ResourceLimit` / `Unsupported` rather than
  handing back a silently-uncapped group. `python -m processkit`, however,
  catches that and silently re-spawns the child in an **uncapped**
  `ProcessGroup()`, only warning on stderr (see [Resource limits: hard cap or
  best effort?](cli.md#resource-limits-hard-cap-or-best-effort)) — if the cap
  exists to contain hostile code, treat that stderr warning as a hard
  failure, not something to shrug off and continue. Captured output is
  bounded independently of these caps, so a chatty or malicious child cannot
  grow the parent's memory without limit (see
  [Bounding captured output](commands.md#bounding-captured-output)).
- **Runaway execution time.** A timeout kills the whole tree at a deadline —
  see [Timeouts & cancellation](timeouts-and-cancellation.md).
- **Ambient credential/environment leakage.** `env_clear()` /
  `inherit_env([...])` starts the child from nothing rather than handing it
  the parent's full environment, secrets included — see
  [Environment and sandboxing](commands.md#environment-and-sandboxing). On
  POSIX you can additionally drop privileges — see
  [Privileges and spawn flags](commands.md#privileges-and-spawn-flags).

**processkit does NOT protect against:**

- **Filesystem access.** The tool can read and write anything the OS
  permits its (possibly privilege-dropped) user to touch. processkit does not
  chroot, bind-mount, or otherwise virtualize the filesystem.
- **Network access.** No firewalling or network namespace is applied; a
  sandboxed tool can still make outbound connections unless you restrict that
  another way (a container, a network policy, an egress proxy).
- **Syscall/namespace isolation.** This is not seccomp, and not a
  PID/mount/user-namespace container. A Job Object, cgroup, or process group
  bounds a *tree*'s lifetime and resource consumption — it does not restrict
  *which* syscalls the tree may issue.
- **Vetting the tool's behavior.** processkit does not sanitize, statically
  analyze, or judge what the program does — it bounds the blast radius (time,
  memory, CPU, process count, orphaned children), not the tool's actions
  within those bounds.

**In short:** this is *resource and lifetime* containment, not *security*
isolation. If you need syscall, filesystem, or network isolation, pair
processkit with an actual sandbox — a container, a VM, gVisor, a seccomp
profile, a restricted service account — processkit composes cleanly with any
of those; it just spawns and bounds whatever program you point it at. Do not
let this guide's checklist read as "fully isolated" — it is not.

## The recipe

Compose these five ingredients, in this order, for a locked-down run of an
untrusted tool:

```python
from processkit import Command, ProcessGroup, ResourceLimit, Unsupported

tool = (
    Command("untrusted-tool")
    .env_clear()
    .inherit_env(["PATH"])  # 1
    .output_limit(max_bytes=8 * 1024 * 1024, on_overflow="error")  # 2
    .timeout(30.0)  # 4
    .kill_on_parent_death()
)

try:
    with ProcessGroup(  # 3
        max_memory=512 * 1024 * 1024,
        max_processes=64,
        cpu_quota=1.0,
    ) as group:
        group.start(tool)
        ...
    # 5. the `with` block's exit reaps the whole tree here — no orphans, ever.
except (ResourceLimit, Unsupported) as exc:
    ...  # no Job Object / cgroup-v2 root here (container, non-root cgroup, macOS)
```

### 1. Locked-down environment

Start the child from nothing and allow-list only what it needs — never hand
an untrusted tool the parent's full environment (which routinely carries
credentials). Full treatment, including the ordering of `env`/`env_remove`
on top: [Environment and sandboxing](commands.md#environment-and-sandboxing).

### 2. Bounded output

Cap `max_bytes` so a chatty or malicious tool cannot grow the parent's memory
without bound (a `max_lines`-only cap does not — one newline-free flood is a
single, unbounded line). `on_overflow="error"` turns hitting the cap into a
failure rather than a silent drop, which is usually what you want for a tool you
don't trust — and in that mode the ceiling counts the raw bytes read from the
child's pipe, so it holds even for output that is binary or not valid UTF-8. (A
drop mode bounds the *retained* output instead, measured in decoded line content;
see [what `max_bytes` counts](commands.md#what-max_bytes-actually-counts).) Full
treatment: [Bounding captured output](commands.md#bounding-captured-output).

### 3. Whole-tree resource limits

`max_memory` / `max_processes` / `cpu_quota` on the `ProcessGroup` cap the
*whole tree* — not just the direct child — at the kernel level. This needs a
real container (a Windows Job Object or a Linux cgroup-v2 root); where one
isn't available, the constructor raises `ResourceLimit` rather than handing
back a silently-unbounded group. Full treatment, including the platform
matrix: [Resource limits: the sandbox](process-groups.md#resource-limits-the-sandbox).

For a long-lived sandbox, `group.update_limits(max_memory=...,
max_processes=..., cpu_quota=...)` replaces the caps on the live kernel
container without restarting its children. The call is a **full replacement**:
an omitted axis is lifted, not retained. A failed multi-axis update is not
rolled back and may have applied some axes, so retry the complete desired set;
the crate's sticky cap record includes every requested capped axis even on that
failure path. If another operation on the group is in flight,
`update_limits()` raises `ProcessError("busy")`; wait for that operation to
complete, then retry the complete desired set.

### 4. A timeout

Untrusted code should never run unbounded. `.timeout(seconds)` kills the
whole process tree at the deadline; pair it with `.timeout_grace(...)` for a
graceful signal-then-kill if the tool might want to clean up first. Full
treatment: [Timeouts & cancellation](timeouts-and-cancellation.md).

### 5. Teardown

Prefer the context manager (`with ProcessGroup() as group: ...` / `with
Command(...).start() as proc: ...`) over any manual verb — its exit path is
the no-orphan guarantee, on every platform, even if the block raises. Never
lean on `__del__` / `atexit`: neither runs if the parent itself is hard-killed.
Full treatment: [Tearing down](process-groups.md#tearing-down).

## Checklist: run an untrusted tool safely

- [ ] Environment locked down: `env_clear()` + `inherit_env([...])` (or an
      explicit allow-list built from `env(...)` calls) — never inherit the
      parent's full environment into an untrusted child.
- [ ] Captured output bounded: `output_limit(max_bytes=...)` — a
      `max_lines`-only cap does not bound memory.
- [ ] Whole-tree resource limits set on a `ProcessGroup`: `max_memory`,
      `max_processes`, `cpu_quota` — with `ResourceLimit` / `Unsupported`
      handled where the kernel container isn't available.
- [ ] A timeout set (`Command.timeout(...)`, and `Pipeline.timeout(...)` for a
      piped chain) — untrusted code should never run unbounded.
- [ ] Teardown via a context manager, never `__del__` / `atexit`.
- [ ] `kill_on_parent_death()` set on the tool, so it dies even if your own
      process crashes before teardown runs — on POSIX this covers only the
      **direct child** (Linux `PR_SET_PDEATHSIG`; not inherited by
      grandchildren, resettable by the child itself via
      `prctl(PR_SET_PDEATHSIG, 0)`, and cleared by credential changes or by
      executing a setuid/setgid/capability-bearing binary; ordinary `execve`
      preserves it), and
      is a documented no-op on macOS/BSD. A tree-wide guarantee against a
      hard kill of your own process is Windows-only (Job Object). See
      [Privileges and spawn flags](commands.md#privileges-and-spawn-flags).
- [ ] (POSIX only, if running as a privileged user) privileges dropped with
      all three of `uid` / `gid` / `groups([...])` set together — `uid` alone
      leaves the child holding the parent's supplementary groups. For this
      incomplete-drop symptom, see
      [Troubleshooting](troubleshooting.md#privilege-drop-sets-uid-but-not-gid-and-groups).
- [ ] Read [the threat model](#the-threat-model) above — this checklist buys
      resource and lifetime containment, not syscall/filesystem/network
      isolation.

## Never detach untrusted code

Do not call `Command.spawn_detached()` for an untrusted tool. Detached launch is
an explicit opt-out from processkit's lifetime containment: it has no owning
handle, timeout, output bound, or teardown path, and dropping its pid-only
`DetachedChild` does nothing. It exists for a trusted updater or daemon that is
supposed to outlive the launcher. In a sandbox, that behavior is precisely the
orphan escape this guide is designed to prevent; use a contained one-shot verb,
`start()` context manager, or `ProcessGroup` instead.

## Full example

`examples/04_sandbox_resource_limits.py` runs this recipe end to end for an
agent making a couple of tool calls in one sandboxed session: a locked-down,
output-capped, per-call-timeout command; whole-tree memory/process/CPU limits
on the shared group; and teardown on context-manager exit — degrading
gracefully to "contained, but uncapped" where the kernel container isn't
available (a container, a non-root cgroup, macOS).

```bash
python examples/04_sandbox_resource_limits.py
```

---

Next: [Process groups](process-groups.md) · [Running commands](commands.md) ·
[Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Cookbook](cookbook.md) · [Platform support](platforms.md)

---

# Streaming & interactive I/O

[‹ docs index](./)

The one-shot verbs in [Running commands](commands.md) — `output()`, `run()`,
`output_bytes()` — buffer the *whole* output and hand it back at exit. That is
exactly what you want for a `git rev-parse`. It is exactly what you *don't* want
for a long-running or conversational child: a dev server you watch, a build you
follow, an interpreter you talk to. For those, `await Command(...).astart()`
returns a live `RunningProcess` you drive yourself — stream stdout as it
arrives, write stdin incrementally, probe for readiness, profile a run, and tear
the tree down deterministically.

- [Lifecycle](#lifecycle)
- [Streaming stdout or stderr](#streaming-stdout-or-stderr)
- [Streaming NDJSON output](#streaming-ndjson-output)
- [Tee output to a file](#tee-output-to-a-file)
- [Live per-line callbacks](#live-per-line-callbacks)
- [Interleaved stdout and stderr](#interleaved-stdout-and-stderr)
- [Full lifecycle event stream](#full-lifecycle-event-stream)
- [Interactive stdin](#interactive-stdin)
- [Interactive PTY sessions](#interactive-pty-sessions)
- [Readiness probes](#readiness-probes)
- [Live introspection and per-run telemetry](#live-introspection-and-per-run-telemetry)
- [Deterministic teardown](#deterministic-teardown)

## Lifecycle

```python
from processkit import Command, Runner

# Async setup — the handle owns a private process tree:
proc = await Command("dev-server").astart()

# Sync setup, same live handle (the consuming verbs below have a sync twin too):
proc = Command("dev-server").start()  # or: Runner().start(Command("dev-server"))
# …or hand the tree to a group that owns its fate instead of the handle:
#   proc = group.start(Command("dev-server"))   # see Process groups

proc.pid  # int | None — None once the handle is consumed
proc.elapsed_seconds  # float | None — wall time since spawn
proc.owns_group  # True for a standalone start()/astart() handle; False under a group
```

Whichever way you start it, **consume the handle exactly one way** — each of
these comes in a sync/async pair (like everywhere else in this library) and
*spends* the handle (afterward the getters return `None` and a second
consuming verb raises):

| Verb pair | Returns | Use when |
| --- | --- | --- |
| `proc.outcome()` / `await proc.aoutcome()` | `Outcome` | you only need the exit; output is discarded |
| `proc.finish()` / `await proc.afinish()` | `Finished` | **after streaming stdout** — exit + captured stderr, *without* buffering stdout |
| `proc.output()` / `await proc.aoutput()` | `ProcessResult` | capture everything (same as the one-shot `output()`) |
| `proc.output_bytes()` / `await proc.aoutput_bytes()` | `BytesResult` | capture, stdout as `bytes` |
| `proc.profile(every_seconds)` / `await proc.aprofile(every_seconds)` | `RunProfile` | full outcome + CPU/memory samples; output discarded |
| `proc.shutdown(grace_seconds)` / `await proc.ashutdown(grace_seconds)` | `Outcome` | graceful signal → wait → hard-kill |

(`outcome`/`aoutcome`, not `wait`/`await` — `await` is a reserved word, so it
can't be a method name.) Use whichever half of a pair matches your calling
code — the sync half blocks the calling thread (the same interruptible driver
as `Command.output()`), the async half is a coroutine.

`Outcome` carries `code: int | None`, `signal: int | None`, `timed_out: bool`,
and `exited_zero: bool` (literal "exit code 0" — it has no `success_codes`
context; for the command's own verdict use `ProcessResult.is_success`). There is
also a synchronous `proc.kill()` (like `subprocess.Popen.kill()`) for "stop it
now, I'll read the code myself with `proc.outcome()` / `await proc.aoutcome()`."

`start()`, `astart()`, and `Runner().start()` put the child in a **private group
the handle owns**: tearing the handle down kills the whole tree, and
`shutdown()`/`ashutdown()` work on it — named to match
`ProcessGroup.shutdown()`/`ashutdown()`. The shared-group variant —
`group.start(cmd)` — gives the same handle, but the *group* controls the
tree's fate (`owns_group` is `False`), so `shutdown()`/`ashutdown()` raise
`Unsupported` there; tear such a child down via the group (or `kill()`). See
[Process groups](process-groups.md).

## Streaming stdout or stderr

`stdout_lines()` is a synchronous setup call that returns a `StdoutLines` async
iterator of decoded lines, yielded as the child produces them — no waiting for
exit, no full-output buffering:

```python
from processkit import Command

proc = await Command("cargo", ["build", "--release"]).astart()

async for line in proc.stdout_lines():
    print("build:", line)

# The stream ended (stdout closed). finish() collects the outcome and stderr —
# stderr was drained in the background the whole time, so a noisy child could
# never block on a full pipe.
finished = await proc.afinish()
if not finished.exited_zero:
    print(finished.outcome.code, finished.stderr)
```

`Finished` exposes `outcome`, `stderr: str`, `code: int | None`, and
`exited_zero: bool` (same "exit code 0" meaning as `Outcome.exited_zero`). Things
to know:

- **Call `stdout_lines()` once.** stdout is consumed a single time; a second
  `stdout_lines()` / `output_events()` call, or a non-piped stdout, raises
  rather than yielding a silently-empty stream.
- **The command's `.timeout(d)` bounds the stream** on an own-group handle: at
  the deadline the tree is killed, the pipes close, and the iterator ends — a
  streamed run can't hang past its deadline. The following `finish()` reflects
  it (`outcome.timed_out`).
- For an *ad-hoc* bound, wrap the loop in `asyncio.timeout(...)` and let the
  [teardown](#deterministic-teardown) kill the tree (shown below).
- The line counters tick live: `proc.stdout_line_count` /
  `proc.stderr_line_count` are cheap progress gauges while you stream.

When a service announces readiness on stderr, use `stderr_lines()` directly:

```python
from processkit import Command, wait_for_line

proc = await Command("my-server").astart()
banner = await wait_for_line(proc.stderr_lines(), "listening", timeout=10)
```

`stderr_lines()` drains stdout in the background but yields only decoded stderr
lines. It consumes the same one-shot output as `stdout_lines()`,
`output_events()`, and `lifecycle_events()`, so choose one of those four for a
handle; afterwards use `finish()`/`afinish()` or `outcome()`/`aoutcome()` to
report the run. Because the
current core adapter starts from the merged stream, stdout must remain piped.

*Deeper: output buffering and capture limits apply to streamed runs too —
[Running commands](commands.md).*

## Streaming NDJSON output

Some tools (agent/LLM CLIs, build tools with a `--json` streaming mode) emit one
JSON object per line as they run. `stdout_json_lines()` is `stdout_lines()`'s
typed twin: same synchronous setup call, same one-shot-stdout and
consuming/streaming-conflict rules, but each item is already the decoded
object instead of a raw `str`:

```python
from processkit import Command, InvalidJson

proc = await Command("agent-tool", ["--emit", "ndjson"]).astart()

async for event in proc.stdout_json_lines():
    print(event["type"], event.get("message"))

finished = await proc.afinish()
```

No manual `json.loads()` loop, and a malformed line raises `InvalidJson`
instead of a bare `json.JSONDecodeError` — the stream continues with the next
line rather than ending, matching every other malformed-item case in this
library:

```python
stream = proc.stdout_json_lines()
while True:
    try:
        event = await anext(stream)
    except StopAsyncIteration:
        break
    except InvalidJson as exc:
        # str(exc) already reports the NDJSON line number and a bounded
        # fragment of that line — no need to reconstruct it yourself. For a
        # genuine JSON syntax error it also reports the real column/byte
        # offset; for the rare non-syntax decode failure (e.g. a bare integer
        # literal past Python's `sys.set_int_max_str_digits()` limit, which
        # has no parser position at all) it says so honestly instead of
        # inventing one.
        log.warning("skipping malformed line from %s: %s", exc.program, exc)
    else:
        handle(event)
```

`InvalidJson.stdout` is `None` here (unlike `Command.run_json()` /
`arun_json()`'s bounded whole-payload fragment): a streamed run never buffers
the whole payload before parsing, so there is nothing to attach under that
name — the per-line diagnostic already lives in `str(exc)`.

## Tee output to a file

Sometimes you want *both*: a live log written somewhere **and** the captured
result in hand — a build whose output tails into `build.log` while you still get
the final `ProcessResult` to inspect. `stdout_tee(sink)` / `stderr_tee(sink)` do
that in one line, with no manual loop over `stdout_lines()`:

```python
from processkit import Command

result = Command("cargo", ["build", "--release"]).stdout_tee("build.log").output()

# The file received the live stream, line by line, as it was produced …
assert open("build.log").read().startswith("   Compiling")
# … and capture is untouched — the tee does not steal output from the result.
print(result.stdout)  # the full captured stdout, same as without the tee
```

Each decoded line is written to the sink as it lands, followed by a `\n` (a CRLF
terminator is normalized to `\n`). The tee runs *independently* of capture, so
`result.stdout` still holds the whole output. It also works with the streaming
verbs — `start()` + `stdout_lines()` / `output_events()` — not just the one-shot
capture verbs; the same lines flow to the iterator and the sink.

The sink can also be a **Python writer** — any object with a `write()` method
(`io.StringIO`, `sys.stderr`, a text-mode file, a logger wrapper) — to mirror
the child's output straight into your own console, buffer, or logger while still
capturing it:

```python
import io
from processkit import Command

buf = io.StringIO()
result = Command("cargo", ["build", "--release"]).stdout_tee(buf).output()

# Each decoded line (plus a "\n") was passed to buf.write() as a str, live …
assert buf.getvalue().startswith("   Compiling")
# … and capture is still whole — the object is only mirrored to, never drained.
print(result.stdout)
```

Things to know:

- **A file path or a Python writer.** The sink is either a filesystem path (`str`
  or `os.PathLike[str]`) or an object with a callable `write()` — the two are
  told apart by whether the argument exposes `write` (neither `str` nor
  `pathlib.Path` does). A writer is a **text** sink: each decoded line is passed
  to `write()` as a `str`, so pass a text-mode object (`io.StringIO`,
  `sys.stderr`, a file opened in text mode, a logger wrapper), not a binary one
  (`io.BytesIO`, a `"wb"` file) whose `write(str)` would raise `TypeError`. The
  writer is **not** owned — it is never closed for you, so you keep using your
  `sys.stderr` / open file after the run. `append` tunes only how a *file path*
  is opened (see below); passing `append=True` with a writer raises `ValueError`
  rather than being silently ignored.
- **A file is opened now, at build time.** `stdout_tee(path)` opens the file
  the moment you call it (the crate takes a concrete sink, not a lazy factory),
  **not** when the command runs. So an unopenable path — a missing parent
  directory, a directory, a permission denial — raises the matching `OSError`
  (`FileNotFoundError`, `IsADirectoryError`, `PermissionError`, …) right at the
  builder call, before any run verb. (A writer object is used as-is, so nothing
  is opened — this timing applies only to the path form.)
- **Truncate by default, or append (file paths).** A file sink is created if
  absent and truncated; pass `append=True` to open it in append mode instead (to
  grow an existing log). Because the open handle is shared across re-runs of the
  *same* built `Command` (retries, a reused command, `Supervisor` incarnations),
  those sequential runs **append** to the one file with no delimiter, and
  concurrent clones (pipeline stages) **interleave**. For per-run separation,
  build a fresh `Command` (a fresh path) per run.
- **A slow sink applies backpressure, it does not block the runtime.** The tee
  write is awaited on the capture pump, so a slow disk slows the pump, fills the
  OS pipe, and makes the child block on its next write — rather than stalling the
  event loop. A Python writer gets the same treatment: each `write()` is
  dispatched to the runtime's blocking pool (re-acquiring the GIL there), so even
  a `write()` that *sleeps* applies backpressure without blocking the async event
  loop or deadlocking the runtime. A sink that blocks *forever* (not merely slow)
  parks the pump until teardown; a plain file or a prompt writer never does this.
- **A tee write error is isolated.** If a write to the sink fails mid-run, the
  tee is disabled for the rest of the run and a warning is emitted (under
  [`enable_logging()`](cookbook.md#see-what-processkit-runs-logging)) — the run
  itself and its captured result are unaffected, never broken by the sink. For a
  Python writer, a `write()` (or `flush()`) exception is additionally reported
  via `sys.unraisablehook`, so it is visible even without `enable_logging()`
  (and catchable in a test via a custom hook).

  An invalid integer count from `write()` — negative, zero before the buffer is
  empty, or larger than the remaining buffer — also disables the tee and is
  reported via `sys.unraisablehook`, making it visible on stderr even without
  logging. This report is separate from, and visible alongside, exception-based
  errors.
- **No-op unless the line pump runs.** The tee fires from the line-capture pump,
  so it is inert under `stdout("inherit")` / `stdout("null")` (no pump) and under
  `output_bytes()` (raw capture, no line pump). Reach for it with the line verbs
  — `output()` / `aoutput()`, `run()`, or `start()` + `stdout_lines()` /
  `output_events()`.

## Raw byte tee

`stdout_tee()` / `stderr_tee()` mirror *decoded* lines. `stdout_raw_tee(sink)`
/ `stderr_raw_tee(sink)` are their undecoded cousins: the **raw pipe bytes**,
before any decoding or line splitting — for a caller that needs a byte-exact
copy of exactly what the child wrote (a checksum/digest, a binary log, a
protocol that isn't line-oriented):

```python
from processkit import Command

result = Command("some-tool").stdout_raw_tee("out.raw").output()
# out.raw has the exact bytes the child wrote to stdout: non-UTF-8 bytes
# untouched, CRLF and a lone "\r" un-normalized, no fabricated final newline.
```

Same two sink forms as the decoded tee — a file path or a Python writer — but
since the whole point is byte-exact fidelity, a writer here receives each
chunk as `bytes`, so it must be a **binary** writer (`io.BytesIO`, a `"wb"`
file), not a text one (`sys.stderr`, `io.StringIO`, whose `write(bytes)` would
raise `TypeError`). It is **independent** of `stdout_tee`/`stderr_tee`/
`on_stdout_line` — all configured stdout sinks fire from the same pump — and
requires that stream to be piped: a no-op under `stdout("inherit")` /
`stdout("null")` / a `stdout_file()` redirect (no capture pump runs), and
under `output_bytes()` too (its own return value already *is* the raw stdout,
a separate raw drain with no line pump — reach for the raw tee alongside the
line/streaming verbs instead). A write error disables it for the rest of the
run, the same isolation as the decoded tee.

## Live per-line callbacks

`stdout_lines()` / `output_events()` are async-only — they hand back an async
iterator, so they need an event loop to drive. `on_stdout_line(callback)` /
`on_stderr_line(callback)` give the **synchronous** surface the same live
observation: `callback` runs on every decoded line *as it is produced*, even
while `.output()` / `.run()` is still blocking:

```python
from processkit import Command


def log_line(line: str) -> None:
    print("build:", line)


result = Command("cargo", ["build", "--release"]).on_stdout_line(log_line).output()
# "build: ..." printed live, one call per line, while output() was still blocking.
print(result.stdout)  # capture is untouched — the callback observes, it doesn't consume.
```

They work identically on the async verbs and on a streamed run (`start()`/
`astart()` + `stdout_lines()` / `output_events()`) — one callback, every path;
adding them does not turn the sync surface async-only, and does not replace the
streaming iterators (which stay the only way to *consume* lines one at a time
from Python — a callback only *observes*).

Things to know:

- **At most one handler per stream.** A repeat call **replaces** the previous
  one (builder semantics, like `timeout()`); compose inside a single Python
  callable to fan out to more than one observer.
- **A raising callback never derails the run.** An exception raised inside
  `callback` is reported via `sys.unraisablehook` (visible on stderr, or
  catchable in a test via a custom `sys.unraisablehook`) instead of
  propagating — the run and its captured result are unaffected either way.
- **No-op unless that stream's line pump runs**, same family as
  `stdout_tee`/`stderr_tee`: `on_stdout_line` is inert under
  `stdout("inherit")` / `stdout("null")` and under `output_bytes()` (stdout is
  captured raw there, bypassing the line pump). `on_stderr_line` is inert under
  `stderr("inherit")` / `stderr("null")` — but **not** under `output_bytes()`:
  that verb only bypasses the *stdout* line pump, stderr keeps decoding through
  it exactly as under `output()`.
- **Runs independently of `stdout_tee`/`stderr_tee`.** Set both and both fire
  per line — a callback and a file tee are not mutually exclusive.

## Interleaved stdout and stderr

When the *interleaving* matters — a `--watch` build that prints progress to
stdout and diagnostics to stderr — `output_events()` returns an `OutputEvents`
async iterator that merges both streams in arrival order:

```python
proc = await Command("vite", ["build", "--watch"]).astart()

async for ev in proc.output_events():
    tag = "ERR" if ev.is_stderr else "out"
    print(f"[{tag}] {ev.text}")  # ev.stream is "stdout" / "stderr"
```

Each `OutputEvent` has `stream: Literal["stdout", "stderr"]`, `is_stderr: bool`,
and `text: str`. Like
`stdout_lines()`, this consumes the pipes once — pick `stdout_lines()` *or*
`output_events()`, not both.

Things to know:

- **Only output lines are yielded.** Underneath, the core stream carries the
  child's whole lifecycle (it reports process start and exit as well as output),
  but those non-line events are filtered out here rather than handed to you as an
  `OutputEvent` with an empty `text` — which would be indistinguishable from a
  real blank line the child printed, and would quietly corrupt anything that
  counts or joins lines. What they carry is already on surfaces you have: the
  start is `proc.pid`, the exit is what the finisher below returns.
- **Iterate fully, then finish.** Draining the iterator also drives the run to
  completion, so the usual order terminates:

  ```python
  async for ev in proc.output_events():
      ...
  finished = await proc.afinish()  # or: await proc.aoutcome()
  ```

  `finish()`/`afinish()` reports the outcome (its `stderr` is empty — you already
  received stderr as events), and `outcome()`/`aoutcome()` reports the exit alone.
- **The capture verbs do not apply to such a run.** `output()` / `output_bytes()`
  / `profile()` (and their `a`-twins) raise a `ProcessError` naming
  `output_events()` once that stream has **taken the run over** — which it does
  as soon as it sees the child exit, and always by the time the iterator ends:
  stdout was consumed by the iterator and stderr was delivered as events, so
  there is nothing left for them to capture, and the run is already complete so
  there is nothing left to sample. Reach for `finish()` / `outcome()` instead.
  (A **breaking** change that came with the processkit 3.0 migration: those verbs
  used to return empty captures alongside the run's real outcome.)
- **Leaving the loop early is fine — with one boundary.** `break` out whenever
  you like: `finish()`/`afinish()` and `outcome()`/`aoutcome()` report the run
  either way, and dropping the handle (or exiting its `with` block) still tears
  the tree down — including after the stream has taken the run over, where the
  teardown claims the run *from* it (see
  [Deterministic teardown](#deterministic-teardown)). The three capture verbs
  above are the exception, and *when* you stopped decides which of two behaviours
  you get:

  | when you stopped iterating | `finish()` / `outcome()` | `output()` / `output_bytes()` / `profile()` |
  |---|---|---|
  | the stream had already seen the child exit — always so once the iterator ended, and possible after a `break` too, out of a command that finished while you were reading it | report the run | raise `ProcessError` |
  | the child was still running | report the run | as before 3.0: wait for exit and return empty captures (`profile()` samples the rest of the run) |

  Which row a given `break` lands in follows the child's timing rather than how
  you wrote the loop, so treat the capture verbs as unavailable once you have
  streamed events and use a finisher.

## Full lifecycle event stream

Runnable version: [`examples/07_lifecycle_events.py`](https://github.com/ZelAnton/processkit-py/blob/main/examples/07_lifecycle_events.py).

For structured logging that needs the pid and terminal outcome in the same
ordered channel as output, use `lifecycle_events()` instead:

```python
proc = await Command("worker").astart()

async for event in proc.lifecycle_events():
    if event.kind == "started":
        print("started", event.pid)
    elif event.kind in {"stdout", "stderr"}:
        print(event.stream, event.text)
    elif event.kind == "exited":
        assert event.outcome is not None
        print("exit", event.outcome.code)

finished = await proc.afinish()
```

The sequence begins with `started`, contains zero or more `stdout`/`stderr`
events, and ends with `exited`. Fields that do not apply to a kind are `None`.
The iterator and `output_events()` are two views over the same one-shot stream,
so choose exactly one. Draining either iterator drives the run to completion;
the following `finish()`/`afinish()` or `outcome()`/`aoutcome()` reports the
same run. `output_events()` remains output-only for compatibility.

## Interactive stdin

Conversational tools — write a request, read the response, repeat. Keep stdin
open with `keep_stdin_open()` on the `Command`, then take the writer with
`take_stdin()`:

```python
# bc evaluates each stdin line and prints the result.
proc = await Command("bc").keep_stdin_open().astart()
stdin = proc.take_stdin()  # ProcessStdin (raises if stdin wasn't kept open)
answers = proc.stdout_lines()

await stdin.write_line("2 + 2")  # writes "2 + 2\n", flushed
print("=", await anext(answers))  # 4

await stdin.write_line("6 * 7")
print("=", await anext(answers))  # 42

await stdin.close()  # send EOF — bc exits (idempotent)
finished = await proc.afinish()
assert finished.exited_zero
```

Full runnable example: `examples/05_interactive_stdin.py` — a request/response
conversation (multiple exchanges) with a small inline calculator REPL.

`ProcessStdin` is fully awaitable: `await write(bytes)`, `write_line(str)`
(newline + flush), `send_control(str)`, `flush()`, and `close()` (EOF).
`send_control()` accepts exactly one recognized control character and writes
the mapped control byte to the child's stdin pipe: for example,
`await stdin.send_control("c")` writes Ctrl-C (`\x03`) and
`await stdin.send_control("d")` writes Ctrl-D (`\x04`). Invalid input raises
`ValueError`.

In the default pipe mode this is only a byte; it affects children that read and
interpret it. Under `Command.pty()`, the same writer targets the terminal master,
so `send_control("c")` receives real terminal handling (Ctrl-C / SIGINT on
POSIX, and the corresponding ConPTY control input on Windows).

## Interactive PTY sessions

Runnable version: [`examples/06_interactive_pty.py`](https://github.com/ZelAnton/processkit-py/blob/main/examples/06_interactive_pty.py).

Use a PTY for programs that change buffering or interaction when stdout is not
a terminal:

```python
from processkit import Command

proc = await Command("interactive-tool").pty(cols=120, rows=40).keep_stdin_open().astart()
stdin = proc.take_stdin()
lines = proc.stdout_lines()  # merged terminal output: stdout plus stderr

await stdin.write_line("status")
print(await anext(lines))
proc.resize_pty(160, 50)
await stdin.send_control("c")
outcome = await proc.aoutcome()
```

The PTY has one merged terminal stream, exposed as stdout; stderr is empty.
Existing line framing still applies, including
`line_terminator("carriage_return")` for progress displays that redraw with
bare `\r`. `resize_pty(cols, rows)` requires positive dimensions and raises
`ProcessError` for a non-PTY or already-exited process.

Terminal-aware tools often fill that merged stream with ANSI colors, cursor
movement, alternate-screen switches, and OSC titles or hyperlinks. Add
`sanitize_vt()` when the consumer needs plain text for logging, parsing, or
assertions:

```python
result = Command("colorful-tool").pty().sanitize_vt().output()
assert "\x1b" not in result.stdout
```

`sanitize_vt()` targets both capture channels; `stdout_sanitize_vt()` and
`stderr_sanitize_vt()` target one channel in ordinary pipe mode. The processing
order is fixed and identical for stdout, stderr, and PTY's merged stdout: raw
bytes are decoded with the configured encoding, decoded text is split using the
configured line terminator, then each line is sanitized before entering the
capture backlog. `ProcessResult`, `run()`/`output()`, and the streaming
`stdout_lines()`/`stderr_lines()`/`output_events()` APIs therefore see clean
text without changing line boundaries.

The sanitizer deliberately does not rewrite independent output paths.
Per-line callbacks and decoded `stdout_tee()`/`stderr_tee()` sinks see the
original decoded lines. `output_bytes()` preserves raw stdout bytes, but stderr
remains line-decoded and is therefore sanitized when stderr sanitization is
enabled. Direct `stdout_file()`/`stderr_file()` redirects preserve original
bytes. It is also inert for an inherited or null stream because no capture pump
runs. This makes it safe to keep a faithful terminal log in a tee while parsing
the cleaned capture.

PTY mode is mutually exclusive with inherited, null, or file-redirected stdio.
Conflicts are rejected while constructing the command. It preserves the same
private-tree containment and context-manager teardown as an ordinary launch.

`take_stdin()` **raises** `ProcessError` if the `Command` didn't
`keep_stdin_open()` or the writer was already taken — so a missing setup fails
right here, not later on a `None`.

**Not the same as `inherit_stdin()`.** `keep_stdin_open()` + `take_stdin()` hands
you a **crate-managed pipe** you write to from Python — the crate mediates every
byte. [`inherit_stdin()`](commands.md#standard-input) is the opposite: it gives
the child the parent's **real** stdin (the actual terminal / file / pipe this
process was launched with), so the crate touches nothing and there is no writer
to take (`take_stdin()` returns nothing there, exactly as for a run that never
kept stdin open). Reach for `inherit_stdin()` when a child must talk to the real
terminal — `git commit` opening `$EDITOR`, a password prompt — and for the
byte-by-byte conversational exchange above, `keep_stdin_open()`. The two are
**mutually exclusive**: setting both is rejected as a `ProcessError` at launch
(not when you build the `Command`).

**Avoid the full-duplex deadlock.** A child's stdout pipe has a finite OS
buffer; once it fills, the child blocks *writing* stdout until something reads
it. The `bc` exchange above is safe because it interleaves one small write with
one read. But if you push a *large* interactive stdin while nothing drains the
child's stdout, the child stops reading stdin (blocked on stdout), your `write`
parks waiting for stdin buffer space, and neither side progresses. When you both
feed a sizable stdin **and** the child talks back, drain stdout from one task
while writing stdin from another:

```python
import asyncio

proc = await Command("filter-tool").keep_stdin_open().astart()
stdin = proc.take_stdin()


async def feed():
    for chunk in big_payload:
        await stdin.write(chunk)
    await stdin.close()


async def drain():
    async for line in proc.stdout_lines():
        handle(line)


await asyncio.gather(feed(), drain())
await proc.aoutcome()
```

*Deeper: the non-interactive `stdin_text` / `stdin_bytes` sources never deadlock
— they're pumped on a background task. See [Running commands](commands.md).*

## Readiness probes

"Start a server, then use it" needs *ready*, not merely *started*. Seven
free async helpers replace the arbitrary `asyncio.sleep`, each bounded by its
own deadline (an eighth kind — waiting on an un-terminated *prompt* — is a
handle method instead; see [Waiting for a prompt](#waiting-for-a-prompt-partial-output)
below):

```python
from processkit import (
    Command,
    wait_until,
    wait_for_named_pipe,
    wait_for_path,
    wait_for_port,
    wait_for_unix_socket,
    wait_for_http,
    wait_for_line,
)

proc = await Command("my-server").astart()
lines = proc.stdout_lines()  # bind once — you reuse this same iterator

# 1. A line on stdout (returns the matching line) — a plain string is a
#    substring-match shorthand for a str-yielding iterator:
banner = await wait_for_line(lines, "listening on", timeout=10)
# …or a callable predicate, which also works over any async iterator, not
# just str lines (e.g. `proc.output_events()`'s OutputEvent items):
banner = await wait_for_line(lines, lambda l: "listening on" in l, timeout=10)

# 2. A TCP port accepting connections:
await wait_for_port("127.0.0.1", 8080, timeout=10)

# 3. An HTTP endpoint answering with an acceptable status (2xx by default) — a
#    stronger signal than the port alone, which a warming-up server accepts
#    while still replying 503. `expected_status` takes a set/range or a predicate:
await wait_for_http("127.0.0.1", 8080, "/health", timeout=10)

# 4. A Unix-domain socket accepting connections (stronger than a path check):
await wait_for_unix_socket("/run/my-server.sock", timeout=10)

# 5. A Windows named-pipe server. A busy pipe is ready too: it proves the
#    server exists even when all pipe instances currently have clients:
await wait_for_named_pipe(r"\\.\pipe\my-server", timeout=10)

# 6. A path appearing on the filesystem (a pid file or other marker, …):
await wait_for_path("/run/my-server.sock", timeout=10)

# 7. Any predicate — sync bool OR an awaitable (a DB ping, …):
await wait_until(lambda: health_check_passes(), timeout=10, interval=0.1)

# ready — keep consuming from the SAME iterator:
async for line in lines:
    ...
```

(Named `wait_until`, not `wait_for` — the latter would collide with
`asyncio.wait_for`, whose semantics differ: it bounds one *awaitable*, not a
*polled predicate*.)

Semantics, deliberately uniform:

- The seven probes are `wait_for_line`, `wait_for_port`, `wait_for_http`,
  `wait_for_unix_socket`, `wait_for_named_pipe`, `wait_for_path`, and
  `wait_until`. `wait_for_named_pipe` raises `Unsupported` outside Windows;
  `wait_for_unix_socket` raises it when the Unix connector is unavailable.
- A probe that can't pass within its deadline raises **`WaitTimeout`**
  (`ProcessError`, `TimeoutError`) — so `except TimeoutError` catches both run
  and readiness timeouts, and `.timeout_seconds` reads the configured deadline
  either way. `wait_for_port` additionally sets `.host`/`.port`, `wait_for_http`
  sets `.host`/`.port`/`.path`, and `wait_for_path` / `wait_for_named_pipe` /
  `wait_for_unix_socket` set `.path`. `wait_for_port` / `wait_for_http` /
  `wait_for_named_pipe` / `wait_for_unix_socket` also chain the last failed
  attempt (a connection error, or — for `wait_for_http` — the last unexpected
  status) as `__cause__`.
- `wait_for_line` additionally raises `ProcessError` if the stdout stream ends
  *before* a match — no waiting out a 10s deadline on a dead server. It
  consumes items up to (and including) a match; iteration may continue
  afterward **only when a match was found** — exactly how far it advanced past
  the last inspected item on a timeout is unspecified, so don't rely on the
  iterator's position there. `wait_for_port` / `wait_for_http` /
  `wait_for_path` / `wait_for_named_pipe` / `wait_for_unix_socket` /
  `wait_until` don't touch the process output pipes at all.
- A failed probe **never kills the child** — you decide: retry, log, or tear
  down.
- `wait_until` / `wait_for_port` / `wait_for_http` / `wait_for_path` /
  `wait_for_named_pipe` / `wait_for_unix_socket` poll every `interval` seconds
  (`ValueError` if `interval <= 0`). A sync `wait_until`
  predicate runs on the event loop, so keep it non-blocking; for blocking work,
  pass an awaitable.

### Waiting for a prompt (partial output)

Every probe above is line-shaped or endpoint-shaped. An interactive **prompt** is
neither: `Password: `, `(y/N) `, a REPL `>>> ` are written *without* a trailing
newline and then blocked on, so they never become a line at all — `wait_for_line`
cannot see them until the stream ends, which for a tool waiting on your answer is
never. PTY sessions are made almost entirely of such prompts.

`RunningProcess` therefore carries its own probe over the live **partial tail** —
the decoded output the pump has not yet split into a line — as the usual
sync/async pair (plus a stderr twin for tools that prompt on stderr):

```python
from processkit import Command

proc = await Command("unlock-tool").pty().keep_stdin_open().astart()

# 1. Wait for the un-terminated prompt itself (str = substring of the tail):
await proc.await_for_output("passphrase", timeout=10)

# 2. …answer it over the stdin writer the handle still owns…
stdin = proc.take_stdin()
await stdin.write_line(passphrase)

# 3. …and wait for whatever the tool prints next — a callable predicate here:
prompt = await proc.await_for_output(lambda tail: tail.endswith("$ "), timeout=10)

outcome = await proc.aoutcome()
```

`wait_for_output` / `await_for_output` watch stdout (which is also a PTY's single
merged terminal stream); `wait_for_stderr_output` / `await_for_stderr_output`
watch stderr. Their semantics:

- `predicate` is a `str` (substring of the tail) or a callable
  `predicate(tail) -> bool`, exactly like `wait_for_line`, and `timeout` is
  keyword-only seconds with the same `ValueError` on NaN/negative. The matching
  tail is returned.
- The deadline raises **`WaitTimeout`** like every other probe, and a failed
  probe **never kills the child** nor arms the run's own `timeout()` watchdog. If
  the stream *ends* before a match, it raises `ProcessError` immediately instead
  of waiting out the deadline — the same "stream ended" rule `wait_for_line` has.
- **Non-consuming and repeatable**: the tail is only peeked at, so a multi-turn
  dialog is a sequence of probe → answer turns, and `pid` / `kill()` /
  `take_stdin()` / context-manager teardown keep working throughout. Answer a
  prompt before waiting for the next one — a still-standing tail matches again.
- The tail is the **whole current partial line**, not just the newest fragment:
  a tool that prints two prompts with no newline between them yields both at once.
  Match with `in` / `endswith`, not equality.
- The tail is **raw**. Capture redaction and `sanitize_vt()` both run per
  *completed line*, so a terminal's escape sequences are still in there (ConPTY
  even renders the space in `"Password: "` as a cursor-move). Match a prompt's
  plain text — `"Password:"` — or strip inside a callable, and never assume the
  fragment is scrubbed.
- stdout and stderr are **not** symmetrical: the stderr twin raises
  `ProcessError` when stderr is not piped, which includes every `pty()` run (a
  PTY has one merged stream — use `wait_for_output` there) and any
  `stderr("null")` / `stderr("inherit")` / `stderr_file(...)` command.
- Probing installs stdout's one line pump, just like the crate's line probes. So
  bind `stdout_lines()` / `stdout_json_lines()` / `output_events()` /
  `stderr_lines()` / `lifecycle_events()` **before** your first probe if you want
  both — they then coexist, since the tail is a side channel that steals nothing
  from the iterator — while a stream opened *after* a probe raises `ProcessError`.
  `finish()` / `outcome()` / `output()` still report the run afterwards;
  `output_bytes()` does not (raw bytes are gone once stdout is decoded to lines).

*Deeper: bounding the whole run (not just the wait) is
[Timeouts & cancellation](timeouts-and-cancellation.md).*

## Live introspection and per-run telemetry

A running child reports its own resource usage live; the getters are properties
(not calls), and each returns `None` once the handle is consumed:

```python
proc = await Command("crunch").astart()
proc.pid  # int | None
proc.elapsed_seconds  # float | None — wall time
proc.cpu_time_seconds  # float | None — user + kernel so far
proc.peak_memory_bytes  # int | None
proc.stdout_line_count  # int | None — progress while you stream
proc.stdout_bytes_seen  # int | None — raw pipe bytes, before decoding/line-splitting
proc.stderr_bytes_seen  # int | None — same, for stderr
```

`stdout_bytes_seen` / `stderr_bytes_seen` are the byte-counter siblings of
`stdout_line_count` / `stderr_line_count`: monotonic counters of the raw pipe
bytes read so far (including bytes an `OutputBufferPolicy` later discards),
stable once the process and its pump have finished. They read `0` — not a
sentinel — for a stream that is never pumped (a file redirect,
`stdout("null")`, `stdout("inherit")`).

Or turn a whole run into a summary with `profile()`/`aprofile()`, which
samples the child every `every_seconds` until exit (the run's normal timeout
still applies; like `outcome()`/`aoutcome()`, the output is drained and
discarded, not returned). `RunProfile` is a **superset of `Outcome`**: it
carries the full `outcome` (`code` / `signal` / `timed_out`) *and* the
resource samples:

```python
proc = await Command("crunch").astart()
prof = await proc.aprofile(every_seconds=0.1)

print(
    f"exit={prof.code} signal={prof.signal} timed_out={prof.timed_out} "
    f"wall={prof.duration_seconds:.2f}s cpu={prof.cpu_time_seconds} "
    f"peak_rss={prof.peak_memory_bytes} "
    f"avg_cpu_cores={prof.avg_cpu_cores} ({prof.samples} samples)"
)
# prof.outcome is the same Outcome outcome()/aoutcome() would return.
# avg_cpu_cores = cpu / wall — e.g. 1.7 ≈ 1.7 cores busy
```

These read the *child process itself*, and availability follows the platform —
full CPU/memory on Windows and Linux, `None` where the kernel doesn't account
per-process cheaply. See [Platform support](platforms.md).

*Deeper: whole-tree (grandchildren included) resource stats live on
[Process groups](process-groups.md).*

## Deterministic teardown

A `RunningProcess` is a context manager — sync and async. For a standalone
`start()` / `astart()` / `Runner().start()` handle, exiting the block hard-kills
its whole private tree (best-effort; see [Platform support](platforms.md)), even
if the block raises, without waiting on Python's GC:

```python
async with await Command("flaky-server").astart() as proc:
    async for line in proc.stdout_lines():
        if "ready" in line:
            break
# proc and its whole private tree are reaped here
```

This composes with an *ad-hoc* time bound — wrap the loop, let the exit clean up:

```python
import asyncio

async with await Command("tail", ["-f", "app.log"]).astart() as proc:
    try:
        async with asyncio.timeout(5):
            async for line in proc.stdout_lines():
                print(line)
    except TimeoutError:
        pass
# context-manager exit kills the tree on the way out
```

Three rules close the loop:

- **A consumed handle is spent.** If you consume inside the block (`await
  proc.output()` / `.outcome()` / `.finish()` / `.shutdown(...)` — or their
  `a`-prefixed async twins), the exit is a
  no-op — the verb already settled the run. Afterward the getters return `None`
  and a second consuming verb raises.
- **Streaming events does not weaken it.** Once an `output_events()` stream has
  [taken the run over](#interleaved-stdout-and-stderr) the completion of that run
  is being driven for you in the background — and leaving the block still ends
  the tree *there*, by claiming that work back rather than waiting on it. This is
  the case that matters after an early `break`: the child may be gone while a
  grandchild still holds its pipe, and the block's exit is what stops that
  grandchild from outliving it (on Windows, that also means the files and
  directories it holds open are released before the `with` returns, not moments
  later).
- **Prefer `shutdown()`/`ashutdown()` for a graceful stop.** `await proc.ashutdown(grace_seconds=5)`
  signals the tree, waits up to `grace_seconds`, then hard-kills — and returns
  the `Outcome`. Reach for the context manager when you just want the tree
  *gone*; reach for `shutdown()` when the child deserves a chance to flush.
  (After an `output_events()` stream has taken the run over there is nothing left
  to signal — the child has exited — so `shutdown()` reports that run's real
  outcome, waiting for its output to finish draining exactly as `finish()` does,
  rather than escalating against surviving grandchildren. When the *bound*
  matters more than the outcome, leave the block.)

Cancellation is plain asyncio here: `task.cancel()` on the task awaiting a
consuming verb tears the tree down and propagates `CancelledError`. The full
treatment — deadlines, cooperative shutdown — is in
[Timeouts & cancellation](timeouts-and-cancellation.md).

*Deeper: drive this entire surface with no subprocess at all — a
`ScriptedRunner.start()` returns a streamable handle whose canned lines flow
through the same pump. See [Testing your code](testing.md).*

---

Next: [Process groups](process-groups.md) ·
[Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Cookbook](cookbook.md)

---

# Pipelines

[‹ docs index](./)

`a | b | c` **without a shell**. Each stage's stdout feeds the next stage's
stdin through an in-process relay — there is no shell string anywhere, so no
quoting rules, no word splitting, no injection surface. Every stage spawns into
its own kill-on-exit [process-group](process-groups.md) sub-group. A checked
stage failure, chain timeout, or cancellation fans teardown across every
sub-group, so the chain still lives and dies as a unit while a per-stage timeout
can first reap that stage's entire subtree.

```python
from processkit import Command

# git log --format=%an | sort | uniq -c
authors = (
    Command("git", ["log", "--format=%an"]) | Command("sort") | Command("uniq", ["-c"])
).run()
print(authors)
```

## Building a pipeline

`Command.pipe(next)` starts a `Pipeline`; chain more stages with
`Pipeline.pipe`. The `|` operator is sugar for the same thing — `a | b | c` is
exactly `a.pipe(b).pipe(c)`:

```python
authors = (
    Command("git", ["log", "--format=%an"])
    .pipe(Command("sort"))
    .pipe(Command("uniq", ["-c"]))
    .run()
)
```

Python's `|` binds *looser* than a method call, so parenthesize the whole chain
before a terminal verb — `(a | b).run()`, never `a | b.run()` (which would call
`run()` on `b` alone). The `.pipe(...).pipe(...)` form chains cleanly without the
extra parentheses.

The verbs mirror a single `Command`'s, each folding the pipefail outcome
(below). Every verb has an `a`-prefixed asyncio twin:

| Sync | Async | Returns | A failing stage is… |
|---|---|---|---|
| `output()` | `aoutput()` | `ProcessResult` | …reported in the result (code/stderr/`program` of the first unclean stage) |
| `output_bytes()` | `aoutput_bytes()` | `BytesResult` | …same, with the last stage's stdout captured as raw `bytes` |
| `run()` | `arun()` | trimmed final stdout (`str`) | …raised as that stage's exception |
| `exit_code()` | `aexit_code()` | `int` | …its attributed code |
| `probe()` | `aprobe()` | `bool` | `0` → `True`, `1` → `False`, else raises |

`output()`/`output_bytes()` capture a non-zero exit, timeout, or signal as
**data** on the result; `run()`/`exit_code()`/`probe()` raise per the pipefail
attribution. An exception that isn't a clean process outcome — a stage that
couldn't be *spawned*, broken plumbing — surfaces as `ProcessError`, never as a
mere non-zero exit. See [Running commands](commands.md) for the full error model
and the structured exception fields.

## The pipefail outcome

The outcome is **pipefail**, like `set -o pipefail` in a shell:

- `stdout` is always the **last** stage's output — that's what the chain
  produced.
- `code`, `stderr`, and the reported `program` come from the **first** stage
  that didn't exit cleanly (non-zero, signal-killed, or timed out) — or from the
  last stage when every stage succeeded.

```python
result = (
    Command("cat", ["data.txt"])
    | Command("grep", ["ERROR"])  # suppose grep exits 2 (bad pattern)
    | Command("wc", ["-l"])
).output()

result.stdout  # whatever wc managed to print (the last stage)
result.code  # 2 — grep, the first unclean stage
result.program  # "grep"
result.is_success  # False
```

`run()` requires **every** stage to succeed and returns the trimmed final
stdout; if any stage exits uncleanly it raises that stage's exception
(`NonZeroExit`, `Timeout`, or `Signalled`) carrying that stage's code, stderr,
and `program`. So the chain above would raise `NonZeroExit(code=2, program="grep")`.

One honest edge: in the `producer | head` shape, a downstream that stops reading
early (`head` exits after one line and closes the pipe) leaves the producer to
die on a **broken pipe** at its next write. Under strict pipefail that counts as
the producer's failure — unless that stage was built with
`.unchecked_in_pipe()`, which exempts it from pipefail attribution (its
unclean exit, including a `SIGPIPE`, is skipped when the chain decides what to
report, and never shields a *checked* stage's own failure):

```python
top = (
    Command("producer").unchecked_in_pipe()  # SIGPIPE from `head` closing early is expected
    | Command("head", ["-1"])
).run()
```

Outside a `Pipeline`, `unchecked_in_pipe()` is a no-op — a single run's status
is already plain data on its own `ProcessResult`, and `ensure_success()` stays
opt-in.

## Merging a stage's stderr into the pipe

`Command.merge_stderr_in_pipe()` is the shell-free equivalent of
`command 2>&1 | next` — set on a **non-final** stage, it sends that stage's
stderr into its own stdout pipe (cloned handles to the same anonymous-pipe
writer, so the OS preserves write order), so the downstream stage reads both
combined over its stdin:

```python
merged = (
    Command("tool").merge_stderr_in_pipe()  # tool's stderr joins its stdout
    | Command("grep", ["WARN"])
).run()
```

It is opt-in per stage and a **no-op** outside a `Pipeline` or on the **final**
stage — a pipeline only activates it on a stage with a downstream neighbor, so
marking the last stage (or a standalone `Command`) has no effect.

**Pipefail diagnostic trade-off.** Once a stage's stderr enters the downstream
pipe it is no longer available as that stage's own stderr capture: if pipefail
attributes the chain's failure to this stage, `ProcessResult.stderr` is empty
for it — the merged bytes may instead surface in the final stage's stdout,
having passed through the rest of the pipeline.

## stdin and stdout at the ends; per-stage env/cwd

The ends of the chain behave like a single `Command`:

- The **first** stage's stdin source is honored — set `stdin_text(...)` /
  `stdin_bytes(...)` on it to feed the whole chain from a string or bytes.
- **Inner** stages read from the pipe, full stop; any stdin set on them is
  ignored. Only the last stage's stdout reaches you; inner stderr is captured
  per-stage for the pipefail diagnostics.

```python
# Feed the chain from a string; inner stages read the pipe.
unique = (
    Command("sort").stdin_text("b\na\nb\nc\n") | Command("uniq") | Command("wc", ["-l"])
).run()
print(unique)  # "3"
```

Per-stage `env` and `cwd` are plain `Command` builders — set them on each stage
**before** piping:

```python
counts = (
    Command("git", ["log", "--format=%an"]).cwd("/srv/repo")
    | Command("sort")
    | Command("uniq", ["-c"])
).run()
```

## Timeouts bound the chain

`Pipeline.timeout(seconds)` bounds the **whole** chain. At the deadline the
teardown fans across every stage's sub-group; the result reports `timed_out`
(and `run()` raises `Timeout`). Its `program` is the composite pipeline name,
with every stage joined by `" | "`, because no individual stage caused this
chain-level deadline. Durations are floats of seconds:

```python
result = (Command("producer") | Command("consumer")).timeout(30.0).output()

result.timed_out  # True if the 30s deadline fired
```

Unlike a single command's captured timeout, a timed-out pipeline keeps the
best-effort stdout and stderr already captured from the last stage before the
deadline, using the same buffer policy as a normal capture. A pipeline has no
`output_limit` cap of its own. A per-stage `Command.timeout(...)` is a separate
mechanism: it first reaps that stage's whole subtree; the resulting checked
`Timeout` then tears down the remaining stage sub-groups and is attributed to
the timed-out stage under pipefail. See
[Timeouts & cancellation](timeouts-and-cancellation.md); cancelling an awaited
`arun()`/`aoutput()` reaps the whole chain's tree the same way, and so does
firing a `CancellationToken` wired with `Pipeline.cancel_on(token)` — **gap-fill**
here, not override: a stage with its own explicit `Command.cancel_on(...)` keeps
it, only stages without one pick up the pipeline-level token.

## Binary tails

For a chain that ends in a binary producer (`... | gzip`), capture the last
stage's stdout raw with `output_bytes()` — its `stdout` is `bytes`, while stderr
stays decoded text:

```python
blob = (Command("cat", ["big.txt"]) | Command("gzip")).output_bytes().stdout
# blob is bytes — the gzip stream
```

The pipeline runs to completion and buffers the tail; this is a captured result,
not a streaming splice.

## Limitations

- **Run-to-completion only.** A `Pipeline` has no `astart()` and no
  line-streaming surface — it consumes its last stage in full to fold the
  pipefail outcome. Stream a *single* [Command](commands.md) when you need
  incremental output, or run the pipeline inside a [process group](process-groups.md)
  alongside other handles.
- **No `output_limit` of its own.** A pipeline can't cap retained output the way
  a single `Command` can. Bound a flooding chain with `.timeout(...)`; cap a
  single noisy stage by running it on its own with `output_limit(...)` first.

---

Next: [Running commands](commands.md) ·
[Process groups](process-groups.md) ·
[Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Cookbook](cookbook.md) · [Platform support](platforms.md)

---

# Timeouts & cancellation

[‹ docs index](./)

Two ways a run can end early, with two different philosophies:

- a **timeout** is part of the run's contract, so its expiry is *data* — captured
  on the capture verbs, raised on the success verbs;
- a **cancellation** is an *abandonment* — the caller changed its mind, so the
  run's tree is torn down and there is no result to inspect. Sync →
  `KeyboardInterrupt`; async → `asyncio.CancelledError`.

The one thing to internalize first: the **same deadline** surfaces differently
*by verb* — captured as `timed_out` on the capture verbs, raised as `Timeout` on
the success verbs. Cancellation is never captured: it is always terminal.

- [Setting a timeout](#setting-a-timeout)
- [Idle (inactivity) timeout](#idle-inactivity-timeout)
- [Graceful timeout](#graceful-timeout)
- [Interrupting a blocked sync call (Ctrl+C)](#interrupting-a-blocked-sync-call-ctrlc)
- [Cancelling an awaited async run](#cancelling-an-awaited-async-run)
- [Timeout vs. cancellation](#timeout-vs-cancellation)
- [Readiness-probe timeouts are separate](#readiness-probe-timeouts-are-separate)

## Setting a timeout

`.timeout(seconds)` bounds the whole run and kills the **entire process tree** at
the deadline — a wrapper script's grandchildren die too, not just the direct
child. Durations are plain floats of seconds.

Where the expiry lands depends only on the verb:

```python
from processkit import Command

# Capture verbs: the deadline is DATA. The run does not raise.
result = Command("slow-tool").timeout(5.0).output()
if result.timed_out:
    print("killed at the deadline; partial output:", result.stdout)

# Success verbs: the deadline is an ERROR.
Command("slow-tool").timeout(5.0).run()  # raises Timeout on expiry
```

Async is identical with the `a`-prefixed verbs:

```python
result = await Command("slow-tool").timeout(5.0).aoutput()  # result.timed_out
```

| Verb | Deadline expiry becomes |
|---|---|
| `output()` / `aoutput()`, `output_bytes()` / `aoutput_bytes()` | a result with `result.timed_out == True`, `result.code == None`, partial output kept |
| `run()` / `arun()`, `exit_code()`, `probe()` | raises `Timeout` (partial output attached) |

The `Timeout` exception carries structured fields — `program`,
`timeout_seconds`, `stdout`, `stderr` — so a hung tool's last words survive the
kill:

```python
from processkit import Timeout

try:
    Command("slow-tool").timeout(5.0).run()
except Timeout as e:
    print(e.program, e.timeout_seconds)
    print("last output before the kill:", e.stderr)
```

`Timeout` is **also** a builtin `TimeoutError`, so `except TimeoutError` catches
it too — handy for callers that don't import the processkit hierarchy.

*Deeper: [Running commands](commands.md) for the full verb surface.*

## Idle (inactivity) timeout

`.timeout(...)` bounds *total* runtime; `.idle_timeout(seconds)` bounds a
**silent gap** instead — it tears the child down if it produces no **watched
output line** for that long. This is the "hung tool" case a wall-clock timeout handles
poorly: a legitimately long job (a build, a test suite, a data export) keeps
printing progress, so you can bound its *silence* tightly without guessing a
generous ceiling for its total runtime. The two **compose** — set both, and
whichever threshold is reached first wins.

Idle-timeout fires as a **distinct** `IdleTimeout` exception — a `ProcessError`
sibling of `Timeout`, deliberately **not** the wall-clock `timed_out`/`Timeout`
signal — so "the child went silent" and "the run took too long overall" stay
tellable apart, and the captured `timed_out` contract is untouched (an
idle-timeout never sets it):

```python
from processkit import Command, IdleTimeout

# A build that keeps printing is fine; one that hangs silently for 30s is killed.
proc = Command("./flaky-build").idle_timeout(30.0).start()
try:
    async with proc:
        async for event in proc.output_events():
            print(event.text)  # live progress, line by line
except IdleTimeout as e:
    print(f"no output for {e.idle_timeout_seconds}s — killed the hung build")
```

Idle monitoring rides the **per-line output channel**, so it is enforced on the
**streaming/interactive surface** — `start()`/`astart()` +
`stdout_lines()`/`stderr_lines()`/`output_events()`/`lifecycle_events()` (piped
stdout, the default). `stdout_lines()` watches stdout alone; the other three
consume the merged event stream, so a line on either piped stream resets their
window. It is **not**
enforced by the one-shot capture verbs (`output`/`run`/`exit_code`/`probe` and
their `a`-twins), `Pipeline`, or `Supervisor`: those run entirely inside the
Rust core, which has no native idle-timeout to observe per-line activity mid-run,
so honoring it there awaits upstream support. The setting is carried on the
command regardless, so nothing breaks if you set it and use a one-shot verb — it
simply doesn't fire there.

Because monitoring needs line events, a **redirected stdout** cannot be watched —
and that combination is *diagnosed*, not silently dropped. Under `stdout_file()`
/ `stdout("inherit")` / `stdout("null")` the streaming verbs already raise
`ProcessError` (`"stdout is not piped …"`) at setup, so an `idle_timeout()` on a
redirected stdout surfaces there. `stderr_file()` is the asymmetric case: it
leaves stdout piped, so idle monitoring keeps working on the stdout channel while
stderr goes to the file.

From the CLI, `python -m processkit run --idle-timeout SECONDS` applies the same
mechanism and exits **123** (distinct from `--timeout`'s 124) on a silent child —
see the [CLI reference](cli.md#--idle-timeout-a-silence-watchdog).

*Deeper: [Running commands](commands.md#idle_timeout--a-silence-watchdog) for the
builder's boundaries.*

## Graceful timeout

By default the deadline **hard-kills** at once. `.timeout_grace(g)` instead asks
the tree to clean up first: at the deadline it sends the terminate signal, gives
the tree up to `g` seconds to exit, then hard-kills whatever is still alive.

```python
# At 30s: send SIGTERM, wait up to 5s, then SIGKILL the tree.
Command("server").timeout(30.0).timeout_grace(5.0).run()
```

Choose the first signal with `.timeout_signal(name)` — one of `term` (default),
`kill`, `int`, `hup`, `quit`, `usr1`, `usr2`:

```python
Command("nginx").timeout(30.0).timeout_signal("quit").timeout_grace(5.0).run()
```

A signal-handling child that exits early ends the grace early. `result.timed_out`
is `True` (or `Timeout` is raised) regardless of whether the child obeyed the
signal or was hard-killed after the grace — the **deadline** is what fired, not
the manner of death. This is the same SIGTERM → wait → SIGKILL tier that
[Process groups](process-groups.md) use for graceful shutdown.

Mind the platform asymmetry: Windows has no signal tier, so `timeout_grace` /
`timeout_signal` are accepted but the deadline kills the job atomically. See
[Platform support](platforms.md).

## Interrupting a blocked sync call (Ctrl+C)

A synchronous verb blocked on a child honors **Ctrl+C** (SIGINT). Instead of
hanging until the child decides to exit, it raises `KeyboardInterrupt` *promptly*
and tears down the run's process tree on the way out:

```python
try:
    Command("long-batch-job").run()  # blocks here…
except KeyboardInterrupt:
    # Ctrl+C: the child tree is already reaped; the exception is re-raised at once.
    print("interrupted by the user")
```

This holds for every sync verb (`output()`, `run()`, `exit_code()`, `probe()`,
…) — no orphaned grandchildren are left behind.

> **Main-thread only.** CPython delivers signals to the main thread, so this
> prompt `Ctrl+C` interruption works only when the sync verb runs on the main
> thread. A sync verb called from a `threading.Thread` (more tempting on a
> free-threaded build) blocks until the child exits — it cannot observe the
> signal. Off the main thread, prefer the async API and cancel the task.

The async surface uses task cancellation instead, below.

## Cancelling an awaited async run

Cancelling the task awaiting a run — directly with `task.cancel()`, or via
`asyncio.wait_for(...)` / `asyncio.timeout(...)` — tears down the **whole process
tree** and surfaces as `asyncio.CancelledError`:

```python
import asyncio
from processkit import Command

# Direct cancel: stop a run from elsewhere.
task = asyncio.ensure_future(Command("long-export").aoutput())
# ... later — a shutdown handler, a sibling failure, a UI action ...
task.cancel()  # the tree is reaped; awaiting `task` raises CancelledError

# Caller-side deadline via asyncio: the run is cancelled, then re-raised to you.
try:
    await asyncio.wait_for(Command("long-export").arun(), timeout=10)
except TimeoutError:  # asyncio re-raises the cancellation as TimeoutError
    ...  # the run's process tree was already torn down
```

`asyncio.wait_for` (and `asyncio.timeout`, 3.11+) cancel the inner run exactly
like `task.cancel()`, then translate the cancellation into a builtin
`TimeoutError` at the `await` boundary — so *inside*, the run was cancelled, even
though *you* catch `TimeoutError`. Either way the tree is gone.

**Cancellation surfaces as `asyncio.CancelledError`** when you cancel through
asyncio itself, as above (a `BaseException`, deliberately not a
`ProcessError`) — there is no separate processkit exception on this path.

## Cancelling with an explicit `CancellationToken`

For a cancel switch that isn't tied to one asyncio task — shared across
several runs, fired from sync code, or from a different task entirely — wire
a `CancellationToken` instead:

```python
from processkit import Command, Cancelled, CancellationToken

token = CancellationToken()
cmd = Command("long-export").cancel_on(token)

# elsewhere — a signal handler, a UI action, another task:
token.cancel()

try:
    await cmd.arun()  # (or cmd.run() from sync code)
except Cancelled:
    ...  # the whole tree was already torn down
```

Unlike asyncio cancellation, this surfaces as `Cancelled` — a `ProcessError`
subclass carrying `.program`, catchable alongside every other processkit
exception, on *either* the sync or async surface. A cancelled token stays
cancelled forever (never use it to mean "pause" — see
[`ProcessGroup.suspend()`/`resume()`](process-groups.md) for that), and a
cancelled run is never retried (`Command.retry()`) or restarted
(`Supervisor`) — another attempt could only fail the same way.

`Command.cancel_on()` **replaces** any previously set token (last write
wins); the *gap-fill* containers `Pipeline.cancel_on()` and `CliClient`'s
`default_cancel_on=` leave an explicit per-stage/per-command token intact,
only filling in where none was set — the same gap-fill convention
`default_timeout` uses. `token.child_token()` derives a token cancelled
automatically when the parent fires, but cancellable independently — for
scoping a broader shutdown token down to one operation while still reacting
to the parent.

## Timeout vs. cancellation

The two can both stop a run, but they are different kinds of event:

| | Timeout | asyncio cancellation | `CancellationToken` |
|---|---|---|---|
| Meaning | the deadline was part of the contract | the caller abandoned the run | an explicit cancel switch fired |
| Capture verbs (`output*`) | captured as `result.timed_out` | terminal — no result | terminal — no result |
| Success verbs (`run`/`exit_code`/`probe`) | raises `Timeout` | terminal — no result | raises `Cancelled` |
| Sync surface | `Timeout` | `KeyboardInterrupt` | `Cancelled` |
| Async surface | `Timeout` | `asyncio.CancelledError` | `Cancelled` |

A timeout still leaves something to inspect on the capture verbs; a cancellation
never does — the run was abandoned, so there is nothing to report but the
cancellation itself. **When a cancel and a timeout race on the same run,
cancellation wins:** you asked the run to stop mattering, so no `timed_out`
result is synthesized.

On a shared [ProcessGroup](process-groups.md) handle, a timeout or cancellation
that hits one child kills **that child only** — the group's siblings keep
running.

## Readiness-probe timeouts are separate

The `timeout` on the readiness helpers — `wait_until`, `wait_for_port`,
`wait_for_line` — is a **different deadline** from a run timeout. It bounds how
long you wait for a *condition*, and on expiry it raises `WaitTimeout` (also a
builtin `TimeoutError`) **without killing the child** — the process keeps
running; only your wait gave up:

```python
from processkit import wait_for_port

await wait_for_port("127.0.0.1", 8080, timeout=10)  # TimeoutError if not listening in 10s
```

Because `Timeout` is itself a `TimeoutError`, a single `except TimeoutError`
catches both a run timeout and a readiness timeout — but only the run timeout
reaped a tree.

*Deeper: [Streaming & interactive I/O](streaming.md).*

## Bounding pipelines & tuning group shutdown

- A [pipeline](pipelines.md) bounds the **whole chain** with
  `Pipeline.timeout(seconds)`. Its capture verbs retain best-effort stdout and
  stderr already captured by the last stage before the deadline, while its
  success verbs raise `Timeout` with that partial output attached. This
  whole-chain timeout is distinct from a per-stage `Command.timeout(...)`.
- A [ProcessGroup](process-groups.md)'s graceful teardown timing is set at
  construction with `shutdown_grace=` and `escalate_to_kill=`, independent of
  any per-run timeout. Note: cancelling an in-flight `await group.ashutdown()` (or
  an `async with` exit) falls back to an immediate hard kill — the tree is still
  reaped (no orphan), but the *graceful* signal-then-wait window is skipped.

## Keeping a flaky thing alive

A timeout stops a single run; it does not restart anything by itself. For a
single command replayed on transient failure (including a timeout expiry),
see [`Command.retry(retry_if, ...)`](commands.md#retrying-a-run) (default is
`retry_never()` — no retries unless opted in). For a *service* kept alive
across crashes — a different, non-exclusive concern from per-command retry —
that is [Supervision](supervision.md) — `Supervisor(...)` with a restart
policy and backoff.

---

Next: [Supervision](supervision.md) ·
[Streaming & interactive I/O](streaming.md) ·
[Async runtimes & event loops](event-loops.md) ·
[Process groups](process-groups.md) ·
[Cookbook](cookbook.md)

---

# Supervision

[‹ docs index](./)

A [`timeout`](timeouts-and-cancellation.md) or a cancelled task *bounds one run* — it
caps a single invocation, and then it's over. A `Supervisor` answers the opposite
need: *keep a long-lived child alive*. It runs a [`Command`](commands.md), and
whenever that command exits it restarts it per policy — with a bounded restart count
and exponential, jittered backoff — until a stop condition is met. Think of it as a
pocket `systemd`/`runit`: a keeper loop you can drop into a script. It is
platform-agnostic.

- [A supervised server](#a-supervised-server)
- [Restart policies](#restart-policies)
- [Backoff and jitter](#backoff-and-jitter)
- [Stopping: the predicate](#stopping-the-predicate)
- [Reading the outcome](#reading-the-outcome)
- [Liveness health checks](#liveness-health-checks)
- [Live supervision sessions](#live-supervision-sessions)
- [Sync vs async](#sync-vs-async)

## A supervised server

The supervisor takes a normal `Command` — build it with all the usual knobs (args,
`env`, `cwd`, `timeout`, …) and they apply to *every* restart:

```python
from processkit import Command, Supervisor

outcome = Supervisor(
    Command("my-server", ["--port", "8080"]).env("LOG", "info"),
    restart="on_crash",  # the default
    max_restarts=5,  # default: unlimited
    backoff_initial=0.2,  # seconds; base delay (default 0.2)
    backoff_factor=2.0,  # multiplier (default 2.0)
    max_backoff=30.0,  # seconds; cap (default 30.0)
).run()  # or: await ....arun()

print(outcome.restarts, outcome.stopped)
```

Each restart is one full captured run of the command. The one-shot stdin caveat
applies from the second run onward — see [Running commands](commands.md). Leave a
knob unset (`None`) and the crate default shown above is used.

Contrast this with a one-shot run wrapped in a hand-rolled `while True:` loop: you'd
reimplement backoff, jitter, and the stop gates yourself. The supervisor *is* that
loop, written once and correctly.

## Restart policies

`restart=` decides what is worth restarting. A **crash** is any run that is not a
success — an exit code outside the accepted set (default `{0}`, widened by the
command's [`success_codes`](commands.md)), a timeout, or a signal-kill:

| `restart=` | Restarts after… |
|---|---|
| `"on_crash"` *(default)* | crashes only; a clean exit ends supervision (`stopped == "policy_satisfied"`) |
| `"always"` | every completed run, clean or not — pair with `stop_when=`/`max_restarts=` or it loops forever |
| `"never"` | nothing: one run, reported as-is |

Because `success_codes` defines success, a command built with `.success_codes([0, 2])` that
exits `2` is *clean*, so `"on_crash"` treats it as a satisfied policy, not a crash.

## Backoff and jitter

Between restarts the supervisor sleeps. The *n*-th restart (0-based) waits:

```text
delay(n) = min(backoff_initial × backoff_factor**n, max_backoff) × jitter
```

with `jitter` drawn uniformly from `[0.5, 1.5)` per restart. With the defaults
(`0.2`, `2.0`, cap `30.0`):

```text
restart #0 → ~0.2s   #1 → ~0.4s   #2 → ~0.8s … #7 → ~25.6s   #8+ → 30.0s (cap)
```

Jitter is **on by default** so a fleet of supervised workers knocked over by one
incident doesn't stampede back in lockstep. Pass `jitter=False` for deterministic
delays (handy in tests). `backoff_factor` is a finite multiplier `>= 1.0`, and it
rides along with `backoff_initial` — set the base to opt into a custom schedule.

## Stopping: the predicate

Four gates are checked, in order, after every completed run:

1. **`stop_when=`** — a callable handed each run's [`ProcessResult`](commands.md);
   returning `True` ends supervision *regardless of policy* (`stopped ==
   "predicate"`). The classic "exit 0 is done" under `restart="always"`:

   ```python
   outcome = Supervisor(
       Command("flaky-worker"),
       restart="always",
       stop_when=lambda r: r.code == 0,  # stop on the first clean exit
   ).run()
   ```

2. **The policy** — `"on_crash"` stops on a clean exit; `"never"` stops after one run.
3. **`give_up_when=`** — a callable consulted only for a crash the policy would
   otherwise restart, ahead of `max_restarts=` and the storm guard. It classifies a
   *permanent* failure so supervision gives up instead of restarting forever. It
   receives one argument mirroring the crate's `GiveUpAttempt` sum type, dispatched
   with `isinstance`: a `ProcessResult` for a crashed run that produced a result
   (classify by e.g. `attempt.code`), or a `ProcessError` subclass for a launch that
   never produced one (classify by e.g. `isinstance(attempt, ProcessNotFound)` for a
   missing binary). Returning `True` for a crash verdict stops with
   `outcome.stopped == "gave_up"`; a launch-failure verdict has no result to report
   and surfaces the classified error directly from `run()`/`arun()`.
4. **`max_restarts=n`** — at most *n* restarts (= *n + 1* total runs); an exhausted
   budget reports the last result (`stopped == "restarts_exhausted"`).
   `max_restarts=0` means exactly one run.

Two honest caveats about `stop_when=`:

- **Inspect the passed result — don't call a synchronous run verb inside it.** Read
  `r.code` / `r.is_success` / `r.stdout` off the argument. The predicate runs *on*
  the runtime, so a nested sync call (`Command(...).run()`/`.probe()`/…) can't drive
  the runtime again — it raises a clear `ProcessError` ("cannot call a synchronous
  processkit verb from inside an async context or a callback"). That error is then
  re-raised from the supervisor's terminal verb, so supervision aborts rather
  than turning the failed check into a false verdict. If you must run a check,
  precompute it before the supervised run, or use the result handed to the
  predicate.
- **A predicate that raises aborts supervision.** The original Python exception is
  re-raised from `run()` / `arun()` or the session's terminal verb; a broken
  predicate is never silently interpreted as "don't stop".

## Reading the outcome

`run()` (and `arun()`) resolve to a `SupervisionOutcome`:

```python
outcome.final_result  # ProcessResult of the LAST run
outcome.restarts  # restarts performed (run #1 is not a restart)
outcome.stopped  # "policy_satisfied" | "predicate" | "restarts_exhausted"
# | "gave_up" | "unhealthy" | "unknown" (forward-compat
# fallback, not emitted by the pinned crate version)
outcome.storm_pauses  # how many failure-storm pauses were taken (see below)
outcome.liveness_kills  # how many wedged incarnations a health check force-killed
# (see "Liveness health checks"; 0 unless one is enabled)
```

A returned outcome means supervision *concluded*, not that the child succeeded —
inspect `final_result` (e.g. `outcome.final_result.is_success`) for the child's own
verdict.

`final_result.stdout` is the **last run's** output, and for a long-lived
supervised process it is kept to a bounded tail (the most recent ~1000 lines)
rather than buffered in full — so `final_result.truncated` may be `True`. Treat it
as a diagnostic tail, not a complete transcript. Widen or re-bound the cap with
`Supervisor`'s own `capture_max_bytes=`/`capture_max_lines=`/`capture_on_overflow=`
constructor kwargs (mirroring `Command.output_limit`'s kwargs — set at least one
of the two cap sizes), or give the base `Command` an explicit
[`output_limit`](commands.md) (respected as-is) before wrapping it in a
`Supervisor`; otherwise stream the process yourself. `capture_max_bytes` uses the
same unit as `output_limit(max_bytes=...)`, which depends on the overflow mode:
`capture_on_overflow` defaults to `"drop_oldest"`, where the cap bounds the
retained decoded line content (unchanged in processkit 3.0.0); pass
`capture_on_overflow="error"` and it becomes a fail-loud ceiling on the raw bytes
read from the pipe instead. See
[what `max_bytes` counts](commands.md#what-max_bytes-actually-counts).

Each real-run incarnation normally receives a fresh private `ProcessGroup`.
Pass `max_memory=`, `max_processes=`, or `cpu_quota=` to `Supervisor` to create
those groups with the matching whole-tree cap. A cap the active platform cannot
enforce raises `ResourceLimit` before that incarnation is spawned. Resource
caps cannot be combined with `runner=`: injected runners own their execution
semantics, so silently wrapping one in a real process group would break the
test-double boundary.

Resource-capped supervision is capture-only. `run()`/`arun()` retain their full
result and restart semantics, but a `start()`/`astart()` session cannot expose a
live child handle: `status.pid` stays `None`, while `status.started_at` still
reports when the current incarnation began. `stop()` uses the session
cancellation path rather than signalling the current child gracefully. The
private limited group is still dropped and the whole incarnation tree is
contained; only live pid introspection and graceful child signalling are
unavailable.

## The failure-storm guard

Backoff slows individual restarts; the **failure-storm guard** distinguishes "fails
once in a blue moon" from "crash-looping" and takes a single collective pause
instead of hammering restarts at backoff speed. It is **off by default** — enable
it by setting `storm_pause`:

```python
outcome = Supervisor(
    Command("flaky-worker"),
    restart="on_crash",
    storm_pause=30.0,  # ENABLES the guard: pause 30s when a storm is detected
    failure_threshold=5.0,  # decaying failure score that trips the pause (optional)
    failure_decay=60.0,  # the score halves every 60s (optional)
).run()

if outcome.storm_pauses:
    log.warning("flaky-worker crash-looped: %d storm pauses", outcome.storm_pauses)
```

Each failure adds to a score that decays every `failure_decay`; once it crosses
`failure_threshold` the supervisor takes one `storm_pause` and increments
`outcome.storm_pauses`. With `storm_pause` unset, the guard is inactive and
`storm_pauses` stays `0` — only the per-restart `backoff` and the lifetime
`max_restarts` cap apply.

A `Supervisor` is single-shot: `run()`/`arun()` consume it, so build a fresh one to
supervise again.

## Liveness health checks

Restart policies react to a process that *exits*. But a long-lived service can wedge
*without* exiting — a deadlocked server, a stuck event loop, a worker that stopped
answering — and an exit-driven policy would happily call that "still running" forever.
A **liveness health check** closes that blind spot: an opt-in probe, re-run on a fixed
cadence, that force-restarts the child when it stops looking healthy. It is the
`Supervisor`'s take on systemd's `WatchdogSec` or a container liveness probe, and it is
**off by default**.

```python
import socket

from processkit import Command, Supervisor


def is_healthy() -> bool:
    # A fast, non-blocking liveness probe: can we still reach the server's port?
    try:
        with socket.create_connection(("127.0.0.1", 8080), timeout=0.5):
            return True
    except OSError:
        return False


outcome = Supervisor(
    Command("my-server", ["--port", "8080"]),
    health_check=is_healthy,  # sync () -> bool; True == healthy
    health_check_interval=5.0,  # seconds between probes — REQUIRED with health_check
    health_check_failures=3,  # consecutive failures before a force-restart (default 3)
    max_restarts=10,
).run()

print(outcome.liveness_kills)  # wedged incarnations that were force-killed
```

`health_check=` is a **synchronous** callable `() -> bool` — *not* a coroutine —
returning `True` for healthy. It is the liveness twin of `stop_when=`/`give_up_when=`
and runs on the supervision runtime, so keep it fast and non-blocking (a quick socket
connect, an HTTP `/healthz` GET, a heartbeat-file check); a slow probe merely stretches
the effective cadence. `health_check_interval=` is its **required** partner — the crate
takes probe and cadence together, so passing either one alone raises `ValueError`. The
first probe fires one interval *after* an incarnation starts (startup grace), then
repeats for that incarnation's life; a healthy child is never disturbed.

A probe that fails `health_check_failures=` checks **in a row** (default `3`; one
healthy probe resets the streak, so a single blip is forgiven) force-restarts the
child. A failed streak is treated **exactly like a crash**: it flows through the restart
policy, `backoff`, the storm guard, and `max_restarts` just as a real crash would — but
it does *not* consult `stop_when=` (there is no cleanly-completed run to judge). So:

- under `restart="never"`, the single force-killed run is the final one, reported as
  `outcome.stopped == "unhealthy"`;
- under a restart-wanting policy (`"on_crash"`/`"always"`), it restarts and surfaces —
  if it ends supervision at all — as the usual `"gave_up"` / `"restarts_exhausted"`.

Either way each force-kill is counted in `outcome.liveness_kills` (and, because it
counts as a crash, is also reflected in `outcome.restarts` when the policy restarted
it), and the final synthetic result is a non-success signal-kill. A probe that *raises*
or returns a non-`bool` cannot answer "healthy": it is treated as unhealthy **and** its
error is surfaced to the caller from `run()`/`arun()` — not swallowed into a spurious
liveness kill — the same fail-loud contract as `stop_when=`/`give_up_when=`.

## Live supervision sessions

`run()` is ideal when the caller only needs the final outcome. Use `start()` when
you need to observe or stop the keeper loop while it is running:

```python
from processkit import Command, Supervisor

with Supervisor(Command("my-server"), restart="always").start() as session:
    status = session.status
    print(status.is_active, status.pid, status.restarts)
    print(status.started_at, status.is_storm_paused)
    outcome = session.stop(5.0)
```

`status` is an atomic snapshot. `pid` and `started_at` are `None` between
incarnations, during backoff, and after completion. Capture-only runners,
including resource-capped supervisors, report the current incarnation's
`started_at` while keeping `pid` as `None`. `wait()` consumes the session and waits for its natural outcome;
`stop(grace_seconds)` requests graceful termination and reports
`outcome.stopped == "stopped"`. Terminal verbs are one-shot.

The async twins are lazy awaitables:

```python
session = await Supervisor(Command("my-server"), restart="always").astart()
async with session:
    print(session.status.pid)
    outcome = await session.astop(5.0)
```

Use `await session.await_wait()` to await natural completion. Exiting either
context-manager form stops an open session with a one-second grace window;
calling a terminal verb inside the block makes the later exit a no-op. Call
`stop()` / `astop()` explicitly when that grace must be configured.

## Sync vs async

Both verbs return the same `SupervisionOutcome`; pick the one that matches your call
site. Durations are plain floats of seconds throughout.

```python
# Synchronous — blocks the calling thread (Ctrl+C interrupts it):
outcome = Supervisor(Command("my-server"), max_restarts=3).run()

# Asyncio — awaitable, integrates with the event loop:
outcome = await Supervisor(Command("my-server"), max_restarts=3).arun()
```

**`arun()` is lazy — nothing runs until you `await` it.** Like every
`a`-prefixed verb, `arun()` returns an awaitable that starts no supervision
until it is first awaited. So an `arun()` you build but never await — a
dropped awaitable, or `asyncio.ensure_future(sv.arun())` you never follow up
on — starts no restart loop at all; dropping it releases the supervisor and
every `stop_when=`/`give_up_when=` callback it captured, rather than pinning
them (and whatever they close over) for the life of the interpreter. The flip
side is that an unawaited `arun()` never supervises anything, so `await` what
it returns — and, for an unbounded `restart="always"`, give it a
`max_restarts=`/`stop_when=` so supervision also has a defined end:

```python
# Bounded and awaited — runs, then stops after at most 5 restarts:
outcome = await Supervisor(Command("flaky-worker"), restart="always", max_restarts=5).arun()

# Backgrounded — keep the task and await it, so supervision actually runs:
task = asyncio.ensure_future(
    Supervisor(Command("flaky-worker"), restart="always", max_restarts=5).arun()
)
outcome = await task
```

A `Supervisor` keeps *one* command alive across restarts; to contain a whole *tree*
of processes under kill-on-exit semantics, reach for a
[process group](process-groups.md) instead. To exercise restart/stop logic without
spawning anything real, see [Testing your code](testing.md), and for the broader
task-oriented recipes, the [Cookbook](cookbook.md).

---

Next: [Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Process groups](process-groups.md) · [Cookbook](cookbook.md)

---

# Testing your code

[‹ docs index](./)

Code that shells out is miserable to test — unless the subprocess sits behind a
seam. In **processkit-py** that seam is a plain object: a *runner*. Write your
code against a `runner` parameter, call its verbs, and never name a concrete
runner inside the logic. In production you pass `Runner()` — the real thing. In
tests you pass a double — a `ScriptedRunner` with canned replies, a replaying
`RecordReplayRunner`, a `RecordingRunner` spy, or a `DryRunRunner` that only
renders each command — and no subprocess is ever spawned. The objects that come
back are genuine `ProcessResult` / `RunningProcess` values, so the code under
test can't tell the difference.

> The doubles — `ScriptedRunner`, `RecordReplayRunner`, `RecordingRunner`,
> `DryRunRunner`, the `Reply` builder, and the `Invocation` record — live in the
> **`processkit.testing`** submodule (mirroring the crate's own
> `processkit::testing` split). `Runner` and the `ProcessRunner` protocol stay
> on the top-level `processkit` — they are production code, not test
> scaffolding.

- [The runner seam](#the-runner-seam)
- [The pytest plugin: ready-made fixtures](#the-pytest-plugin-ready-made-fixtures)
- [Scripting replies: ScriptedRunner](#scripting-replies-scriptedrunner)
- [Scripted streaming: a live handle, no child](#scripted-streaming-a-live-handle-no-child)
- [Record/replay cassettes: RecordReplayRunner](#recordreplay-cassettes-recordreplayrunner)
- [Asserting on calls: RecordingRunner](#asserting-on-calls-recordingrunner)
- [Rendering commands without running: DryRunRunner](#rendering-commands-without-running-dryrunrunner)
- [Wrapping a CLI tool: CliClient](#wrapping-a-cli-tool-cliclient)

## The runner seam

`Runner()` is the real implementation; every double exposes the **same verb
surface**, so swapping one in is the whole technique. Each verb takes a
`Command` and returns the same type the bare `Command` methods do:

| Sync | Async | Returns | Notes |
|---|---|---|---|
| `output(cmd)` | `aoutput(cmd)` | `ProcessResult` | full result; a non-zero exit is *data*, not a raise |
| `output_bytes(cmd)` | `aoutput_bytes(cmd)` | `BytesResult` | raw-bytes stdout |
| `run(cmd)` | `arun(cmd)` | `str` | trimmed stdout; raises on failure |
| `exit_code(cmd)` | `aexit_code(cmd)` | `int` | the raw exit code |
| `probe(cmd)` | `aprobe(cmd)` | `bool` | exit 0 as a boolean |
| `start(cmd)` | `astart(cmd)` | `RunningProcess` | a live handle for streaming / readiness probes |

Write production code against the seam; hand it the real runner there:

```python
from processkit import Command, ProcessRunner, Runner


def current_branch(runner: ProcessRunner) -> str:
    return runner.run(Command("git", ["branch", "--show-current"]))


# Production: the real runner, which actually spawns git.
branch = current_branch(Runner())
```

Annotate the injected runner as **`ProcessRunner`** — a `typing.Protocol` that
describes the verb surface. `Runner`, `ScriptedRunner`, `RecordReplayRunner`,
`RecordingRunner`, and `DryRunRunner` all satisfy it structurally, so the
annotation type-checks (strict `mypy`) against any of them. A custom double can implement the capture/check verbs directly; the
streaming `start`/`astart` verbs must return a `RunningProcess` (no public
constructor), so reach for `ScriptedRunner` when you need a streaming double rather
than building one from scratch. `CliClient` is also a `ProcessRunner`: its sync
and async capture/check verbs accept either per-call `Args` (combined with its
bound program) or a `Command` (whose explicit settings win over client
defaults). It is not a `StreamingRunner`, because it has no `start`/`astart`.

The sync and async surfaces are twins (`run` ↔ `arun`), so async code injects
the very same runner objects and awaits the `a`-prefixed verbs.

> These doubles are the *real* ones — they return genuine `ProcessResult` /
> `RunningProcess` objects, so the code under test behaves identically. (The Rust
> crate also ships a `mock` Cargo feature — a `mockall`-generated mock of its
> runner trait — but that is for *Rust* tests; it has no Python use, so the binding
> does not enable it. You get your doubles here, not from a mocking library.)

*Deeper: the verb vocabulary and what each return type carries — [Running commands](commands.md).*

## The pytest plugin: ready-made fixtures

Installing processkit registers a **pytest plugin** — a `pytest11` entry point,
autoloaded in every pytest session, with nothing to add to your `conftest.py`. It
turns the doubles above into fixtures, so wiring one into a test is a single
parameter rather than a line of construction. The runner fixtures yield the
doubles below, so they satisfy the same `ProcessRunner` seam and spawn no real
process. A companion fixture configures cassette redaction:

| Fixture | Yields | Notes |
|---|---|---|
| `scripted_runner` | a fresh [`ScriptedRunner`](#scripting-replies-scriptedrunner) | teach it replies with `.on()` / `.when()` / `.fallback()` |
| `recording_runner` | a [`RecordingRunner`](#asserting-on-calls-recordingrunner) spy | replies `Reply.ok("")` (a clean exit 0, empty stdout — the neutral default) to every call and records each one |
| `record_replay_runner` | a [`RecordReplayRunner`](#recordreplay-cassettes-recordreplayrunner) cassette | replay by default, record on demand — see below |
| `processkit_cassette_scrubber` | `None` by default | override with a deterministic `(field, text) -> str` callback to redact the cassette fixture in both modes |
| `dry_run_runner` | a fresh [`DryRunRunner`](#rendering-commands-without-running-dryrunrunner) | renders each command to text instead of running it |

```python
from processkit import Command
from processkit.testing import Reply


def latest_commit(runner):
    return runner.run(Command("git", ["rev-parse", "HEAD"]))


def test_latest_commit(scripted_runner):
    scripted_runner.on(["git", "rev-parse"], Reply.ok("deadbeef"))
    assert latest_commit(scripted_runner) == "deadbeef"  # no git spawned
```

### The cassette fixture: record ↔ replay

`record_replay_runner` binds a [cassette](#recordreplay-cassettes-recordreplayrunner)
to the test. Which way it runs is a **switch, off (replay) by default** so CI
never spawns by accident — chosen the way vcr-like tools do it, in precedence
order:

1. `pytest --processkit-record` (CLI flag) forces **record** mode; otherwise
2. the `PROCESSKIT_RECORD` environment variable, when set, decides by its
   truthiness (`1`/`true`/`yes`/`on` → record); otherwise
3. the `processkit_record` ini option (a bool) decides; defaulting to **replay**.

In record mode the cassette is captured against real processes and `save()`d on
teardown; in replay mode it is served offline, never spawning. The file lives
under the test's `tmp_path` by default — set the `processkit_cassette_dir` ini
option (a relative path resolves against the rootdir) to a committed fixtures
directory to **keep** cassettes across runs. Its name is derived deterministically
from the test's node id, so each test gets its own. If replay reports that the
cassette is absent, see
[Troubleshooting](troubleshooting.md#record_replay_runner-cassette-not-found).

The workflow is the usual vcr one — *record once, replay forever*:

```ini
# pytest.ini (or [tool.pytest.ini_options] in pyproject.toml)
[pytest]
processkit_cassette_dir = tests/cassettes
```

```python
import sys
from processkit import Command


def test_offline(record_replay_runner):
    # `pytest --processkit-record` once: spawns for real and writes the cassette.
    # Every run after: served from tests/cassettes/…json, no process spawned.
    out = record_replay_runner.run(Command(sys.executable, ["--version"]))
    assert out.startswith("Python")
```

> Cassettes store `program`/`args`/`cwd`/`stdout`/`stderr` **verbatim** and can
> carry secrets unless a scrubber is configured. Override the
> `processkit_cassette_scrubber` fixture for cassettes kept in VCS (see
> [Record/replay cassettes](#recordreplay-cassettes-recordreplayrunner) for the
> full semantics and the redaction boundary).

### The no-real-spawn guard

Mark a test `@pytest.mark.no_real_spawn` and any **real** process spawn through
`Command` / `Pipeline` / `Runner` / `ProcessGroup` inside it fails loudly (via
`pytest.fail`, which no `except` in the code under test can swallow) — so a
forgotten double can't quietly reach the OS:

```python
import pytest
from processkit import Command


@pytest.mark.no_real_spawn
def test_stays_hermetic(scripted_runner):
    scripted_runner.fallback(Reply.ok("ok"))
    assert my_code(scripted_runner) == "ok"  # injected double: fine
    # Command("git", ["status"]).run()        # would fail the test, loudly
```

The marker is registered by the plugin, so it passes `--strict-markers`. Injected
doubles keep working — only the real-spawn primitives are blocked. The interception
replaces those verbs on the compiled classes for the duration of the test (the
reliable seam, since PyO3 forbids subclassing or per-instance patching of them),
which catches a spawn even through a `Command` reference imported before the test
ran. The honest boundary: the injection-point APIs (`CliClient`, `output_all` and
friends, `Supervisor`) reach the OS entirely inside the Rust extension when given
the default real runner, with no Python seam to intercept — so pass them a
test-double `runner=` in a guarded test rather than relying on the guard to catch
their default path.

## Scripting replies: ScriptedRunner

`ScriptedRunner` is the work-horse double: it returns a canned `Reply` for each
command you teach it. Match rules with `.on(prefix, reply)`; add an optional
`.fallback(reply)` for everything else.

```python
from processkit import Command
from processkit.testing import Reply, ScriptedRunner


def current_branch(runner):
    return runner.run(Command("git", ["branch", "--show-current"]))


def test_detects_the_branch():
    runner = ScriptedRunner()
    # Match by program + argument PREFIX (element-wise; the program is the first
    # element). Rules are tried in registration order; first match wins.
    runner.on(["git", "branch", "--show-current"], Reply.ok("main\n"))
    runner.fallback(Reply.ok(""))  # optional catch-all
    assert current_branch(runner) == "main"
```

Build the canned outcomes with the `Reply` factories:

- **`Reply.ok(stdout)`** — exit 0 with this stdout.
- **`Reply.fail(code, stderr)`** — a non-zero exit; `run` / `exit_code` raise `NonZeroExit`, while `output` reports it as data.
- **`Reply.lines([...])`** — exit 0 with the lines joined (and streamed one-by-one on a scripted [`start`](#scripted-streaming-a-live-handle-no-child)).
- **`Reply.timeout()`** — a timed-out run; `run` and the checking verbs raise `Timeout`.
- **`Reply.signalled(signal=None)`** — a signal-killed run; `run` raises `Signalled`.
- **`Reply.pending()`** — parks the call like a hung child; pair it with `asyncio.wait_for` / a `Command.timeout()` to prove your orchestration actually cancels a blocked call.
- **`.with_stdout(text)`** — an instance method that attaches stdout to any reply (e.g. the `CONFLICT …` text git prints on a *failing* merge).
- **`.with_line_delay(seconds)`** — sleep `seconds` before each scripted stdout line on a `start()`/`astart()` run, so a hermetic streaming test can observe genuinely incremental delivery instead of every line arriving at once.

Prefix matching is element-wise over the program name then the arguments, so
`on(["git", "branch"])` matches `git branch --show-current` but not `git
branchx` (and not `hg branch`). An **unmatched command with no fallback raises
a plain `ProcessError`** (not `ProcessNotFound`/`FileNotFoundError` — a miss is
a scripting gap, not a missing *program*) — loud enough that an unexpected
invocation can't slip through a test silently, but distinguishable from a
genuinely missing binary.

Reply each of several successive calls in turn with **`.on_sequence(prefix,
replies)`** — the declarative form for "fail once, then succeed" retry
scenarios: the first matching call gets `replies[0]`, the second `replies[1]`,
and so on; once exhausted, the **last** reply repeats forever.

```python
runner = ScriptedRunner()
runner.on_sequence(["deploy"], [Reply.fail(1, "transient"), Reply.ok("deployed")])
```

For a match that isn't a plain argv prefix, **`.when(predicate, reply)`**
replies with `reply` when `predicate(command)` accepts it — inspecting
`command.cwd`/`command.arguments`/whatever `Command`'s own inspection
accessors expose:

```python
runner = ScriptedRunner()
runner.when(lambda cmd: "--dangerous" in cmd.arguments, Reply.fail(1, "blocked"))
runner.fallback(Reply.ok(""))
```

`predicate` is infallible from the crate's perspective, like
`Supervisor.stop_when`: a raising or non-`bool` predicate is treated as "does
not match" rather than propagating, with the error surfaced via
[`sys.unraisablehook`](https://docs.python.org/3/library/sys.html#sys.unraisablehook)
(visible on stderr) so a buggy predicate is noisy, not silently wrong.

*Deeper: outcome semantics and the exception hierarchy — [Running commands](commands.md).*

## Scripted streaming: a live handle, no child

`ScriptedRunner.start(cmd)` (and `astart`) returns a real `RunningProcess`
backed by the canned reply instead of an OS child. The scripted stdout flows
through the **same line pumps** a real child uses, so `stdout_lines()`,
readiness probing, and `finish()` all behave identically — letting you test a
readiness-gate orchestration hermetically:

```python
import asyncio
from processkit import Command
from processkit.testing import Reply, ScriptedRunner


async def becomes_ready(runner):
    proc = runner.start(Command("server", ["serve"]))
    async for line in proc.stdout_lines():
        if "listening" in line:
            break
    return (await proc.afinish()).exited_zero


def test_server_becomes_ready():
    runner = ScriptedRunner()
    runner.on(["server", "serve"], Reply.lines(["booting", "listening on 8080"]))
    assert asyncio.run(becomes_ready(runner))  # satisfied by the canned banner
```

`Reply.lines([...])` scripts the stdout lines and the scripted run "exits" after
the last one; `Reply.pending()` scripts a run that never ends on its own (bound
it with the command's own `timeout()`). The honest boundary: a scripted handle
has no OS identity — `pid` is `None` and `profile` reports empty samples — so it
tests orchestration logic, not real I/O timing.

*Deeper: the live streaming surface (`stdout_lines`, `output_events`, `take_stdin`) — [Streaming & interactive I/O](streaming.md).*

## Record/replay cassettes: RecordReplayRunner

`RecordReplayRunner` closes the loop: capture real runs to a JSON *cassette*
once, then replay them offline — fast, deterministic, no subprocess in CI. It
shares the `Runner` verb surface, so it drops into the same seam.

```python
from processkit import Command
from processkit.testing import RecordReplayRunner

CMD = Command("python", ["-c", "import random; print(random.random())"])

# Record once against the real tool (an opt-in test run, say):
rec = RecordReplayRunner.record("fixtures/random.json")  # records via the real Runner
recorded = rec.run(CMD)  # spawns python once, captures it
rec.save()  # write the cassette to disk

# Replay everywhere else — NEVER spawns:
rep = RecordReplayRunner.replay("fixtures/random.json")
assert rep.run(CMD) == recorded
```

That last assertion is the **no-respawn proof**: the recorded command prints a
fresh random number every real run, so if replay equals the recorded value,
nothing was spawned. (This is exactly how our suite proves it.)

`start()` is covered too: the cassette records a streamed run (capture-whole — the
child runs to completion, then the handle replays its captured lines through a real
`RunningProcess`) and replays it offline, so a readiness-gated `start` flow tests
hermetically. Two limits: an *interactive* run fed stdin mid-stream can't be
cassette-recorded (bound it with `Command.timeout()`, or script it with
`ScriptedRunner`); and **`output_bytes` is not supported through a cassette** — it
stores lossy-UTF-8 *text*, so it can't reproduce exact bytes and raises
`Unsupported` (capture bytes from a real or scripted runner instead).

Semantics worth knowing before you commit a cassette:

| Aspect | Behavior |
|---|---|
| Match key | program + args + a stdin **source digest**; cwd is stored for visibility but is not matched by default |
| Environment | override **values never reach the file** — only sorted variable names; env is *not* matched, so env differences can't cause spurious misses |
| Duplicates of one key | replayed in capture order, then the **last entry repeats** — a changing sequence (`rev-parse HEAD` before/after a commit) replays faithfully, while a retry/probe loop keeps getting a stable final answer |
| Miss | an invocation **absent from the cassette is a strict error** — replay never spawns a surprise subprocess, so a stale cassette fails loudly |

Only environment **values** are omitted automatically. `program`, `args`, `cwd`,
`stdout`, and `stderr` are otherwise stored verbatim and can carry secrets. Use
the opt-in `scrub=` hook for arguments, cwd, and captured output:

```python
import os
from pathlib import Path


def scrub_cassette(field: str, text: str) -> str:
    if field in {"argument", "stdout", "stderr"}:
        return text.replace(os.environ["TOOL_TOKEN"], "<token>")
    if field == "cwd":
        return text.replace(str(Path.home()), "<home>")
    return text


rec = RecordReplayRunner.record("fixtures/tool.json", scrub=scrub_cassette)
# ... run commands, then rec.save()

# The same deterministic callback preserves redacted argument match keys.
rep = RecordReplayRunner.replay("fixtures/tool.json", scrub=scrub_cassette)
```

Record-mode callers still receive the real unsanitized result; only the stored
entry is transformed. Replay returns the fixture-safe stored output. A scrubber
exception or non-string result aborts the runner verb and uses a fail-closed
placeholder internally, never the raw field. The program name is deliberately
not scrubbed because it is the stable tool identity.

For the pytest fixture, override one companion fixture in `conftest.py`; the
plugin applies it in both modes:

```python
import pytest


@pytest.fixture
def processkit_cassette_scrubber():
    return scrub_cassette
```

`save()` writes the file owner-only (`0600` on Unix) and refuses to follow a
symlink, but still review a fixture before committing it.

Record from a single thread. The capture buffer is per-runner; recording the same
`RecordReplayRunner` from several threads at once (only possible on a free-threaded
build) can interleave entries non-deterministically. Replay is read-only and has no
such constraint.

*Deeper: how a `ProcessResult` is shaped before it's captured — [the Cookbook](cookbook.md).*

## Asserting on calls: RecordingRunner

`RecordingRunner` is the *spy*: it replies to every command with one canned
`Reply` and records each call, so a test can assert on **what** your code ran —
not just react to a reply. It shares the `Runner` verb surface.

```python
from processkit import Command
from processkit.testing import RecordingRunner, Reply


def deploy(runner) -> None:
    runner.run(Command("git", ["push", "--tags"]))


def test_deploy_pushes_tags() -> None:
    runner = RecordingRunner.replying(Reply.ok(""))
    deploy(runner)

    inv = runner.only_call()  # the one call (raises unless exactly one)
    assert inv.program == "git"
    assert inv.args == ["push", "--tags"]
    assert inv.has_flag("--tags")
```

- **`replying(reply)`** — every command gets `reply`, built with the same `Reply`
  factories as `ScriptedRunner`.
- **`new(inner)`** — wrap `inner` (any of `Runner`, `ScriptedRunner`,
  `RecordReplayRunner`, or another `RecordingRunner`), recording every call
  made through it. The general form behind `replying()`, for combining
  recording with a double you've already built (e.g. a `RecordReplayRunner`
  cassette, or a `ScriptedRunner` with several `.on()` rules already wired
  up) instead of a fresh runner that just replies with one canned `Reply`.
- **`calls()`** — every recorded `Invocation`, in call order.
- **`only_call()`** — the single invocation, or a `ProcessError` if there wasn't
  exactly one.

Each `Invocation` exposes `program`, `args`, `cwd`, `env` (a `dict[str, str |
None]`; a `None` value is an `env_remove`), `has_stdin`, and a `has_flag(flag)`
helper. The values are there for your assertions, but its `repr` is **redacted**
(program, arg count, cwd, env names, has_stdin — never argv or env values), like
`Command`'s — a failing assertion that prints the invocation won't leak a
secret-bearing flag.

Reach for `RecordingRunner` when the *call* is what matters (did my code push the
tags?); for canned per-command replies use
[`ScriptedRunner`](#scripting-replies-scriptedrunner), and to replay real output
offline use [`RecordReplayRunner`](#recordreplay-cassettes-recordreplayrunner).

## Rendering commands without running: DryRunRunner

`DryRunRunner` is the double behind a tool's own `--dry-run`/`--echo` mode: it
never spawns anything, renders each command to its display-quoted line, and
returns a synthetic success. There is nothing to script — a dry run has no real
output to fake, only a command line to show — so every call just succeeds
(empty stdout; an exit code drawn from the command's own `success_codes`, so
the checking verbs stay in agreement even for a command whose accepted set
excludes `0`). It shares the `Runner` verb surface, so it drops into the same
seam.

```python
from processkit import Command
from processkit.testing import DryRunRunner


def prune(runner) -> None:
    runner.run(Command("rm", ["-rf", "build"]))
    runner.run(Command("rm", ["-rf", "dist"]))


def test_prune_targets_the_right_dirs() -> None:
    runner = DryRunRunner()
    prune(runner)  # nothing spawned
    assert runner.commands() == ["rm -rf build", "rm -rf dist"]
```

- **`commands()`** — the rendered command line of every call so far, in order,
  each produced by [`Command.command_line()`](commands.md) (the same display
  quoting you'd reach for by hand).
- **`only_command()`** — the single rendered line, or a `ProcessError` if there
  wasn't exactly one call (like `RecordingRunner.only_call()`).
- **`on_invocation(callback)`** — call `callback(line)` with each rendered line
  *as the call happens* — e.g. to print the echo live for a real `--dry-run`
  flag — **in addition to** the collected `commands()` snapshot. The callback is
  a fire-and-forget side effect: a raising one is surfaced via
  [`sys.unraisablehook`](https://docs.python.org/3/library/sys.html#sys.unraisablehook)
  rather than derailing the run it was only observing.

```python
runner = DryRunRunner()
runner.on_invocation(print)  # echo each command as it's "run"
deploy_plan(runner)  # prints: kubectl apply -f manifest.yaml, …
```

Reach for `DryRunRunner` when the rendered *command line* is what you want to
assert on (or echo), with no reply to script and no output to replay — the
`--dry-run` seam. When a call needs a specific canned outcome, use
[`ScriptedRunner`](#scripting-replies-scriptedrunner); when you also need the
structured call record (cwd/env/stdin), use
[`RecordingRunner`](#asserting-on-calls-recordingrunner).

## Wrapping a CLI tool: CliClient

`CliClient` binds a program to per-call defaults, so repeated calls usually
pass only their `Args`. Every sync and async capture/check verb (`run`,
`output`, `output_bytes`, `exit_code`, `probe`, plus the `a`-prefixed twins)
accepts `Args | Command`: args are combined with the bound program and client
defaults; a `Command` can carry per-call customization, whose explicit settings
win over client defaults. This broader input type makes `CliClient` a valid
`ProcessRunner` implementation. It is not a `StreamingRunner`, because it does
not provide `start`/`astart`:

```python
from processkit import CliClient

git = CliClient("git", default_timeout=30.0)
head = git.run(["rev-parse", "HEAD"])  # or: await git.arun([...])
clean = git.probe(["diff", "--quiet"])
git.run(["fetch", "--quiet"])  # raises on failure; ignore the stdout
```

`CliClient` accepts an optional `runner=` too, driving every verb through the
given runner instead of the real one — a `ScriptedRunner` (or `RecordingRunner`
/ `RecordReplayRunner`) makes a `CliClient`-based wrapper hermetically testable
without restructuring it around a `runner` parameter of its own:

```python
from processkit import CliClient
from processkit.testing import Reply, ScriptedRunner

scripted = ScriptedRunner()
scripted.on(["git", "rev-parse", "HEAD"], Reply.ok("deadbeef\n"))
git = CliClient("git", runner=scripted)
assert git.run(["rev-parse", "HEAD"]) == "deadbeef"  # no real git spawned
```

`run_json` / `arun_json` run like `run` (requiring a zero exit) but parse the
stdout as JSON and return the decoded object — the wrapper for tools that speak
JSON (`gh`, `kubectl`, `docker`, `az`, `jj`). They go through the same `runner=`
seam, so a scripted reply's stdout is parsed exactly as a real tool's would be —
no process, no filesystem:

```python
from processkit import CliClient
from processkit.testing import Reply, ScriptedRunner

scripted = ScriptedRunner()
scripted.on(["gh", "pr", "view", "42", "--json", "state"], Reply.ok('{"state": "OPEN"}'))
gh = CliClient("gh", runner=scripted)
assert gh.run_json(["pr", "view", "42", "--json", "state"]) == {"state": "OPEN"}
```

Stdout that is not valid JSON raises `InvalidJson` (a `ProcessError` carrying the
`program` and a bounded stdout fragment) rather than a bare
`json.JSONDecodeError`, and a scripted reply exercises that path too:

```python
from processkit import CliClient, InvalidJson
from processkit.testing import Reply, ScriptedRunner

scripted = ScriptedRunner()
scripted.on(["gh", "pr", "view"], Reply.ok("not json at all"))
gh = CliClient("gh", runner=scripted)
try:
    gh.run_json(["pr", "view"])
except InvalidJson as exc:
    assert exc.program == "gh"
    # `run_json()`/`arun_json()` always attach `.stdout` (unlike the streaming
    # `RunningProcess.stdout_json_lines()` case, where it is `None`) — narrow the
    # type before indexing/membership-testing it.
    assert exc.stdout is not None
    assert "not json at all" in exc.stdout
```

`output_all`/`aoutput_all` (and their `_bytes` twins) and `Supervisor` accept
the same `runner=` keyword, for the same reason — a batch or a supervised
command can be driven through a double in a test, with the real `Runner`
the default when `runner=` is omitted.

*Deeper: per-client defaults and the full verb set — [the Cookbook](cookbook.md) → "Wrap a CLI tool".*

---

Next: [Running commands](commands.md) ·
[Streaming & interactive I/O](streaming.md) ·
[Supervision](supervision.md) · [Cookbook](cookbook.md)

---

# Command-line usage

[‹ docs index](./)

Most of this package's value lives behind Python code — but sometimes the
caller is a shell script or a CI step, not a Python program. `python -m
processkit run` is a thin CLI wrapper over `Command` / `ProcessGroup` for
exactly that case: kill-on-exit containment and resource limits for a single
shell command, with no Python to write. `python -m processkit supervise`
exposes restart-based keep-alive supervision (`Supervisor`) the same way, and
`python -m processkit doctor` is `run`'s read-only companion: a preflight
diagnosis of what this environment's kernel actually grants, without running
anything (see [below](#doctor-preflight-diagnose-the-environment)).

After `pip install processkit-py`, the same wrapper is also on `PATH` as the
short `processkit` command — `processkit run -- pytest -x` and `processkit
doctor` work exactly like their `python -m processkit ...` equivalents below,
sharing the identical flag set and exit-code contract (both forms delegate to
the same entry point). `python -m processkit` remains fully supported and is
what the rest of this page uses throughout — reach for it explicitly when
several interpreters are on the machine and the `processkit` command on
`PATH` might not be the one you mean.

- [Basic usage](#basic-usage)
- [Flags](#flags)
- [`--profile`: machine-readable resource usage](#--profile-machine-readable-resource-usage)
- [Exit codes](#exit-codes)
- [supervise](#supervise)
- [Resource limits: hard cap or best effort?](#resource-limits-hard-cap-or-best-effort)
- [`doctor`: preflight-diagnose the environment](#doctor-preflight-diagnose-the-environment)
- [What you don't get here](#what-you-dont-get-here)

## Basic usage

```bash
python -m processkit run -- pytest -x
# or, once installed, the shorter console script (identical behavior):
processkit run -- pytest -x
```

Everything after the **first** `--` is the child's own argv, untouched — a
second `--` in there belongs to the child, not to this wrapper:

```bash
python -m processkit run -- git log -- README.md
#                          ^ separator            ^ the child's own "--"
```

The child runs inside a `ProcessGroup`: even for one command, its whole
process tree — every grandchild it forks — is torn down when this wrapper
exits, and by default its stdin/stdout/stderr are inherited straight through
to your terminal: the child reads from the same stdin and its output is live,
not buffered up and dumped at the end. Output-control flags below deliberately
replace that default for the selected streams.

```bash
# Bound the whole run to 30 seconds.
python -m processkit run --timeout 30 -- pytest -x

# Cap memory and process count too (needs a real container — see below).
python -m processkit run --max-memory 536870912 --max-processes 64 -- ./build.sh
```

## Flags

| Flag | Maps to | Notes |
|---|---|---|
| `--timeout SECONDS` | `Command.timeout(seconds)` | Kills the whole tree once the deadline passes. |
| `--timeout-grace SECONDS` | `Command.timeout_grace(seconds)` | Signal first, hard-kill after `SECONDS`. Requires `--timeout`; a usage error otherwise. |
| `--idle-timeout SECONDS` | `Command.idle_timeout(seconds)` | Kill the child if it emits no output line for `SECONDS`. Exit `123` (distinct from `--timeout`'s `124`). Pipes and re-emits stdout/stderr line-by-line; incompatible with `--profile` and `--stdout-file`. See [below](#--idle-timeout-a-silence-watchdog). |
| `--max-memory BYTES` | `ProcessGroup(max_memory=...)` | Whole-tree memory cap; accepts `1..=2^64-1` bytes. |
| `--max-processes N` | `ProcessGroup(max_processes=...)` | Fork-bomb ceiling for the tree; accepts `1..=2^32-1`. |
| `--cpu-quota FLOAT` | `ProcessGroup(cpu_quota=...)` | Fraction of a **single** core (`0.5` = half, `2.0` = two cores). |
| `--env-clear` | `Command.env_clear()` | Start the child with an empty environment. |
| `--inherit-env NAME` | `Command.inherit_env([...])` | Allow-list a parent variable through (implies `--env-clear`). Repeatable. |
| `--env-file PATH` | Repeated `Command.env(key, value)` | Load docker-style `KEY=VALUE` lines. Blank lines and lines beginning with `#` are ignored. Repeatable. |
| `--env KEY=VALUE` | `Command.env(key, value)` | Set/override a child environment variable. Repeatable. A value without `=` is a usage error. |
| `--cwd DIR` | `Command.cwd(dir)` | Run the child with `DIR` as its working directory. |
| `--profile [FILE]` | `RunningProcess.profile(...)` | After the child exits, emit a JSON resource profile — to stderr if `FILE` is omitted, or written to `FILE` otherwise. See [below](#--profile-machine-readable-resource-usage). |
| `--create-no-window` | `Command.create_no_window()` | Do not create a console window for the child. No-op outside Windows (same as the underlying binding method). |
| `--output-limit BYTES` | `Command.output_limit(max_bytes=..., on_overflow="error")` | Pipe and re-emit output line-by-line, failing with `125` if captured stdout/stderr exceeds the raw-byte ceiling. Incompatible with `--profile` and `--stdout-file`. |
| `--sanitize-vt` | `Command.sanitize_vt()` | Strip ANSI/VT terminal escapes from captured stdout/stderr and re-emit clean text line-by-line. Incompatible with `--profile` and `--stdout-file`. |
| `--stdout-file PATH` | `Command.stdout_file(path)` | Redirect stdout directly to a newly created or truncated file. |
| `--stderr-file PATH` | `Command.stderr_file(path)` | Redirect stderr directly to a newly created or truncated file. |
| `--kill-on-parent-death` | `Command.kill_on_parent_death()` | Best-effort abrupt-owner-death cleanup; platform scope is unchanged from the API. |
| `--priority LEVEL` | `Command.priority(level)` | CPU priority: `idle`, `below_normal`, `normal`, `above_normal`, or `high`. |
| `--io-priority CLASS[:LEVEL]` | `Command.io_priority(...)` | Linux I/O priority: `idle`, `best_effort:0..7`, or `real_time:0..7`; unsupported elsewhere. |
| `--cpu-affinity CPU[,CPU...]` | `Command.cpu_affinity([...])` | Pin the child tree to logical CPUs on Linux/Windows; unsupported elsewhere. |
| `--pty` | `Command.pty(...)` | Allocate a pseudo-terminal and relay its one merged terminal stream on stdout. The child does not inherit the wrapper's stdin; use the Python API for an interactive writer. |
| `--pty-cols N` / `--pty-rows N` | `Command.pty(cols=..., rows=...)` | Initial terminal size. Requires `--pty`; provide both dimensions together. |

Every numeric flag rejects zero and negative values at the argument-parsing
stage (a usage error, not a traceback). See `docs/process-groups.md` and
`docs/commands.md` for what each underlying builder method does in full —
including how the environment builders (`env_clear` / `inherit_env` / `env`)
compose regardless of call order. CLI environment layers have a fixed
precedence: the inherited base (or `--env-clear`), then `--inherit-env`, then
`--env-file` values in file/argument order, then explicit `--env` flags. A later
entry in the same layer wins; explicit flags therefore override every file.
Files are UTF-8 (an optional BOM is accepted); values are literal and may contain
additional `=` characters. Missing/unreadable files and non-comment lines
without `=` are usage errors with the file and line number, never tracebacks.

The resource and restart limits are parsed against the widths of their binding
types before a `ProcessGroup` or `Supervisor` is constructed: `--max-memory`
accepts `1..=2^64-1` (`u64`), while `--max-processes` and `--max-restarts`
accept `1..=2^32-1` (`u32`). Values above those bounds are argparse usage
errors; the other positive-integer flags keep their existing contracts.

### `--sanitize-vt`: clean terminal output

Use `--sanitize-vt` for a color-sensitive tool whose output must become plain
text for CI logs or downstream parsing. It is particularly useful with `--pty`,
where stdout and stderr are one escape-heavy terminal stream:

```bash
python -m processkit run --pty --sanitize-vt -- colorful-tool status
```

The flag selects the managed line relay even without `--pty`: bytes are decoded,
split into lines, sanitized through `Command.sanitize_vt()`, and then re-emitted
to the wrapper's stdout/stderr. This preserves stream identity in pipe mode and
uses stdout for PTY's merged stream. It is incompatible with `--profile`
(which consumes the live handle through the profiling wait) and `--stdout-file`
(which removes the stdout capture pipe).

### `--profile`: machine-readable resource usage

Without `--profile`, `run` only ever reports an exit code — the resource side
of the run (wall time, CPU time, peak memory) is invisible from the CLI, even
though the binding already tracks it end-to-end (`RunningProcess.profile()` /
`RunProfile`, see [Streaming & interactive
I/O](streaming.md#live-introspection-and-per-run-telemetry)). `--profile`
exposes exactly that, for a CI step that wants a machine-readable resource
accounting of a containerized run without writing any Python:

```bash
# Print the profile to stderr once the child exits.
python -m processkit run --profile -- pytest -x

# Or write it to a file instead.
python -m processkit run --profile /tmp/run-profile.json -- pytest -x
```

Either way, the child's own stdin/stdout/stderr are still inherited straight
through exactly as without the flag — the profile is only ever emitted
**after** the child has fully exited (the same point `outcome()` itself
returns at), so it never interleaves with the child's own output. It is one
line of JSON with these fields:

| Field | Type | Meaning |
|---|---|---|
| `duration_seconds` | `float` | Wall-clock time the run took. |
| `cpu_time_seconds` | `float \| null` | User + kernel CPU time consumed by the whole run. |
| `peak_memory_bytes` | `int \| null` | Peak memory observed during the run. |
| `avg_cpu_cores` | `float \| null` | `cpu_time_seconds / duration_seconds` — e.g. `1.7` means ~1.7 cores kept busy on average. |
| `samples` | `int` | How many resource samples were taken while the child ran. |
| `code` | `int \| null` | Same meaning as the process's own exit code (`null` if the run ended some other way). |
| `signal` | `int \| null` | Set if the child was killed by a signal (POSIX only). |
| `timed_out` | `bool` | Whether `--timeout` expired. |

The `cpu_time_seconds` / `peak_memory_bytes` / `avg_cpu_cores` fields need the
same kernel-level accounting `ProcessGroup`'s own resource limits do (a
Windows Job Object or a Linux cgroup-v2 root — see [Resource limits: hard cap
or best effort?](#resource-limits-hard-cap-or-best-effort) above); where the
environment doesn't grant that, they serialize as JSON `null` rather than
failing the run — `duration_seconds`/`samples`/`code`/`signal`/`timed_out` are
always available. `--profile`'s own exit-code contract is otherwise unchanged
from the table above — it never introduces a new exit code, and a failure
writing the profile to `FILE` (e.g. an unwritable path) surfaces as the
existing internal-failure code `125`, with a one-line message on stderr.

### `--idle-timeout`: a silence watchdog

`--idle-timeout SECONDS` maps to `Command.idle_timeout(seconds)` — it kills the
child if it produces no output line for that long, for a tool that hangs
silently while a healthy long job keeps printing. It exits **123**, deliberately
distinct from `--timeout`'s `124`, so the two timeout classes stay tellable
apart by exit code.

```bash
# Kill the build if it goes quiet for 30s, even though its total budget is high.
python -m processkit run --timeout 3600 --idle-timeout 30 -- ./flaky-build
```

Idle monitoring needs the per-line output channel, so with this flag `run`
**pipes** the child's stdout/stderr and re-emits each decoded line (one at a
time, with a trailing newline) instead of inheriting the raw streams. That is a
deliberate fidelity trade taken only when the flag is set: output is UTF-8
decoded and line-framed, and the child's streams are not a TTY. For the same
reason `--idle-timeout` is **incompatible with `--profile`** (they need
different consuming operations on the one handle) — combining them is a usage
error. It is also incompatible with `--stdout-file`, because streaming requires
a piped stdout; redirect stderr instead when stdout activity alone is a useful
silence signal.

`--output-limit` uses that same line pump without adding an idle deadline. Its
`on_overflow="error"` policy counts raw bytes read from both pipes, kills the
run when the cap is crossed, and reports the existing internal-failure exit
code `125`; output beyond the ceiling is never relayed. Direct `--stdout-file`
redirection has no stdout pipe to monitor, so combining it with
`--output-limit` is a usage error. `--stderr-file` remains compatible: stderr
goes directly to the file and the ceiling applies to the still-captured stdout.

`--pty` also uses the line pump, but the source is a real pseudo-terminal: tools
that require `isatty()` see a terminal and stdout/stderr arrive as one merged
stream on the wrapper's stdout. Optional dimensions must be supplied together.
PTY owns the child's stdio, so direct file redirects and `--profile` are usage
errors; `--output-limit`, idle/wall-clock timeouts, resource limits, and
`--create-no-window` keep their normal semantics. This CLI mode is intended for
non-interactive TTY-sensitive tools and does not forward the wrapper's stdin;
use `Command.pty().keep_stdin_open()` plus `take_stdin()` for interactive input.
If the platform cannot allocate a PTY, the request fails through the existing
`Unsupported`/internal-error path (`125`) rather than silently using pipes.

`--idle-timeout` is **not** available under `supervise`: each supervised
incarnation runs through `Supervisor`'s one-shot verbs, which processkit's core
gives no idle-timeout hook, so passing it there is a usage error until upstream
support lands. Use `run --idle-timeout` for a single command.

## Exit codes

This wrapper's own exit code mirrors the child's — plus a small set of
reserved codes for cases where there is no child exit code to report,
following the same convention GNU coreutils' `timeout` and POSIX shells use:

| Exit code | Meaning |
|---|---|
| *(the child's own code)* | Normal completion — passed through unchanged. |
| `119` | This wrapper could not deliver its own buffered output (see [How the wrapper terminates](#how-the-wrapper-terminates)). Shared by every subcommand. |
| `123` | `--idle-timeout` expired; the child produced no output line in time and was killed. Distinct from `124`. |
| `124` | `--timeout` expired; the tree was killed. |
| `125` | An internal / containment failure (see below). |
| `126` | The program was found but could not be executed. |
| `127` | The program could not be found. |
| `128 + N` | The child was killed by signal `N` (POSIX only). |
| `128 + SIGINT` (`130`) | `python -m processkit` itself was interrupted (Ctrl+C) — anywhere, including during startup, argument parsing, or `doctor`. |

None of these ever surface as a raw Python traceback — every documented
processkit exception (`Timeout`, `Signalled`, `ProcessNotFound`,
`PermissionDenied`, `ResourceLimit`, `Unsupported`) is caught and turned into
one of the codes above, with a one-line message on stderr. Ctrl+C is part of
that promise too: it always ends as `128 + SIGINT` with a single
`processkit: interrupted` line, never a `KeyboardInterrupt` traceback.

### How the wrapper terminates

`python -m processkit` flushes its own stdout/stderr, raises `SystemExit` with
the selected code, and then runs ordinary interpreter finalization (`atexit`
hooks, garbage-collected finalizers, and module teardown included).

`--idle-timeout` is the one path that drives the async surface. Its completion
handoff wakes the event loop through a socket and resolves the Future on the
loop thread, so no detached tokio thread remains inside Python after the await
resumes; normal finalization cannot race the bridge. See
[Async runtimes & event loops](event-loops.md#interpreter-shutdown-and-the-async-bridge).

Two exit duties remain explicit so their outcomes stay part of the CLI's
documented contract rather than depending on CPython's fallback behavior:

- **The final flush of its own stdout/stderr.** Redirected into a pipe,
  stdout is block-buffered, so this is what makes the last lines arrive at
  all. If that flush fails in a way that *loses* output — a full or failing
  disk, a stream closed underneath the process — the wrapper exits **119**
  with one line on stderr instead of the code it was about to report. That is
  deliberate: reporting the child's own code would claim a complete,
  faithfully relayed run. A receiver that simply went away
  (`BrokenPipeError`, e.g. `python -m processkit run ... | head`) is *not*
  that case and stays silent — no exit code can deliver output to a closed
  pipe.
- **Ctrl+C that lands outside `run`/`supervise`'s own guarded blocks** — during
  startup, argument parsing, or `doctor`. It exits `128 + SIGINT` (`130`) with
  the same one-line `processkit: interrupted` message the guarded paths print,
  on every platform. For `doctor` this matters beyond tidiness: `1` is a valid
  `doctor` verdict, so an interrupted probe must never be reported as one.

## supervise

**Basic usage:**

```bash
python -m processkit supervise [OPTIONS] -- PROGRAM [ARG ...]
```

`supervise` keeps a command alive by restarting it according to a selected
policy, with configurable exponential backoff. Its child's stdin is inherited
exactly as with `run` (`Command.inherit_stdin()`). Stdout/stderr are handled
differently than `run`, though: `Supervisor` requires a **piped** stdout to
capture each incarnation's result (to evaluate the restart policy and
populate `SupervisionOutcome.final_result`) — a non-piped stdout errors every
incarnation. To still stream live to this terminal, this wrapper pipes both
streams and tees every decoded line straight through to its own inherited
stdout/stderr (`Command.stdout_tee`/`stderr_tee`); output still appears live,
just line-buffered rather than a byte-for-byte fd passthrough.

| Flag | Description |
|---|---|
| `--restart {always,on_crash,never}` | Restart policy passed to `Supervisor`. |
| `--max-restarts N` | Stop after `N` restarts; accepts `1..=2^32-1` (`u32`). |
| `--backoff-initial SECONDS` | Initial delay before a restart. Must be positive. |
| `--backoff-factor FLOAT` | Multiplier for successive restart delays. Must be at least `1`. |
| `--max-backoff SECONDS` | Upper bound for restart delay. Must be positive. |
| `--no-jitter` | Disable restart-delay jitter; jitter is enabled by default. |
| `--timeout SECONDS` | Apply `Command.timeout(seconds)` independently to every incarnation. A final timed-out incarnation exits `124`. |
| `--max-memory BYTES` | Cap every incarnation's whole process tree memory; accepts `1..=2^64-1` bytes (`u64`). |
| `--max-processes N` | Cap every incarnation's process-tree size; accepts `1..=2^32-1` (`u32`). |
| `--cpu-quota FLOAT` | Cap every incarnation's CPU as a fraction of one core. |
| `--cpu-affinity CPU[,CPU...]` | Pin every incarnation to logical CPUs on Linux/Windows. |
| `--create-no-window` | Apply `Command.create_no_window()` to every incarnation. |
| `--health-port HOST:PORT` | Probe a TCP endpoint; bracket IPv6 literals, for example `[::1]:8080`. Mutually exclusive with `--health-http`. |
| `--health-http URL` | Probe an absolute HTTP(S) URL; any 2xx response is healthy. Mutually exclusive with `--health-port`. |
| `--health-interval SECONDS` | Probe cadence, default `5`; requires a health probe. |
| `--health-timeout SECONDS` | Per-probe network timeout, default `1`; requires a health probe. |
| `--env-clear` | Start the child with an empty environment. |
| `--inherit-env NAME` | Allow-list a parent variable (implies `--env-clear`). Repeatable. |
| `--env-file PATH` | Load docker-style `KEY=VALUE` lines. Repeatable; later files win and `--env` wins over files. |
| `--env KEY=VALUE` | Set or override a child variable. Repeatable. |
| `--cwd DIR` | Run the child with `DIR` as its working directory. |

```bash
python -m processkit supervise --restart always --max-restarts 5 -- some_command
```

Health checks are synchronous, bounded probes passed to `Supervisor`'s
`health_check=` hook. The first probe runs after one interval, giving the child
a startup grace period; three consecutive failures use the binding's default
threshold and force-kill the wedged incarnation. A restart policy then treats
that kill like any other crash. With `--restart never`, a POSIX signal kill
uses `128 + N`; on a platform that reports no signal it uses the existing
internal-failure code `120` with a health-check diagnostic.

| Exit code | Meaning |
|---|---|
| *(the final child result's code)* | Supervision stopped because the restart policy was satisfied. |
| `119` | This wrapper could not deliver its own buffered output — the entry-point-wide code from [How the wrapper terminates](#how-the-wrapper-terminates), not a `supervise` one. |
| `120` | An internal command/supervisor failure, including a missing or unexecutable program. |
| `121` | The restart policy required another attempt, but `--max-restarts` was exhausted. |
| `122` | Supervision gave up due to a `give_up_when` condition (reserved for API-driven outcomes). |
| `124` | The final incarnation hit its per-incarnation `--timeout`. |
| `128 + N` | The final incarnation was killed by signal `N` (POSIX only) — mirrors `run`'s own convention. |
| `128 + SIGINT` (`130`) | `python -m processkit` itself was interrupted with Ctrl+C. |

## Resource limits: hard cap or best effort?

The `run` and `supervise` forms of `--max-memory` / `--max-processes` /
`--cpu-quota` need a real container — a
Windows Job Object or a Linux **cgroup-v2 root** (see
[Process groups](process-groups.md#resource-limits-the-sandbox) and
[Platform support](platforms.md)). Inside an ordinary container, a systemd
user session, or on macOS, the kernel refuses these caps outright.

Rather than fail the operation over a cap the environment can't grant, this
CLI **degrades**: it prints a warning to stderr and runs (or supervises) the
child in a plain, uncapped `ProcessGroup` — "contained, but uncapped" — the same
fallback `examples/04_sandbox_resource_limits.py` uses. The no-orphan
containment guarantee still applies either way; only the specific numeric
caps are dropped. If your script depends on the cap actually being enforced,
check stderr for that warning rather than assuming it always held.

## `doctor`: preflight-diagnose the environment

`--max-memory`/`--max-processes`/`--cpu-quota` depend on kernel primitives
that are not guaranteed to be there (see above) — until now, the only way to
find out was to run `run` for real and read a warning on stderr, or catch
`ResourceLimit`/`Unsupported` from the Python API. `python -m processkit
doctor` answers the same question up front, without running anything:

```bash
python -m processkit doctor
```

```text
processkit doctor
  graceful-stop scope     : whole_tree
  parent-death cleanup    : whole_tree
  processkit-rs version   : 3.1.0
  containment mechanism : cgroup_v2
  resource limits        : available
  verdict: OK - containment and resource limits are both available (exit 0)
```

Degraded (containment holds, but the kernel refuses at least one resource
limit — the typical container / systemd user session / non-root cgroup /
macOS case; `--max-memory`, `--max-processes`, and `--cpu-quota` are probed
**independently**, since on Linux cgroup-v2 they are separate controllers
that can be unavailable one without the others):

```text
processkit doctor
  graceful-stop scope     : opt_in_members
  parent-death cleanup    : direct_child_only
  processkit-rs version   : 3.1.0
  containment mechanism : process_group
  resource limits        : unavailable --max-memory (ResourceLimit: cgroup v2 root required)
  note: --max-memory/--max-processes/--cpu-quota need a Windows Job Object or
  a Linux cgroup-v2 root; the kernel typically refuses them inside
  containers, systemd user sessions, and non-root cgroups, and always on
  macOS (docs/cli.md#resource-limits-hard-cap-or-best-effort).
  verdict: DEGRADED - containment is enforced, but resource limits are not (exit 1)
```

It never spawns a child process — only constructs (and immediately drops) a
few throwaway `ProcessGroup` instances to see what the kernel actually
grants (one for the containment mechanism, one per resource-limit
controller). `doctor` has its own exit-code namespace, deliberately disjoint
from `run`'s codes above (`124`/`125`/`126`/`127`/`128 + signal`) *and* from
argparse's own usage-error code `2` (the same code `run` itself uses for a
bad invocation) — `doctor` never returns `2` as a diagnostic verdict, so a
CI gate can always read `2` as "you called this wrong", unambiguous from any
of the codes below:

| Exit code | Meaning |
|---|---|
| `0` | Resource limits are available (containment *and* all three caps hold). |
| `1` | Containment is enforced, but at least one resource limit is not — the same "contained, but uncapped" gap `run` degrades around. |
| `2` | *(not returned by `doctor` itself)* — a usage error, e.g. an unknown flag or `doctor`'s disallowed trailing command; reserved to keep it unambiguous from a real diagnostic result. |
| `3` | Containment itself is unavailable (should not happen on any supported platform). |
| `4` | A probe raised an unexpected operational error (`OSError`/`PermissionError`, e.g. failing to read cgroup state) rather than a definitive result — the environment's actual availability could not be determined. |

The two entry-point-wide codes from
[How the wrapper terminates](#how-the-wrapper-terminates) — `119` (output the
wrapper could not deliver) and `128 + SIGINT` (`130`, interrupted) — are
disjoint from those verdicts by construction, so a CI gate reading `doctor`'s
code never has to disambiguate them from a diagnosis. In particular an
interrupted `doctor` reports `130`, never `1`.

For CI, `doctor --json` replaces that text report with one JSON object on
stdout while preserving the same exit code. Its stable base schema is:

```json
{
  "mechanism": "cgroup_v2",
  "host_containment": {
    "mechanism": "cgroup_v2",
    "soft_stop_scope": "whole_tree",
    "parent_death_cleanup": "whole_tree",
    "crate_version": "3.1.0"
  },
  "verdict": "OK",
  "exit_code": 0,
  "resource_limits": {
    "max_memory": true,
    "max_processes": true,
    "cpu_quota": true
  },
  "caveat": "--max-memory/--max-processes/--cpu-quota need a Windows Job Object or a Linux cgroup-v2 root; the kernel typically refuses them inside containers, systemd user sessions, and non-root cgroups, and always on macOS (docs/cli.md#resource-limits-hard-cap-or-best-effort)."
}
```

`mechanism`, `verdict`, and `caveat` are strings; `exit_code` is an integer;
all `host_containment` fields are strings; and all `resource_limits` fields are
booleans. `verdict` is one of `OK`,
`DEGRADED`, `UNAVAILABLE`, or `ERROR`, matching exit codes `0`, `1`, `3`, and
`4`. When an `OSError` prevents a definitive probe result, the payload also
contains `error_probe_failures`, a list of error strings.

`doctor` takes only `-h`/`--help` and `--json` — in particular, no trailing
`-- PROGRAM ...` (it is diagnostic-only and never runs a command).

## What you don't get here

This is a v1, deliberately minimal wrapper — reach for the Python API
directly for anything beyond it: piping several commands together
([Pipelines](pipelines.md)), advanced supervision callbacks such as `stop_when` and `give_up_when`
([Supervision](supervision.md)), line-by-line streaming ([Streaming &
interactive I/O](streaming.md)), or running a batch of commands concurrently
(`output_all` / `aoutput_all`). There is also no `--dry-run` mode yet — a
plausible follow-up, not implemented today.

---

Next: [Process groups](process-groups.md) ·
[Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Cookbook](cookbook.md)

---

# Performance & overhead

**Short answer: the bridge adds no silly overhead.** Spawning a child process
is fundamentally *syscall-bound* — `fork`/`exec`/`posix_spawn` on POSIX,
`CreateProcess` plus Job Object setup on Windows — and that OS-side cost
dominates the total wall-clock time of a run by a wide margin. The PyO3 glue
between Python and the `processkit` Rust crate (argument marshaling, the
async-runtime hop for the asyncio surface, error mapping) adds a small,
constant-time cost on top of a syscall path that already dominates the total.
This page explains what "no silly overhead" means concretely, points at the
benchmark suite that backs the claim with a real number instead of a loose
pass/fail bound, and shows how to reproduce it yourself.

## Why the workload is syscall-bound

Every one-shot verb (`output()`, `run()`, …) and every `ProcessGroup.start()`
does the same thing under the hood: ask the kernel to create a process (and,
for a group, first create and enter its containment mechanism — a Windows Job
Object, a Linux cgroup v2, or a POSIX process group), wait for it to produce
output and/or exit, then tear the containment down. None of that work can be
made faster by changing what happens *above* the crate boundary — the crate
already does the minimum number of syscalls the OS requires, with no
busy-polling. See [Architecture](internals.md#two-layers-one-boundary) for
where the binding crate's thin glue ends and the `processkit` crate's platform
logic begins; the binding layer never reimplements any OS mechanism, so it
never becomes a bottleneck.

Because process creation is what dominates, the per-call overhead in the
Python↔Rust boundary is lost in the noise next to a kernel-side operation
costing orders of magnitude more — see [What each benchmark
measures](#what-each-benchmark-measures) below for the harness that turns
this into a reproducible number instead of a fixed figure here. That is the
whole argument behind "no silly overhead": not that the bridge is free, but
that its cost is negligible relative to the workload it wraps.

## What each benchmark measures

The [`benchmarks/`](https://github.com/ZelAnton/processkit-py/tree/main/benchmarks)
suite (`pytest-benchmark` based) turns that qualitative argument into
reproducible numbers, answering the question
[ROADMAP.md](https://github.com/ZelAnton/processkit-py/blob/main/ROADMAP.md)'s
Phase 5 asks with a real measurement instead of only the loose sanity bound in
`tests/test_hardening.py::test_no_silly_per_call_overhead`:

- **`test_spawn_capture.py`** — spawn + capture a single short-lived command:
  `processkit`'s `Command(...).output()` against the two stdlib "naive"
  equivalents, `subprocess.run(..., capture_output=True)` and
  `asyncio.create_subprocess_exec(...)` + `communicate()`. Same payload on all
  three, so the comparison isolates per-call overhead rather than a differing
  workload.
- **`test_process_group.py`** — `ProcessGroup` start/exit: creating the
  group's kernel container, entering it, starting one short-lived child, and
  tearing the whole tree down. This is the cost of containment itself, on top
  of a bare spawn.
- **`test_streaming_throughput.py`** — `RunningProcess.stdout_lines()` (see
  [Streaming & interactive I/O](streaming.md)) draining a known number of
  lines end to end, i.e. sustained line-streaming throughput rather than a
  single spawn/exit round trip.
- **`test_output_all.py`** — `output_all()` / `aoutput_all()` (see
  [Cookbook](cookbook.md)) at 1/10/50-way concurrency, i.e. how batched
  fan-out scales as concurrency grows.
- **`test_pty.py`** — merged PTY output relay over a bounded fixed-width
  workload. POSIX runs use the native pty path; Windows runs require ConPTY
  (Windows 10 1809 or newer).
- **`test_lifecycle_events.py`** — the complete `lifecycle_events()` stream,
  including start, mixed stdout/stderr output, and exit events.
- **`test_aoutput_as_completed.py`** — completion-order delivery from a bounded
  `aoutput_as_completed()` batch, including per-slot result handling.
- **`test_supervisor.py`** — a live `Supervisor` session that performs a fixed
  number of crash restarts and then waits for the restart policy to finish.

The PTY benchmark is intentionally platform-aware. The nightly benchmark job
runs on Ubuntu, so its PTY result represents the POSIX implementation only;
Windows ConPTY measurements are useful on their own host class and are not
promised to be directly comparable with that nightly point. Unsupported PTY
platforms skip the scenario instead of contributing a misleading measurement.

## Reproducing locally

The suite is **not** part of the PR gate — it lives in its own `bench`
dependency-group and is excluded from `testpaths`, so an ordinary
`pytest`/`uv run pytest` never collects it. Install the group and run it
explicitly:

```console
uv sync --group bench
uv run pytest benchmarks/ --benchmark-only -p no:xdist -o addopts=""
```

`-p no:xdist -o addopts=""` disables `-n auto` (the repo's default
`addopts`) — `pytest-benchmark` needs to run in the main process, in a single
worker, to produce meaningful timings; under `pytest-xdist` it silently skips
measuring instead. See
[`benchmarks/README.md`](https://github.com/ZelAnton/processkit-py/blob/main/benchmarks/README.md)
for the full set of useful flags (`--benchmark-compare`,
`--benchmark-autosave`, `--benchmark-json`, …).

## Qualitative expectations

Rather than pin numbers here — which drift with hardware, OS, and Python
version, and would go stale the moment they were written down — this section
sets expectations you can sanity-check against your own run of the harness
above:

- **Single-call overhead is small relative to spawn cost.** The gap between
  `processkit`'s `output()` and the stdlib equivalents in
  `test_spawn_capture.py` should be a small fraction of the total per-call
  time, not a multiple of it — the bulk of the time in every one of the three
  compared approaches is the OS spawning and reaping the child.
- **Containment adds a bounded, one-time setup/teardown cost per group**, not
  a per-member one — creating and entering a Job Object / cgroup / process
  group happens once in `ProcessGroup.start()`/`__aenter__`, so starting many
  members into an already-open group is cheap relative to opening the group
  itself.
- **Line-streaming throughput scales with the amount of output**, not with a
  fixed per-line Python↔Rust round trip — `stdout_lines()` batches reads on
  the Rust side, so throughput should stay close to linear as line count
  grows.
- **`output_all()`/`aoutput_all()` scale sub-linearly with concurrency** up to
  the point where the workload becomes bound by the number of OS threads/CPUs
  available to run children concurrently, not by anything in the binding
  layer.

## Batch default concurrency

Leaving `concurrency` unset uses the CPU count available to the *process*, not
merely the host-wide hardware count. The Rust batch verbs use
`std::thread::available_parallelism()`; the streaming Python helpers use
`os.process_cpu_count()` on Python 3.13+ and fall back to `os.cpu_count()` on
Python 3.10-3.12. These sources account for CPU affinity and cgroup quotas where
the platform exposes them. If no count is available, every batch entry point
uses `4`. Pass an explicit positive `concurrency` to choose a different cap;
zero and negative values raise `ValueError`.

## Continuous tracking

The `bench` job in
[`nightly-hardening.yml`](https://github.com/ZelAnton/processkit-py/blob/main/.github/workflows/nightly-hardening.yml)
runs this suite on the same `schedule`/`workflow_dispatch` triggers as the
`stress` job — never on `push`/`pull_request` — and publishes the results as a
table in the job summary, so a regression shows up as a trend across nights
rather than only when someone happens to run the harness locally.

---

# Async runtimes & event loops

[‹ docs index](./)

processkit's async surface is **asyncio-native**. Every `a`-prefixed verb
(`aoutput`, `arun`, `astart`, …) and every streaming handle (`stdout_lines()`,
`output_events()`, interactive stdin) is bridged onto the running asyncio event
loop by [`pyo3-async-runtimes`], so it needs a real asyncio loop underneath.
This page says exactly which runtimes provide one — and which don't.

- [Support at a glance](#support-at-a-glance)
- [asyncio & uvloop](#asyncio--uvloop)
- [anyio](#anyio)
- [trio](#trio)
- [Why asyncio-native](#why-asyncio-native)
- [The readiness helpers](#the-readiness-helpers)

## Support at a glance

| Runtime | Supported | Why |
|---|---|---|
| **asyncio** (stdlib) | Yes — native | The bridge targets it directly |
| **uvloop** | Yes | A drop-in asyncio loop policy — the bridge sees an ordinary running asyncio loop |
| **anyio** on the **asyncio** backend | Yes | anyio's asyncio backend runs a real asyncio loop; the bridged awaitables await normally |
| **anyio** on the **trio** backend | No | No asyncio loop is present |
| **trio** (native) | No | No asyncio loop, and the bridge has no trio backend |
| **curio** | No | Same reason as trio |

The dividing line is simple: **is a real asyncio event loop running?** If yes
(plain asyncio, uvloop, or anyio-on-asyncio), the whole async surface works
unchanged. If no (trio, anyio-on-trio, curio), the `a`-prefixed verbs can't be
awaited — the sync surface (`output()`, `run()`, `ProcessGroup`, …) still works
from any thread, since it doesn't touch an event loop at all.

## asyncio & uvloop

The default. Nothing to configure:

```python
import asyncio
from processkit import Command


async def main():
    result = await Command("git", ["rev-parse", "HEAD"]).aoutput()
    print(result.stdout.strip())


asyncio.run(main())
```

[uvloop] is a faster asyncio loop implementation, installed as the loop policy.
Because it *is* an asyncio loop, processkit needs no special handling — install
the policy and every verb behaves identically (only with faster I/O
scheduling):

```python
import asyncio
import uvloop
from processkit import Command


async def main():
    await Command("./build.sh").arun()


uvloop.install()  # or asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
asyncio.run(main())  # 3.12+: asyncio.run(main(), loop_factory=uvloop.new_event_loop)
```

## anyio

[anyio] runs on one of two backends. On its **default asyncio backend**,
processkit works today with no changes — anyio does not hide the underlying
asyncio loop, so the bridged awaitables await normally, and asyncio
cancellation (which anyio maps onto its own cancel scopes) still tears the tree
down:

```python
import anyio
from processkit import Command


async def main():
    result = await Command("git", ["status", "--short"]).aoutput()
    print(result.stdout)


anyio.run(main)  # default backend="asyncio" — supported
```

On the **trio backend** (`anyio.run(main, backend="trio")`) there is no asyncio
loop, so the `a`-prefixed verbs cannot be awaited — see below.

## trio

Native [trio] (and anyio's trio backend, and curio) are **not supported**. A
trio program runs trio's own scheduler, not an asyncio loop, so the awaitables
processkit hands back — `asyncio.Future`s produced by the asyncio-wired bridge —
aren't trio-awaitable, and the binding refuses with a clear "no running asyncio
event loop" error anyway. For a quick symptom-to-solution map, see
[Troubleshooting](troubleshooting.md#a-prefixed-verbs-report-no-running-asyncio-event-loop).

If you're on trio and need processkit, the pragmatic bridge is
[`trio-asyncio`], which runs an asyncio loop inside a trio program; processkit's
verbs then execute in that asyncio context. That is a user-side integration
this package does not ship or test — treat it as unsupported-but-possible, not
a guarantee. The reliable alternative is the **synchronous** surface
(`output()`, `run()`, `ProcessGroup`, …), which needs no event loop and is
usable from a trio worker thread.

## Why asyncio-native

This is a deliberate, standing decision (project ROADMAP, Open decision #2),
not an oversight or a v1-only stopgap:

- The async surface is bridged tokio ↔ asyncio by [`pyo3-async-runtimes`],
  which targets asyncio and ships **no trio backend**. Native trio would mean
  writing a loop-agnostic bridge from scratch.
- That bridge is the single highest-risk part of the binding. Re-implementing
  it against trio's cancellation model — level-triggered cancel scopes and
  checkpoints, versus asyncio's edge-triggered `CancelledError` — while
  preserving the [kill-on-cancel no-orphan guarantee](timeouts-and-cancellation.md#cancelling-an-awaited-async-run)
  is a research effort in its own right, on a binding whose whole thesis is to
  stay thin and *not* reimplement hard concurrency logic.
- The anyio ecosystem is not actually shut out — anyio-on-asyncio works — so
  the excluded slice is specifically the trio-family loops, a smaller segment.

The path if this is ever revisited: port the pure-Python readiness helpers to
anyio primitives first (cheap, and it makes `wait_for_port` / `wait_until`
loop-agnostic), then evaluate a loop-agnostic compiled bridge once
`pyo3-async-runtimes` grows a trio backend or a concrete demand signal appears.

## Interpreter shutdown and the async bridge

Completed async operations are handed back on the **event-loop thread**. The
tokio task stores a type-erased outcome in Rust memory and wakes one shared
socket per event loop; `loop.sock_recv(...)` receives the wakeup and performs
the Python value conversion plus `Future.set_result()` / `set_exception()` on
the loop itself. Repeated stream steps reuse that same dispatcher rather than
opening a socket for every `__anext__`.
`sock_recv` is supported by the standard selector and Windows proactor loops,
uvloop, and anyio's asyncio backend, so this safety property does not narrow
the supported event-loop set.

This design matters at process exit. The previous upstream completion path
entered Python from a detached tokio blocking thread and called
`loop.call_soon_threadsafe(...)`. The awaiting coroutine could resume while
that foreign thread was still returning through Python frames; a short-lived
program whose last act was the `await` could then begin `Py_FinalizeEx`
underneath it and intermittently die from SIGSEGV after all useful work had
finished. The socket handoff never enters Python from the completing runtime
thread, so once the await resumes there is no bridge thread left inside the
interpreter. Ordinary interpreter finalization is safe; no `os._exit` or
post-await delay is required.

Cancellation keeps the same contract: cancelling the backing asyncio Future
signals the tokio work to drop and cancels the private socket receive, which
preserves `asyncio.CancelledError` and process-tree teardown.

## The readiness helpers

The readiness helpers ([`wait_for_port`](streaming.md#readiness-probes),
`wait_for_line`, `wait_for_path`, `wait_until`) are pure Python but built on
asyncio primitives, so they follow the same rule as the rest of the surface:
they need a running asyncio loop (asyncio, uvloop, or anyio-on-asyncio). In
particular `wait_for_line` consumes a `RunningProcess` stream, which is itself
asyncio-bridged — so there is no configuration in which the streaming surface is
asyncio-only while the helpers are not.

`sample_stats` (see [Process groups](process-groups.md#live-monitoring)) is the
same story: pure Python built on `asyncio.sleep`, needing the same running
asyncio loop as everything else here.

---

Next: [Timeouts & cancellation](timeouts-and-cancellation.md) ·
[Streaming & interactive I/O](streaming.md) ·
[Platform support](platforms.md) ·
[Cookbook](cookbook.md)

[`pyo3-async-runtimes`]: https://github.com/PyO3/pyo3-async-runtimes
[uvloop]: https://github.com/MagicStack/uvloop
[anyio]: https://anyio.readthedocs.io/
[trio]: https://trio.readthedocs.io/
[`trio-asyncio`]: https://github.com/python-trio/trio-asyncio

---

# Platform support & caveats

processkit's guarantee is strongest on Windows and weakest on macOS. This is
inherent to what each OS offers, and it is documented here rather than hidden.
`host_containment()` reports the expected mechanism, host-level soft-stop reach,
abrupt parent-death cleanup, and Rust crate version without creating a group or
spawning. `ProcessGroup.mechanism` and `ProcessGroup.soft_stop_scope` then report
the actual mechanism and current per-group soft-stop reach at runtime.

## Teardown (the no-orphan guarantee)

| | Mechanism | When the `with` / `async with` block exits | If the Python process is hard-killed (`SIGKILL`, `os._exit`) |
|---|---|---|---|
| **Windows** | Job Object | Whole tree reaped (kernel-enforced) | **Still reaped** — `KILL_ON_JOB_CLOSE` fires when the last handle closes |
| **Linux** | cgroup v2 (else process group) | Whole tree reaped | No whole-tree cleanup. Opt-in `kill_on_parent_death()` kills the direct child only; grandchildren survive |
| **macOS / BSD** | process group | Tree reaped, *except* children that called `setsid()` | No automatic cleanup; the platform has no parent-death signal equivalent |

The takeaway: the `with` / `async with` exit path (and ordinary GC) reaps the
tree on every platform. Whole-tree cleanup after a hard kill of the parent is a
Windows-only property. `Command.kill_on_parent_death_scope()` reports the
actual abrupt-death capability as `"whole_tree"`, `"direct_child_only"`, or
`"unsupported"`. Lean on the context managers; don't rely on `__del__` or
`atexit`, which don't run on `SIGKILL` / `os._exit`.

Cancelling an awaited run (`task.cancel()`, `asyncio.wait_for`,
`asyncio.timeout`) reaps the run's tree on every platform — the dropped future
tears it down.

## Resource limits (`ProcessGroup(max_memory=…, max_processes=…, cpu_quota=…)`)

| | Support |
|---|---|
| **Windows** | Job Object enforces memory / active-process / CPU-rate caps |
| **Linux** | cgroup v2 — **only when this process runs at the cgroup-v2 root**. Under a container, a systemd session/scope/service, or any non-root cgroup, the kernel's "no internal processes" rule forbids it and `ResourceLimit` is raised |
| **macOS / BSD** | No whole-tree limit primitive — requesting any limit raises `ResourceLimit` (a fail-fast, never a silently-unbounded group) |

If you need limits inside a container, run the process at the container's cgroup
root (the create-leaf / migrate-self / enable-controllers dance), or use a
runtime that grants cgroup delegation. For the symptom-first version of this
failure, see [Troubleshooting](troubleshooting.md#resourcelimit-under-docker-systemd-or-a-non-root-cgroup).

## Signals, suspend/resume, stats

| | `signal()` / `suspend()` / `resume()` | `stats()` |
|---|---|---|
| **Windows** | Only `kill` is deliverable — it terminates the job; **every other name, including `term`, raises `Unsupported`**. suspend/resume freeze/thaw the job | Memory + process count via the OS process APIs |
| **Linux** | Real signals to the cgroup/process group; freeze via cgroup or `SIGSTOP`/`SIGCONT` | cgroup + `/proc` |
| **macOS / BSD** | Real signals to the process group | Process count only; CPU / peak-memory are `None` (no whole-tree kernel accounting) |

Operations a platform can't perform raise `Unsupported` — catch it if you target
multiple platforms.

## Pseudo-terminals (`Command.pty()`)

| | Launch and resize | Terminal control input | Output |
|---|---|---|---|
| **Windows** | ConPTY; initial `cols`/`rows` and `RunningProcess.resize_pty()` | `ProcessStdin.send_control()` targets the pseudo-console | One merged terminal stream exposed as stdout |
| **Linux / macOS / BSD** | Native PTY; initial size and `TIOCSWINSZ` resize | Terminal line discipline turns controls such as Ctrl-C into signals | One merged terminal stream exposed as stdout |

PTY mode is opt-in and keeps the same containment backend as an ordinary
launch. It is incompatible with inherited, null, or file-redirected stdio;
conflicts fail while the command is built rather than becoming platform-specific
no-ops.

The command-line wrapper exposes the non-interactive form as `python -m
processkit run --pty [--pty-cols N --pty-rows N] -- PROGRAM ...`; its merged
terminal stream is relayed on stdout. Interactive input and live resizing remain
available through the Python `RunningProcess` API.

## I/O scheduling priority (`Command.io_priority()`)

| Platform | Behavior |
|---|---|
| **Linux** | Applies the requested `idle`, `best_effort` (0–7), or `real_time` (0–7) kernel I/O class at launch; privilege failures remain errors |
| **Windows / macOS / BSD** | Launch raises `Unsupported`; the request is never silently ignored |

This is independent of the cross-platform CPU `Command.priority()` setting.

## CPU affinity (`Command.cpu_affinity()`)

| Platform | Behavior |
|---|---|
| **Linux** | Applies a `sched_setaffinity` mask before `exec`; descendants inherit it. |
| **Windows** | Applies a process affinity mask while the child is suspended, before resume. |
| **macOS / BSD** | Launch raises `Unsupported`; the request is never silently ignored. |

Affinity selects allowed logical CPUs; `ProcessGroup(cpu_quota=...)` separately
limits aggregate CPU consumption.

## Multiprocessing: use `spawn` or `forkserver`, not `fork`

processkit runs a tokio runtime with background worker threads, started lazily
the first time you call any verb. A bare POSIX `fork()` copies that runtime into
the child **without** its worker threads — `fork()` carries only the calling
thread across — and any lock a worker held at fork time stays locked forever in
the child. Driving the copied runtime there (any further processkit call) would
deadlock or panic with no recovery. This is the standard "don't `fork()` a
multi-threaded process" hazard; processkit is not special here, but its runtime
makes the process multi-threaded as soon as you use it.

**What processkit does about it.** Rather than hang, a processkit verb called
from a process that `fork()`ed *after* the runtime was initialized fails fast
with a clear `ProcessError` (it detects the PID change and refuses before
touching the dead runtime). Nothing is spawned, so nothing is orphaned. It does
**not** transparently rebuild the runtime in the child: the managed runtime lives
in a process-global that cannot be soundly reset, so a clean refusal is the safe
contract.

**What you should do.** Choose a fork-free start method for `multiprocessing` /
`concurrent.futures.ProcessPoolExecutor` whenever the workers use processkit:

```python
import multiprocessing as mp

ctx = mp.get_context("spawn")  # or "forkserver"
with ctx.Pool() as pool:
    ...
```

`spawn` and `forkserver` start each worker from a fresh interpreter, so every
worker initializes its own runtime cleanly. On macOS and Windows `spawn` is
already the default; on Linux the default is still `fork` for `multiprocessing`
below Python 3.14, so set the context explicitly there. If you must call
`os.fork()` directly, do it **before** the first processkit call in the parent —
a child that forks before the runtime is initialized simply builds its own.

## Python build

- Distributed as **abi3 wheels for CPython 3.10+** (one wheel per OS/arch runs on
  every supported minor version, 3.14 included).
- **Free-threaded CPython (PEP 703) is supported.** The extension declares
  `gil_used = false`, so importing it on a free-threaded build does *not* re-enable
  the GIL. Because the limited API (abi3) isn't available on free-threaded builds,
  this ships as a **version-specific wheel** for CPython 3.14t (where free-threading
  is officially supported, per PEP 779) alongside the abi3 GIL wheel. The full test
  suite runs on the free-threaded interpreter in CI. The binding holds no
  unsynchronized shared state, so calling it from many threads is memory-safe —
  PyO3's per-object borrow checking still serializes *mutating* calls on a single
  shared handle (a concurrent mutate raises rather than racing), so give each
  thread its own `Command` / `RunningProcess` / runner as you would any object.

## Wheel availability

Published on [PyPI](https://pypi.org/project/processkit-py/) with prebuilt
wheels covering:

| Platform | Architectures |
|---|---|
| **Linux** (manylinux, glibc) | x86_64, aarch64 |
| **Linux** (musllinux, musl — Alpine) | x86_64, aarch64 |
| **macOS** | arm64 (Apple Silicon), x86_64 (Intel) |
| **Windows** | x64, arm64 |

Each row ships both the abi3 GIL wheel (CPython 3.10+) and the free-threaded
cp314t wheel, with an sdist alongside for source builds anywhere. The Windows
arm64 and Linux aarch64 wheels are built natively on GitHub's ARM runners (the
free-for-public-repos `windows-11-arm` and `ubuntu-24.04-arm`). The Intel macOS
wheel is cross-compiled from the arm64 (Apple Silicon) runner — GitHub retired
the free Intel `macos-13` runner, but Rust cross-compiles darwin-x86_64
trivially. Not prebuilt: 32-bit targets (incl. 32-bit musl, which has no Rust
toolchain) — there, `pip install processkit-py` builds from the sdist, which
needs a [Rust toolchain](https://rustup.rs/).

---

# Troubleshooting & FAQ

[‹ docs index](./)

Use this page to map a symptom to the guide that explains the full platform or
runtime contract. The entries are deliberately short; follow the links for the
complete behavior and examples.

## `ResourceLimit` under Docker, systemd, or a non-root cgroup

On Linux, whole-tree limits require a real cgroup-v2 root; the kernel's "no
internal processes" rule rejects the setup from a container, systemd
session/scope/service, or another non-root cgroup. Check whether the process is
at the cgroup root or has delegated controllers, then see [Resource limits: the
sandbox](process-groups.md#resource-limits-the-sandbox) and [Platform support](platforms.md#resource-limits-processgroupmax_memory-max_processes-cpu_quota).

## `Unsupported` from `signal("term")` on Windows

Windows Job Objects can terminate the whole job, but they do not deliver POSIX
signals, so only `signal("kill")` is supported and `"term"` raises
`Unsupported`. Use a platform-appropriate shutdown path; see [Signalling the
whole tree](process-groups.md#signalling-the-whole-tree) and [Platform
support](platforms.md#signals-suspendresume-stats).

## A child escaped a POSIX process group with `setsid()`

`ProcessGroup.mechanism` honestly reports `"process_group"` when Linux cannot
use cgroup-v2 delegation and falls back to a POSIX process group. A child that
calls `setsid()` or `setpgid()` leaves that group, so `killpg` cannot reach it;
see [Tearing down](process-groups.md#tearing-down) for the escape boundary and
the stronger backends.

## A process started by another library survives group teardown

Use `group.adopt_external(process.pid)` when the process is already running and
you have its pid. Adoption places the process under the group's signal and
teardown boundary, but it does not give processkit a child handle: the original
parent must still call `wait()` for completion and exit status. If the platform
returns `Unsupported` (FreeBSD and other BSDs), move creation to a `Command` /
`ProcessGroup`, or put the whole supervisor under a host-managed containment
boundary. See
[Existing processes and
containment](process-groups.md#existing-processes-and-containment).

## `a`-prefixed verbs report no running asyncio event loop

The async surface is asyncio-native, so `aoutput()`, `arun()`, and related
verbs need an active asyncio loop; native trio, curio, and anyio on trio do not
provide one. Use asyncio/uvloop or the synchronous surface as appropriate; see
[Async runtimes & event loops](event-loops.md#support-at-a-glance).

## `record_replay_runner` cassette not found

The pytest fixture replays by default, so a missing cassette means it has not
been recorded at the expected fixture path. Run pytest once with
`--processkit-record` and configure `processkit_cassette_dir` if the cassette
must persist; see [Record/replay cassettes:
RecordReplayRunner](testing.md#recordreplay-cassettes-recordreplayrunner).

## Privilege drop sets `uid` but not `gid` and `groups`

Dropping only `uid` leaves the inherited supplementary groups in place, which
can retain privileges you meant to remove. Set `groups`, then `gid`, then `uid`;
see [Sandboxing untrusted tools](sandboxing.md) and [Privileges and spawn
flags](commands.md#privileges-and-spawn-flags).

## The parent is hard-killed with `SIGKILL` or `os._exit`

Normal context-manager teardown cannot run after a hard kill; only Windows Job
Objects retain a kernel-enforced whole-tree cleanup when their last handle
closes. Linux and macOS/BSD are best-effort in that case, so design the child
lifetime accordingly; see [Tearing down](process-groups.md#tearing-down) and
[Platform support](platforms.md#teardown-the-no-orphan-guarantee).

---

<!-- Generated by scripts/gen_changelog_page.py from ../CHANGELOG.md. Do not edit. -->
# Release notes

[‹ docs index](./)

All notable changes to **processkit** are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
-

### Changed
-

### Fixed
-

## [1.5.0] - 2026-08-08

### Added
- Add cumulative whole-tree I/O counters (`ProcessGroupStats.io_read_bytes`
  and `io_write_bytes`) and the optional kernel high-water mark
  `ProcessGroupStats.peak_process_count`. Availability and units remain
  mechanism-dependent: Windows Job Objects report transfer bytes, Linux cgroup
  v2 may report block-layer bytes and task counts, and unsupported measurements
  are exposed as `None` rather than a synthetic zero.
- Add `ProcessGroup.adopt_external(pid)` to bring an already-running external
  process under group signalling and teardown when only its pid is available.
  Adoption captures process identity during the call, never reaps the process
  or exposes its exit status, and documents the Windows, Linux cgroup,
  POSIX-fallback, and BSD support boundaries.
- Add `Command.arg0()`/`configured_arg0`, `Command.merge_stderr_in_pipe()`,
  `Command.stdout_raw_tee()`/`stderr_raw_tee()`, and
  `RunningProcess.stdout_bytes_seen`/`stderr_bytes_seen` — the last remaining
  small binding gaps against the `processkit` core: a Unix-only `argv[0]`
  override (raising `Unsupported` off-Unix), a per-stage `2>&1 |`-equivalent
  pipeline marker, an undecoded byte-exact stdout/stderr tee alongside the
  existing decoded `stdout_tee()`/`stderr_tee()`, and raw pipe byte counters
  alongside the existing `stdout_line_count`/`stderr_line_count`.
- Add partial-tail readiness probes on `RunningProcess` —
  `wait_for_output()`/`await_for_output()` for stdout and
  `wait_for_stderr_output()`/`await_for_stderr_output()` for stderr — which
  match an *un-terminated* prompt (`Password: `, `(y/N) `, a REPL `>>> `) that
  the line-oriented probes can never see, so a PTY or CLI dialog can wait for a
  prompt and answer it over `take_stdin()`. Non-consuming and repeatable, they
  raise `WaitTimeout` on their own deadline without ever killing the child.
- Add `Command.sanitize_vt()`, `stdout_sanitize_vt()`, and
  `stderr_sanitize_vt()` for clean captured and streaming terminal text, plus
  `processkit run --sanitize-vt` for ANSI-free CLI relay output.
- Add a `processkit` console-script entry point
  (`processkit run -- pytest -x`, `processkit doctor`), alongside the
  still-supported `python -m processkit` form, sharing the identical
  exit-code contract.
- Add `ProcessGroup.update_limits(*, max_memory=None, max_processes=None,
  cpu_quota=None)` for full-replacement, synchronous, dynamic adjustment of a
  live group's resource limits without recreating the group.
- Add `Command.rlimit(resource, soft, hard)` for POSIX per-process
  `setrlimit(2)` limits (`RlimitResourceName`: `"cpu"`, `"core"`, `"data"`,
  `"file_size"`, `"no_file"`, `"stack"`), raising `Unsupported` off-POSIX.
- Add `RunningProcess.stdout_json_lines()` for built-in NDJSON line
  streaming — an async iterator that decodes each stdout line as a standalone
  JSON value, the streaming counterpart to `Command.run_json()`/`arun_json()`.
- Add nightly benchmark coverage for PTY relay, lifecycle events,
  completion-order batches, and live Supervisor restart sessions, with
  platform-specific PTY handling documented.
- Add `wait_for_named_pipe()` readiness probing for Windows services, including
  busy-server detection and symmetric `Unsupported` behavior elsewhere.
- Add Linux/Windows child CPU affinity through `Command.cpu_affinity(...)` and
  the `run`/`supervise` CLI `--cpu-affinity` flag.
- Add reuse-safe `process_info()` / `process_is_alive()` helpers for bare pids
  returned by detached launches, supervision, and process groups.
- Add spawn-free `host_containment()` capability reports, the current
  `ProcessGroup.soft_stop_scope` graceful-stop reach, and the same host details
  to human-readable and JSON `doctor` reports.
- Add deterministic cassette secret scrubbing through
  `RecordReplayRunner.record(..., scrub=)` / `replay(..., scrub=)` and the
  overridable `processkit_cassette_scrubber` pytest fixture, backed by
  ProcessKit-rs 3.1.0's symmetric scrub hook.
- Add `--pty` with optional `--pty-cols`/`--pty-rows` to
  `python -m processkit run`, exposing a merged pseudo-terminal output stream
  for tools that require a TTY.
- Add runnable examples for managed PTY sessions, lifecycle events,
  completion-order batches, intentionally detached helpers, shell-free
  pipelines, and hermetic runner/cassette testing seams.
- Expand `python -m processkit run` with fail-loud captured-output limits,
  direct stdout/stderr file redirects, abrupt-parent-death cleanup, and CPU/I/O
  priority controls.
- Expand `python -m processkit supervise` with per-incarnation timeouts and
  resource caps, headless Windows launches, and proactive TCP or HTTP health
  checks. `Supervisor` now accepts the matching `max_memory=`,
  `max_processes=`, and `cpu_quota=` constructor options.
- Add `RunningProcess.stderr_lines()` for stderr-only line streaming and direct
  use with readiness helpers such as `wait_for_line`.
- Add `Command.run_json()` / `arun_json()` with the same typed JSON decoding and
  `InvalidJson` diagnostics as `CliClient`.
- Add a **CLI Runner** link immediately after the Rust version in the Pages
  navigation.
- Add opt-in pseudo-terminal launches with `Command.pty(...)` and live terminal
  resizing through `RunningProcess.resize_pty(...)`. PTY output is a single
  merged stdout stream, and interactive stdin uses the existing writer API.
- Add live `Supervisor.start()` / `astart()` sessions with status snapshots,
  graceful stop, completion waits, and sync/async context management.
- Add `ProcessGroup.stop()` / `astop()` with a `ShutdownReport` describing the
  graceful signal, remaining members, elapsed time, and hard-kill escalation.
- Add `RunningProcess.lifecycle_events()` for one ordered stream containing the
  start pid, stdout/stderr lines, and final `Outcome`, while preserving the
  output-only `output_events()` contract.
- Add Linux disk-I/O scheduling controls through `Command.io_priority(...)`;
  launching a configured command on another platform raises `Unsupported`.
- Add the deliberately uncontained `Command.spawn_detached()` escape hatch and
  pid-only `DetachedChild` for helpers that must outlive their launcher.
- Add repeatable `--env-file PATH` support to the `run` and `supervise` CLI
  commands for docker-style `KEY=VALUE` files with deterministic overrides.
- Add a generated Release notes page to the documentation site, kept in sync
  with this changelog by local and CI drift checks.

### Changed
- Widen `InvalidJson.stdout` from `str` to `str | None`: it is `None` only when
  the exception comes from the new `RunningProcess.stdout_json_lines()`
  streaming iterator, which cannot buffer the full payload, while
  `run_json()`/`arun_json()` still always populate it. Typed consumers should
  narrow the type before use; the full diagnostic remains available through
  `str(exc)` regardless of the source.
- Bump the bundled ProcessKit-rs core to 3.3.0, preserving the existing Python
  API and feature set while bringing upstream fixes for merged-stderr pipe
  teardown, failed PTY-launch cleanup, pipeline pipefail attribution,
  process-identity-safe metrics, and cassette version validation. The upstream
  `ProcessGroupStats` statistics additions are exposed by the binding as
  documented under Added.
- Bump the bundled ProcessKit-rs core to 3.2.0, preserving the existing Python
  API and cancellation defaults while bringing upstream compatibility fixes for
  ConPTY, PTY EOF, readiness, pipelines, environment resolution, and supervision.
- Document and pin idle monitoring per iterator: `stdout_lines()` watches
  stdout activity, while merged-event and stderr-only streams count both pipes.
- Exercise Windows ARM64 in regular and nightly test matrices, and add a
  sharded nightly cargo-mutants signal for the Rust binding layer.

### Fixed
- Reject CR/LF, control, and whitespace characters in a `wait_for_http` host
  before HTTP serialization, preventing header injection.
- Reject CLI resource and restart limit values above their binding widths with
  an argparse usage error before constructing a `ProcessGroup` or `Supervisor`.
- CLI duration, CPU, backoff, and health-check numeric options now reject
  non-finite values before constructing a command or supervisor.
- Keep Nightly hardening actionable: its mutation sandbox now includes the
  changelog required by release-note drift tests, first-run benchmark history
  can initialize its branch without runner-global git identity, the detached
  helper example waits for its process to release Windows resources, and the
  PTY alias/status tests no longer race short-lived incarnations. Resource-capped
  supervision documentation now also reflects the observable contract:
  `status.pid` is unavailable, while `status.started_at` identifies the current
  capture-only incarnation.
- Python writer objects used by decoded and raw output tees now retry partial
  integer `write()` counts to completion without truncating mirrored output;
  `None` and other non-integer return values remain supported and mean the full
  buffer was accepted, while invalid integer counts are reported via
  `sys.unraisablehook`.
- Close the completion hub's socket at the OS level when Python-level cleanup
  raises, preventing pending anyio-on-asyncio reader tasks and socket-resource
  warnings after an awaited operation is cancelled during loop shutdown.
- Deliver the output `python -m processkit` relays itself line by line when its
  own stdout or stderr is a pipe rather than a terminal: the `run` modes that
  re-emit the child's output (`--idle-timeout`, `--output-limit`, `--pty`) and
  the live tee of `supervise`. A piped reader — `| grep`, a log collector, a CI
  step — previously got those lines in ~8 KiB blocks or in one dump when the run
  ended, unlike the inherited-stream default; both paths now match the live
  output `docs/cli.md` describes.
- Block every direct `Command` spawn path under pytest's `no_real_spawn`
  marker, including JSON, async JSON, and deliberately detached launches.
- Preserve completion-hub rearm errors while still attempting every pending
  awaiter cancellation when secondary cleanup fails.
- Let `python -m processkit supervise` run when the parent interpreter has no
  stdout or stderr stream by omitting the unavailable live-output tee.
- Reject an empty key passed through the CLI's `--env` flag with the same usage
  error used for `--env-file` entries.
- Treat every accepted spelling of piped stdout/stderr consistently when
  combining `Command.stdout()` or `stderr()` with PTY mode.
- Prevent a concurrent lifecycle-event finisher from making a still-reportable
  `RunningProcess` appear consumed or letting context-manager teardown become a
  silent no-op.
- Stop `Supervisor` immediately on the first failing `ScriptedRunner.when`
  predicate, including under an unbounded restart policy.
- Accept bracketed IPv6 literals in `wait_for_http` and keep scoped IPv6 hosts
  from being percent-encoded twice between socket and `Host` header forms.
- Prevent the command-line wrapper's own intermediate output (`doctor`, idle
  streaming, diagnostics, and `--profile`) from producing a traceback when a
  pipe closes or another output write fails. A vanished receiver stays silent;
  other write failures use exit code 119.
- Keep async batch result conversion and `CliClient.arun_json()` parsing on the
  Python event-loop thread, and make worker-side error conversion safe during
  interpreter finalization.
- Let `Outcome` and `Finished` pickle payloads be restored without entering the
  Tokio runtime, including from supervisor callbacks and post-fork children.

## [1.4.2] - 2026-07-26

### Added
- New `python -m processkit` exit code **119**, shared by `run`, `supervise`,
  and `doctor`: the command finished, but the wrapper could not deliver its own
  buffered output (a final flush that failed with e.g. `ENOSPC`/`EIO`, or on a
  stream closed underneath the process). It is reported *instead of* the code
  the run would otherwise have returned — including the child's own — because
  that code would claim a complete, faithfully relayed run. A receiver that
  simply went away (`BrokenPipeError`, e.g. `... | head`) is deliberately not
  this case and stays silent, as before. See "Exit codes" and "How the wrapper
  terminates" in `docs/cli.md`.

### Changed
- Migrate the Rust core to **processkit 3.0.0** (a breaking major release;
  `Cargo.toml` now requires `3`, resolved to 3.0.1). The **Python API is
  unchanged**: `OutputEvent`, `OutputEvents` and `RunningProcess.output_events()`
  keep their names, signatures and meaning — the core's rename of those types
  (`OutputEvent` → `ProcessEvent`, `OutputEvents` → `ProcessEvents`,
  `output_events()` → `events()`) stays an internal detail of the binding, and
  its `Error` → `Error`/`ErrorReason` split changes nothing about the exception
  classes or their structured fields. The enabled feature set is unchanged; 3.0's
  optional new surface (the PTY launch mode, Linux I/O priority, PTY window-size
  control, the capture-redaction hook, the flat error classifier) is **not**
  adopted here.

  Two user-visible consequences, both confined to `output_events()`:

  - The merged event stream became the child's whole **lifecycle** in the core,
    so it now also reports process start and exit. Those non-line events are
    **filtered out** rather than yielded as an `OutputEvent` with an empty
    `text` — which would be indistinguishable from a real blank line the child
    printed. `async for ev in proc.output_events()` therefore yields exactly what
    it always did: output lines. What the lifecycle events carry is already
    available: process start is `RunningProcess.pid`, and the exit is what the
    finisher you call afterwards returns.
  - The core now delivers that stream's terminal event only when the run is
    reaped, which means a consumer that drains the stream and *then* finishes
    would deadlock. The documented Python order — iterate fully, then
    `await proc.afinish()` (or `aoutcome()`) — is unaffected: the binding drives
    the run's completion itself once the child is observed to exit, so the
    iterator ends on its own and the finisher afterwards reports that same run.
    One deliberate narrowing comes with it — see the **BREAKING** entry below.
- **BREAKING** — `output()` / `output_bytes()` / `profile()` (and their
  `a`-twins) now raise a `ProcessError` naming `output_events()` once that stream
  has taken the run over, instead of returning the empty captures they used to:
  it consumed stdout, delivered stderr as events, and (since the 3.0 migration
  above) completed the run, so there is nothing left for them to capture or to
  sample. Use `finish()`/`afinish()` (outcome + stderr) or
  `outcome()`/`aoutcome()` instead — those report such a run either way. Code
  that called `output()` after `output_events()` and used the result got an empty
  `stdout`/`stderr` with a real outcome; it now has to read that outcome from a
  finisher.

  The stream takes the run over as soon as it observes the child exit. Iterating
  to the end always reaches that point, but an early `break` can too — out of a
  command that finished while you were reading it. Break out while the child is
  **still running** and nothing has been taken over: the old behaviour stands
  there (empty captures with a real outcome; `profile()` samples the rest of the
  run). Which side of that line a given `break` falls on is a matter of the
  child's timing, not of how the loop is written, so after streaming events reach
  for a finisher rather than a capture verb. See "Interleaved stdout and stderr"
  in `docs/streaming.md`.
- `output_limit(max_bytes=...)` under **`on_overflow="error"`** — and with it the
  `total_bytes` an `OutputTooLarge` reports — now counts the **raw bytes read
  from the child's output pipe** rather than the bytes of the decoded text,
  following the same change in the Rust core. Line terminators (`\n`, or both
  bytes of a CRLF) and bytes that are not valid UTF-8 are charged against the
  ceiling too, so a cap sized against decoded text raises marginally sooner: by
  one byte per line for ordinary UTF-8 output, and by more for CRLF or binary-ish
  output. The **drop modes are unaffected** — `drop_oldest` (the default) and
  `drop_newest` still bound the *retained* output by decoded line content, as
  does `Supervisor`'s `capture_max_bytes=`, whose `capture_on_overflow` defaults
  to `drop_oldest`. Raw stdout captured by `output_bytes()` is never decoded, so
  its cap is unchanged in every mode. No API change; re-check any
  `on_overflow="error"` threshold you sized against decoded text. See "What
  `max_bytes` actually counts" in `docs/commands.md`.
- `Ctrl+C` that interrupts `python -m processkit` outside `run`/`supervise`'s
  own guarded blocks — during startup, argument parsing, or `doctor` — now
  reports the documented `128 + SIGINT` (`130`) with the same one-line
  `processkit: interrupted` message those paths print, instead of ending
  through the interpreter's own unhandled-`KeyboardInterrupt` path. This makes
  the Ctrl+C contract uniform across the entry point and platforms; for
  `doctor` it also keeps an interrupted run
  distinguishable from its valid `1` verdict ("containment enforced, limits
  not").

### Fixed
- Fix an intermittent SIGSEGV at interpreter exit after a program's final
  processkit `await`. The async bridge no longer completes Python Futures from
  a detached tokio thread through `call_soon_threadsafe`; tokio stores the
  outcome and wakes one shared `loop.sock_recv` dispatcher per event loop, then
  the event-loop thread converts and resolves it. Repeated stream steps reuse
  that socket, and the dispatcher closes it after the loop becomes idle rather
  than leaving a pending receive behind at loop teardown. A short-lived script
  can now end immediately after any `a`-prefixed verb without racing
  `Py_FinalizeEx`. This also restores ordinary interpreter finalization for
  `python -m processkit` — including `atexit` hooks and finalizers — instead of
  the temporary `os._exit` workaround.

## [1.4.1] - 2026-07-24

### Added
- Add `CliClient.run_json(call)` / `arun_json(call)`: run a wrapped tool like
  `run` (requiring a zero exit) and return its stdout **parsed as JSON** — the
  `run(...)` + `json.loads(...)` + error-mapping boilerplate the many CLIs that
  emit machine JSON (`gh`, `kubectl`, `docker`, `az`, `jj`) otherwise force on
  every caller. Stdout that does not parse raises a new `InvalidJson` exception
  (a `ProcessError` carrying the client's `program` and a bounded stdout
  fragment, with the parser message in `str(exc)`) instead of a bare,
  unattributed `json.JSONDecodeError`; a non-zero exit still raises `NonZeroExit`
  as `run` does. Both verbs go through the same `default_env_fn`/`when`-capture
  pipeline and injectable `runner=` seam as the other `CliClient` verbs, so they
  are hermetically testable with a `ScriptedRunner` and no real process.
- Add `Command.idle_timeout(seconds)`, an inactivity timeout that tears the
  child down if it produces no stdout/stderr line for that long — for the
  "hung tool" case a wall-clock `timeout()` handles poorly, where a legitimately
  long job keeps printing progress. It fires as a new, distinct `IdleTimeout`
  exception (a `ProcessError` sibling of `Timeout`, carrying
  `idle_timeout_seconds`), deliberately **not** the wall-clock
  `timed_out`/`Timeout` signal, so the two timeout classes stay tellable apart
  and the existing captured `timed_out` contract is untouched. Enforced on the
  streaming/interactive surface (`start()`/`astart()` +
  `stdout_lines()`/`output_events()`), where the binding drives the per-line
  output channel; a redirected/inherited stdout is diagnosed by the existing
  "stdout is not piped" error rather than silently un-watched. **Scope note:**
  the one-shot capture verbs (`output`/`run`/`exit_code`/`probe` and their
  `a`-twins), `Pipeline`, and `Supervisor` do not enforce it — processkit
  2.3.x has no native idle-timeout to observe per-line activity mid-run through
  those paths, so honoring it there awaits upstream crate support; the setting
  is carried on the command regardless.
- Add `python -m processkit run --idle-timeout SECONDS`: kills the child and
  exits **123** (distinct from `--timeout`'s 124) if it produces no output line
  for that long. Because idle monitoring rides the per-line channel, this flag
  pipes and re-emits the child's stdout/stderr (decoded, one line at a time)
  instead of inheriting them raw, and is incompatible with `--profile`. The
  flag is also present on `supervise` for parity but is a usage error there
  until upstream `Supervisor` idle-timeout support lands (its incarnations run
  through one-shot verbs the idle watchdog cannot observe).

### Changed
-

### Fixed
- `wait_for_http` now forms a correct HTTP/1.1 request line for edge-case
  `host`/`path` values: an IPv6 literal `host` (e.g. `"::1"`) is bracketed in
  the `Host` header per RFC 9112/3986 (`Host: [::1]:8080`, not the previously
  ambiguous `Host: ::1:8080`), and a `path` containing whitespace, a control
  character (including CR/LF — previously a header-injection-shaped hazard),
  or a character outside latin-1 now raises `ValueError` up front, before any
  connection is attempted, instead of silently corrupting the request line or
  raising a raw `UnicodeEncodeError`.

## [1.4.0] - 2026-07-23

### Added
- Add an actionlint CI gate for semantic GitHub Actions and shell-script checks
- Add the `supervise` CLI subcommand with restart-policy and backoff flags.
- Add `wait_for_http(host, port, path="/", *, timeout, interval=0.05,
  expected_status=None)`, a readiness helper that polls an HTTP endpoint (a
  hand-rolled GET over asyncio streams, no new dependency) and succeeds only on
  an accepted status code (any 2xx by default; a set/range or a predicate
  overrides) — a stronger signal than `wait_for_port` for a server that accepts
  connections while still warming up
- Add `aoutput_as_completed`/`aoutput_as_completed_bytes`, the streaming
  counterpart to the `aoutput_all` family: an async iterator that yields each
  `(index, result)` pair as its command finishes rather than waiting for the
  whole batch, with the same hard concurrency cap and no-orphan teardown on
  cancellation or early exit
- Add `python -m processkit run --profile [FILE]`, emitting a one-line JSON
  resource profile (duration, CPU time, peak memory, average CPU cores,
  sample count, exit code/signal, timed-out flag) after the child exits — to
  stderr if `FILE` is omitted, or written to `FILE` otherwise
- Add `python -m processkit run --create-no-window`, applying
  `Command.create_no_window()` to the child so the wrapper does not create a
  console window on Windows — a no-op outside Windows (same as the
  underlying binding method)
- Add `Command.stdout_file()` / `stderr_file()`, spawn-time direct-redirect
  sinks that send a stream straight to a file with no parent-side pump or
  capture in between (`append=False`, the default, truncates the file on each
  spawn; `append=True` appends — e.g. a shared log across `Supervisor`
  incarnations or `retry()` attempts). A file-redirected stdout makes
  `output()` / `run()` / `output_bytes()` (and their async twins) raise the
  usual "not piped" `ProcessError`, but `exit_code()` / `probe()` still work
  since they never touch the stdout pipe; a file-redirected stderr leaves
  `output()` working, with `result.stderr` coming back empty
- Add `ProcessGroup.members_info()` / `MemberInfo`, an enriched process-tree
  snapshot alongside `members()`: each pid comes with best-effort
  `ppid`/`exe_name`/`start_time` metadata (`None` wherever the platform can't
  report it). `exe_name` is a short image name, not a path, and `start_time`
  is an opaque, platform-specific identity token, not wall-clock — its sole
  use is pairing with `pid` across two snapshots to tell a recycled pid apart
  from the original
- Add `Command.windows_graceful_ctrl_break()`, an opt-in Windows-only graceful
  shutdown: at a graceful timeout (`timeout_grace`) or a group shutdown it
  sends the direct console child a `CTRL_BREAK` before the hard
  `TerminateJobObject` fallback, giving a child that handles it a chance to
  exit cleanly first. Console-only (inert under `create_no_window` /
  detached) and a harmless no-op outside Windows
- Add opt-in `Supervisor` liveness health checks via three new keyword-only
  constructor parameters: `health_check` (a synchronous `() -> bool`
  callable), `health_check_interval` (required alongside it), and
  `health_check_failures`. After `health_check_failures` consecutive probe
  failures the supervisor force-restarts the run; each force-restart
  increments the new `SupervisionOutcome.liveness_kills` counter, and the
  final such stop under `restart="never"` reports `SupervisionOutcome.stopped
  == "unhealthy"`
- Add `Command.kill_on_parent_death_scope()`, a read-only capability query
  reporting the scope of parent-death cleanup the current platform actually
  achieves when the owner dies abruptly, as a string: `"whole_tree"` on Windows
  (the Job Object reaps the whole tree on owner death), `"direct_child_only"`
  on Linux (`PR_SET_PDEATHSIG` reaches only the direct child; grandchildren
  survive), or `"unsupported"` on macOS/BSD (no `pdeathsig` equivalent). A
  static query fixed at build time — read it off the class or any instance,
  with no prior `kill_on_parent_death()` call — so a caller can state the real
  reach of the best-effort hardening instead of overpromising a whole-tree
  guarantee the OS cannot keep

### Changed
- Refresh the GitHub Pages landing page from the README: its cover, status
  badges, no-orphan introduction, runnable example, and capability summary now
  appear before the guide index.
- Bump the processkit dependency to 2.3.1 (lockfile pinned via `cargo update -p
  processkit --precise 2.3.1`; the Cargo.toml requirement stays at the broad
  `2.3` range). 2.3.1 also added new upstream public surface (Command
  stdout/stderr file-redirect sinks, `windows_graceful_ctrl_break`,
  `ProcessGroup::members_info`/`MemberInfo`, `Supervisor` liveness health
  checks) that this binding has since adopted — see the `Added` entries above.
- Bump the processkit dependency to 2.3.2 (lockfile pinned via `cargo update -p
  processkit --precise 2.3.2`; the Cargo.toml requirement stays at the broad
  `2.3` range). 2.3.2 adds new upstream public surface
  (`Command::kill_on_parent_death_scope` and the `ParentDeathCleanup` enum it
  returns) that this binding adopts — see the `Added` entry above.

### Fixed
- Correct the pipeline documentation to describe per-stage kill-on-drop
  sub-groups, chain-wide teardown fan-out, and composite timeout attribution,
  matching the processkit 2.3.x core.
- Fix rendered mdBook links that pointed at the nonexistent `README.html`,
  correct the uvloop section anchor, and align contributor/release instructions
  with the current mdBook-to-GitHub-Pages workflow.
- Make the documented `just` recipes run on Windows by selecting PowerShell 7
  instead of relying on an unavailable `sh` executable.

## [1.3.0] - 2026-07-19

### Added
- Add `sample_stats(group, every)`, a pure-Python async generator for live
  `ProcessGroup` monitoring: a fused, periodic series of `ProcessGroupStats`
  snapshots built on top of `ProcessGroup.stats()`

## [1.2.4] - 2026-07-12

### Added
- Add Open Graph and Twitter Card metadata to the docs site
- Add real Rust crate and .NET documentation links
- Add table border, header fill, and row striping to match the reference site

### Changed
- Publish the documentation site to GitHub Pages on push to main
- Pin the GitHub Pages deploy actions to a commit SHA
- Reserve navigation placeholders for the Rust crate and .NET variant
- Restyle the docs site navigation and tables to match the ProcessKit look
- Move the Rust crate and .NET nav entries right after Home, then link them
  directly to their docs sites
- Match the reference site's pinned nav-group title styling, typography, CSS,
  and table borders/header fill/row striping/dark-theme colors (navy, not
  coal) more closely
- Move the implementation switcher above Home, then the Rust/Python/.NET
  version switcher above Overview, in the docs nav
- Rebuild the docs site with mdBook to match the ProcessKit family
- Render API-reference signatures as text and fix the generator's griffe types
- Give a clear diagnostic for `parse_signal` with out-of-range ints and
  floats, and align the property test with the corrected diagnostic
- Convert `Supervisor` to the frozen + `Mutex<Option<...>>` pattern
- Unify the named-preset parsers (and their property tests) on
  case-insensitive matching
- Bump the processkit dependency to 2.2.4

### Fixed
- Fix wide-table scrolling specificity and code word-breaking
- Fix doc comment list-bullet misparse and reformat long test line

### Removed
- Drop the external crates.io link from the Rust crate placeholder
- Remove stray trailing blank line from mkdocs.yml

## [1.2.3] - 2026-07-11

### Changed
- Bump the processkit dependency to 2.2.3

### Fixed
- Fix broken repo-relative README links for PyPI rendering

## [1.2.2] - 2026-07-10

### Changed
- `ProcessResult` and `SupervisionOutcome` are no longer picklable — pickling
  either now raises `TypeError` (they were advertised as picklable in 1.2.0).
  Their equality is the underlying `processkit` crate's own comparison, which
  also spans a command's configured `timeout` and accepted `success_codes` —
  two fields the crate exposes through no accessor. A pickle could not read them
  back to reconstruct them, so a result from a command that set `.timeout(...)`
  or `.success_codes(...)` unpickled **unequal** to its original (identical
  visible fields and `hash()`, but `!=`), silently breaking the round-trip
  invariant a picklable value type promises. Rather than hand back a
  subtly-wrong value, both refuse loudly, matching `BytesResult`/`RunProfile`.
  `Outcome` and `Finished` remain picklable and round-trip **exactly** (an
  `Outcome` is fully determined by its Python-visible `code`/`signal`/
  `timed_out`; a `Finished` adds only its `stderr`). To move a captured result
  across a process boundary — e.g. back from a
  `concurrent.futures.ProcessPoolExecutor` worker — pickle `result.outcome`
  (an `Outcome`), or persist `result.stdout`/`.stderr`/`.code` yourself.

### Fixed
- `CancellationToken`'s docstring (Rust doc comment and the `.pyi` stub) no
  longer claims that a `child_token()` shares the same cancellation state as
  its parent and siblings. The actual, already-tested behavior is
  parent-to-child only: a parent cancels its children, but cancelling a
  child never affects the parent or its other children.
- `processkit.__version__` now matches its own docstring: the first access
  computes it via `importlib.metadata.version()` and caches the result
  (including the source-tree `"unknown"` outcome) for every later access,
  instead of re-scanning package metadata on every read. The first access is
  single-flight even under concurrent readers on a free-threaded build.
- `CliClient` `default_env_fn` resolvers are now fail-closed: a resolver that
  raises or returns a non-`str` aborts the triggering `command()`/verb with that
  exception, *before* the runner is reached, so no process is spawned. Previously
  the failure was only reported via the unraisable hook and the resolved value
  fell back to an empty string — running the command with a silently-missing
  credential. Applies uniformly to `command()`, the sync verbs, and the async
  verbs; a resolver whose key is already set by an explicit per-command `env()`
  or a static `default_env` still never runs (and so cannot abort the call).

## [1.2.1] - 2026-07-09

### Added
- Add `Command.prefer_local`, exposing crate 2.2's bare-name resolution override
- Add a runnable `Command.prefer_local` usage example to `docs/commands.md`
- Add `ProcessStdin.send_control` for interactive control-byte delivery

### Changed
- Broaden the `Command.priority` docstring privilege caveat to cover
  `above_normal` and a niced-parent `normal`
- Bump the processkit dependency requirement and lockfile to 2.2.0
- Apply rustfmt to the `send_control` signature
- Bump the processkit dependency to 2.2.1

### Fixed
- Fix Windows-incompatible relative path-form assertion in the prefer_local example

## [1.2.0] - 2026-07-08

### Added
- `Command.stdout_tee` / `Command.stderr_tee` now accept a **Python writer**
  object (anything with a callable `write()` — `io.StringIO`, `sys.stderr`, a
  text-mode file, a logger wrapper) in addition to a file path, mirroring the
  child's output straight into your own console/buffer/logger while still
  capturing it. Each decoded line (plus a `"\n"`) is passed to `write()` as a
  `str` via an async-write bridge: every write is dispatched to the runtime's
  blocking pool (re-acquiring the GIL there) and awaited on the capture pump, so
  a slow — even sleeping — `write()` applies backpressure without blocking the
  event loop or deadlocking the runtime. The object is discriminated from a path
  by exposing `write` (neither `str` nor `pathlib.Path` does) and is never closed
  for you; `append=True` is meaningful only for a file path and raises
  `ValueError` if combined with a writer. A `write()` exception disables the tee
  for the rest of the run (a `tracing` warning under `enable_logging()`, the same
  isolation as the file tee) and is additionally reported via `sys.unraisablehook`
  — the run and its captured result are unaffected. The previous "a file path
  only, an arbitrary Python writer is deliberately not supported" restriction is
  lifted. See `docs/streaming.md#tee-output-to-a-file`.
- `Command.on_stdout_line(callback)` / `Command.on_stderr_line(callback)`: a
  `Callable[[str], None]` invoked with every decoded line as it is produced —
  the way to give the **synchronous** surface (`.output()`/`.run()`) live
  progress observation during an otherwise-blocking call, without losing the
  full capture. Also fires on the async verbs and on a streamed run
  (`start()`/`astart()` + `stdout_lines()`/`output_events()`); at most one
  handler per stream (a repeat call replaces the previous one); a raising
  callback is reported via `sys.unraisablehook` rather than propagated or
  breaking the run. Inert under `stdout("inherit")`/`stdout("null")` (resp.
  `stderr(...)`) and, for `on_stdout_line` only, under `output_bytes()` (which
  captures stdout raw, bypassing the line pump — stderr still goes through it,
  so `on_stderr_line` still fires there). See
  `docs/streaming.md#live-per-line-callbacks`.
- A `benchmarks/` suite (`pytest-benchmark`, new `bench` dependency-group)
  measuring spawn+capture overhead against `subprocess`/`asyncio.subprocess`,
  `ProcessGroup` start/exit, line-streaming throughput, and `output_all`
  concurrency scaling — dev tooling only, no public API change. Runs nightly
  via the `bench` job in `nightly-hardening.yml`, never in the PR gate; see
  `benchmarks/README.md`.
- `wait_for_path(path, *, timeout, interval=0.05)` — a new async readiness
  helper alongside `wait_until` / `wait_for_port` / `wait_for_line`, polling
  until a filesystem path appears (a unix socket, a pid file, or any other
  marker a daemon creates once ready). Same timeout/interval discipline as its
  siblings (NaN/negative `timeout` and non-positive `interval` raise
  `ValueError`; `timeout=0` still checks the path at least once) and raises
  `WaitTimeout` (also a `TimeoutError`) on expiry, now carrying a `path` field
  (`WaitTimeout.__init__` gained a `path: StrPath | None = None` parameter).
- `python -m processkit run -- <cmd> [args...]`: a CLI wrapper that runs a
  command inside a kill-on-exit `ProcessGroup` with inherited stdio, for
  shell scripts and CI steps with no Python to write. Supports `--timeout`,
  `--timeout-grace`, `--max-memory`, `--max-processes`, and `--cpu-quota`;
  the child's own exit code is passed through unchanged,
  and a timeout / missing program / rejected resource limit is reported as a
  one-line stderr message with a documented, GNU-`timeout`-style exit code
  instead of a traceback. See `docs/cli.md`.
- `Finished` gains `timed_out` and `signal` properties that delegate to the
  nested `outcome`, so it now mirrors `Outcome` fully — matching `code` and
  `exited_zero`, which were already exposed directly — instead of requiring
  `finished.outcome.timed_out` / `finished.outcome.signal`.
- `Command.stdin_file(path)` — feed the child's stdin from a file, streamed in
  chunks by the crate rather than read whole into a Python `bytes` object, for
  large inputs (a `psql` dump, a `tar` archive, a multi-gigabyte log). Like
  most other builder methods (`stdout_tee`/`stderr_tee` are the deliberate
  exception), it does not touch the filesystem at build time — the path is
  opened lazily at spawn, so a missing/unreadable file surfaces as the generic
  `ProcessError` from the run/output verb, not `FileNotFoundError`. Reusable
  across retries/re-runs, like `stdin_bytes`/`stdin_text`; the usual "last
  stdin method wins" rule applies alongside `stdin_bytes()`/`stdin_text()`/
  `keep_stdin_open()`.
- `ProcessResult` and `BytesResult` gain `diagnostic: str | None` (stderr if it
  carries text, otherwise stdout, otherwise `None` — the same preference order
  as `NonZeroExit`/`Timeout`/`Signalled.diagnostic` on the exceptions) and
  `outcome: Outcome` (the same value `RunProfile.outcome` and the checking-verb
  exceptions expose). A result held as data (`output()`/`output_bytes()`
  without `ensure_success()`) no longer requires re-deriving these by hand.
  (An `output_contains_any` convenience was considered alongside these and
  rejected: the underlying `processkit` crate has no such method, so it
  wouldn't be parity with the crate or the exceptions like `diagnostic`/
  `outcome` are — and it's a one-liner callers can already write themselves
  via `combined`, e.g. `any(s in result.combined for s in needles)`.)
- Value semantics for the result types: `ProcessResult`, `BytesResult`,
  `Outcome`, `Finished`, `RunProfile`, and `SupervisionOutcome` now define
  `__eq__` (comparing every field the underlying `processkit` crate's own
  `PartialEq` compares — not `object`'s previous identity comparison) and a
  consistent `__hash__` (none of their fields are stored floats, so hashing is
  sound), so two results can now be compared with `==` and used in a `set` or
  as a `dict` key without a manual field-by-field comparison.
  `ProcessResult`/`Outcome`/`Finished`/`SupervisionOutcome` are also picklable
  — e.g. to return a `ProcessResult` from a
  `concurrent.futures.ProcessPoolExecutor` worker. The underlying crate has no
  public constructor for any of these types, so unpickling reconstructs one via
  `processkit.testing.ScriptedRunner` (an in-memory, no-subprocess replay) —
  faithful for every field the Python binding exposes, but a command that
  customized `success_codes()`/`timeout()` is not guaranteed to compare `==`
  its original after a round trip (those two fields have no Python accessor to
  reconstruct exactly). `BytesResult` (raw stdout may not be valid UTF-8, and
  the only reconstruction channel available is text-only) and `RunProfile`
  (reports live OS resource-sampling telemetry with no synthesis path outside
  an actual monitored run) explicitly do **not** support pickling and raise a
  clear `TypeError` rather than failing silently or fabricating the missing
  data.

### Changed
- `CliClient(default_env_fn=...)` now validates that every value in the
  mapping is callable **at construction time**, raising `TypeError` (naming
  the offending key) immediately instead of silently accepting a non-callable
  value and only discovering the mistake later — once per built command, as
  an unraisable-hook warning plus an always-empty resolved env var. Valid
  callables behave exactly as before.

### Fixed
- `Args` (`from processkit import Args`) no longer rejects the single most
  common real call site — a variable annotated `list[str]` (or
  `list[pathlib.Path]` / `list[os.PathLike[str]]`) passed straight through to
  an argv-like parameter, e.g. `args: list[str] = [...]; cmd.args(args)`.
  `list` is invariant, so the original `list[StrPath] | tuple[StrPath, ...]`
  spelling only ever accepted a `list[StrPath]`-annotated variable or a
  literal, not a `list[str]`/`list[Path]`/`list[os.PathLike[str]]`-annotated
  one, even though the values are runtime-identical — a static-typing-only
  false positive with no runtime effect. `Args` is now a union of the concrete
  homogeneous list shapes (`list[str]`, `list[Path]`, `list[os.PathLike[str]]`)
  instead of the single invariant `list[StrPath]`; a *mixed* `str`/
  `os.PathLike[str]` argv is still accepted, now spelled as a `tuple` rather
  than a `list` literal (e.g. `cmd.args((path, "literal"))`). A bare `str`
  still does not type-check as `Args` (unchanged; see the `Args` docstring).

## [1.1.1] - 2026-07-06

### Added
- `Command.line_terminator(mode)` / `Command.stdout_line_terminator(mode)` /
  `Command.stderr_line_terminator(mode)` — choose where the line pump splits a
  stream into lines: `"newline"` (default, splits on `\n` only, unchanged
  behavior) or `"carriage_return"` (also splits on a bare `\r`, delivering each
  frame of a `curl`/`pip`/`apt`-style redrawn-in-place progress bar live instead
  of piling it all up into one line at EOF). `line_terminator` sets both
  streams at once; the `stdout_`/`stderr_` variants target one stream, leaving
  the other's framing untouched. Binds `processkit` 2.1.0's
  `Command::line_terminator`/`stdout_line_terminator`/`stderr_line_terminator`
  (`LineTerminator`), exposed as the new `LineTerminatorName` string-preset
  alias.
- `testing.Reply.with_stderr(text)` — attach stderr to a scripted reply,
  including a successful (`Reply.ok(...)`) one, without resorting to
  `Reply.fail(0, ...)` as a workaround.
- `processkit.testing.DryRunRunner` — a render-only test double that never
  spawns a process: every verb renders the command to its display-quoted line
  (via the crate's own `Command.command_line()` quoting) and returns a
  synthetic success, the seam behind a tool's own `--dry-run`/`--echo` mode.
  Inspect the rendered lines with `commands()` / `only_command()`, or stream
  them live as each call happens with `on_invocation(callback)`. Works at every
  runner injection point (`output_all` and friends, `Supervisor`, `CliClient`,
  `runner=`), like the other doubles. (Binds `processkit` 2.1.0's
  `testing::DryRunRunner`.)
- `Supervisor(..., give_up_when=classifier)` — classify a permanent failure so
  supervision gives up instead of restarting a crash forever, reporting the new
  `SupervisionOutcome.stopped == "gave_up"`. Bound as a **Python callable**
  (like `stop_when`, not a `retry_if`-style string preset — the crate's
  classifier is a per-attempt closure, and a useful verdict is result-specific,
  not a fixed vocabulary). The callback receives one argument mirroring the
  crate's `GiveUpAttempt` sum type, dispatched with `isinstance`: a
  `ProcessResult` for a crashed run that produced a result (classify by e.g.
  `attempt.code`), or a `ProcessError` subclass for a launch that never produced
  one (classify by e.g. `isinstance(attempt, ProcessNotFound)` for a missing
  binary). Consulted only for a crash the policy would otherwise restart, ahead
  of `max_restarts` and the failure-storm guard. A crash verdict stops with
  `stopped == "gave_up"`; a launch-failure verdict has no result to report and
  surfaces the classified error directly from `run()`/`arun()`. Off by default —
  a permanent failure restarts as before. The classifier runs on the runtime
  thread under the GIL; a raising or non-bool callback reads as "not permanent"
  (keep restarting) and is surfaced via the unraisable hook, never silently
  swallowed.
- `Command.umask(mask)` — set the child's POSIX file-mode creation mask; on a
  non-POSIX platform the run raises `Unsupported`, matching the existing
  `uid`/`gid`/`groups`/`setsid` verbs.
- `Command.priority(level)` — set the child's CPU-scheduling priority, one of
  the named presets `"idle"`, `"below_normal"`, `"normal"`, `"above_normal"`,
  `"high"` (new `Priority` type alias). Unix `nice`/`setpriority`, Windows
  priority class — unlike the privilege/POSIX-only verbs above, supported on
  **both** platform families, so it never raises `Unsupported`. Raising to
  `"high"` on Unix without `CAP_SYS_NICE`/root raises `PermissionDenied`
  instead of silently applying a lower priority.
- `Command.timeout_opt(seconds)` — like `timeout()`, but takes `float | None`,
  convenient when a timeout arrives from config as `Optional[float]`: a value
  behaves exactly like `timeout(seconds)`, `None` clears a prior `timeout()`
  exactly like `no_timeout()`.
- `Command.retry_never()` — explicitly opt one command out of retrying, even
  when it runs through a `CliClient` configured with a `default_retry_if`.
- `NonZeroExit` / `Timeout` / `Signalled` now carry a `stdout_bytes: bytes | None`
  field — the exact raw stdout bytes when the error came from a checking verb over
  `output_bytes()` (e.g. `BytesResult.ensure_success()`), `None` on the text path
  (`run()` / `output()`) where `stdout` is already the complete decoded text.
  When present, these are the exact pre-decode bytes `stdout` is a lossy UTF-8
  view of (they differ only for non-UTF-8 output). Binds processkit 2.1.0's
  `Error::stdout_bytes()`.

### Changed
- `Command.output_limit(max_bytes=...)`'s byte ceiling now also bounds the raw
  stdout of `output_bytes()` / `aoutput_bytes()`, matching processkit 2.1.0 —
  previously a byte cap bounded only the line-pumped stderr and raw stdout was
  always unbounded. Under `on_overflow="error"` an over-cap `output_bytes()` run
  now raises `OutputTooLarge` (with `max_lines=None` — raw bytes have no line
  count) where it once returned all bytes; under a drop mode its retained bytes
  are bounded to a head/tail with `BytesResult.truncated` set. A `max_lines` cap
  still never bounds raw stdout. This applies to every inherited `output_bytes`
  consumer that runs a `Command` built with such a policy (`CliClient`,
  `Pipeline`, `RunningProcess`, `ProcessGroup`, and the `runner=` doubles). The
  `Supervisor` capture policy is unaffected — it captures line-based output only
  and has no `output_bytes` verb.

## [1.1.0] - 2026-07-06

### Breaking
- `RunningProcess`'s consuming verbs now come in a sync/async pair, like
  everywhere else in this library, instead of being coroutine-only. Migration:
  `await proc.wait()` → `await proc.aoutcome()` (renamed — `await` is a
  reserved word, so the async twin of the new sync `outcome()` couldn't be
  called `await()`); `await proc.finish()` → `await proc.afinish()`;
  `await proc.output()` → `await proc.aoutput()`; `await proc.output_bytes()`
  → `await proc.aoutput_bytes()`; `await proc.profile(...)` →
  `await proc.aprofile(...)`; `await proc.shutdown(...)` →
  `await proc.ashutdown(...)`. Each bare name is now a new **synchronous**
  method (`proc.outcome()`, `proc.finish()`, `proc.output()`,
  `proc.output_bytes()`, `proc.profile(...)`, `proc.shutdown(...)`), making a
  handle from the synchronous `Command.start()` / `Runner.start()` genuinely
  usable end-to-end with no event loop at all — not just for the
  monitor-and-`kill()` pattern. No aliasing was possible (the old bare names
  now mean something different — synchronous — so keeping them pointing at the
  old async behavior would be actively misleading, not merely redundant).
  `RunningProcess.shutdown()`/`ashutdown()` also now match
  `ProcessGroup.shutdown()`/`ashutdown()`'s naming exactly, closing a trap
  where the same verb name meant "call it" on one class but "await it" on the
  other.
- `ProcessRunner` no longer includes `start`/`astart` — it is now the
  capture/check verb surface only (`output`/`run`/`exit_code`/`probe` and
  their `a`-prefixed twins). A new `StreamingRunner(ProcessRunner)` protocol
  adds `start`/`astart` back for code that also needs a live `RunningProcess`
  handle. Migration: annotate an injection point that only calls the
  capture/check verbs as `ProcessRunner` (now narrower, easier for a custom
  double to satisfy); annotate one that also calls `start`/`astart` as
  `StreamingRunner`. Every built-in runner (`Runner`, `ScriptedRunner`,
  `RecordingRunner`, `RecordReplayRunner`) satisfies `StreamingRunner` (and
  therefore `ProcessRunner` too), so existing injected-runner call sites are
  unaffected — only code that annotated *against* `ProcessRunner` expecting
  `start`/`astart` to be part of it needs to switch to `StreamingRunner`. The
  internal `_runner.py` module (never part of the public import path) is
  renamed `_protocols.py` to reflect holding two protocols now, not one.
- `wait_for()` is renamed `wait_until()` — the old name collided with
  `asyncio.wait_for`, which bounds one *awaitable*, not a *polled predicate*
  (different semantics entirely). Migration: `await wait_for(...)` →
  `await wait_until(...)`, same arguments. No alias was kept — a `wait_for`
  alias sitting next to `asyncio.wait_for` in the same import line would
  perpetuate exactly the confusion this rename fixes. All three readiness
  helpers (`wait_until`, `wait_for_port`, `wait_for_line`) now raise
  `WaitTimeout` (`ProcessError`, `TimeoutError`) instead of a bare
  `TimeoutError` on their own deadline — still catchable as `except
  TimeoutError`, but now carrying `timeout_seconds` (and, for
  `wait_for_port`, `host`/`port`) as structured fields instead of only a
  message string.

### Added
- A **pytest plugin**, autoloaded via a `pytest11` entry point in every pytest
  session where processkit is installed (nothing to add to `conftest.py`; the
  plugin module is pure Python and import-safe). It exposes the
  `processkit.testing` doubles as ready-made fixtures — `scripted_runner` (a fresh
  `ScriptedRunner`), `recording_runner` (a `RecordingRunner` spy replying
  `Reply.ok("")`, the neutral default), and `record_replay_runner` (a
  `RecordReplayRunner` bound to a per-test cassette) — so injecting a test double
  is a single fixture parameter. The cassette fixture is replay-by-default with a
  vcr-style switch to record (`--processkit-record` CLI flag, then the
  `PROCESSKIT_RECORD` env var, then the `processkit_record` ini option, in that
  precedence); its file lives under the test's `tmp_path` unless the
  `processkit_cassette_dir` ini option points at a kept directory, and its name is
  derived deterministically from the test's node id. A `@pytest.mark.no_real_spawn`
  marker (registered so it passes `--strict-markers`) makes any real spawn through
  `Command`/`Pipeline`/`Runner`/`ProcessGroup` inside the marked test fail loudly,
  while injected doubles keep working. Documented in `docs/testing.md` and the
  cookbook.
- `Args` and `ReadableBuffer` type aliases (`from processkit import Args,
  ReadableBuffer`). `Args` (`list[StrPath] | tuple[StrPath, ...]`) replaces
  `Sequence[str]`/`Sequence[StrPath]` on every argv-like parameter
  (`Command`'s `args`, `ScriptedRunner.on()`/`on_sequence()`'s `prefix`,
  `CliClient.command()`/its verbs) — deliberately **not** `Sequence[StrPath]`,
  since `str` is itself structurally a `Sequence[str]` (each character is a
  `str`), so that spelling let a bare string slip through everywhere an argv
  list was expected (`cmd.args("--flag")` type-checked, then exploded into
  one argument *per character* at runtime). This is a static-typing-only
  tightening — runtime behavior (and any caller not using mypy) is
  unaffected; a mypy-strict caller passing something other than a `list`/
  `tuple` (an arbitrary custom `Sequence`) at one of these call sites may
  need to wrap it in `list(...)`. `ReadableBuffer` (`bytes | bytearray |
  memoryview`) replaces the too-narrow `bytes` on `Command.stdin_bytes()` /
  `ProcessStdin.write()` — both already accepted `bytearray`/`memoryview` at
  runtime (PyO3's buffer-protocol extraction), so this only catches up the
  stub to reality, no runtime change.
- `CliClient`'s `command()` and every verb (`run`/`output`/`output_bytes`/
  `exit_code`/`probe`, `a`-prefixed twins) now accept a `str` or any
  `os.PathLike[str]` for each argv element, unified with `Command`'s own
  `arg`/`args` typing — previously `CliClient` was `str`-only, so a
  `pathlib.Path` argument needed a manual `str()` there but not on `Command`.
- Documented explicitly: `Timeout`, `ProcessNotFound`, and `PermissionDenied`
  are transitively `OSError` subclasses too (since their builtin second base
  — `TimeoutError`/`FileNotFoundError`/`PermissionError` — has itself been an
  `OSError` subclass since Python 3.3), so `except OSError` catches all
  three alongside `except ProcessError`. No behavior change — this was
  already true; it just wasn't written down anywhere.
- Fixed: `PermissionDenied.program` is now typed `str | None` (was `str`) and
  reliably reads `None` — not a missing-attribute `AttributeError` — on the
  broader OS-refusal path with no program to name (`is_permission_denied()`
  also classifies a program-less `Io` failure, e.g. a group signal the OS
  refused, alongside the ordinary spawn-time denial that does name one).
  Mirrors the class-level default already used for `Timeout.timeout_seconds`.
- `CancellationToken` — a portable cancel switch: `Command.cancel_on(token)`
  (replaces any prior token — last write wins), `Pipeline.cancel_on(token)`
  (gap-fill — a stage with its own explicit token keeps it), and `CliClient`'s
  `default_cancel_on=` (also gap-fill) tear the run/chain down when `token`
  fires, surfacing the new `Cancelled` exception. `token.cancel()` is
  idempotent; `token.child_token()` derives a token cancelled automatically
  with its parent but cancellable independently, for scoping a broader
  shutdown token down to one operation.
- `Cancelled` exception — a run deliberately cancelled via a
  `CancellationToken`. Previously such a cancellation surfaced only as a
  plain `ProcessError` (no dedicated subclass existed since `cancel_on` had
  no binding yet); now a distinct, terminal exception — never retried by
  `Command.retry()` or restarted by `Supervisor`, matching the crate's own
  contract (a cancelled token stays cancelled forever, so a replay could only
  fail the same way).
- `ScriptedRunner.when(predicate, reply)` — reply with `reply` when
  `predicate(command)` accepts it, for a match that isn't a plain argv
  prefix (`on()`) — e.g. inspecting `cwd`/`arguments`/flags via `Command`'s
  own inspection accessors. `predicate` is infallible from the crate's
  perspective, like `Supervisor.stop_when`: a raising or non-`bool` predicate
  reads as "does not match", surfaced via the unraisable hook.
- `Reply.with_line_delay(seconds)` — sleep `seconds` before each scripted
  stdout line on a `start()`/`astart()` run, so a hermetic streaming test can
  observe genuinely incremental delivery instead of every line arriving at
  once.
- `RecordingRunner.new(inner)` — wrap any of `Runner`, `ScriptedRunner`,
  `RecordReplayRunner`, or another `RecordingRunner`, recording every call
  made through it. The general form behind the existing `replying(reply)`
  (a recorder whose inner runner is always a fresh `ScriptedRunner` replying
  with one canned `Reply`) — `new()` lets a test combine recording with a
  double it already built (e.g. a `RecordReplayRunner` cassette) or with the
  real `Runner`.
- `ProcessGroup` is now itself a runner: `group.output(cmd)` / `.run(cmd)` /
  `.exit_code(cmd)` / `.probe(cmd)` / `.output_bytes(cmd)` (+ `a`-prefixed
  twins) run `cmd` as a *shared* member of the group (not a standalone
  private tree) — the same verb surface `Runner`/`ScriptedRunner`/… expose,
  for code written against that seam that should route every spawn through
  one shared group. (Not registered as a `runner=` injection target — a
  `ProcessGroup` carries real OS resources and is injected directly by
  callers who already hold one, not through that kwarg seam.)
- `output_all()` / `aoutput_all()` / `output_all_bytes()` / `aoutput_all_bytes()`
  now reject `concurrency=0` with `ValueError` instead of silently clamping it
  to `1` (a confusing "asked for none, got some anyway").
- `Command.no_timeout()` — run without a timeout, and (unlike simply leaving
  it unset) opt out of a client-wide `CliClient` `default_timeout` gap-fill.
  Clears a prior `.timeout()`; the last of the two wins.
- `Command.stdout_tee(path, *, append=False)` / `stderr_tee(path, *,
  append=False)` — tee every decoded line of the stream to a file *as it is
  produced* (the line plus a `\n`, CRLF normalized) while the run **also** keeps
  capturing the full output: the one-line way to "stream a log to a file and
  still get the captured `ProcessResult`", without a manual loop over
  `stdout_lines()`. The sink is a **file path** (`str` / `os.PathLike[str]`);
  teeing to an arbitrary Python object as a live async writer is deliberately
  **not** supported yet (a separate, deferred feature — dispatching each line to
  a thread, re-acquiring the GIL, honoring backpressure across the FFI boundary
  is its own scope). The file is opened **at build time** — the crate takes a
  concrete sink, not a lazy factory — so an unopenable path (missing parent
  directory, a directory, a permission denial) raises the matching `OSError`
  subclass right at the builder call, not at run; it is created/truncated by
  default, or appended to with `append=True`. Inherited crate semantics: a slow
  sink applies backpressure (it does not block the runtime); a tee write error
  disables the tee for the rest of the run without breaking the run or its
  captured result (warned under `enable_logging()`); and the tee is inert unless
  the line pump runs — a no-op under `stdout("inherit")` / `stdout("null")` and
  under `output_bytes()` (raw capture), working with the line verbs (`output()`
  / `aoutput()` / `run()`, `start()` + `stdout_lines()` / `output_events()`). A
  reused command's shared sink handle **appends** across sequential re-runs
  (retries, `Supervisor` incarnations) and **interleaves** across concurrent
  pipeline stages.
- `Command.command_line()` — render the command as a single shell-quoted line
  for display (logs, error messages, a dry-run echo); includes argv, unlike
  the redacted `repr()`. Never used to actually execute anything. Plus
  `Command.program` / `Command.arguments` read-only properties (named
  `arguments`, not `args` — that name is already the builder method that
  appends args).
- `Command.unchecked_in_pipe()` — exempt a command, as a `Pipeline` stage,
  from pipefail attribution (its unclean exit, including a `SIGPIPE`, is
  skipped when the chain decides what to report); a no-op outside a
  `Pipeline`.
- `ProcessResult.ensure_success()` / `BytesResult.ensure_success()` — raise
  the same exception a checking verb would if the result's exit isn't in
  `success_codes`, for turning an already-captured `output()`/`output_bytes()`
  result into an error after the fact. Returns `self` unchanged on success, so
  it composes: `cmd.output().ensure_success().stdout`.
- `.diagnostic: str | None` on `NonZeroExit`, `Timeout`, and `Signalled` — the
  best human-facing message (captured stderr if it carries text, otherwise
  captured stdout; `None` if both streams are blank), so a generic `except
  ProcessError` handler can log/report something useful without knowing which
  of the three stream-bearing exceptions it caught.
- `Command.timeout_signal()` / `ProcessGroup.signal()` now also accept a raw
  platform signal number (an `int`), not just a portable name — the crate's
  `Signal::Other` escape hatch (Unix only; a raw number is `Unsupported` on
  Windows like every non-`Kill` signal, same as the named variants).
- `CliClient.command(args)` — a `Command` for `program <args>` with the
  client's defaults (timeout/env/retry/cancel) pre-applied; chain more
  builders for a customized one-off call, then pass the result to `run()` /
  `output()` / … (which now accept either a plain arg list or such a
  `Command` — the `IntoCommand` path). An explicit setting on the returned
  `Command` always wins over the client's default; only the gaps get filled.
- `CliClient`'s `default_env_fn={key: resolver, ...}` — a per-key zero-arg
  resolver called fresh each time a command is *built* (not each retry
  attempt) to fill an environment variable, for a credential that should be
  read freshly rather than baked in once at client-construction time (a
  static `default_env` value). An explicit per-call `env`/`default_env` at
  the same key still wins — this only fills the gap.
- `Supervisor`'s `capture_max_bytes=`/`capture_max_lines=`/
  `capture_on_overflow=` — bound (or widen) the output captured from each
  supervised incarnation; the default is already a sensible bounded tail
  (`Command.output_limit`'s own kwargs, applied here as constructor kwargs
  instead of a builder method, per the config-struct convention). Setting any
  of the three requires at least one of the two cap sizes, mirroring
  `output_limit`'s own validation.
- `Command.retry(retry_if, *, max_retries=, initial_backoff=, multiplier=,
  max_backoff=, jitter=)` and `CliClient`'s `default_retry_if=` (+
  `default_max_retries=`/`default_initial_backoff=`/`default_multiplier=`/
  `default_max_backoff=`/`default_jitter=`) — retry a run with exponential
  backoff, a cap, and jitter, while `retry_if` accepts the resulting error.
  Honored only by the success-checking verbs (`run`/`exit_code`/`probe`, and
  `CliClient`'s equivalents); ignored by `Supervisor` (its own `RestartPolicy`
  governs keep-alive restarts — a different concern), `output_all`, and
  `Pipeline`. Bound as kwargs over the crate's `RetryPolicy`, not a mirrored
  pyclass (the established config-struct convention — see `AGENTS.md`).
  `retry_if` is a named preset over the crate's own error-classification
  accessors, not an arbitrary Python callable crossing the FFI boundary:
  `"transient"` (a bare-retry-clears spawn/IO condition — interrupted,
  would-block, a busy resource) or `"transient_or_timeout"` (also retries a
  `.timeout()` expiry). `CliClient`'s tuning knobs require
  `default_retry_if=` to be set (raises `ValueError` otherwise) — the same
  explicit opt-in `Command.retry()`'s required `retry_if` already enforces.
- `wait_for_line(lines, predicate, *, timeout)` is generalized over the
  iterator's item type (previously hardcoded to `AsyncIterator[str]`) — it now
  works over any async iterator (e.g. `RunningProcess.output_events()`'s
  `OutputEvent` items), not just stdout lines, given a callable predicate.
  `predicate` also accepts a plain `str` as a substring-match shorthand
  (`wait_for_line(lines, "listening on", timeout=10)`) when the iterator
  yields `str`. Purely additive: an existing callable-predicate,
  `str`-iterator call site is unaffected.
- `Invocation.env_is(name, value)` / `has_env(name)` — the platform-correct
  (case-insensitive on Windows, last write wins) effective-override check. The
  existing `env` dict is plain Python dict semantics, not platform env-key
  rules: a same-case duplicate key collapses to its last value, but a
  differently-cased Windows duplicate (`"Path"`/`"PATH"`) survives as two
  separate entries — use `env_is()`/`has_env()` for the correct answer either
  way.
- `runner=` keyword on `output_all` / `aoutput_all` / `output_all_bytes` /
  `aoutput_all_bytes`, `Supervisor(...)`, and `CliClient(...)` — drives the
  batch/supervision/client through an injected runner (`Runner`,
  `ScriptedRunner`, `RecordingRunner`, or `RecordReplayRunner`) instead of the
  real one, so a test double stands in with no real process spawned. Defaults
  to the real `Runner` when omitted (no behavior change). `CliClient` was
  previously locked to the real runner; it is now just as testable as raw
  `Command` code.
- `ScriptedRunner.on_sequence(prefix, replies)` — reply with each of `replies`
  in turn on successive matching calls (fail a few times, then succeed), then
  repeat the last reply once exhausted. The declarative form for retry/
  supervision test scenarios.
- Prebuilt wheels for **Intel macOS** (x86_64), cross-compiled from the arm64
  (Apple Silicon) runner. Previously Intel Mac users installed from the sdist
  (needing a Rust toolchain); both macOS architectures are now covered.
- Prebuilt wheels for **Windows on ARM (arm64)**, built natively on GitHub's
  free-for-public-repos `windows-11-arm` runner. Both families ship — the abi3
  GIL wheel (CPython 3.10+) and the free-threaded cp314t wheel — so ARM64
  Windows users (a growing laptop segment) get a binary `pip install` instead
  of a from-source build needing a Rust toolchain. No cibuildwheel override was
  needed: it already provides a native ARM64 CPython 3.10 (for the abi3 wheel)
  and a native ARM64 cp314t, so the existing `build`/`skip` selectors cover
  win_arm64 unchanged.
- An **API reference** section on the documentation site — a complete,
  per-symbol index of the public surface (every class, function, protocol, type
  alias, and exception, plus the `processkit.testing` submodule), reachable from
  the site navigation. It is rendered by `mkdocstrings` straight from the type
  stub (`_processkit.pyi`) and docstrings via griffe's *static* analysis (no
  compiled extension needed, so it builds in the extension-free Docs CI), and a
  drift guard (`scripts/gen_api_reference.py --check` plus
  `tests/test_api_reference.py`) fails if the page ever omits — or invents — a
  public symbol, so the reference cannot silently diverge from the real API.

### Changed
- `[project.urls] Homepage` in `pyproject.toml` now points at the project
  overview site (https://zelanton.github.io/processkit/) instead of the
  GitHub repository, which is still linked separately as `Repository`.

### Fixed
- Fixed the macOS x86_64 release wheel build: `delocate-wheel` was rejecting
  the cross-compiled Intel wheel because the compiled extension's embedded
  minimum macOS target (10.12, the current Rust default for
  `x86_64-apple-darwin`) didn't match the wheel's `macosx_10_9` tag. The
  x86_64 cibuildwheel build now sets `MACOSX_DEPLOYMENT_TARGET=10.12`
  explicitly so the tag matches the binary.
- `wait_for()`'s deadline handling no longer swallows the *caller's* own
  cancellation (turning it into a misleading `TimeoutError`) if that cancellation
  lands while the timed-out predicate is being cancelled and drained; it also no
  longer cancels a pre-existing `asyncio.Future`/`Task` passed in as the
  predicate's own awaitable (only a task it created itself), no longer discards a
  condition that turns out true in the same tick as the deadline, and no longer
  swallows a `SystemExit`/`KeyboardInterrupt` raised by the predicate.
- `wait_for_line()` no longer masks a builtin-`TimeoutError`-family exception
  raised by the predicate or the stream itself behind the generic timeout
  message; it now shares `wait_for()`'s bounding, so `timeout=0` reliably
  evaluates once instead of sometimes short-circuiting first.
- `wait_for()`, `wait_for_line()`, and `wait_for_port()` now reject a NaN
  `timeout` with `ValueError` instead of polling forever; `wait_for()` and
  `wait_for_port()` reject a NaN `interval` the same way (`wait_for_line()` has
  no `interval` parameter).
- `wait_for_port()` now chains the last connection attempt's exception (e.g. a
  DNS failure) as the raised `TimeoutError`'s `__cause__` instead of discarding
  it.
- A consuming verb called without the context it needs — an async verb
  (`RunningProcess.wait`/`finish`/`output`/`output_bytes`/`profile`/`shutdown`/
  `__aexit__`, `Supervisor.arun`, `ProcessGroup.ashutdown`/`__aexit__`) called
  with no running `asyncio` event loop, or a sync verb (`Supervisor.run`,
  `ProcessGroup.shutdown`/`__exit__`) called from inside an already-running
  async context — now raises a clear error and leaves the handle intact and
  reusable. Previously the same misuse destroyed the live process (or spent the
  handle) as a side effect of the error path.
- `Timeout.timeout_seconds` is now `None` (not a misleading `0.0`) when the
  deadline wasn't known to the checking verb (a scripted/cassette-replayed
  timeout with no `timeout()` configured).
- `ProcessStdin.write()` / `write_line()` / `flush()` / `close()` now raise the
  matching stdlib `OSError` subclass (e.g. `BrokenPipeError` for a closed
  child), not a bare `OSError`.
- `ProcessGroup.signal()`'s docstring no longer claims Windows "emulates" the
  POSIX signals — a Job Object only delivers `kill` there; every other name
  raises `Unsupported`, as it always has.
- Error mapping now uses the `processkit` 1.2.0 crate's `Error` accessors
  instead of hand-matching each variant, closing two gaps: a cancelled run's
  exception now carries `.program` (previously missing); and a spawn/IO
  failure refused for a permission reason is now consistently `PermissionDenied`
  (previously only a spawn-time refusal was — e.g. an OS-refused
  `ProcessGroup.signal()` used to surface as a plain `ProcessError`).
- `docs/testing.md`/`docs/cookbook.md` no longer claim an unmatched
  `ScriptedRunner` call with no fallback raises `ProcessNotFound` (it raises a
  plain `ProcessError` — that was always the actual behavior, the docs were
  wrong) or that `CliClient` is un-injectable (see `runner=` above).

## [1.0.0] - 2026-07-04

### Added
- Synchronous `Command` builder over the `processkit` Rust crate (pinned at
  `=1.2.0`): `output()` (captures a non-zero exit, timeout, and signal-kill as
  data), `output_bytes()` (raw-bytes stdout → `BytesResult`), `run()` (returns
  trimmed stdout, raises on failure), `exit_code()`, and `probe()`, configured
  with `arg`/`args`/`cwd`/`env`/`envs`/`env_remove`/`env_clear`/`timeout`/
  `output_limit`. The program and working directory accept any `os.PathLike`, not
  only `str`.
- Full environment control on `Command`: `envs(mapping)` (set many at once),
  `env_remove(key)`, and `env_clear()` (start from an empty environment) — for
  reproducible or locked-down (sandboxed) children.
- Output caps on `Command`: `output_limit(max_bytes=…, max_lines=…,
  on_overflow="drop_oldest"|"drop_newest"|"error")` bounds how much captured
  output is retained (cap `max_bytes` to bound the parent's memory against an
  untrusted child; a `max_lines`-only cap does not); on `"error"` overflow the
  run raises `OutputTooLarge`.
- More `Command` knobs: `success_codes([…])` (treat the given exit codes as
  success, replacing the default `{0}` — for `grep`/`diff`-style tools),
  `inherit_env([…])`
  (allowlist inheritance), `timeout_grace()` / `timeout_signal()` (graceful
  timeout), `stdout("inherit"|"null")` / `stderr(…)` redirection, `encoding(…)` /
  `stdout_encoding` / `stderr_encoding` (decode non-UTF-8 output),
  `kill_on_parent_death()`, `create_no_window()` (Windows), and POSIX
  `uid` / `gid` / `groups` / `setsid`.
- Concurrent batch execution: `output_all` / `aoutput_all` (and `…_bytes`
  variants) run many commands with bounded `concurrency`, returning each
  `ProcessResult` — or a `ProcessError` for a spawn/I/O failure — in input order.
- `CliClient(program, *, default_timeout=…, default_env=…, default_env_remove=…)`
  — a typed wrapper for a tool you call repeatedly, with `run` / `output` /
  `output_bytes` / `exit_code` / `probe` (+ async) taking just the per-call args.
- `enable_logging()` — opt-in observability: forwards the core's per-run events to
  Python's `logging` (a `processkit` logger; DEBUG for a run, WARNING for an edge
  case). Idempotent; off by default; `argv`/`env` are never logged (secrets). Use
  `logging.basicConfig(level=…)` and filter the `processkit` logger as usual.
- `RunningProcess` live introspection (`elapsed_seconds`, `cpu_time_seconds`,
  `peak_memory_bytes`, `stdout_line_count` / `stderr_line_count`, `owns_group`),
  plus `output_bytes()` and `profile(every_seconds)` → `RunProfile`. A `RunProfile`
  carries the run's full `outcome` (`code` / `signal` / `timed_out` — a superset of
  `wait()`) alongside the CPU/memory samples (`cpu_time_seconds`,
  `peak_memory_bytes`, `avg_cpu_cores`, `samples`).
- Synchronous `Command.start()` — a blocking twin of `astart()` returning a live
  `RunningProcess` for streaming a child from synchronous code (its consuming
  methods `wait` / `finish` / `output` / … remain coroutines, awaited from an
  event loop).
- `RecordReplayRunner` test double — `record(path)` real runs then `save()`, and
  `replay(path)` offline; plus `output_bytes` on `Runner` / `ScriptedRunner`. It
  records and replays the streaming `start()` verb too (record is capture-whole;
  interactive mid-stream stdin can't be cassette-recorded — script those with
  `ScriptedRunner`); `output_bytes` through a cassette raises `Unsupported` (a
  text fixture can't reproduce exact bytes).
- `RecordingRunner` spy test double — `RecordingRunner.replying(reply)` answers
  every command with one canned `Reply` and records each call, so a test can
  assert on *what* its code ran: `calls()` returns every `Invocation` (in order)
  and `only_call()` the single one. Each `Invocation` exposes `program`, `args`,
  `cwd`, `env`, `has_stdin`, and `has_flag(flag)`; its `repr` is redacted (program
  + arg count + env names, never values). Completes the test-double set.
- `ProcessResult` with `stdout`, `stderr`, `code`, `is_success`, `timed_out`,
  `signal`, `program`, `duration_seconds`, `truncated`, and `combined`; plus a
  `BytesResult` (raw-bytes `stdout`, text `stderr`) from `output_bytes()` /
  `aoutput_bytes()`.
- `ProcessGroup` context manager — a kill-on-drop container for a process tree;
  `start()` a command into it, inspect `mechanism` / `members()`, and the whole
  tree (grandchildren included) is reaped on `with`-exit or `shutdown()`.
- `RunningProcess` handle exposing the child `pid`.
- Exception hierarchy rooted at `ProcessError`: `NonZeroExit`, `Timeout`,
  `Signalled`, `ProcessNotFound`, `PermissionDenied`, `Unsupported`,
  `OutputTooLarge`. `Timeout` is also a builtin `TimeoutError`, `ProcessNotFound`
  is also a `FileNotFoundError`, and `PermissionDenied` is also a
  `PermissionError` (matching `asyncio` / `subprocess`), so the stdlib `except`
  clauses catch them. The data-carrying ones expose structured fields — e.g.
  `NonZeroExit.code` / `.stdout` / `.stderr` / `.program`,
  `Timeout.timeout_seconds`, `Signalled.signal`, `OutputTooLarge.max_bytes` /
  `.total_bytes`, `Unsupported.operation` — so a failure can be inspected
  programmatically, not just read as a message. (`ResourceLimit` carries no extra
  field; its reason is `str(exc)`.)
- Blocking synchronous calls are interruptible: `Ctrl+C` (SIGINT) raises
  `KeyboardInterrupt` promptly and tears down the run's process tree, instead of
  hanging until the child exits.
- Asyncio-native surface (tokio ↔ asyncio bridge). Cancelling an awaited run —
  directly, or via `asyncio.wait_for` / `asyncio.timeout` — tears down the whole
  process tree and raises `asyncio.CancelledError`.
  - `Command`: `aoutput()`, `aoutput_bytes()`, `arun()`, `aexit_code()`,
    `aprobe()`, and `astart()` (returns a `RunningProcess` for
    streaming/interactive I/O).
  - `RunningProcess`: `async for line in proc.stdout_lines()`, `output_events()`
    (stdout+stderr as `OutputEvent`s), interactive `take_stdin()` →
    `ProcessStdin` (`write`/`write_line`/`flush`/`close`), and `await`able
    `wait()` → `Outcome`, `finish()` → `Finished`, `output()` → `ProcessResult`,
    plus `kill()` / `shutdown(grace_seconds)`. It is also a context manager
    (`with` / `async with`): exiting the block tears the process down
    deterministically — a hard kill of the whole private tree for a standalone
    `start()`/`astart()` handle — without relying on Python's GC.
  - `ProcessGroup`: `async with`, `astart()`, `ashutdown()`.
- `Command` stdin configuration: `stdin_bytes()` / `stdin_text()` (feed input
  upfront) and `keep_stdin_open()` (write interactively after start).
- New result types: `Outcome`, `Finished`, `OutputEvent`.
- Higher-level features:
  - **Resource limits** on `ProcessGroup`: keyword-only `max_memory`,
    `max_processes`, `cpu_quota`, `shutdown_grace`, `escalate_to_kill`
    (enforced via the Windows Job Object or a Linux cgroup-v2 *root*).
  - **Signals & observability** on `ProcessGroup`: `signal("term"|…)`,
    `suspend()`, `resume()`, `kill_all()`, and `stats()` →
    `ProcessGroupStats`.
  - **Pipelines**: `Command | Command` (or `.pipe()`) → `Pipeline`, with the
    sync/async run verbs (incl. `output_bytes()` / `aoutput_bytes()` for a binary
    tail) and `timeout()`.
  - **Supervision**: `Supervisor(cmd, restart=…, max_restarts=…, backoff_initial=…,
    backoff_factor=…, max_backoff=…, jitter=…, stop_when=…, storm_pause=…,
    failure_threshold=…, failure_decay=…)` with `run()` / `arun()` →
    `SupervisionOutcome`. Setting `storm_pause` enables the failure-storm guard
    (crash-loop circuit-breaker), reported via `SupervisionOutcome.storm_pauses`.
  - **Readiness probes**: `await wait_for_port(host, port, *, timeout)`,
    `await wait_for_line(lines, predicate, *, timeout)`, and
    `await wait_for(predicate, *, timeout)` (poll any sync-or-async condition).
  - New types/exception: `Pipeline`, `ProcessGroupStats`, `Supervisor`,
    `SupervisionOutcome`, `ResourceLimit`.
- Testing seam: a `Runner` (real) and a `ScriptedRunner` (test double) with a
  uniform sync + async (`a`-prefixed) `output`/`run`/`exit_code`/`probe`/`start`
  interface, plus `Reply`
  (`ok`/`fail`/`timeout`/`signalled`/`lines`/`pending`). Inject a `Runner` in
  production and a `ScriptedRunner` in tests — no real processes spawned; the
  results returned are genuine `ProcessResult` / `RunningProcess` objects. The
  injected runner is typed by the `ProcessRunner` `typing.Protocol`, which
  `Runner` / `ScriptedRunner` / `RecordReplayRunner` / `RecordingRunner` all
  satisfy structurally. The test doubles (`ScriptedRunner`, `RecordReplayRunner`,
  `RecordingRunner`) plus `Reply` and `Invocation` live in the **`processkit.testing`**
  submodule; `Runner` and `ProcessRunner` are top-level (production).
- A full [documentation guide set](./): a task-oriented
  [cookbook](cookbook.md) plus deep guides for
  [running commands](commands.md), [process groups](process-groups.md),
  [streaming & interactive I/O](streaming.md), [pipelines](pipelines.md),
  [timeouts & cancellation](timeouts-and-cancellation.md),
  [supervision](supervision.md), and [testing](testing.md), tied
  together by a progressively-disclosed README with a cover illustration.
- Type stubs (`_processkit.pyi`) for the compiled extension.
- A [platform support & caveats](platforms.md) matrix documenting per-OS
  teardown, resource-limit, signal, and stats behaviour.
- **Stability commitment:** as of 1.0 the public API follows SemVer — breaking
  changes land only in a new major version.
- **Free-threaded CPython (PEP 703):** the extension declares `gil_used = false`,
  so importing it on a free-threaded build (CPython 3.14t) does **not** re-enable
  the GIL. Shipped as a version-specific free-threaded wheel alongside the
  abi3 (GIL) wheel, and the full test suite runs on the free-threaded interpreter
  in CI. Also adds CPython **3.14** to the supported set (the abi3 wheel already
  runs there).
- **musllinux (Alpine/musl) wheels** for x86_64 and aarch64, alongside the
  existing manylinux (glibc) wheels — so `pip install` gets a binary wheel on
  Alpine-based images instead of building from the sdist. Both the abi3 GIL wheel
  and the free-threaded cp314t wheel ship per libc. CI builds and smoke-tests the
  x86_64 musllinux wheels on every push (aarch64 builds natively at release).
- Packaging metadata for the PyPI page: Trove classifiers (CPython 3.10–3.14, the
  supported operating systems, topics) and project URLs (Documentation, Issues).
- Runnable [`examples/`](https://github.com/ZelAnton/processkit-py/tree/main/examples/) — self-contained, cross-platform programs, one
  per target niche (whole-tree no-orphan teardown, a readiness-gated server,
  supervision-until-healthy, a resource-limited sandbox). Each is exercised in CI.
- Docs: a **"Coming from subprocess"** guide that maps `subprocess` /
  `asyncio.subprocess` patterns onto their processkit equivalents (verbs, flags,
  pipelines, the exception mapping) and shows the whole-tree containment the stdlib
  can't express.

### Changed
- Pipeline timeout results now retain best-effort partial stdout and stderr
  captured by the last stage before the deadline.
- Renamed `Command.ok_codes()` → **`success_codes()`** (clearer that it is the
  whole success set, not an addition), and an empty sequence now raises
  `ValueError` instead of being silently ignored.
- Renamed `RunProfile.exit_code` → **`code`**, matching the exit-code field on
  every other result type (`ProcessResult`, `Outcome`, …).
- `Command.encoding()` / `stdout_encoding` / `stderr_encoding` now also accept
  common **Python codec aliases** (`latin_1`, `utf_8`, `euc_jp`, …) in addition to
  WHATWG labels, normalized to the WHATWG form; an unmappable label raises
  `ValueError` naming the WHATWG equivalent. (WHATWG `iso-8859-1` / Python
  `latin_1` decode as windows-1252.)
- `Command.arg()` / `args()` and the `Command(...)` constructor's args accept any
  `os.PathLike[str]` (e.g. `pathlib.Path`), not only `str`, so a `Path` argument
  needs no `str()`. (`bytes` paths are not accepted; `StrPath` was narrowed to
  `str | os.PathLike[str]` to match.)
- Closed-set string parameters and return values are typed as `Literal` in the
  stubs (signal names, `restart`, `mechanism`, `SupervisionOutcome.stopped`,
  `OutputEvent.stream`) for editor autocomplete and `mypy` typo-catching.
- Exported the `StrPath` (`str | os.PathLike[str]`) and `SignalName` (the signal-name
  `Literal`) type aliases from the package, so your own wrappers can annotate against
  the same types the API accepts.
- Renamed `ProcessGroup(memory_max=…)` → **`max_memory`**, so every ceiling on the
  surface follows the `max_*` convention (`max_processes`, `output_limit(max_bytes=…,
  max_lines=…)`, `Supervisor(max_restarts=…, max_backoff=…)`). The crate builder
  remains `memory_max()`.
- Renamed `RunProfile.avg_cpu` → **`avg_cpu_cores`** (self-documenting: the value is
  CPU-cores, e.g. `1.7` ≈ 1.7 cores busy).
- Renamed `RunningProcess.start_kill()` → **`kill()`**, matching
  `subprocess.Popen.kill()` (fire-and-forget; does not wait for exit).
- Renamed `ProcessGroup.terminate_all()` → **`kill_all()`** and the
  `ProcessGroup(shutdown_timeout=…)` ceiling → **`shutdown_grace`**, so the group's
  teardown surface reads as what it does — a hard kill of the whole tree, after an
  optional grace period — and lines up with `RunningProcess.kill()` and
  `Command.timeout_grace()`. The crate keeps `terminate_all()` / `shutdown_timeout()`.
- Renamed the `OutputTooLarge` overflow fields `line_limit` / `byte_limit` →
  **`max_lines`** / **`max_bytes`**, so the caps reported on overflow match the
  `output_limit(max_bytes=…, max_lines=…)` kwargs that set them.
- Moved the runner test doubles — `ScriptedRunner`, `RecordReplayRunner`,
  `RecordingRunner`, the `Reply` builder, and the `Invocation` record — into a new
  **`processkit.testing`** submodule (mirroring the crate's `processkit::testing`
  split), so the top-level `processkit` namespace is the production surface and the
  test scaffolding is one explicit import away (`from processkit.testing import
  ScriptedRunner`). `Runner` and the `ProcessRunner` protocol stay top-level.
- `ProcessResult.combined` is now a **property** (was `combined()`), matching the
  other read accessors (`stdout`, `code`, …).
- Renamed `Outcome.is_success` / `Finished.is_success` → **`exited_zero`**. These
  test literal "exit code 0" and — unlike `ProcessResult.is_success` — carry no
  `success_codes` context, so the new name no longer implies the command's own
  success verdict. Use `ProcessResult.is_success`, or test `code` against your set.
- `RunningProcess.take_stdin()` now **raises** `ProcessError` (instead of returning
  `None`) when stdin was not kept open or was already taken — so a missing
  `keep_stdin_open()` fails at the call, not later with an `AttributeError`. Its
  return type is now `ProcessStdin` (no longer `... | None`).
- The readiness helpers `wait_for()` / `wait_for_port()` / `wait_for_line()` now
  take `timeout` as a **keyword-only** argument, for uniformity.

### Removed
- `Cancelled` exception. It was never raised from the Python surface (the binding
  exposes no cancellation token; cancelling an awaited run surfaces as
  `asyncio.CancelledError`), so it was pure catch-list clutter. Re-addable
  (additive) if a token-style cancellation API is ever exposed.
- `CliClient.run_unit()` / `arun_unit()`. The success-only `-> None` verb existed
  nowhere else on the surface; use `run()` / `arun()` and ignore the returned
  stdout for the same "run, raise on failure" behavior.
- `ResourceLimit.message`. It duplicated `str(exc)` — idiomatic Python 3 exceptions
  carry no separate `.message` attribute. Read the reason via `str(exc)`.

### Fixed
- A synchronous verb called from inside a `Supervisor` `stop_when` predicate no
  longer re-enters the tokio runtime and panics (the panic was previously
  swallowed, so the predicate silently never fired); it now raises a clear
  `ProcessError`. Documented that the predicate must read the result handed to it
  rather than run new verbs.
- `Supervisor(backoff_factor=…)` is now applied (and validated) independently of
  `backoff_initial` — previously the factor was silently dropped unless
  `backoff_initial` was also passed.
- A `RecordReplayRunner.replay()` cassette miss now carries the `.program` field,
  matching every other program-bearing `ProcessError`.
- `wait_for_port()` no longer leaks the probe socket if the awaiting task is
  cancelled just after the connection is accepted.
- `wait_for()` now bounds its predicate by `timeout` — an async predicate that
  hangs no longer ignores the deadline — while propagating the predicate's own
  exception unchanged and cancelling the in-flight predicate (rather than orphaning
  it) when the awaiting task is cancelled.

### Security
- `repr(Command(...))` no longer renders argv (or env *values*): it now uses the
  crate's redacted form — program, argument *count*, and env *names* only. A repr
  is emitted everywhere (logging `%r`, f-strings, tracebacks, test diffs), so this
  prevents a secret passed as an argument from leaking through any of them. (The
  Python surface exposes no way to recover the full command line; argv remains
  visible to the OS via `ps` / `/proc` while the child runs.)
- Documentation hardening: the sandbox/privilege-drop guidance now sets all of
  `gid` / `groups` / `uid` (dropping `uid` alone leaves the child holding the
  parent's supplementary groups — a sandbox-escape footgun); documents that
  record/replay cassettes are written owner-only (`0600`, no symlink follow) on
  Unix; and warns that exception `stdout`/`stderr` still carry raw values — pass
  secrets via `env(...)`, not flags.

### Notes

- This is the **1.0** release: the public API is frozen.
- Distributed as abi3 wheels for CPython 3.10+ (standard/GIL builds), **plus a
  version-specific free-threaded wheel** for CPython 3.14t (PEP 703).
- The `RecordReplayRunner` test double enables the crate's `record` feature,
  which pulls `serde` / `serde_json` into the compiled wheel.
- `enable_logging()` enables the crate's `tracing` feature; the bridge pulls
  `tracing` / `tracing-subscriber` (registry only) into the compiled wheel.

[Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.5.0...HEAD
[1.5.0]: https://github.com/ZelAnton/processkit-py/compare/v1.4.2...v1.5.0
[1.4.2]: https://github.com/ZelAnton/processkit-py/compare/v1.4.1...v1.4.2
[1.4.1]: https://github.com/ZelAnton/processkit-py/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/ZelAnton/processkit-py/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/ZelAnton/processkit-py/compare/v1.2.4...v1.3.0
[1.2.4]: https://github.com/ZelAnton/processkit-py/compare/v1.2.3...v1.2.4
[1.2.3]: https://github.com/ZelAnton/processkit-py/compare/v1.2.2...v1.2.3
[1.2.2]: https://github.com/ZelAnton/processkit-py/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/ZelAnton/processkit-py/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/ZelAnton/processkit-py/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/ZelAnton/processkit-py/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/ZelAnton/processkit-py/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/ZelAnton/processkit-py/releases/tag/v1.0.0

---

# API reference

The complete, per-symbol reference for the public `processkit` surface —
every class, function, protocol, type alias, and exception exported by the
package, plus the `processkit.testing` submodule.

It is generated from the type stub (`processkit/_processkit.pyi`) and the
docstrings, the same source your IDE and `mypy` read, so it cannot drift from
the real API. The narrative [guides](./) explain how the pieces compose;
this page is the exhaustive index. Both surfaces are covered together: the
synchronous verbs and their `a`-prefixed asyncio twins.


## Building & running commands

Construct a command and run it — capturing everything, or checking for success — synchronously or with the `a`-prefixed asyncio twins. `CliClient` binds a program to reusable defaults; `Pipeline` chains commands shell-free; `RunningProcess` is the live handle a started child hands back.

### `Command`

```text
Command(program: StrPath, args: Args | None = ...)
```

A command builder. Builder methods return a new `Command`.

Its `a`-verbs return custom awaitables, rather than coroutine objects:
await them directly, or pass one to ``asyncio.ensure_future(...)`` when a
Task/Future is required.

#### `arg`

```text
def arg(arg: StrPath) -> Command
```

#### `args`

```text
def args(args: Args) -> Command
```

#### `arg0`

```text
def arg0(arg0: str) -> Command
```

Override the child's ``argv[0]`` independently of the executable
``program`` — supports multicall binaries (BusyBox/Toybox) and
conventions like a login shell's ``-bash``. Program lookup,
``prefer_local``, preflight, spawn diagnostics, and containment all
keep using ``program``; only the argument vector delivered to the
child changes.

**Unix only.** Applied through the OS command's ``arg0`` spawn seam.
On a non-Unix platform the run raises ``Unsupported`` rather than
silently passing the executable name instead — ``configured_arg0``
stays observable there even though a run can never use it. Repeated
calls are last-write-wins.

#### `cwd`

```text
def cwd(path: StrPath) -> Command
```

#### `prefer_local`

```text
def prefer_local(dir: StrPath) -> Command
```

Search this directory before ``PATH`` when resolving a bare-name
program. Repeated calls accumulate in priority order, path-form
programs are unchanged, and the child's own ``PATH`` is not rewritten.

#### `env`

```text
def env(key: str, value: str) -> Command
```

#### `envs`

```text
def envs(vars: Mapping[str, str]) -> Command
```

#### `env_remove`

```text
def env_remove(key: str) -> Command
```

#### `env_clear`

```text
def env_clear() -> Command
```

#### `inherit_env`

```text
def inherit_env(names: Sequence[str]) -> Command
```

#### `stdin_bytes`

```text
def stdin_bytes(data: ReadableBuffer) -> Command
```

#### `stdin_text`

```text
def stdin_text(text: str) -> Command
```

#### `stdin_file`

```text
def stdin_file(path: StrPath) -> Command
```

#### `keep_stdin_open`

```text
def keep_stdin_open() -> Command
```

#### `inherit_stdin`

```text
def inherit_stdin() -> Command
```

Give the child this process's **own** stdin — it reads directly from
whatever the parent's stdin is connected to (a terminal, a file, a pipe)
instead of a crate-managed pipe. The stdin counterpart of
``stdout("inherit")``: the child *shares* the parent's stream. Reach for
it when a child must talk to the real terminal — ``git commit`` opening
``$EDITOR``, a tool prompting for a password/confirmation, or forwarding
the parent's piped stdin straight through. There is no writer to
``RunningProcess.take_stdin()`` (it raises, as for a non-kept-open run);
stdout/stderr are unaffected, so ``run()``/``output()`` still return the
child's stdout. Mutually exclusive with a *mediated* stdin — a configured
``stdin_bytes()``/``stdin_text()``/``stdin_file()`` source or
``keep_stdin_open()``: the conflict is rejected as a ``ProcessError`` at
**launch** (from the run/output verb), not when you build the ``Command``;
the live ``Runner`` and the test doubles reject it identically. Drop the
other stdin knob to resolve it.

#### `timeout`

```text
def timeout(seconds: float) -> Command
```

#### `idle_timeout`

```text
def idle_timeout(seconds: float) -> Command
```

Set an idle (inactivity) timeout: tear the child down if it produces
no watched output *line* for ``seconds`` (finite, ``> 0`` — else
``ValueError``, exactly like ``timeout``). For the "hung tool" case a
plain ``timeout`` handles poorly: a legitimately long job keeps emitting
progress, so you bound its *silence* instead of guessing a generous
wall-clock ceiling. Composes with ``timeout``; last write wins against an
earlier ``idle_timeout``.

When it fires, the streaming iterator raises ``IdleTimeout`` (a
``ProcessError`` sibling of ``Timeout`` carrying ``idle_timeout_seconds``)
— a *distinct* signal, deliberately not the wall-clock ``timed_out`` /
``Timeout``, so the two timeout classes stay tellable apart and the
captured ``timed_out`` contract is untouched (an idle-timeout never sets
it).

Enforced on the streaming/interactive surface only — ``start()`` /
``astart()`` + one of the output iterators (``stdout_lines()``,
``stderr_lines()``, ``output_events()``, or ``lifecycle_events()``) — since idle
monitoring rides that per-line channel. ``stdout_lines()`` watches only
stdout activity; the other three use the merged event stream, so either
piped stream resets their window. The one-shot capture verbs
(``output``/``run``/``exit_code``/``probe`` and their ``a``-twins), the
``Pipeline``, and ``Supervisor`` run entirely inside the crate, which has
no native idle-timeout to enforce, so they do **not** honor it (that needs
upstream support). Monitoring rides the streaming verbs, which the crate
gates on stdout being piped: under
``stdout_file``/``stdout("inherit")``/``stdout("null")`` both
all four output iterators raise ``ProcessError`` ("stdout is not piped")
at setup, so an idle-timeout on a redirected stdout is
diagnosed there — never silently un-enforced. ``stderr_file`` leaves
stdout piped, so idle monitoring keeps working on the stdout channel.

#### `timeout_grace`

```text
def timeout_grace(seconds: float) -> Command
```

#### `timeout_signal`

```text
def timeout_signal(name: SignalName | int) -> Command
```

The signal sent first on a graceful timeout (default ``"term"``): a
name (``term``/``kill``/``int``/``hup``/``quit``/``usr1``/``usr2``) or a
raw platform signal number (Unix only). A raw number is validated as a
real, deliverable signal — on Unix ``1..=SIGRTMAX`` (``0``, the existence
probe that delivers nothing, negatives, and out-of-range raise
``ValueError``); on Windows a raw number raises ``Unsupported`` (only
``"kill"`` is deliverable there). A ``bool`` raises ``TypeError`` — it is
an ``int`` subtype that would otherwise silently become raw signal
``1``/``0``.

#### `no_timeout`

```text
def no_timeout() -> Command
```

#### `timeout_opt`

```text
def timeout_opt(seconds: float | None) -> Command
```

#### `cancel_on`

```text
def cancel_on(token: CancellationToken) -> Command
```

#### `success_codes`

```text
def success_codes(codes: Sequence[int]) -> Command
```

#### `retry`

```text
def retry(
    retry_if: RetryIf,
    *,
    max_retries: int | None = ...,
    initial_backoff: float | None = ...,
    multiplier: float | None = ...,
    max_backoff: float | None = ...,
    jitter: bool | None = ...,
) -> Command
```

#### `retry_never`

```text
def retry_never() -> Command
```

#### `pty`

```text
def pty(*, cols: int | None = ..., rows: int | None = ...) -> Command
```

Spawn under a pseudo-terminal, optionally with an initial size.

The terminal merges stdout and stderr into the stdout stream. Provide
``cols`` and ``rows`` together; both must be positive. Combine with
``keep_stdin_open()`` for interactive input through ``take_stdin()``.
Inherited or redirected stdio conflicts are rejected while building the
command, before a child can spawn.

#### `stdout`

```text
def stdout(mode: Literal['pipe', 'inherit', 'null']) -> Command
```

#### `stderr`

```text
def stderr(mode: Literal['pipe', 'inherit', 'null']) -> Command
```

#### `encoding`

```text
def encoding(label: str) -> Command
```

#### `stdout_encoding`

```text
def stdout_encoding(label: str) -> Command
```

#### `stderr_encoding`

```text
def stderr_encoding(label: str) -> Command
```

#### `line_terminator`

```text
def line_terminator(mode: LineTerminatorName) -> Command
```

Choose where the line pump splits **both** streams into lines.
``"newline"`` (the default) splits on ``\n`` only; ``"carriage_return"``
also splits on a bare ``\r`` (one not immediately followed by ``\n``),
delivered live — for ``curl``/``pip``/``apt``-style ``\r``-redrawn
progress output that would otherwise pile up into a single line until
EOF. A ``\r\n`` pair still counts as one terminator. Shared by
``stdout_lines()``/``output_events()``, the per-line handlers
(``on_stdout_line``/``on_stderr_line``), ``stdout_tee``/``stderr_tee``,
and ``output_string`` alike; set both streams here or independently
with ``stdout_line_terminator``/``stderr_line_terminator``. Unknown
preset raises ``ValueError``.

#### `stdout_line_terminator`

```text
def stdout_line_terminator(mode: LineTerminatorName) -> Command
```

Choose where the line pump splits **stdout** into lines (see
``line_terminator``); stderr framing is left untouched.

#### `stderr_line_terminator`

```text
def stderr_line_terminator(mode: LineTerminatorName) -> Command
```

Choose where the line pump splits **stderr** into lines (see
``line_terminator``); stdout framing is left untouched. Handy when
progress output lands on stderr while stdout stays newline-structured.

#### `sanitize_vt`

```text
def sanitize_vt() -> Command
```

Strip VT/ANSI escapes and lone terminal controls from captured
**stdout and stderr**, especially a PTY's merged stdout.

Bytes are decoded first, then split using the configured
``line_terminator``; sanitization runs on each decoded line immediately
before capture. ``ProcessResult`` and the line-streaming iterators
therefore expose clean text with unchanged line boundaries.

Capture-only: per-line callbacks and ``stdout_tee``/``stderr_tee`` see
the original decoded lines. ``output_bytes()`` preserves raw stdout
bytes, but stderr remains line-decoded and is therefore sanitized when
stderr sanitization is enabled. Direct ``stdout_file``/``stderr_file``
redirects preserve original bytes. Inert for an inherited or null
stream because no capture pump runs. Available on every supported
platform and off by default.

#### `stdout_sanitize_vt`

```text
def stdout_sanitize_vt() -> Command
```

Enable ``sanitize_vt`` for captured **stdout** only. Decoding and
line splitting happen first; tees and direct redirects remain raw.

#### `stderr_sanitize_vt`

```text
def stderr_sanitize_vt() -> Command
```

Enable ``sanitize_vt`` for captured **stderr** only. Decoding and
line splitting happen first; tees and direct redirects remain raw.

#### `stdout_tee`

```text
def stdout_tee(sink: StrPath | SupportsWrite, *, append: bool = ...) -> Command
```

Tee every decoded stdout line (line + ``\n``) to ``sink`` as it is
produced, while the run *also* keeps capturing the full output (the sink
does not steal from ``ProcessResult.stdout``).

``sink`` is either a **file path** (``str`` / ``os.PathLike[str]``) or a
**Python writer** — any object with a callable ``write()`` (an
``io.StringIO``, ``sys.stderr``, a text-mode file, a logger wrapper),
picked apart by whether it exposes ``write`` (neither ``str`` nor
``pathlib.Path`` does).

- *File path:* teed as raw UTF-8 bytes, opened **at build time** (not at
  run) — created if absent and truncated, or append mode when
  ``append=True``; an unopenable path raises the matching ``OSError``
  subclass right here.
- *Writer:* each decoded line (then ``"\n"``) is passed to ``write()``
  as a ``str`` (a text sink — a binary writer whose ``write(str)`` raises
  ``TypeError`` is the wrong object here). Every write is dispatched to a
  blocking thread and awaited on the pump, so a slow ``write()`` applies
  backpressure without blocking the event loop; the object is **not**
  closed for you. ``append`` is meaningless for a writer — passing
  ``append=True`` with one raises ``ValueError``.

A write error disables the tee for the rest of the run (a ``tracing``
warning under ``enable_logging()``) while the run and its captured result
continue unaffected; a writer's ``write()`` exception is additionally
reported via ``sys.unraisablehook``. Inert unless stdout is piped through
the line pump — a no-op under ``stdout("inherit")`` / ``stdout("null")``
and under ``output_bytes()`` (raw capture).

#### `stderr_tee`

```text
def stderr_tee(sink: StrPath | SupportsWrite, *, append: bool = ...) -> Command
```

Tee every decoded stderr line to ``sink``. Same contract as
``stdout_tee`` — a file path (opened at build time, truncate by default
or ``append``) or a Python writer object with a callable ``write()`` (fed
each decoded line as a ``str`` via the same blocking-pool async-write
bridge, never closed for you), coexisting with capture, inert unless
stderr is piped through the line pump.

#### `stdout_raw_tee`

```text
def stdout_raw_tee(sink: StrPath | SupportsWriteBytes, *, append: bool = ...) -> Command
```

Tee the child's stdout to ``sink`` **byte for byte, before any
decoding or line splitting** — the raw-bytes cousin of ``stdout_tee``.

Same sink forms as ``stdout_tee`` — a file path (opened at build time,
truncated by default or ``append``) or a Python writer with a callable
``write()`` — but since the whole point is byte-exact fidelity, a
writer here receives each chunk as ``bytes``, never decoded, so it must
be a **binary** writer (``io.BytesIO``, a ``"wb"`` file — not
``sys.stderr`` or an ``io.StringIO``, whose ``write(bytes)`` raises
``TypeError``). Non-UTF-8 output, CRLF, a lone ``\r``, and a missing
final newline all pass through untouched, and a line an
``OutputBufferPolicy`` drops from every decoded sink still reaches this
tee whole.

Independent of the decoded path — coexists with ``stdout_tee``,
``on_stdout_line``, and ordinary capture; all configured sinks fire
from the same pump. Requires a piped stdout: a no-op under
``stdout("inherit")`` / ``stdout("null")`` / a ``stdout_file()``
redirect (no capture pump runs) and under ``output_bytes()`` (its own
return value already *is* the raw stdout, a separate raw drain with no
line pump) — use it alongside the line/streaming verbs instead. A
second call replaces an earlier one; a write error disables the raw
tee for the rest of the run, leaving the run and its captured result
unaffected.

#### `stderr_raw_tee`

```text
def stderr_raw_tee(sink: StrPath | SupportsWriteBytes, *, append: bool = ...) -> Command
```

Tee the child's stderr to ``sink`` byte for byte, before any
decoding or line splitting. Same contract as ``stdout_raw_tee`` —
verbatim bytes (non-UTF-8, CRLF, a missing final newline, and
buffer-policy-dropped lines all preserved), a binary writer required
for the Python-writer sink form, independent of
``stderr_tee``/``on_stderr_line``, and requiring stderr to be piped.

#### `stdout_file`

```text
def stdout_file(path: StrPath, *, append: bool = ...) -> Command
```

Redirect the child's stdout **straight to a file**, opened at spawn
time — the child writes to the file's own descriptor, with no
parent-side pump, tee, or capture buffer. The direct-redirect cousin of
``stdout_tee`` (which instead *also* captures and mirrors each line): a
``>`` / ``>>`` shell redirect, minus the shell.

The binding folds the crate's three spellings (``stdout_file`` /
``stdout_file_append`` / ``stdout_file_truncate``) into one ``append=``
kwarg, mirroring the sibling ``stdout_tee(sink, *, append=False)`` rather
than a 1:1 copy of the core's convenience aliases. ``append=False`` (the
default) **creates or truncates** the file on every spawn; ``append=True``
**creates or appends** — the mode for a shared log across ``Supervisor``
incarnations / ``retry()`` attempts (each re-run appends to the one file
with no separator).

**Opened at spawn, not now (unlike ``stdout_tee``).** Only the path is
stored; the file is opened when the command launches, so a not-yet-
existing path is not a build-time error and each re-run / retry reopens
it. An unopenable path (a missing parent directory, a permission denial)
surfaces from the run verb at launch, not from this call.

**No capture — use a non-capturing verb.** With stdout on the file there
is no pipe to read, so the capture/streaming verbs (``output()`` /
``run()`` / ``output_bytes()`` / their ``a``-twins, and ``start()`` +
``stdout_lines()`` / ``output_events()``) raise ``ProcessError`` ("stdout
is not piped … so the capture verbs have nothing to read") instead of
returning empty output — drive it with ``exit_code()`` / ``probe()``. A
later ``stdout("pipe"/"inherit"/"null")`` **clears** the redirect and
restores the ordinary stdio mode.

#### `stderr_file`

```text
def stderr_file(path: StrPath, *, append: bool = ...) -> Command
```

Redirect the child's stderr **straight to a file**, opened at spawn
time. Same contract as ``stdout_file`` — a child-owned descriptor with no
parent-side pump/tee/buffer, the path opened lazily at launch (a missing
path is not a build-time error), ``append=False`` truncating on every
spawn while ``append=True`` appends (the shared-``Supervisor``-log mode),
and a later ``stderr("pipe"/"inherit"/"null")`` clearing the redirect.

Unlike ``stdout_file``, this does **not** disable the capture verbs: only
a non-piped *stdout* gates them, so ``output()`` / ``run()`` keep working
and return the child's stdout while stderr is diverted to the file and
``result.stderr`` comes back empty.

#### `on_stdout_line`

```text
def on_stdout_line(callback: Callable[[str], None]) -> Command
```

Call ``callback`` with every decoded stdout line as it is produced —
the way to give the **synchronous** surface (``.output()``/``.run()``)
live progress observation during an otherwise-blocking call, without
losing the full capture: ``callback`` observes the same decoded lines
that land in ``ProcessResult.stdout``, it does not replace them. Also
fires on the async verbs and on a streamed run (``start()``/
``astart()`` + ``stdout_lines()``/``output_events()``) — one callback,
every path.

``callback`` is infallible: an exception raised inside it is reported
via ``sys.unraisablehook`` rather than propagated — it never derails
the run or alters the captured result. At most one handler per stream
— a repeat call replaces the previous one (builder semantics, like
``timeout()``); compose inside one callable to fan out.

Inert under ``stdout("inherit")``/``stdout("null")`` (no pump runs)
and under ``output_bytes()`` (stdout is captured raw there, bypassing
the line pump).

#### `on_stderr_line`

```text
def on_stderr_line(callback: Callable[[str], None]) -> Command
```

Call ``callback`` with every decoded stderr line as it is produced.
Same contract as ``on_stdout_line`` — full capture unaffected, fires on
sync/async/streamed paths alike, infallible (a raising callback goes to
``sys.unraisablehook``, never propagates), at most one handler per
stream.

Inert under ``stderr("inherit")``/``stderr("null")``. Unlike
``on_stdout_line``, **not** silenced by ``output_bytes()``: that verb
only bypasses the *stdout* line pump for its raw-bytes capture —
stderr still decodes through the line pump exactly as under
``output()``, so this callback still fires.

#### `kill_on_parent_death`

```text
def kill_on_parent_death() -> Command
```

Add best-effort hardening for abrupt owner death, beyond ordinary
kill-on-drop teardown. Windows already reaps the whole Job Object tree;
Linux arms ``PR_SET_PDEATHSIG`` for the direct child only (grandchildren
survive); macOS/BSD have no equivalent and treat this as a no-op. Use
``kill_on_parent_death_scope()`` to report the platform's actual reach.

#### `kill_on_parent_death_scope`

```text
def kill_on_parent_death_scope() -> str
```

The scope of parent-death cleanup this platform actually achieves
when the owner dies **abruptly** (a ``SIGKILL`` or crash, where graceful
``Drop`` teardown never runs), as a stable string:

- ``"whole_tree"`` — Windows: the kernel closes the Job Object handle on
  owner death and kill-on-close reaps the direct child and every
  descendant.
- ``"direct_child_only"`` — Linux: ``PR_SET_PDEATHSIG`` reaches only the
  direct child; grandchildren survive the owner's abrupt death.
- ``"unsupported"`` — macOS/BSD: no ``pdeathsig`` equivalent, so an
  abrupt owner death triggers no cleanup at all.

An honest capability report, not a request — read it to state the real
reach of ``kill_on_parent_death()`` (best-effort on Unix) instead of
overpromising a whole-tree guarantee the OS cannot keep. It covers only
the abrupt-death path: ordinary graceful teardown still kills the whole
tree on every platform regardless.

A ``staticmethod`` — the scope is fixed per target at build time and does
not depend on instance state or on whether ``kill_on_parent_death()`` was
called, so call it on the class (``Command.kill_on_parent_death_scope()``)
or on any instance for the same answer.

#### `create_no_window`

```text
def create_no_window() -> Command
```

#### `windows_graceful_ctrl_break`

```text
def windows_graceful_ctrl_break() -> Command
```

Windows: opt in to a **graceful teardown** — at a graceful timeout
(``timeout_grace``) or a ``ProcessGroup`` shutdown, send the direct
child a console ``CTRL_BREAK`` before the grace window, so a console
child (a CLI, Node, Python, or Go service that installs a ``CTRL_BREAK``
handler) can flush and exit cleanly ahead of the hard
``TerminateJobObject`` fallback. Without it Windows has no soft-signal
tier and a graceful timeout collapses straight to an atomic Job Object
kill; any survivor past the grace is still hard-killed.

**Boundaries.** *Console-only* — a child spawned ``create_no_window()``
(or otherwise detached) shares no console, never receives the event, and
rides the grace to the hard kill; a GUI/service parent with no console
of its own can't deliver it either. It is ``CTRL_BREAK``, **not**
``CTRL_C``, and ``timeout_signal`` does not apply. Only the **direct
child** is addressed — its own descendants get it via the shared
console/group, but an ``adopt``ed process does not. A harmless **no-op
outside Windows** (Unix's graceful tier already sends a real signal) —
unlike the POSIX-only ``uid``/``gid``/``groups``/``setsid``/``umask``,
which raise ``Unsupported`` off-platform rather than silently
no-op'ing.

#### `uid`

```text
def uid(uid: int) -> Command
```

#### `gid`

```text
def gid(gid: int) -> Command
```

#### `groups`

```text
def groups(gids: Sequence[int]) -> Command
```

#### `setsid`

```text
def setsid() -> Command
```

#### `umask`

```text
def umask(mask: int) -> Command
```

#### `rlimit`

```text
def rlimit(resource: RlimitResourceName, soft: int, hard: int) -> Command
```

Set a POSIX per-process ``setrlimit(2)`` resource limit for the child,
installed after ``fork`` and before ``exec``.

``resource`` is one of ``RlimitResourceName``: ``"cpu"``, ``"core"``,
``"data"``, ``"file_size"``, ``"no_file"``, ``"stack"`` — an unknown
name raises ``ValueError`` immediately. ``soft``/``hard`` use the
resource's native unit (bytes for size limits, seconds for CPU, a
count for open files); ``soft`` must not exceed ``hard`` — an invalid
pair raises a predictable error before spawning, never a silent
correction. Calls for different resources accumulate; repeating the
same resource is last-write-wins. Complements the group-wide
``ProcessGroup(max_memory=...)`` cap with a finer per-command knob
that also works where cgroup limits are unavailable (non-root
cgroup, macOS/BSD). Raises ``Unsupported`` off-POSIX, like
``uid``/``gid``/``groups``/``setsid``/``umask``.

#### `priority`

```text
def priority(level: Priority) -> Command
```

#### `cpu_affinity`

```text
def cpu_affinity(cpus: Sequence[int]) -> Command
```

Restrict the child tree to logical CPU indices.

Supported on Linux and Windows. Launching elsewhere raises
``Unsupported`` rather than silently ignoring the request. The set must
be non-empty and representable by the platform; duplicates are removed,
indices are sorted, and repeated calls are last-write-wins.

#### `io_priority`

```text
def io_priority(class_name: IoPriorityClass, *, level: int | None = ...) -> Command
```

Set Linux I/O scheduling priority.

``idle`` accepts no level; ``best_effort`` and ``real_time`` require
``level=0..7`` (zero is highest). Launching outside Linux raises
``Unsupported`` rather than silently ignoring the requested QoS.

#### `output_limit`

```text
def output_limit(
    *,
    max_bytes: int | None = ...,
    max_lines: int | None = ...,
    on_overflow: Literal['drop_oldest', 'drop_newest', 'error'] = ...,
) -> Command
```

#### `output`

```text
def output() -> ProcessResult
```

#### `output_bytes`

```text
def output_bytes() -> BytesResult
```

#### `run`

```text
def run() -> str
```

#### `run_json`

```text
def run_json() -> Any
```

Require a zero exit and decode stdout with ``json.loads``.

Process failures match ``run()``. Invalid JSON raises ``InvalidJson``
with ``program`` and a bounded ``stdout`` fragment.

#### `exit_code`

```text
def exit_code() -> int
```

#### `probe`

```text
def probe() -> bool
```

#### `resolve_program`

```text
def resolve_program() -> str
```

Resolve this command's ``program`` to a concrete executable path
**without launching it** — a spawn-free, side-effect-free preflight
("is this tool installed?"). Reuses the same PATH/PATHEXT/execute-bit
lookup a real run performs — a bare name against this command's
``prefer_local()`` directories (priority order) then the effective
``PATH``, a path-form program directly — honoring a relocated child
``PATH`` (``env()``/``env_remove()``/``env_clear()``/``inherit_env()``),
so the result is exactly what a spawn of this same command would find.
Returns the resolved **absolute** path; raises ``ProcessNotFound`` (also
a ``FileNotFoundError``, with a ``searched`` diagnostic) on a miss. No
``a``-prefixed async twin — the probe is synchronous and needs no
runtime.

#### `spawn_detached`

```text
def spawn_detached() -> DetachedChild
```

Launch outside processkit containment and return a pid-only handle.

This deliberately opts out of the no-orphan guarantee: dropping the
handle does not kill or reap the child. Owner-dependent configuration
is rejected with ``Unsupported`` instead of being ignored.

#### `aoutput`

```text
def aoutput() -> Awaitable[ProcessResult]
```

#### `aoutput_bytes`

```text
def aoutput_bytes() -> Awaitable[BytesResult]
```

#### `arun`

```text
def arun() -> Awaitable[str]
```

#### `arun_json`

```text
def arun_json() -> Awaitable[Any]
```

Async counterpart of ``run_json()``.

#### `aexit_code`

```text
def aexit_code() -> Awaitable[int]
```

#### `aprobe`

```text
def aprobe() -> Awaitable[bool]
```

#### `start`

```text
def start() -> RunningProcess
```

#### `astart`

```text
def astart() -> Awaitable[RunningProcess]
```

#### `unchecked_in_pipe`

```text
def unchecked_in_pipe() -> Command
```

#### `merge_stderr_in_pipe`

```text
def merge_stderr_in_pipe() -> Command
```

Merge this stage's stderr into its stdout pipe when it is a
**non-final** ``Pipeline`` stage — the shell-free equivalent of
``command 2>&1 | next``. Stdout and stderr get cloned handles to the
same anonymous-pipe writer; the OS preserves write order, and the
downstream stage reads the combined byte stream from its stdin.

Opt-in per stage and a **no-op outside a ``Pipeline`` or on the final
stage** — no effect on a standalone command, and a pipeline only
activates it on a non-final stage. On an affected stage it overrides
that stage's configured stdout/stderr destinations, since both
streams must point at the downstream pipe.

**Pipefail diagnostic trade-off.** Once stderr enters the downstream
pipe it is no longer available as that stage's own stderr capture: if
pipefail attributes the chain's failure to this stage,
``ProcessResult.stderr`` is empty for it — the merged bytes may
instead surface in the final stage's stdout after passing through the
rest of the pipeline.

#### `program`

```text
program: str
```

#### `arguments`

```text
arguments: list[str]
```

#### `configured_arg0`

```text
configured_arg0: str | None
```

The explicit Unix ``argv[0]`` override configured via ``arg0()``,
or ``None`` if unset. Exposes the routing input to
``ScriptedRunner.when()`` predicates and other command inspection
without conflating it with ``program``, which remains the executable
lookup key. Still set on a non-Unix platform before a run would
reject it (see ``arg0()``).

#### `command_line`

```text
def command_line() -> str
```

#### `pipe`

```text
def pipe(other: Command) -> Pipeline
```

### `DetachedChild`

```text
class DetachedChild
```

Pid-only handle for a child deliberately outside containment.

Dropping this object has no effect on the child. It intentionally has no
kill, wait, capture, timeout, or stdin surface.

#### `pid`

```text
pid: int
```

### `CliClient`

```text
CliClient(
    program: StrPath,
    *,
    default_timeout: float | None = ...,
    default_env: Mapping[str, str] | None = ...,
    default_env_remove: Sequence[str] | None = ...,
    default_env_fn: Mapping[str, Callable[[], str]] | None = ...,
    default_retry_if: RetryIf | None = ...,
    default_max_retries: int | None = ...,
    default_initial_backoff: float | None = ...,
    default_multiplier: float | None = ...,
    default_max_backoff: float | None = ...,
    default_jitter: bool | None = ...,
    default_cancel_on: CancellationToken | None = ...,
    runner: RunnerLike | None = ...,
)
```

A program bound to default timeout/env/retry, run with the real
`Runner` by default or an injected `runner=` (a `ScriptedRunner` and
friends, for testable code with no real spawns). The verbs take just the
per-call arguments.

#### `command`

```text
def command(args: Args) -> Command
```

A `Command` for `program <args>`, the client's defaults pre-applied
— chain more builders, then pass it to a verb below instead of a plain
arg list. An explicit setting on it always wins over the default.

#### `run`

```text
def run(call: Args | Command) -> str
```

#### `run_json`

```text
def run_json(call: Args | Command) -> Any
```

Run like ``run`` (require a zero exit) and parse the stdout as JSON,
returning the decoded object (a ``dict``/``list``/``str``/number/
``bool``/``None``) — the ``run(...)`` + ``json.loads(...)`` +
error-attribution boilerplate the many CLIs that emit machine JSON
otherwise force on every caller. A non-zero exit raises ``NonZeroExit``
(like ``run``); stdout that does not parse raises ``InvalidJson`` (a
``ProcessError`` carrying the client's ``program`` and a bounded stdout
fragment), never a bare ``json.JSONDecodeError``. Returns ``Any``: JSON
admits any of those shapes, so narrow the result yourself.

#### `output`

```text
def output(call: Args | Command) -> ProcessResult
```

#### `output_bytes`

```text
def output_bytes(call: Args | Command) -> BytesResult
```

#### `exit_code`

```text
def exit_code(call: Args | Command) -> int
```

#### `probe`

```text
def probe(call: Args | Command) -> bool
```

#### `resolve_program`

```text
def resolve_program() -> str
```

Resolve this client's ``program`` to a concrete executable path
**without spawning it** — the client-level preflight ("is this tool
installed?"), with no side effects. Applies the client's defaults (so a
``default_env``/``default_env_fn`` that relocates ``PATH`` is honored
as at launch), then resolves via the same PATH/PATHEXT/execute-bit logic
a real run uses. Returns the resolved **absolute** path; a
``default_env_fn`` that raises or returns a non-``str`` aborts it
fail-closed (like the run verbs), and a miss raises ``ProcessNotFound``
(also a ``FileNotFoundError``, with a ``searched`` diagnostic). No
``a``-prefixed async twin — the probe is synchronous.

#### `arun`

```text
def arun(call: Args | Command) -> Awaitable[str]
```

#### `arun_json`

```text
def arun_json(call: Args | Command) -> Awaitable[Any]
```

Async counterpart of ``run_json()`` — await it for the decoded JSON
object. Same contract: a non-zero exit propagates ``NonZeroExit``,
stdout that does not parse propagates ``InvalidJson`` (never a bare
``json.JSONDecodeError``), both out of the ``await``.

#### `aoutput`

```text
def aoutput(call: Args | Command) -> Awaitable[ProcessResult]
```

#### `aoutput_bytes`

```text
def aoutput_bytes(call: Args | Command) -> Awaitable[BytesResult]
```

#### `aexit_code`

```text
def aexit_code(call: Args | Command) -> Awaitable[int]
```

#### `aprobe`

```text
def aprobe(call: Args | Command) -> Awaitable[bool]
```

### `Pipeline`

```text
class Pipeline
```

A shell-free pipeline `a | b | c`. Each stage runs in its own
kill-on-drop sub-group; checked failure, chain timeout, or cancellation fans
teardown across every sub-group, while outcome attribution follows pipefail
semantics.

By design, no `start`/`astart` — see `Command.pipe()`'s stub/binding
comment: a pipeline is a whole-chain verb, with no natural "handle to a
live chain" to hand back. Stream an individual stage by `start()`ing that
one `Command` directly instead.

#### `pipe`

```text
def pipe(other: Command) -> Pipeline
```

#### `timeout`

```text
def timeout(seconds: float) -> Pipeline
```

#### `cancel_on`

```text
def cancel_on(token: CancellationToken) -> Pipeline
```

#### `output`

```text
def output() -> ProcessResult
```

#### `output_bytes`

```text
def output_bytes() -> BytesResult
```

#### `run`

```text
def run() -> str
```

#### `exit_code`

```text
def exit_code() -> int
```

#### `probe`

```text
def probe() -> bool
```

#### `aoutput`

```text
def aoutput() -> Awaitable[ProcessResult]
```

#### `aoutput_bytes`

```text
def aoutput_bytes() -> Awaitable[BytesResult]
```

#### `arun`

```text
def arun() -> Awaitable[str]
```

#### `aexit_code`

```text
def aexit_code() -> Awaitable[int]
```

#### `aprobe`

```text
def aprobe() -> Awaitable[bool]
```

### `RunningProcess`

```text
class RunningProcess
```

A handle to a started process: stream output, write stdin, wait for exit.

Usable as a (async) context manager — exiting the block tears the process
down (a hard kill of the whole private tree for a standalone
``start()``/``astart()`` handle). ``stdout_lines()`` / ``stderr_lines()`` /
``output_events()`` / ``lifecycle_events()`` / ``take_stdin()`` / ``kill()``
are *synchronous* setup calls; the
iterator / handle they return is what you await.

Every consuming verb — ``outcome``/``finish``/``output``/``output_bytes``/
``profile``/``shutdown`` — comes in a sync/async pair, like everywhere else
in this library: the bare name blocks the calling thread (via the same
interruptible driver as ``Command.output()``), the ``a``-prefixed twin is a
coroutine (``outcome``/``aoutcome`` rather than the unusable ``wait``/
``await``, since ``await`` is a reserved word). Either member of a pair
**consumes** the handle — afterwards it is spent (``pid`` and the other
getters return ``None``, and every consuming verb raises). Use whichever
matches your calling code, regardless of whether the handle came from
``start()`` or ``astart()``.

The partial-tail readiness probes — ``wait_for_output``/``await_for_output``
and ``wait_for_stderr_output``/``await_for_stderr_output`` — follow the same
naming rule but are **not** consuming: they only peek at the live output
tail, so the handle stays fully usable (that is what makes the "wait for the
prompt, then answer it over ``take_stdin()``" dialog possible).

#### `pid`

```text
pid: int | None
```

#### `elapsed_seconds`

```text
elapsed_seconds: float | None
```

#### `cpu_time_seconds`

```text
cpu_time_seconds: float | None
```

#### `peak_memory_bytes`

```text
peak_memory_bytes: int | None
```

#### `stdout_line_count`

```text
stdout_line_count: int | None
```

#### `stderr_line_count`

```text
stderr_line_count: int | None
```

#### `stdout_bytes_seen`

```text
stdout_bytes_seen: int | None
```

Raw bytes read from stdout's pipe so far, before decoding or line
splitting (``None`` once consumed). Monotonic; includes bytes
discarded by any ``OutputBufferPolicy`` (including oversized lines);
stable after the process and its pump complete. ``0`` — not a
sentinel — for a stream that is never pumped (a file redirect,
``stdout("null")``, ``stdout("inherit")``).

#### `stderr_bytes_seen`

```text
stderr_bytes_seen: int | None
```

Raw bytes read from stderr's pipe so far, before decoding or line
splitting (``None`` once consumed). Same contract as
``stdout_bytes_seen`` — monotonic, includes discarded bytes, stable
after completion, ``0`` (not a sentinel) for an unpumped stream.

#### `owns_group`

```text
owns_group: bool | None
```

#### `stdout_lines`

```text
def stdout_lines() -> StdoutLines
```

#### `stdout_json_lines`

```text
def stdout_json_lines() -> JsonLines
```

Stream stdout as one decoded JSON value per line (strict NDJSON).

A malformed line raises `InvalidJson` — carrying the line number and a
bounded fragment of that line in its message, plus ``program``, but
(unlike ``run_json()`` / ``arun_json()``) **no** ``stdout`` (a
streamed run never buffers the whole payload) — and the stream
continues with the next line. The message also carries a real
column/byte offset for a genuine JSON syntax error (whether caught by
the crate itself or by Python's own `json.loads()`); the rare
non-syntax decode failure (e.g. a bare integer literal past Python's
`sys.set_int_max_str_digits()` limit) has no parser position to
report and says so instead of inventing one. Same one-shot-stdout and
consuming/streaming-conflict rules as ``stdout_lines()``: call once,
and never after another consumer already took stdout.

#### `stderr_lines`

```text
def stderr_lines() -> StderrLines
```

Stream decoded stderr lines while background-draining stdout.

Consumes the same one-shot output as ``stdout_lines()``,
``output_events()``, and ``lifecycle_events()``. Afterwards use
``finish()``/``afinish()`` or
``outcome()``/``aoutcome()`` to report the run.

#### `output_events`

```text
def output_events() -> OutputEvents
```

Interleaved stdout+stderr lines as an async iterator (call once).

Consumes both pipes, so pick this **or** ``stdout_lines()``. Report the
run afterwards with ``finish()``/``afinish()`` (outcome + stderr) or
``outcome()``/``aoutcome()`` — they report it whether you iterate to the
end or ``break`` out early. ``output()``/``output_bytes()``/``profile()``
raise once this stream has taken the run over, which it does as soon as
it observes the child exit: its stdout was streamed away and its stderr
was delivered as events, so they have nothing left to capture or
sample. Teardown is unaffected — leaving the handle's context-manager
block (or dropping it) hard-kills the whole tree even after the stream
has taken the run over, which is what stops a grandchild still holding
the pipe from outliving the block.

#### `lifecycle_events`

```text
def lifecycle_events() -> LifecycleEvents
```

The full ordered process lifecycle as an async iterator (call once).

The first event is ``started`` with the pid, stdout/stderr events carry
decoded lines, and the final ``exited`` event carries the ``Outcome``.
This consumes the same one-shot stream as ``output_events()``; choose
one. Draining it drives the run to completion, after which a finisher
reports the same run.

#### `wait_for_output`

```text
def wait_for_output(predicate: str | Callable[[str], bool], *, timeout: float) -> str
```

Wait until stdout's un-terminated **tail** matches, and return it.

The ``expect``-style probe for prompts that never become lines —
``"Password: "``, ``"(y/N) "``, a REPL ``">>> "``: written without a
trailing newline and then blocked on, so ``stdout_lines()`` (and
`wait_for_line` over it) cannot see them until the stream ends. This
watches the live partial line the output pump has decoded but not yet
split, so you can match a prompt and answer it with ``take_stdin()``.
PTY dialogs are the motivating case; a newline-less progress meter on an
ordinary pipe works the same way.

``predicate`` is a `str` (substring of the tail) or a callable
``predicate(tail) -> bool``, exactly like `wait_for_line`. ``timeout``
is keyword-only seconds — `ValueError` for NaN/negative, and
``timeout=0`` still checks the current tail once ("whatever is decoded
by now", so it is only meaningful on a handle already probed or
streamed — the call that installs the output pump has nothing decoded
yet). On expiry raises `WaitTimeout` (a `ProcessError` **and** a
`TimeoutError`, carrying ``timeout_seconds``), like every other
readiness probe here; if the stream *ends* before a match it raises
`ProcessError` right away rather than waiting out the deadline, exactly
as `wait_for_line` does. A failed probe **never kills the child** and
never arms the run's own ``timeout()`` watchdog, so it cannot flip an
outcome to timed-out.

**Non-consuming and repeatable** — a multi-turn dialog is a sequence of
probe → answer turns. Answer a prompt before waiting for the next: a
still-standing tail matches again, and a tail moves on only once the
child ends that line (making it an ordinary `stdout_lines()` line).

The tail is the **whole current partial line**, not just the newest
fragment (two prompts with no newline between them arrive concatenated),
so match with ``in``/``endswith`` rather than equality. It is also
**raw** — capture redaction and ``sanitize_vt()`` both run per completed
line, so on a terminal the tail still carries its escape sequences.
Match the plain text of a prompt, never assume a scrubbed fragment, and
match on prompts rather than on secret-bearing text.

**Order against the streaming verbs.** Probing installs stdout's one
line pump, like the crate's own line probes: bind ``stdout_lines()`` /
``stdout_json_lines()`` / ``output_events()`` / ``stderr_lines()`` /
``lifecycle_events()`` **before** the first probe and both work together
(the tail is a side channel and steals nothing from the iterator), but a
stream opened *after* a probe raises `ProcessError`.
``finish()``/``outcome()``/``output()`` (and their ``a``-twins) still
report the run afterwards; ``output_bytes()``/``aoutput_bytes()`` do not
— raw bytes are unrecoverable once stdout is decoded into lines.

#### `await_for_output`

```text
def await_for_output(
    predicate: str | Callable[[str], bool],
    *,
    timeout: float,
) -> Awaitable[str]
```

Async counterpart of `wait_for_output` (the ``a``-prefixed twin —
``await`` is a reserved word). Awaiting it never blocks the event loop,
so a task answering the previous prompt keeps running.

#### `wait_for_stderr_output`

```text
def wait_for_stderr_output(
    predicate: str | Callable[[str], bool],
    *,
    timeout: float,
) -> str
```

Wait until **stderr's** un-terminated tail matches, and return it.

The stderr counterpart of `wait_for_output` — same predicate shapes,
same keyword-only ``timeout`` and `WaitTimeout` deadline, same
non-consuming, non-killing, repeatable semantics — for tools that prompt
on stderr and keep stdout for data.

The streams are **not** symmetrical: this raises `ProcessError` when
stderr is not piped, which includes every ``pty()`` run (a PTY has one
merged terminal stream, exposed as stdout — use `wait_for_output` for
terminal prompts) and any command built with
``stderr("null")``/``stderr("inherit")``/``stderr_file(...)``.

#### `await_for_stderr_output`

```text
def await_for_stderr_output(
    predicate: str | Callable[[str], bool],
    *,
    timeout: float,
) -> Awaitable[str]
```

Async counterpart of `wait_for_stderr_output`.

#### `take_stdin`

```text
def take_stdin() -> ProcessStdin
```

The writable stdin handle. Raises `ProcessError` if stdin was not kept
open (build the `Command` with ``keep_stdin_open()``) or was already
taken — so a missing setup fails here, not with a later `AttributeError`.

#### `resize_pty`

```text
def resize_pty(cols: int, rows: int) -> None
```

Resize a live pseudo-terminal; reject non-PTY or exited runs.

#### `kill`

```text
def kill() -> None
```

Begin tearing the tree down without waiting (like
``subprocess.Popen.kill()``: fire-and-forget).

#### `outcome`

```text
def outcome() -> Outcome
```

#### `aoutcome`

```text
def aoutcome() -> Awaitable[Outcome]
```

#### `finish`

```text
def finish() -> Finished
```

#### `afinish`

```text
def afinish() -> Awaitable[Finished]
```

#### `output`

```text
def output() -> ProcessResult
```

Wait for exit and capture the full `ProcessResult`; consumes the handle.

Raises `ProcessError` once an ``output_events()`` stream has taken this
run over — which that stream does as soon as it observes the child exit,
whether or not you iterated to the end: it consumed stdout, delivered
stderr as events and completed the run, so there is nothing left to
capture. Read such a run with ``finish()``/``afinish()`` or
``outcome()``/``aoutcome()`` instead. Stopping the iteration while the
child is still running leaves the run with this handle, and this still
does what it did before the processkit 3.0 migration: returns empty
captures with a real outcome. Which of the two a given ``break`` gets
depends on the child's timing, so after streaming events prefer a
finisher.

#### `aoutput`

```text
def aoutput() -> Awaitable[ProcessResult]
```

Async counterpart of `output` — same ``output_events()`` restriction.

#### `output_bytes`

```text
def output_bytes() -> BytesResult
```

Wait for exit and capture raw stdout as a `BytesResult`; consumes the
handle. Raises `ProcessError` once an ``output_events()`` stream has
taken the run over, under the same condition and for the same reason as
`output`.

#### `aoutput_bytes`

```text
def aoutput_bytes() -> Awaitable[BytesResult]
```

Async counterpart of `output_bytes` — same ``output_events()`` restriction.

#### `profile`

```text
def profile(every_seconds: float) -> RunProfile
```

Wait for exit while sampling resource usage every ``every_seconds``,
returning a `RunProfile`; consumes the handle.

Raises `ProcessError` once an ``output_events()`` stream has taken this
run over — which that stream does as soon as it observes the child exit,
whether or not you iterated to the end: the run is over, so there is no
live run left to sample. Use ``finish()``/``afinish()`` or ``outcome()``/
``aoutcome()`` for its outcome. Stopping the iteration while the child is
still running leaves the run with this handle, and this still profiles
the rest of it.

#### `aprofile`

```text
def aprofile(every_seconds: float) -> Awaitable[RunProfile]
```

Async counterpart of `profile` — same ``output_events()`` restriction.

#### `shutdown`

```text
def shutdown(grace_seconds: float) -> Outcome
```

Graceful teardown (signal -> wait ``grace_seconds`` -> hard kill),
returning the `Outcome`; consumes the handle. Only for a standalone
``start()``/``astart()`` handle — a handle from ``ProcessGroup.start()``
raises `Unsupported`; tear such a child down via the group (or `kill()`).
Named to match ``ProcessGroup.shutdown()``/``ashutdown()``.

After an ``output_events()`` stream has taken the run over the child has
already exited, so there is nothing to signal: this reports that run's
real outcome, waiting for its output to finish draining just as
``finish()`` does, rather than escalating against surviving
grandchildren. Leave the handle's context-manager block instead when a
hard bound matters more than the outcome.

#### `ashutdown`

```text
def ashutdown(grace_seconds: float) -> Awaitable[Outcome]
```

Async counterpart of `shutdown`.

## Program resolution

Resolve a program to its concrete executable path *without* launching it — a spawn-free preflight ("is this tool installed?") that reuses the same PATH/PATHEXT/execute-bit lookup a real run performs, so it never disagrees with what a spawn would find. The module-level `which` searches the process `PATH`; `Command.resolve_program()` and `CliClient.resolve_program()` additionally honor a `prefer_local` directory and a relocated child `PATH`. A miss raises `ProcessNotFound`.

### `which`

```text
def which(program: StrPath) -> str
```

## Results & outcomes

What a finished (or streamed) run reports back. A non-zero exit, a timeout, and a signal-kill are all *data* on these types — never raised by the capturing verbs.

### `ProcessResult`

```text
class ProcessResult
```

The captured result of a finished run. A non-zero exit, a timeout, and a
signal-kill are all reported as data here — never raised by `output()`.

Value semantics: `==`/`hash()` compare every field (program/stdout/stderr/
outcome/success codes — not the incidental `duration_seconds`/`truncated`).
**Not** picklable: equality also spans the configured `timeout` and accepted
`success_codes`, which processkit exposes no accessor to read back, so a
pickled result could not reconstruct them and would compare unequal to its
original for any command that set `.timeout(...)`/`.success_codes(...)`;
pickling raises `TypeError`. Pickle `result.outcome` (an `Outcome`, which
round-trips exactly — e.g. to return it from a
`concurrent.futures.ProcessPoolExecutor` worker), or persist
`result.stdout`/`.stderr`/`.code` yourself, to cross a process boundary.

#### `stdout`

```text
stdout: str
```

#### `stderr`

```text
stderr: str
```

#### `code`

```text
code: int | None
```

#### `is_success`

```text
is_success: bool
```

#### `timed_out`

```text
timed_out: bool
```

#### `signal`

```text
signal: int | None
```

#### `program`

```text
program: str
```

#### `duration_seconds`

```text
duration_seconds: float
```

#### `truncated`

```text
truncated: bool
```

#### `combined`

```text
combined: str
```

#### `diagnostic`

```text
diagnostic: str | None
```

The best human-facing message: stderr if it carries text, otherwise
stdout, otherwise ``None`` if both are blank — the same preference
order as ``NonZeroExit``/``Timeout``/``Signalled.diagnostic``.

#### `outcome`

```text
outcome: Outcome
```

The full run outcome (``code`` / ``signal`` / ``timed_out``), the
same value ``RunProfile.outcome`` and the checking-verb exceptions
expose.

#### `ensure_success`

```text
def ensure_success() -> ProcessResult
```

Raise the same exception a checking verb would if this result's
exit isn't in ``success_codes``; returns ``self`` unchanged otherwise,
so it composes: ``cmd.output().ensure_success().stdout``.

### `BytesResult`

```text
class BytesResult
```

The captured result of a run with raw-bytes stdout (`Command.output_bytes()`);
stderr stays decoded text. A non-zero exit, a timeout, and a signal-kill are
all data here, never raised.

Value semantics: `==`/`hash()` compare every field, same as `ProcessResult`.
**Not** picklable — raw stdout may not be valid UTF-8 and processkit has no
way to reconstruct one from arbitrary bytes outside a real run; pickling
raises `TypeError`. Pickle a `ProcessResult` (`Command.output()`) instead,
or persist the fields you need yourself.

#### `stdout`

```text
stdout: bytes
```

#### `stderr`

```text
stderr: str
```

#### `code`

```text
code: int | None
```

#### `is_success`

```text
is_success: bool
```

#### `timed_out`

```text
timed_out: bool
```

#### `signal`

```text
signal: int | None
```

#### `program`

```text
program: str
```

#### `duration_seconds`

```text
duration_seconds: float
```

#### `truncated`

```text
truncated: bool
```

Whether captured output was truncated by an ``output_limit(...)`` cap
— the line-captured stderr under any cap, and (since processkit 2.1.0)
the raw stdout too when an ``output_limit(max_bytes=...)`` byte ceiling
bounds it to a head/tail. A ``max_lines`` cap never truncates raw stdout
(bytes have no line count); only a ``max_bytes`` cap does.

#### `diagnostic`

```text
diagnostic: str | None
```

See ``ProcessResult.diagnostic``. Raw stdout is lossily decoded to
text for this message when stderr is blank.

#### `outcome`

```text
outcome: Outcome
```

See ``ProcessResult.outcome``.

#### `ensure_success`

```text
def ensure_success() -> BytesResult
```

See ``ProcessResult.ensure_success()``.

### `Outcome`

```text
class Outcome
```

How a process ended.

There is no `is_success` here on purpose: an `Outcome` carries no
`success_codes` context, so it cannot give the command's own success verdict
the way `ProcessResult.is_success` does. Use `exited_zero` for the literal
"exit code 0" test, or compare `code` against your accepted set.

Value semantics: `==`/`hash()` compare `code`/`signal`/`timed_out`
(equivalently, which variant this is and its payload); picklable.

#### `code`

```text
code: int | None
```

#### `signal`

```text
signal: int | None
```

#### `timed_out`

```text
timed_out: bool
```

#### `exited_zero`

```text
exited_zero: bool
```

### `Finished`

```text
class Finished
```

A process's outcome plus captured stderr (stdout was streamed).

Mirrors `Outcome`'s `code`, `exited_zero`, `timed_out`, and `signal`
directly (in addition to the nested `outcome`), so callers don't need to
reach through `.outcome` for fields they already use on `Outcome`. Like
`Outcome`, it exposes `exited_zero` (literal "exit code 0"), not an
`is_success` that would falsely imply `success_codes` were considered.

Value semantics: `==`/`hash()` compare `outcome`/`stderr`; picklable.

#### `outcome`

```text
outcome: Outcome
```

#### `stderr`

```text
stderr: str
```

#### `code`

```text
code: int | None
```

#### `exited_zero`

```text
exited_zero: bool
```

#### `timed_out`

```text
timed_out: bool
```

#### `signal`

```text
signal: int | None
```

### `RunProfile`

```text
class RunProfile
```

A resource-usage profile sampled across a run (`RunningProcess.profile`),
plus the run's `outcome` — `profile()` is a superset of `outcome()`.

Value semantics: `==`/`hash()` compare every field (`outcome`/
`duration_seconds`/`cpu_time_seconds`/`peak_memory_bytes`/`samples`; all
exact underneath, though two are exposed here as `float`). **Not**
picklable — it reports live OS resource-sampling telemetry that processkit
has no way to reconstruct outside an actual monitored run; pickling raises
`TypeError`.

#### `code`

```text
code: int | None
```

#### `signal`

```text
signal: int | None
```

#### `timed_out`

```text
timed_out: bool
```

#### `outcome`

```text
outcome: Outcome
```

#### `duration_seconds`

```text
duration_seconds: float
```

#### `cpu_time_seconds`

```text
cpu_time_seconds: float | None
```

#### `peak_memory_bytes`

```text
peak_memory_bytes: int | None
```

#### `samples`

```text
samples: int
```

#### `avg_cpu_cores`

```text
avg_cpu_cores: float | None
```

## Streaming & interactive I/O

The live handles a started `RunningProcess` hands out: async iterators over its output (line by line, or as interleaved stdout/stderr events) and a writable stdin.

### `StdoutLines`

```text
class StdoutLines
```

Async iterator over a process's stdout, line by line.

### `JsonLines`

```text
class JsonLines
```

Async iterator over a process's stdout, one decoded JSON value per line
(strict NDJSON: every line, including a blank one, must independently
parse). A malformed line raises `InvalidJson` and the stream continues with
the next line — see `RunningProcess.stdout_json_lines()`.

### `StderrLines`

```text
class StderrLines
```

Async iterator over a process's stderr, line by line.

Backed by the merged lifecycle stream: stdout is drained but not yielded.
Choose this or the other one-shot output streams on a process handle.

### `OutputEvents`

```text
class OutputEvents
```

Async iterator over stdout + stderr as interleaved `OutputEvent`s.

Yields **output lines only**. The underlying core stream is the child's whole
lifecycle (it also reports process start and exit), but those non-line events
are filtered out here rather than surfaced as an `OutputEvent` with an empty
``text`` — which would be indistinguishable from a real blank output line.
Process start is `RunningProcess.pid`; the exit is what the finisher you call
afterwards returns.

Draining this iterator to its end also drives the run to completion, so the
documented order — iterate fully, then ``await proc.afinish()`` (or
``aoutcome()``) — terminates. The finisher then reports that same run.

### `OutputEvent`

```text
class OutputEvent
```

One captured line and the stream it came from.

Value semantics: `==`/`hash()` compare `is_stderr`/`text`; picklable.

#### `stream`

```text
stream: Literal['stdout', 'stderr']
```

#### `is_stderr`

```text
is_stderr: bool
```

#### `text`

```text
text: str
```

### `LifecycleEvents`

```text
class LifecycleEvents
```

Async iterator yielding started, output-line, and exited events in order.

### `LifecycleEvent`

```text
class LifecycleEvent
```

One ordered event from a process's full lifecycle stream.

#### `kind`

```text
kind: Literal['started', 'stdout', 'stderr', 'exited', 'unknown']
```

#### `pid`

```text
pid: int | None
```

#### `stream`

```text
stream: Literal['stdout', 'stderr'] | None
```

#### `text`

```text
text: str | None
```

#### `outcome`

```text
outcome: Outcome | None
```

### `ProcessStdin`

```text
class ProcessStdin
```

A writable handle to a running process's stdin (all methods awaitable).

#### `write`

```text
def write(data: ReadableBuffer) -> Awaitable[None]
```

#### `write_line`

```text
def write_line(line: str) -> Awaitable[None]
```

#### `send_control`

```text
def send_control(control: str) -> Awaitable[None]
```

Write one mapped control byte, e.g. ``"c"`` -> Ctrl-C (``\x03``).

With ``Command.pty()`` the byte passes through the terminal line
discipline and can produce a real signal. On an ordinary pipe it remains
a byte that only a cooperating child interprets.

#### `flush`

```text
def flush() -> Awaitable[None]
```

#### `close`

```text
def close() -> Awaitable[None]
```

## Process groups

Kill-on-drop containment for a whole process tree — start children into it, signal or suspend the group, and reap the entire tree (grandchildren included) on exit. `MemberInfo` is the enriched per-member snapshot `members_info()` returns; `sample_stats` turns a one-shot `stats()` snapshot into a periodic async series for live monitoring. The lookup helpers inspect arbitrary processes, while `HostContainment` reports the containment guarantees available on the current host.

### `ProcessGroup`

```text
ProcessGroup(
    *,
    max_memory: int | None = ...,
    max_processes: int | None = ...,
    cpu_quota: float | None = ...,
    shutdown_grace: float | None = ...,
    escalate_to_kill: bool | None = ...,
)
```

A kill-on-drop container for a process tree; use as a (async) context
manager. Also a `ProcessRunner` in its own right (see `_RunnerVerbs`):
its run verbs run `command` as a *shared* member of this group (not a
standalone tree) — the same verb surface `Runner`/`ScriptedRunner`/…
expose (not an `extract_runner` target, though — see `runner.rs`).

#### `mechanism`

```text
mechanism: Literal['job_object', 'cgroup_v2', 'process_group', 'unknown']
```

#### `soft_stop_scope`

```text
soft_stop_scope: Literal['whole_tree', 'opt_in_members', 'none']
```

Current reach of a graceful ``term``/``int`` stop for this group.

#### `members`

```text
def members() -> list[int]
```

#### `members_info`

```text
def members_info() -> list[MemberInfo]
```

An enriched, point-in-time snapshot of the group's members — the same
set as ``members()``, but each pid carried in a `MemberInfo` alongside
best-effort ``ppid``/``exe_name``/``start_time``. Synchronous only (the
crate offers no async twin). See `MemberInfo` for the per-field platform
matrix and the ``start_time`` opacity/pid-reuse note.

#### `adopt_external`

```text
def adopt_external(pid: int) -> None
```

Adopt an already-running external process by pid for containment and
teardown. The pid is an address, not a handle: the crate captures the
process identity during this call, so a later pid reuse is not signalled;
a race before the call, after the caller read the pid, cannot be checked.

Adoption never reaps the process and exposes no completion handle or exit
status. The group can only list it with ``members()`` / ``members_info()``
and signal or tear it down. Windows Job Objects and Linux cgroup v2 also
contain future children; the POSIX process-group fallback normally tracks
only the adopted process individually. FreeBSD and other BSDs raise
``Unsupported``. Linux cgroup-v2 adoption moves the process out of its
previous cgroup; Windows job nesting may be accepted or rejected by the
kernel depending on the existing jobs and call order. ``pid=0`` and the
current process pid, plus a pid naming no process, raise ``ProcessError``;
the latter carries the upstream ``NotFound`` IO condition rather than
``ProcessNotFound``, which is reserved for missing programs.

#### `signal`

```text
def signal(name: SignalName | int) -> None
```

Send a signal to every process in the tree: a name
(``term``/``kill``/``int``/``hup``/``quit``/``usr1``/``usr2``) or a raw
platform signal number (Unix only). On Windows a Job Object has no POSIX
signals, so only ``"kill"`` is deliverable and any other name/number
raises ``Unsupported``. A raw number is validated as a real, deliverable
signal (``1..=SIGRTMAX`` on Unix); ``0`` (the existence probe), negatives,
and out-of-range values raise ``ValueError`` instead of a silent no-op,
and a ``bool`` raises ``TypeError``.

#### `suspend`

```text
def suspend() -> None
```

#### `resume`

```text
def resume() -> None
```

#### `kill_all`

```text
def kill_all() -> None
```

#### `stats`

```text
def stats() -> ProcessGroupStats
```

#### `update_limits`

```text
def update_limits(
    *,
    max_memory: int | None = ...,
    max_processes: int | None = ...,
    cpu_quota: float | None = ...,
) -> None
```

Replace the live group's complete resource-limit set.

Omitted axes become unbounded; this is not a partial merge. The method
is synchronous because the core operation does no asynchronous work.
It raises ``ProcessError`` with ``"busy"`` if another operation on this
group is in flight; after that operation completes, retry the complete
desired set.

#### `stop`

```text
def stop(grace_seconds: float, *, escalate: bool = ...) -> ShutdownReport
```

#### `astop`

```text
def astop(grace_seconds: float, *, escalate: bool = ...) -> Awaitable[ShutdownReport]
```

#### `shutdown`

```text
def shutdown() -> None
```

#### `ashutdown`

```text
def ashutdown() -> Awaitable[None]
```

### `ProcessGroupStats`

```text
class ProcessGroupStats
```

A snapshot of a `ProcessGroup`'s resource usage.

``io_read_bytes`` and ``io_write_bytes`` are cumulative whole-tree counters
when the platform's containment mechanism accounts for them.
``peak_process_count`` is a kernel high-water mark where available; on
Linux cgroup v2 it counts tasks, including threads. ``None`` means that the
mechanism does not provide that measurement, never a fabricated zero. The
exact I/O traffic counted is platform-dependent.

#### `active_process_count`

```text
active_process_count: int
```

#### `peak_memory_bytes`

```text
peak_memory_bytes: int | None
```

#### `total_cpu_time_seconds`

```text
total_cpu_time_seconds: float | None
```

#### `io_read_bytes`

```text
io_read_bytes: int | None
```

#### `io_write_bytes`

```text
io_write_bytes: int | None
```

#### `peak_process_count`

```text
peak_process_count: int | None
```

### `ShutdownReport`

```text
class ShutdownReport
```

Observed facts from one graceful ``ProcessGroup.stop()``.

Member counts describe the platform's containment membership and are
``None`` only when that query failed. On the POSIX process-group fallback,
``members_after`` can temporarily include an unreaped zombie.

#### `soft_signal`

```text
soft_signal: Literal['sent', 'unsupported', 'failed', 'unknown']
```

#### `attempted_signal`

```text
attempted_signal: Literal['term'] | None
```

#### `members_before`

```text
members_before: int | None
```

#### `members_after`

```text
members_after: int | None
```

#### `drained_within_grace`

```text
drained_within_grace: bool
```

#### `escalated`

```text
escalated: bool
```

#### `elapsed_seconds`

```text
elapsed_seconds: float
```

### `MemberInfo`

```text
class MemberInfo
```

An enriched, point-in-time snapshot of one member of a `ProcessGroup`'s
tree — its pid plus best-effort metadata.

The metadata-carrying companion to a bare pid from `ProcessGroup.members()`.
*Which* members appear follows the same platform matrix as ``members()`` (the
whole tree on Windows and the Linux cgroup backend; the tracked group leaders
on the POSIX process-group fallback). Every field beyond ``pid`` is
independently ``None`` wherever the platform can't report it — never a
fabricated value — and a member that exits mid-snapshot is silently omitted,
not invented.

The raw command line / environment is **deliberately never** carried, on any
platform: an argv routinely holds secrets, and redaction is the consumer's
policy to own.

Field availability (``None`` where the platform can't report it): ``ppid``,
``exe_name``, and ``start_time`` are populated on Windows, Linux, and macOS,
and are always ``None`` on the BSDs (no wired-up per-process reader).

#### `pid`

```text
pid: int
```

The member's process id — always present. Point-in-time, like a pid
from ``members()``: pair it with ``start_time`` to tell a recycled number
apart from the original process.

#### `ppid`

```text
ppid: int | None
```

The member's parent process id, or ``None`` where unreadable (always
``None`` on the BSDs).

#### `exe_name`

```text
exe_name: str | None
```

The member's short image **base name** — never a full path, and never a
command line (the crate never exposes argv/env). ``None`` where unreadable
(always ``None`` on the BSDs).

#### `start_time`

```text
start_time: int | None
```

An **opaque per-process identity token**, or ``None`` where unreadable —
**not** a wall-clock timestamp. Its unit and epoch are platform-specific
(Windows creation ``FILETIME``, 100ns intervals since 1601; Linux
``/proc/<pid>/stat`` field 22, clock ticks since boot; macOS microseconds
since the Unix epoch; always ``None`` on the BSDs), so do not interpret it
or compare it across platforms. Its sole use is pairing with ``pid``: two
snapshots whose ``pid`` **and** ``start_time`` both match name the same
process instance, telling a recycled pid apart from the original.

### `HostContainment`

```text
class HostContainment
```

A spawn-free snapshot of this host's containment capabilities.

#### `mechanism`

```text
mechanism: Literal['job_object', 'cgroup_v2', 'process_group']
```

#### `soft_stop_scope`

```text
soft_stop_scope: Literal['whole_tree', 'opt_in_members', 'none']
```

#### `parent_death_cleanup`

```text
parent_death_cleanup: Literal['whole_tree', 'direct_child_only', 'none']
```

#### `crate_version`

```text
crate_version: str
```

### `process_info`

```text
def process_info(pid: int) -> MemberInfo | None
```

Return best-effort metadata for a live pid, or ``None`` when it is gone.

### `process_is_alive`

```text
def process_is_alive(pid: int, start_time: int | None = ...) -> bool
```

Return whether the pid still names the saved process instance.

Pair ``pid`` with ``MemberInfo.start_time`` to reject a recycled pid. When
either token is unavailable, this honestly degrades to bare-pid liveness.
Inspection errors raise ``OSError`` rather than being misreported as dead.

### `host_containment`

```text
def host_containment() -> HostContainment
```

Return a side-effect-free report without creating a group or spawning.

### `sample_stats`

```text
async def sample_stats(
    group: ProcessGroup,
    every: float,
) -> AsyncIterator[ProcessGroupStats]
```

Sample ``group.stats()`` on an interval, forever, as an async series of
`ProcessGroupStats` snapshots — a pure-Python analogue of the crate's
`ProcessGroup::sample_stats` (its `StatsSampler` borrows the group by
lifetime and has no FFI-safe equivalent here; this is plain Python built
directly on the already-public `group.stats()`, living alongside the
readiness helpers above for the same reason).

``async for snapshot in sample_stats(group, every): ...`` — the first
snapshot is taken immediately (no initial sleep), then one every ``every``
seconds, for as long as you keep consuming. There is no overall deadline;
stop by ``break``ing out of the loop or otherwise abandoning/closing the
generator yourself.

**Fused, and louder than the crate's stream.** The crate's `StatsSampler`
swallows the error on the first failed sample and just ends the series
silently — a caller has to separately call `stats()` to learn why. This
generator instead lets `group.stats()`'s own exception (a `ProcessError` —
e.g. "ProcessGroup is already closed" once the group has torn down, or an
`Unsupported`/OS-error-derived failure from the platform's resource query)
propagate out of the ``async for`` untouched — the underlying cause is
never hidden behind a quiet end-of-series. That still fuses the series:
once this generator function raises, it is exhausted by Python's own
async-generator protocol, so a further ``__anext__`` (another loop
iteration, a second ``async for`` over the same object) raises
`StopAsyncIteration` rather than calling `group.stats()` again or
replaying the same error. If the group is already closed/invalid *before
the first snapshot* (e.g. iteration starts only after `group.shutdown()`
already ran), that same exception surfaces on the very first ``async
for`` step, not silently as an empty series.

``every`` is validated up front: NaN and negative values raise
`ValueError` (the shared convention with the readiness helpers'
``timeout``/``interval``). Unlike the crate — which clamps a zero period
to 1 ms because `tokio` panics on a zero-duration interval — ``every=0``
is accepted here as-is: `asyncio.sleep(0)` has no such restriction, so it
means "sample as fast as the event loop allows," with no artificial floor.

## Supervision

Keep a command alive: restart it per a policy, with backoff and jitter, until a stop condition is met.

### `Supervisor`

```text
Supervisor(
    command: Command,
    *,
    restart: Literal['always', 'on_crash', 'never'] | None = ...,
    max_restarts: int | None = ...,
    backoff_initial: float | None = ...,
    backoff_factor: float | None = ...,
    max_backoff: float | None = ...,
    jitter: bool | None = ...,
    stop_when: Callable[[ProcessResult], bool] | None = ...,
    give_up_when: Callable[[ProcessResult | ProcessError], bool] | None = ...,
    storm_pause: float | None = ...,
    failure_threshold: float | None = ...,
    failure_decay: float | None = ...,
    capture_max_bytes: int | None = ...,
    capture_max_lines: int | None = ...,
    capture_on_overflow: Literal['drop_oldest', 'drop_newest', 'error'] | None = ...,
    health_check: Callable[[], bool] | None = ...,
    health_check_interval: float | None = ...,
    health_check_failures: int | None = ...,
    max_memory: int | None = ...,
    max_processes: int | None = ...,
    cpu_quota: float | None = ...,
    runner: RunnerLike | None = ...,
)
```

Keep a command alive: restart per policy with backoff until a stop condition.

``max_memory``, ``max_processes``, and ``cpu_quota`` apply whole-tree
resource caps to the fresh private process group created for every real-run
incarnation. They cannot be combined with ``runner`` because an injected
runner owns its execution semantics. These caps use a capture-only runner:
``start()``/``astart()`` sessions still support status, wait, and stop, but
status has no pid/start time and stop cancels the run instead of gracefully
signalling a live child handle.

#### `run`

```text
def run() -> SupervisionOutcome
```

#### `arun`

```text
def arun() -> Awaitable[SupervisionOutcome]
```

#### `start`

```text
def start() -> SupervisionSession
```

#### `astart`

```text
def astart() -> Awaitable[SupervisionSession]
```

### `SupervisionSession`

```text
class SupervisionSession
```

A live one-shot handle to background supervision.

``wait`` and ``stop`` (or one of their async twins) are terminal, one-shot
operations. Dropping an open session aborts supervision and tears down its
current private process tree.

#### `status`

```text
status: SupervisionStatus
```

#### `wait`

```text
def wait() -> SupervisionOutcome
```

Wait for supervision to end naturally and consume this session.

#### `await_wait`

```text
def await_wait() -> Awaitable[SupervisionOutcome]
```

Async counterpart of ``wait`` (``await`` itself is reserved).

#### `stop`

```text
def stop(grace_seconds: float) -> SupervisionOutcome
```

Gracefully stop the current incarnation and consume this session.

#### `astop`

```text
def astop(grace_seconds: float) -> Awaitable[SupervisionOutcome]
```

Async counterpart of ``stop``.

### `SupervisionStatus`

```text
class SupervisionStatus
```

A consistent point-in-time snapshot of a live supervision session.

``pid`` and ``started_at`` are ``None`` between incarnations, during a
backoff or storm pause, and after completion. A capture-only runner cannot
expose a pid, but still reports the current incarnation's start time.
Resource-capped supervisors use that capture-only path.

#### `is_active`

```text
is_active: bool
```

#### `restarts`

```text
restarts: int
```

#### `is_storm_paused`

```text
is_storm_paused: bool
```

#### `pid`

```text
pid: int | None
```

The current live child pid, or ``None``. Resource-capped supervisors
run capture-only and therefore always report ``None`` here.

#### `started_at`

```text
started_at: float | None
```

Unix timestamp for the current incarnation, or ``None`` between runs.

### `SupervisionOutcome`

```text
class SupervisionOutcome
```

The result of a `Supervisor.run()`.

Value semantics: `==`/`hash()` compare every field (`final_result` via
`ProcessResult`'s own comparison, plus
`restarts`/`stopped`/`storm_pauses`/`liveness_kills`).
**Not** picklable: its identity includes `final_result` (a `ProcessResult`),
which cannot be faithfully reconstructed from a pickle (its `timeout`/
`success_codes` have no accessor to read back), so pickling raises
`TypeError`. Read the fields you need, or pickle `final_result.outcome` (an
`Outcome`, which round-trips exactly), to cross a process boundary.

#### `final_result`

```text
final_result: ProcessResult
```

#### `restarts`

```text
restarts: int
```

#### `stopped`

```text
stopped: Literal['policy_satisfied', 'predicate', 'restarts_exhausted', 'gave_up', 'unhealthy', 'stopped', 'unknown']
```

#### `storm_pauses`

```text
storm_pauses: int
```

#### `liveness_kills`

```text
liveness_kills: int
```

## Cancellation

A portable cancel switch, wired into a run via `Command.cancel_on()`, `Pipeline.cancel_on()`, or `CliClient`'s `default_cancel_on=`.

### `CancellationToken`

```text
class CancellationToken
```

A cancel switch: fire it to tear down every run wired to it via
`Command.cancel_on()` / `CliClient`'s `default_cancel_on=` /
`Pipeline.cancel_on()` — surfacing `Cancelled`. Cheap to clone/share:
every clone refers to the same underlying state, so cancelling any clone
cancels every run wired to it. A cancelled token stays cancelled forever.

`child_token()` derives a separate, scoped token: it is cancelled
automatically when this one is, but cancelling it back does NOT
propagate to this token or to its other children — cancellation only
flows parent-to-child, never child-to-parent or between siblings.

#### `cancel`

```text
def cancel() -> None
```

#### `is_cancelled`

```text
def is_cancelled() -> bool
```

#### `child_token`

```text
def child_token() -> CancellationToken
```

A new token that is cancelled automatically when this one is, but
can also be cancelled independently — cancelling the child does not
affect this token or its other children.

## Batch execution

Run many commands with bounded concurrency, each result — or a `ProcessError` for a spawn/I/O failure — in its own slot. The `output_all` family is *collect-all* (every result in input order once the whole batch finishes); `aoutput_as_completed` and its `_bytes` twin instead stream each `(index, result)` pair *as it finishes*, for progress and early reaction on a large fan-out.

### `output_all`

```text
def output_all(
    commands: Sequence[Command],
    *,
    concurrency: int | None = ...,
    runner: RunnerLike | None = ...,
) -> list[ProcessResult | ProcessError]
```

Run a collect-all batch in input order. With ``concurrency=None``, use
the process-available CPU count (CPU affinity/cgroup-aware), falling back to
``4`` if it cannot be determined. A non-positive value raises `ValueError`.

### `output_all_bytes`

```text
def output_all_bytes(
    commands: Sequence[Command],
    *,
    concurrency: int | None = ...,
    runner: RunnerLike | None = ...,
) -> list[BytesResult | ProcessError]
```

Raw-bytes `output_all` with the same concurrency default and validation.

### `aoutput_all`

```text
def aoutput_all(
    commands: Sequence[Command],
    *,
    concurrency: int | None = ...,
    runner: RunnerLike | None = ...,
) -> Awaitable[list[ProcessResult | ProcessError]]
```

Async `output_all` with the same concurrency default and validation.

### `aoutput_all_bytes`

```text
def aoutput_all_bytes(
    commands: Sequence[Command],
    *,
    concurrency: int | None = ...,
    runner: RunnerLike | None = ...,
) -> Awaitable[list[BytesResult | ProcessError]]
```

Async raw-bytes batch with the same concurrency default and validation.

### `aoutput_as_completed`

```text
def aoutput_as_completed(
    commands: Sequence[Command],
    *,
    concurrency: int | None = None,
) -> AsyncIterator[tuple[int, ProcessResult | ProcessError]]
```

Run ``commands`` with bounded concurrency, yielding each ``(original
index, ProcessResult | ProcessError)`` pair **as that command finishes** —
the streaming, pure-Python counterpart to the compiled `aoutput_all`.

Where `aoutput_all` is *collect-all* (nothing is visible until the whole
batch is done), this is an async iterator — ``async for index, result in
aoutput_as_completed(commands, concurrency=8): ...`` — that hands each
result back the moment its command completes, so a large fan-out reports
progress and lets you react to early finishers instead of blocking on the
slowest command in the batch.

**Completion order, not input order.** Pairs arrive in the order their
commands *finish*, which is generally not the input order; the ``index`` (a
command's position in ``commands``) is what re-associates a result with the
command that produced it. Every command is yielded exactly once, and the
iterator is exhausted once all of them have been.

**Errors are per-slot data, not a series-ending raise** (aligned with
`output_all`): a command that fails to *spawn* — or hits an I/O error, or is
cancelled through its own `CancellationToken` — yields its `ProcessError` in
its own pair, and never short-circuits the others. A non-zero exit, a
timeout, and a signal-kill are, as everywhere in this library, *data* on a
`ProcessResult`, not errors at all.

**Hard concurrency cap.** At most ``concurrency`` commands are ever live at
once (an `asyncio.Semaphore` gates each `Command.aoutput()`), so fanning out
hundreds of commands can't exhaust file descriptors or the process table —
the same bound `aoutput_all` gives, held *while* streaming. ``concurrency``
defaults to the process-available CPU count (CPU affinity/cgroup-aware on
Python 3.13+), falling back to `os.cpu_count()` and then ``4``; this matches
the batch family. A non-positive value raises `ValueError` rather than being
silently clamped.

**No orphans on cancellation or early exit.** Cancelling the task consuming
this iterator — or simply ``break``ing out of the ``async for`` early — tears
down every command still in flight: each `Command.aoutput()` reaps its whole
process subtree (grandchildren included) on cancellation, and this iterator
drives that teardown for *all* live slots before it finishes unwinding. No
started child is left orphaned, whether the batch ran to completion, was
abandoned partway, or was cancelled outright.

Built directly on `Command.aoutput()`; unlike the compiled `aoutput_all`
family it takes no ``runner=`` double — the streaming layer is deliberately
kept minimal, so for a hermetic batch that doesn't need streaming reach for
`aoutput_all(..., runner=...)` instead. For raw ``bytes`` output (no UTF-8
decode) use the twin `aoutput_as_completed_bytes`.

### `aoutput_as_completed_bytes`

```text
def aoutput_as_completed_bytes(
    commands: Sequence[Command],
    *,
    concurrency: int | None = None,
) -> AsyncIterator[tuple[int, BytesResult | ProcessError]]
```

The raw-``bytes`` twin of `aoutput_as_completed`: the identical streaming,
concurrency-cap, per-slot-error, and no-orphan-on-cancellation contract, but
each finished command yields a `BytesResult` — raw-``bytes`` stdout for
non-UTF-8 or binary output, while stderr stays decoded text — in place of a
text `ProcessResult`, mirroring how `aoutput_all_bytes` relates to
`aoutput_all`.
With ``concurrency=None``, it uses the same process-available CPU-count
default (and fallbacks) as every other batch entry point. See
`aoutput_as_completed` for the full contract.

## Readiness helpers

Asyncio helpers that wait for a condition — a matching output line, an open TCP port, an HTTP endpoint answering with an expected status, a filesystem path, a Windows named pipe, or a Unix-domain socket, or any polled predicate — bounded by a deadline.

### `wait_until`

```text
async def wait_until(
    predicate: Callable[[], bool | Awaitable[bool]],
    *,
    timeout: float,
    interval: float = 0.05,
) -> None
```

Poll ``predicate`` until it returns true, or ``timeout`` seconds elapse.

(Named ``wait_until``, not ``wait_for`` — the latter would collide with
``asyncio.wait_for``, whose semantics differ: it bounds one *awaitable*,
not a *polled predicate*.)

``predicate`` may be synchronous or return an awaitable. Polls every
``interval`` seconds; raises `WaitTimeout` (also a `TimeoutError`) if the
deadline passes first. A synchronous ``predicate`` runs on the event loop,
so keep it non-blocking — use an async ``predicate`` for anything that does
I/O. If ``predicate``'s awaitable is already a `asyncio.Future`/`asyncio.Task`
you own, note it is never cancelled by this helper on timeout — only
abandoned, so cancel or await it yourself afterwards if that matters.

``timeout<=0`` contract (shared with `wait_for_port` / `wait_for_line`):
at ``timeout=0``, ``predicate`` is still evaluated (at least once) before
any deadline check, so an already-true predicate succeeds instead of
failing before it was ever checked. A **negative** ``timeout`` is rejected
outright — raises `ValueError`, same as NaN — rather than being treated as
"expired" or silently accepted.

### `wait_for_line`

```text
async def wait_for_line(
    lines: AsyncIterator[Any],
    predicate: str | Callable[[Any], bool],
    *,
    timeout: float,
) -> Any
```

Consume from an async iterator until ``predicate`` matches an item.

``predicate`` is either a callable (``predicate(item) -> bool``) or, for a
`str`-yielding iterator only, a plain `str` — a shorthand for "the item
contains this substring" (``predicate in item``). Not just for
`StdoutLines`: any async iterator works (e.g. `OutputEvents`, with a
callable predicate over its `OutputEvent` items).

Returns the matching item. Raises `WaitTimeout` (also a `TimeoutError`,
carrying ``timeout_seconds``) if nothing matches within ``timeout``
seconds, or propagates whatever ``predicate`` or the iterator itself
raised (a `ProcessError` if the stream ends first) untouched — never
masked behind the timeout. Items read before the match are consumed;
iteration may continue afterward **only when a match was found** — on a
`WaitTimeout`, exactly how far the iterator advanced past the last
inspected item is unspecified (cancellation of the internal scan races the
iterator's own advancement), so don't rely on its position after a
timeout.

``timeout<=0`` contract (shared with `wait_until` / `wait_for_port`): at
``timeout=0``, the iterator is still scanned (at least one tick), so an
item that already matches (already sitting in the iterator) succeeds
instead of failing before it was ever inspected. A **negative** ``timeout``
is rejected outright — raises `ValueError`, same as NaN — rather than being
treated as "expired" or silently accepted.

### `wait_for_port`

```text
async def wait_for_port(
    host: str,
    port: int,
    *,
    timeout: float,
    interval: float = 0.05,
) -> None
```

Wait until a TCP connection to ``(host, port)`` succeeds.

Polls every ``interval`` seconds until the port accepts a connection or
``timeout`` seconds elapse, in which case `WaitTimeout` (also a
`TimeoutError`) is raised — carrying ``host``/``port`` — chained from the
last connection attempt's exception (e.g. a DNS failure survives as the
cause instead of being silently dropped).

``timeout<=0`` contract (shared with `wait_until` / `wait_for_line`): at
``timeout=0``, a connection attempt is still made (at least one), so an
already-ready port succeeds instead of failing before a connection was
ever tried — this first attempt is not cut short by the already-expired
deadline. It IS bounded, though: to a short, fixed event-loop tick (or a
smaller caller-supplied ``interval``), not left uncapped — an
unresolvable/blackhole address would
otherwise be free to block on the OS's own (much longer, or absent)
connect/DNS timeout well past the caller's requested deadline. A
**negative** ``timeout`` is rejected outright — raises `ValueError`, same
as NaN — rather than being treated as "expired" or silently accepted.

### `wait_for_http`

```text
async def wait_for_http(
    host: str,
    port: int,
    path: str = '/',
    *,
    timeout: float,
    interval: float = 0.05,
    expected_status: Container[int] | Callable[[int], bool] | None = None,
) -> None
```

Wait until an HTTP ``GET`` of ``http://host:port/path`` answers with an
acceptable status code.

A stronger readiness signal than `wait_for_port`: a server often *accepts*
TCP connections while still warming up and answering ``503``, so a bare port
probe reports ready too early. This one performs a minimal HTTP/1.1 ``GET``
(hand-rolled over `asyncio.open_connection` — no `http.client` / `urllib` /
third-party dependency) every ``interval`` seconds and succeeds only once the
response's status code is accepted.

``expected_status`` decides what "accepted" means: either a container tested
with ``in`` or a predicate ``Callable[[int], bool]`` for arbitrary logic
(e.g. ``lambda c: c == 204``). The default (``None``) accepts any 2xx code —
equivalent to passing ``range(200, 300)``. The whole request/response is
bounded by the deadline, so a server that accepts the connection but never
answers can't outlive ``timeout``.

On failure the deadline raises `WaitTimeout` (also a `TimeoutError`),
carrying ``host`` / ``port`` / ``path`` and chained (as ``__cause__``) from
the last attempt's failure — a connection error (e.g. a refused connect or a
DNS failure) or a `ProcessError` recording the last unexpected status code —
so the evidence for *why* it never became ready survives.

``timeout<=0`` contract (shared with `wait_until` / `wait_for_port` /
`wait_for_line` / `wait_for_path`): at ``timeout=0`` one request attempt is
still made (at least one), so an already-ready endpoint succeeds instead of
failing before it was ever probed; that first attempt is bounded to a short,
fixed event-loop tick (or a smaller caller-supplied ``interval``), never left
uncapped. A **negative** ``timeout`` is rejected outright — raises
`ValueError`, same as NaN — as is a non-positive ``interval``.

``host`` and ``path`` are validated up front, before any connection is
attempted (fail-fast, not "after one retry cycle"): an IPv6 literal
``host`` may be raw or already bracketed (e.g. ``"::1"`` / ``"[::1]"``);
brackets are removed for the socket connection and present exactly once in
the ``Host`` header per RFC 9112/3986 (``Host: [::1]:8080``, never the
ambiguous ``Host: ::1:8080``). An encoded IPv6 scope separator (``%25``)
is decoded for the socket and encoded exactly once in the header;
a ``path`` containing whitespace or a control character (including
CR/LF — which could otherwise inject extra request/header lines from an
untrusted ``path``) raises `ValueError`; and a ``host``/``path`` with a
character that can't be encoded as latin-1 (required for the request
line) raises `ValueError` instead of a raw `UnicodeEncodeError`.

### `wait_for_path`

```text
async def wait_for_path(
    path: StrPath,
    *,
    timeout: float,
    interval: float = 0.05,
) -> None
```

Wait until ``path`` exists on the filesystem.

Polls every ``interval`` seconds until ``path.exists()`` returns true or
``timeout`` seconds elapse, in which case `WaitTimeout` (also a
`TimeoutError`) is raised, carrying ``path``. A unix-socket, a pid file, or
any other marker file a daemon creates once ready are all typical uses. For
a Unix-domain socket that must actually accept connections, use
`wait_for_unix_socket`; for a TCP port or an arbitrary predicate, see
`wait_for_port` / `wait_until` instead (`wait_until(lambda: path.exists(),
...)` is exactly what this helper does, named for readability and given the
same `WaitTimeout` discipline as its siblings).

``timeout<=0`` contract (shared with `wait_until` / `wait_for_port` /
`wait_for_line`): at ``timeout=0``, ``path`` is still checked (at least
once) before any deadline check, so an already-existing path succeeds
instead of failing before it was ever checked. A **negative** ``timeout``
is rejected outright — raises `ValueError`, same as NaN — rather than
being treated as "expired" or silently accepted.

### `wait_for_named_pipe`

```text
async def wait_for_named_pipe(
    name: str,
    *,
    timeout: float,
    interval: float = 0.05,
) -> None
```

Wait until a Windows named pipe is available or has a busy server.

``name`` is the full pipe path, such as ``r"\\.\pipe\my-service"``.
The pipe's availability is checked with `WaitNamedPipeW`, a non-destructive
operation that does not consume the pipe's instances. A pipe with a busy
server (all instances occupied) is also readiness: it proves that the
server exists. Other failures are retried every ``interval`` seconds until
``timeout`` elapses, then raised as the cause of `WaitTimeout`, whose
``path`` is ``name``.

Platforms without the Windows named-pipe API raise `Unsupported`. At
``timeout=0`` one bounded attempt still runs; negative and NaN timeouts are
rejected with `ValueError`.

### `wait_for_unix_socket`

```text
async def wait_for_unix_socket(
    path: StrPath,
    *,
    timeout: float,
    interval: float = 0.05,
) -> None
```

Wait until a Unix-domain socket at ``path`` accepts a connection.

Unlike `wait_for_path`, this proves that the socket has started accepting
connections, rather than only that its filesystem entry exists. Polls every
``interval`` seconds until a connection succeeds or ``timeout`` seconds
elapse, in which case `WaitTimeout` (also a `TimeoutError`) is raised,
carrying ``path`` and chained from the last connection failure.

Platforms lacking Unix-domain-socket support — no ``socket.AF_UNIX`` or no
``asyncio.open_unix_connection`` (asyncio binds the latter only when the
former existed at import) — raise `Unsupported` instead of silently
downgrading to a filesystem-existence check. At ``timeout=0`` one bounded
connection attempt still runs, so an already-ready socket succeeds; negative
and NaN timeouts are rejected with `ValueError`.

### `WaitTimeout`

```text
WaitTimeout(
    message: str,
    *,
    timeout_seconds: float,
    host: str | None = None,
    port: int | None = None,
    path: StrPath | None = None,
)
```

A readiness helper (`wait_until` / `wait_for_line` / `wait_for_port` /
`wait_for_http` / `wait_for_path` / `wait_for_named_pipe` /
`wait_for_unix_socket`) didn't succeed within its deadline.

Also a builtin `TimeoutError`, so `except TimeoutError` catches it too —
the same convention a run's own `.timeout()` uses (see `Timeout`). Always
carries `timeout_seconds`; `wait_for_port` and `wait_for_http` additionally
set `host` / `port` (and `wait_for_http` also `path`), while `wait_for_path`
`wait_for_named_pipe`, and `wait_for_unix_socket` set `path` (all `None`
for `wait_until` / `wait_for_line`, which have none of these).
`wait_for_port` / `wait_for_http` / `wait_for_named_pipe` /
`wait_for_unix_socket` also chain the last attempt's failure as `__cause__`
(a connection error, or — for `wait_for_http` — the last unexpected status
code).

#### `timeout_seconds`

```text
timeout_seconds = timeout_seconds
```

#### `host`

```text
host = host
```

#### `port`

```text
port = port
```

#### `path`

```text
path = path
```

## Observability

Opt-in bridging of the core's per-run `tracing` events to Python `logging`.

### `enable_logging`

```text
def enable_logging() -> bool
```

## The runner seam

The dependency-injection seam: annotate your code against a protocol, inject the real `Runner` in production and a test double (see the Testing section) in tests. `ProcessRunner` is the capture/check verbs; `StreamingRunner` adds `start`/`astart`.

### `ProcessRunner`

```text
class ProcessRunner
```

The capture/check run verbs as a structural type: `output`/`output_bytes`/
`run`/`exit_code`/`probe` and their `a`-prefixed async twins — no streaming.

Every built-in runner satisfies this (and the wider `StreamingRunner`).
Prefer this narrower protocol when your own code only calls these verbs —
a hand-rolled double then only needs to implement five verbs (times two
for the async twins), not the full runner surface.
`CliClient` also satisfies `ProcessRunner`: each capture/check verb accepts
either per-call `Args` (which it combines with its bound program) or a
`Command` (whose explicit settings win over client defaults). It is not a
`StreamingRunner`, because it has no `start`/`astart` verbs.

#### `output`

```text
def output(command: Command, /) -> ProcessResult
```

#### `output_bytes`

```text
def output_bytes(command: Command, /) -> BytesResult
```

#### `run`

```text
def run(command: Command, /) -> str
```

#### `exit_code`

```text
def exit_code(command: Command, /) -> int
```

#### `probe`

```text
def probe(command: Command, /) -> bool
```

#### `aoutput`

```text
def aoutput(command: Command, /) -> Awaitable[ProcessResult]
```

#### `aoutput_bytes`

```text
def aoutput_bytes(command: Command, /) -> Awaitable[BytesResult]
```

#### `arun`

```text
def arun(command: Command, /) -> Awaitable[str]
```

#### `aexit_code`

```text
def aexit_code(command: Command, /) -> Awaitable[int]
```

#### `aprobe`

```text
def aprobe(command: Command, /) -> Awaitable[bool]
```

### `StreamingRunner`

```text
class StreamingRunner
```

`ProcessRunner` plus `start`/`astart` — the full runner verb surface,
for code that also needs a live `RunningProcess` handle to stream.

`Runner`, `ScriptedRunner`, `RecordReplayRunner`, `RecordingRunner`, and
`DryRunRunner` all satisfy it. A hand-rolled double can implement the
capture/check verbs easily, but `start`/`astart` must return a
`RunningProcess`, which has no public constructor — and the built-in
runners are `@final`, so a fully-conforming custom runner in practice means
*wrapping* one (delegating `start`/`astart` to it; use `ScriptedRunner`
for streaming doubles).

#### `start`

```text
def start(command: Command, /) -> RunningProcess
```

#### `astart`

```text
def astart(command: Command, /) -> Awaitable[RunningProcess]
```

### `Runner`

```text
class Runner
```

The real process runner — inject it for testable code.

## Exceptions

Every error raised by the package descends from `ProcessError`, so a single `except ProcessError` catches them all. `Timeout`, `ProcessNotFound`, and `PermissionDenied` also subclass a builtin (`TimeoutError` / `FileNotFoundError` / `PermissionError`, each itself an `OSError`), so the stdlib `except` clauses catch them too.

### `ProcessError`

```text
class ProcessError
```

Base class for every error raised by this package.

### `NonZeroExit`

```text
class NonZeroExit
```

`run()` / `exit_code()` got a non-zero exit.

#### `program`

```text
program: str
```

#### `code`

```text
code: int
```

#### `stdout`

```text
stdout: str
```

#### `stderr`

```text
stderr: str
```

#### `stdout_bytes`

```text
stdout_bytes: bytes | None
```

#### `diagnostic`

```text
diagnostic: str | None
```

### `Timeout`

```text
class Timeout
```

A run exceeded its configured timeout.

Also a builtin `TimeoutError`, so `except TimeoutError` catches it too —
and since `TimeoutError` is itself an `OSError` subclass (as of Python
3.3), `except OSError` catches it as well (the same is true of
`ProcessNotFound`/`FileNotFoundError` and
`PermissionDenied`/`PermissionError` below — all three dual-base
exceptions are transitively `OSError`).

#### `program`

```text
program: str
```

#### `timeout_seconds`

```text
timeout_seconds: float | None
```

#### `stdout`

```text
stdout: str
```

#### `stderr`

```text
stderr: str
```

#### `stdout_bytes`

```text
stdout_bytes: bytes | None
```

#### `diagnostic`

```text
diagnostic: str | None
```

### `IdleTimeout`

```text
class IdleTimeout
```

A run produced no line on the iterator's watched output channel for its
`Command.idle_timeout(...)` window and was killed.

``stdout_lines()`` watches stdout only; ``stderr_lines()``,
``output_events()``, and ``lifecycle_events()`` count activity on either
piped stream.

A deliberate *sibling* of `Timeout`, not a subclass: an idle (inactivity)
timeout is a distinct condition from a wall-clock `timeout()` expiry — "the
child went silent" vs "the run took too long overall" — so `except
IdleTimeout` does not swallow a wall-clock `Timeout` and vice-versa, while
`except ProcessError` still catches both. Raised from the streaming
output iterators on the handle from
`start()`/`astart()`; the one-shot capture verbs do not enforce
`idle_timeout` (see its docstring).

#### `idle_timeout_seconds`

```text
idle_timeout_seconds: float
```

### `Signalled`

```text
class Signalled
```

A run was killed by a signal.

#### `program`

```text
program: str
```

#### `signal`

```text
signal: int | None
```

#### `stdout`

```text
stdout: str
```

#### `stderr`

```text
stderr: str
```

#### `stdout_bytes`

```text
stdout_bytes: bytes | None
```

#### `diagnostic`

```text
diagnostic: str | None
```

### `ProcessNotFound`

```text
class ProcessNotFound
```

The program could not be found / spawned.

Also a builtin `FileNotFoundError` (what `subprocess` raises), so
`except FileNotFoundError` catches it too.

#### `program`

```text
program: str
```

#### `searched`

```text
searched: str | None
```

### `PermissionDenied`

```text
class PermissionDenied
```

The program could not be spawned because of insufficient permissions
(e.g. a non-executable file), or a permission-denied OS error surfaced
from elsewhere in the run (e.g. a group signal the OS refused).

Also a builtin `PermissionError`, so `except PermissionError` catches it too.

#### `program`

```text
program: str | None
```

### `ResourceLimit`

```text
class ResourceLimit
```

A resource limit (memory / processes / CPU) was invalid or could not be
enforced by the active containment mechanism. The reason is the exception
message (``str(exc)``); it carries no extra structured field.

### `Unsupported`

```text
class Unsupported
```

The operation is not supported on this platform.

#### `operation`

```text
operation: str
```

### `OutputTooLarge`

```text
class OutputTooLarge
```

Captured output hit an `output_limit(..., on_overflow="error")` ceiling.

``total_bytes`` (and the ``max_bytes`` ceiling it crossed) count **raw bytes
read from the child's output pipe** — line terminators and invalid-UTF-8
bytes included — not the bytes of the decoded text on
``ProcessResult.stdout``, so ``total_bytes`` can exceed
``len(stdout.encode())`` for the same run. ``total_lines`` is the line count
of the line-captured output.

#### `program`

```text
program: str
```

#### `max_lines`

```text
max_lines: int | None
```

#### `max_bytes`

```text
max_bytes: int | None
```

#### `total_lines`

```text
total_lines: int
```

#### `total_bytes`

```text
total_bytes: int
```

### `Cancelled`

```text
class Cancelled
```

The run was deliberately cancelled via a `CancellationToken` wired
with `Command.cancel_on()` / `CliClient`'s `default_cancel_on=` /
`Pipeline.cancel_on()`. Terminal — never retried by `Command.retry()` or
restarted by `Supervisor` (the token stays cancelled forever, so another
attempt could only fail the same way).

#### `program`

```text
program: str
```

### `InvalidJson`

```text
class InvalidJson
```

A JSON verb ran the command successfully (a zero exit, like `run`) but
its output did not parse as JSON: a `Command`/`CliClient` `run_json()` /
`arun_json()` whose whole stdout failed to parse, or a
`RunningProcess.stdout_json_lines()` whose current NDJSON line did
(the stream continues with the next line rather than ending).

A `ProcessError` subclass raised in place of a bare `json.JSONDecodeError`,
so the failure is attributed and a single `except ProcessError` still
catches it. `str(exc)` carries the parser's own diagnostic — for the
streaming case, the NDJSON line number and a bounded fragment of that
line, plus the real column/byte offset for a genuine JSON syntax error
(whether the crate itself caught it or Python's own `json.loads()` did);
the rare non-syntax decode failure that has no parser position (e.g. an
integer literal past Python's `sys.set_int_max_str_digits()` limit) says
so instead of inventing one. A deliberate *sibling* of `NonZeroExit`, not
a subclass: the run itself succeeded — only its output *shape* is wrong —
so `except InvalidJson` isolates a bad-payload failure without also
catching a genuine non-zero exit.

#### `program`

```text
program: str
```

#### `stdout`

```text
stdout: str | None
```

## Type aliases

Exported so your own wrappers can annotate against the same types the API accepts.

### `Args`

```text
Args = list[str] | list[Path] | list[os.PathLike[str]] | tuple[StrPath, ...]
```

### `IoPriorityClass`

```text
IoPriorityClass = Literal['idle', 'best_effort', 'real_time']
```

### `LineTerminatorName`

```text
LineTerminatorName = Literal['newline', 'carriage_return']
```

### `Priority`

```text
Priority = Literal['idle', 'below_normal', 'normal', 'above_normal', 'high']
```

### `ReadableBuffer`

```text
ReadableBuffer = bytes | bytearray | memoryview
```

### `RetryIf`

```text
RetryIf = Literal['transient', 'transient_or_timeout']
```

### `RlimitResourceName`

```text
RlimitResourceName = Literal['cpu', 'core', 'data', 'file_size', 'no_file', 'stack']
```

### `SignalName`

```text
SignalName = Literal['term', 'kill', 'int', 'hup', 'quit', 'usr1', 'usr2']
```

### `StrPath`

```text
StrPath = str | os.PathLike[str]
```

## Testing

Runner test doubles, in the `processkit.testing` submodule. Inject one in tests — all satisfy the `ProcessRunner` protocol — so the code under test spawns no real processes.

### `ScriptedRunner`

```text
class ScriptedRunner
```

A scripted test double for `Runner`.

#### `on`

```text
def on(prefix: Args, reply: Reply) -> None
```

#### `on_sequence`

```text
def on_sequence(prefix: Args, replies: Sequence[Reply]) -> None
```

#### `when`

```text
def when(predicate: Callable[[Command], bool], reply: Reply) -> None
```

#### `fallback`

```text
def fallback(reply: Reply) -> None
```

### `RecordReplayRunner`

```text
class RecordReplayRunner
```

Records real runs to a cassette file (`record`) and replays them without
spawning (`replay`); shares the `Runner` run-verb surface.

``scrub`` receives one of ``argument`` / ``cwd`` / ``stdout`` / ``stderr``
plus its text and returns the fixture-safe replacement. Configure the same
deterministic callback for record and replay so redacted match keys agree.

#### `record`

```text
def record(
    path: StrPath,
    *,
    scrub: Callable[[Literal['argument', 'cwd', 'stdout', 'stderr', 'unknown'], str], str] | None = ...,
) -> RecordReplayRunner
```

#### `replay`

```text
def replay(
    path: StrPath,
    *,
    scrub: Callable[[Literal['argument', 'cwd', 'stdout', 'stderr', 'unknown'], str], str] | None = ...,
) -> RecordReplayRunner
```

#### `save`

```text
def save() -> None
```

### `RecordingRunner`

```text
class RecordingRunner
```

A recording test double: replies to every command with a canned `Reply`
and records each call, so a test can assert on what its code ran. Shares the
`Runner` run-verb surface; inspect calls with `calls()` / `only_call()`.

#### `replying`

```text
def replying(reply: Reply) -> RecordingRunner
```

#### `new`

```text
def new(inner: RunnerLike) -> RecordingRunner
```

#### `calls`

```text
def calls() -> list[Invocation]
```

#### `only_call`

```text
def only_call() -> Invocation
```

### `DryRunRunner`

```text
class DryRunRunner
```

A dry-run test double: never spawns a process. Every verb renders the
command to its display-quoted line (like `Command.command_line()`) and
returns a synthetic successful result — the seam behind a tool's own
`--dry-run`/`--echo` mode. Shares the `Runner` run-verb surface; inspect the
rendered lines with `commands()` / `only_command()`, or stream them live
with `on_invocation()`.

#### `on_invocation`

```text
def on_invocation(callback: Callable[[str], None]) -> None
```

#### `commands`

```text
def commands() -> list[str]
```

#### `only_command`

```text
def only_command() -> str
```

### `Reply`

```text
class Reply
```

A canned reply for a `ScriptedRunner` rule.

#### `ok`

```text
def ok(stdout: str) -> Reply
```

#### `fail`

```text
def fail(code: int, stderr: str) -> Reply
```

#### `timeout`

```text
def timeout() -> Reply
```

#### `signalled`

```text
def signalled(signal: int | None = ...) -> Reply
```

#### `pending`

```text
def pending() -> Reply
```

#### `lines`

```text
def lines(lines: Sequence[str]) -> Reply
```

#### `with_stdout`

```text
def with_stdout(stdout: str) -> Reply
```

#### `with_stderr`

```text
def with_stderr(stderr: str) -> Reply
```

#### `with_line_delay`

```text
def with_line_delay(seconds: float) -> Reply
```

### `Invocation`

```text
class Invocation
```

One call captured by a `RecordingRunner`: the program, args, cwd, env
overrides, and whether stdin was supplied. Values are inspectable for
assertions; the `repr` stays redacted (program, arg count, cwd, env names,
has_stdin — never argv or env values).

#### `program`

```text
program: str
```

#### `args`

```text
args: list[str]
```

#### `cwd`

```text
cwd: str | None
```

#### `env`

```text
env: dict[str, str | None]
```

#### `env_is`

```text
def env_is(name: str, value: str) -> bool
```

#### `has_env`

```text
def has_env(name: str) -> bool
```

#### `has_stdin`

```text
has_stdin: bool
```

#### `has_flag`

```text
def has_flag(flag: str) -> bool
```

---

# Architecture

This page is for contributors: how the binding is put together, where the line
between "binding" and "crate" runs, and the conventions that keep the two
layers — and the three parallel views of the public API — in sync. The user-
facing guides (linked from the [docs home](./)) explain *what* the
library does; this page explains *how the code that implements it is
organized*.

## Two layers, one boundary

**processkit-py is a thin PyO3 binding to the [`processkit`](https://crates.io/crates/processkit)
Rust crate — not a reimplementation.** Concretely:

```text
┌───────────────────────────────────────────────────────────────────┐
│  Python package (src/processkit/)                                 │
│  __init__.py facade · _aio.py · _protocols.py · _types.py         │
├───────────────────────────────────────────────────────────────────┤
│  Binding crate (src/*.rs) — cdylib `_processkit`                  │
│  pyclasses/verbs, error mapping, runtime driving — thin glue only │
├───────────────────────────────────────────────────────────────────┤
│  `processkit` crate (crates.io, pinned exact version)             │
│  ALL platform logic: Windows Job Objects, Linux cgroup v2,        │
│  POSIX process groups, race-free spawn, async-throughout (tokio)  │
└───────────────────────────────────────────────────────────────────┘
```

Everything that decides *how a process tree is actually contained and torn
down on a given OS* — Job Object completion ports on Windows, cgroup v2 on
Linux, process-group fallbacks, the race-free spawn sequencing — lives in the
`processkit` crate (see its own docs at [docs.rs/processkit](https://docs.rs/processkit)).
The binding crate (`src/*.rs`, compiled to the cdylib `_processkit`) never
reimplements any of that; it exists solely to:

- expose the crate's types as PyO3 pyclasses with a Python-shaped verb surface
  (kwargs, `str`/`os.PathLike`, sync **and** async pairs),
- drive the crate's `async`-throughout futures to completion from Python's
  sync and async worlds (the `runtime.rs` trio, below),
- map the crate's single `processkit::Error` onto a typed Python exception
  hierarchy (`errors.rs`'s `map_err`, below),
- and re-export a small amount of pure-Python convenience on top (`src/processkit/`,
  further below) that composes on the compiled surface instead of touching the
  OS itself.

If you find yourself teaching the binding crate a new fact about an OS
mechanism, that fact almost certainly belongs upstream in `processkit`
instead — bump the pinned crate version and bind the new capability, don't
duplicate it here.

## The Rust module map

`src/lib.rs` is the `#[pymodule(gil_used = false)]` entry point. It declares no
logic of its own beyond calling each module's `register(m)` — registration is
delegated so that adding a new pyclass or function touches only its own
module, not this central list:

```rust
mod batch;       mod cancellation; mod cli;      mod command;
mod convert;     mod errors;       mod group;    mod logging;
mod result;      mod runner;       mod running;  mod runtime;
mod supervisor;
```

| Module | Owns |
|---|---|
| `command.rs` | The `Command` builder and shell-free `Pipeline`. |
| `runner.rs` | The runner seam: `Runner`, the `ScriptedRunner`/`RecordReplayRunner`/`RecordingRunner`/`DryRunRunner` test doubles, the `Reply` builder, and the `runner_pymethods!` macro (below). |
| `running.rs` | The async streaming/interactive handles: `RunningProcess`, `ProcessStdin`, `StdoutLines`, `OutputEvents`. |
| `group.rs` | The `ProcessGroup` containment container and its `ProcessGroupStats`. |
| `supervisor.rs` | The `Supervisor` (restart/backoff) and its `SupervisionOutcome`. |
| `cli.rs` | `CliClient` — a program plus default timeout/env/retry, with verbs that take just per-call args. |
| `batch.rs` | Module-level batch execution: many `Command`s with bounded concurrency. |
| `result.rs` | The captured-result value types: `ProcessResult`, `BytesResult`, `Outcome`, `OutputEvent`, `Finished`, `RunProfile`. |
| `cancellation.rs` | `CancellationToken`, a portable cancel switch shared by `Command`/`CliClient`/`Pipeline`. |
| `logging.rs` | Opt-in bridge forwarding the crate's `tracing` events to Python's `logging`. |
| `convert.rs` | Small converters from Python-facing strings/numbers to crate types (durations, encodings, retry policy). |
| `errors.rs` | The exception hierarchy and `map_err` — the single crate-error → Python-exception funnel (below). |
| `runtime.rs` | The single tokio runtime and the interruptible blocking driver (`block_on` / `drive_async` / `block_on_interruptible`, below). |

This table is a map, not a promise: consult each module's own doc comment for
the authoritative, current description.

`gil_used = false` opts the module into PEP 703 free-threaded CPython (on a
free-threaded build, importing it does not force the GIL back on). This is
sound only because the binding holds no unsynchronized shared state — see
`lib.rs`'s own comment for the itemized reasons (the tokio runtime is a
managed singleton, exception caches use `PyOnceLock`, stream handles are
`Arc<Mutex<…>>`, the stateful pyclasses that carry consumable/reconfigurable
state — `ProcessGroup`, `RunningProcess`, `ScriptedRunner`, `DryRunRunner` —
are `#[pyclass(frozen)]` with an interior `std::sync::Mutex` that serializes
cross-thread access, and the remaining immutable pyclasses lean on PyO3's own
per-object borrow checking). Keep that invariant in mind before adding any new
shared mutable state to a pyclass.

## The call flow: Python → crate → typed exception

Every consuming verb (`output`, `run`, `exit_code`, `probe`, `start`, and their
`a`-prefixed async twins) funnels through the same shape:

```text
Python call
  │
  ▼
PyO3 pyclass method (#[pymethods], e.g. PyCommand::output / Runner::aoutput)
  │
  ▼
crate future (processkit::Command::output_string(&cmd), etc. — async-throughout)
  │
  ▼
runtime.rs: block_on(...) [sync verbs]  or  drive_async(...) [async verbs]
  │                                            │
  │  block_on_interruptible: GIL released,      │  PyLazyFuture starts work on
  │  polls the future on a fixed tick so a       │  tokio; completion writes to a
  │  blocked Ctrl+C still raises on the main      │  socket and loop.sock_recv
  │  thread; a reentrant call from inside the     │  resolves the Python Future on
  │  runtime (e.g. a Supervisor stop_when         │  the event-loop thread
  │  thread; a reentrant call from inside the     │
  │  runtime (e.g. a Supervisor stop_when         │
  │  callback) is rejected with a clear error      │
  │  instead of panicking tokio                    │
  ▼                                            ▼
Result<T, processkit::Error>
  │
  ▼
errors.rs: map_err(error) -> PyErr   (the ONLY place a crate Error becomes a PyErr)
  │
  ▼
Typed Python exception (ProcessError subclass, or a dual-base one like
Timeout/ProcessNotFound/PermissionDenied that also inherits a builtin)
```

Two invariants worth internalizing when adding a new verb:

- **`runtime.rs` is the only place a future is driven** (`block_on`,
  `drive_async`, and the lower-level `block_on_interruptible` that both build
  on). A new verb should call one of these three, never hand-roll its own
  `rt().block_on(...)` — that's how the reentrancy guard and the Ctrl+C
  polling stay uniform across the whole surface.
- **`map_err` is the only funnel from `processkit::Error` to `PyErr`.** It
  picks the exception class from the error's own accessors
  (`is_timeout()`/`is_not_found()`/`is_permission_denied()`, falling back to a
  match on the enum variant for the rest) and attaches the structured fields
  (`code`, `stdout`, `stderr`, `program`, `signal`, `timeout_seconds`,
  `diagnostic`, output-cap counters) via `setattr`. A new crate error variant
  is covered automatically as long as it exposes the right accessor; no other
  module should construct a `ProcessError` subclass by hand from a crate error.

## Conventions

- **Per-module `register(m)`.** Every `src/*.rs` module exposes
  `pub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()>` that adds
  its own classes/functions (and, for `errors.rs`, the whole exception
  hierarchy). `lib.rs` only calls each `register`; it never lists an
  individual class or function itself. Adding a pyclass or function means
  adding it to its module's `register`, nothing in `lib.rs`.
- **`runner_pymethods!` (`runner.rs`).** PyO3's `multiple-pymethods` feature is
  off, so a pyclass may have only one `#[pymethods]` impl. Five runner
  pyclasses (`Runner`, `ScriptedRunner`, `RecordReplayRunner`,
  `RecordingRunner`, `DryRunRunner`) each need the identical twelve-verb
  surface (`output`/`output_bytes`/`run`/`exit_code`/`probe`/`start`, times
  their `a`-prefixed async twins) forwarding to the generic `runner_*` helper
  functions over `ProcessRunner`. The macro splices that shared block together
  with each type's own unique members (constructor, builders, `__repr__`)
  passed in as a token tree, so the run-verb surface has a single source of
  truth instead of five hand-copied blocks that could drift.
- **Config struct → kwargs, not a mirror pyclass.** When the crate exposes a
  builder/options struct (e.g. `ProcessGroupOptions`), the binding does *not*
  create a matching Python class for it. Instead the pyclass constructor takes
  the options as `#[pyo3(signature = (*, field=None, ...))]` keyword
  arguments, builds the crate's options struct from defaults, and applies only
  what was actually passed (see `PyProcessGroup::new` in `group.rs`). This
  keeps the Python surface flat (`ProcessGroup(max_memory=..., cpu_quota=...)`)
  instead of forcing callers to construct and thread through a second object.
- **Sync/async verb parity (the `a`-prefix).** Every consuming verb ships as a
  pair: a blocking one (`output`, `run`, `start`, …) and an `a`-prefixed
  asyncio one (`aoutput`, `arun`, `astart`, …) that accepts the identical
  arguments and returns the identical wrapped type. This holds across
  `Command`/`Pipeline`, every runner (real and test doubles), `RunningProcess`,
  `ProcessGroup`, `Supervisor`, and `CliClient`. A new verb should ship both
  halves together, wired through `block_on`/`drive_async` respectively.
  `drive_async` returns a **lazy** awaitable (`PyLazyFuture`): it schedules
  nothing until the first `await`, so an `a`-verb built but never awaited starts
  no work and, when dropped, releases what it captured (and tears down a process
  it already owns). Once awaited it delegates to a real `asyncio.Future` for
  cancellation. Tokio stores the completed outcome in Rust memory and wakes
  one shared per-loop `sock_recv` dispatcher; value conversion and Future
  resolution happen on the event-loop thread, so completion never attaches to
  Python from a foreign runtime thread and repeated stream steps do not allocate
  a socket each.
- **`gil_used = false`.** See the free-threading note above — a deliberate,
  narrowly-justified opt-in, not a default to imitate carelessly in a module
  that *does* need shared mutable state outside PyO3's own guarding.

## The pure-Python layer (`src/processkit/`)

Alongside the compiled `_processkit` extension, a small amount of hand-written
Python composes on top of it rather than adding more Rust surface:

- **`_aio.py`** — asyncio readiness helpers (`wait_until`, `wait_for_line`,
  `wait_for_port`, `wait_for_path`) and `WaitTimeout`. These compose on the
  already-compiled async surface (a `StdoutLines` iterator, a plain TCP
  connect) instead of bridging the crate's own probing methods, which keeps
  them simpler and usable against *any* server, not only one this package
  started. It also holds `sample_stats(group, every)`, a periodic
  `ProcessGroupStats` series built directly on `ProcessGroup.stats()` — the
  crate's own `StatsSampler` borrows the group by lifetime and has no
  FFI-safe equivalent, so this is plain Python for the same reason as the
  readiness helpers.
- **`_protocols.py`** — the `ProcessRunner` / `StreamingRunner` `Protocol`
  classes: the typed dependency-injection seam that lets code written against
  "a runner" accept the real `Runner`, any of the test doubles, or a
  hand-rolled double, all checked structurally by the type checker.
- **`_types.py`** — the public type aliases (`StrPath`, `Args`, `SignalName`,
  `RetryIf`, `ReadableBuffer`, `LineTerminatorName`, `Priority`) exported so
  callers can annotate their own wrappers with the same vocabulary the API
  uses.
- **`__init__.py`** — the facade. It re-exports the compiled classes/functions
  from `_processkit` together with the pure-Python helpers above, and its
  `__all__` list **is** the public surface: anything not listed there is not
  public, regardless of what's importable by digging into a submodule. The
  test-double runners (`ScriptedRunner`, `RecordReplayRunner`,
  `RecordingRunner`, `DryRunRunner`, `Reply`, `Invocation`) are deliberately
  excluded from the top-level `__all__` and re-exported instead from
  `processkit.testing`, so the production surface and the testing surface
  stay visibly separate.

## Guarding against drift: the stub/runtime/surface triangle

The compiled module (`_processkit`), the hand-written type stub
(`src/processkit/_processkit.pyi`), and the package's `__all__` re-exports are
three independent, hand-maintained mirrors of one surface. Nothing keeps them
in sync automatically — a renamed method, a new pyclass, or a dropped kwarg
default can drift silently in any one of them. Two independent mechanisms
catch that:

1. **`tests/test_api_surface.py`** is an AST-based drift guard, run as part of
   the normal test suite. It parses `_processkit.pyi` and compares it against
   the compiled module at runtime: every compiled class/function must be
   stubbed (and vice versa), every class's members must match (name, and
   property-vs-method kind), every `__all__` must be sorted/unique/importable
   and cover every compiled export and every shim module's own `__all__`, the
   (async) context-manager dunders must be declared where promised, and every
   exported exception must remain a `ProcessError` subclass. A dedicated test
   (`test_signature_parameters_match_the_stub`) additionally compares each
   callable's actual *parameter list* (name, kind, whether it has a default)
   against the stub's — catching a renamed/reordered kwarg or a dropped
   default that the name-only checks can't see.
2. **`stubtest` (mypy.stubtest)**, run in CI's `typecheck` job
   (`uv run python -m mypy.stubtest processkit --ignore-disjoint-bases
   --allowlist stubtest-allowlist.txt`), checks the stub against the compiled
   module from the opposite direction — signature *shape* (parameter
   names/kinds/defaults) and member existence both ways, at a level
   `test_api_surface.py`'s hand-written checks don't reach. `stubtest-allowlist.txt`
   suppresses only the small set of irreducible false positives this pairing
   produces (PyO3's `__new__`-only construction vs. the stub's `__init__`
   form, module-level `Literal` aliases stubtest doesn't recognize as such,
   and the compiled module's own auto-generated `__all__`) — every entry there
   documents *why* it's a false positive, not a real gap, and an unused entry
   fails CI (`--ignore-disjoint-bases` is passed but
   `--ignore-unused-allowlist` is not), so a stale suppression surfaces on its
   own.

**When you add a new pyclass, method, property, or module-level function:**
add it to the `#[pymethods]`/`#[pyfunction]` in Rust, add the matching
declaration to `_processkit.pyi`, and re-export it (top-level `__init__.py`
for production surface, `processkit/testing.py` for a test double) if it's
meant to be public. Run `uv run pytest tests/test_api_surface.py` and
`uv run python -m mypy.stubtest processkit --ignore-disjoint-bases --allowlist
stubtest-allowlist.txt` locally (both also run in CI) before opening a pull
request — they will fail loudly, and specifically, if any of the three views
disagree.

## Rust unit tests (`cargo test`) vs. the Python suite (`tests/`)

The binding has two independent levels of test coverage, split by what they
can exercise without a live Python interpreter:

- **Rust `#[cfg(test)]` modules** (`src/convert.rs`, `src/supervisor.rs`) cover
  the crate's *pure, PyO3-free helpers* — string/number parsing
  (`parse_priority`, `parse_signal`/`parse_signal_name`, `parse_overflow_mode`,
  `parse_line_terminator`, `parse_restart_policy`, `stop_reason_str`) and
  boundary-value validation (`positive_duration`/`nonnegative_duration`'s
  NaN/infinite/negative/overflowing-`Duration` cases,
  `build_output_buffer_policy`'s cap combinations). These are cheap to write
  and run per-case (every named preset, every alias, the unknown-name
  rejection), which the Python suite can only reach indirectly and rarely
  exhaustively. `cargo test` runs them **without** the `extension-module`
  feature — the crate is deliberately structured (see the `[features]` comment
  in `Cargo.toml`) so `cargo test`/`cargo check` work without ever linking as a
  Python extension; the handful of these tests that do need the GIL (e.g.
  `parse_signal`'s `Bound<'_, PyAny>` argument) call `Python::initialize()`
  first, since nothing else brings up the interpreter in a plain test binary.
  `cargo test` runs in CI's `rust-lint` job alongside `cargo fmt`/`cargo
  clippy`.
- **`tests/` (pytest, `uv run pytest`)** covers everything that needs PyO3, the
  GIL, or a real child process/event loop: the compiled classes' behavior
  (`Command`, `Pipeline`, `ProcessGroup`, `Supervisor`, `CliClient`, the runner
  doubles), the sync/async verb pairs, exception mapping, the stdout/stderr
  capture and tee pipeline, and the stub/runtime/surface drift guards above.
  This is also where a parsing helper's *observable* behavior through the
  Python-facing API is covered end-to-end (e.g. `Command.priority("bogus")`
  raising `ValueError`), even though the exhaustive boundary-value cases for
  the helper itself live in the Rust tests instead.

When adding a new pure helper to `convert.rs`/`supervisor.rs`, prefer a Rust
`#[cfg(test)]` case for its boundary values; reach for a Python test only for
behavior that's actually observable through the compiled API (an exception's
type/message, a builder's resulting policy) rather than the helper's internals
directly.

## ProcessKit 3.2.0 surface audit

The 3.2.0 crate release adds several public Rust surfaces that are intentionally
not swept into the Python binding as an automatic parity exercise. Each item
needs its own contract, tests, and review before it becomes a Python API:

- `SupervisionSession::events()` and `SupervisionEvent` are a plausible future
  async iterator, but the Python representation of the event enum, including
  the bounded-channel `Lagged` case, must be designed first.
- `ProcessGroup::limit_evidence()` is useful post-run evidence, but its
  `Tripped`/`NotTripped`/`Unknown` result must remain explicitly tri-state; it
  must not be reduced to a boolean on platforms where the crate cannot prove
  the verdict.
- `cancel_signal` and `cancel_grace` require an explicit compatibility design
  against the binding's current cancellation and timeout behavior. They are
  not safe as passive builder kwargs until the soft-cancellation ordering and
  platform defaults are documented and tested.
- `output_json` is presently a likely duplicate of the binding's existing
  `run_json()`/`arun_json()` contract. A future proposal should prove a user
  visible semantic gap before adding another verb.
- `report-serde` is an opt-in crate feature and is not needed while the Python
  `ShutdownReport` exposes structured fields directly. Enabling it would be a
  dependency/feature decision, not a free API-parity improvement.
- `Mechanism::ProcessReaper` is platform-specific and the crate enum is
  non-exhaustive. Any Python-facing representation must preserve unknown future
  mechanisms and document the FreeBSD-only availability.
- `wait_for_path` and the crate's HTTP probe overlap with the binding's
  Python-side probes. The Python implementation remains canonical until a
  measured compatibility or performance gap justifies a separate migration.

This audit deliberately creates follow-up scope rather than adding public
symbols here. A follow-up that changes `src/*.rs`, `_processkit.pyi`, or the
top-level exports must carry its own API-surface tests and human-review gate.
