Architecture overview

This document is the map that ties the per-area normative documents together: the module layout, how one run moves data from spawn to exit, how the control-plane clients (inspect/cancel/kill) reach a live runner, where this repository's responsibility ends and the processkit crate's begins, and how the test suite is layered. It does not restate the normative contracts themselves — each links to its own document below — and it is not a substitute for reading the source; treat it as the entry point for a new contributor, sketched from the code as of this writing rather than from memory.

Module map

processkit-cli is a thin binary (src/main.rs) over an internal library (src/lib.rs): every other src/*.rs file is a mod of the library, and the binary only parses argv and dispatches into it (see "Target structure: library and binary" below for why the split exists and what stays stable). Responsibilities, in the order data flows through a run:

ModuleResponsibility
src/lib.rsThe internal library crate root: declares every src/*.rs module and re-exports nothing publicly-supported. It carries the "not a stable public API" disclaimer and marks each module #[doc(hidden)], so the library is only a foundation for the crate's own tooling — never a semver-stable Rust surface.
src/main.rsThe thin binary entry point. Parses Cli, dispatches into the library's subcommand module, and maps the result onto a process exit code: run owns its own exit path (it hard-exits with the child's code and never returns here); every other subcommand's Result<(), RunnerError> is mapped through RunnerError::code(), and a clap parse failure is mapped onto the runner's own USAGE code rather than clap's default.
src/cli/The CLI-flags half of the compatibility surface: the clap-derived Cli/Command types for run, inspect, cancel, kill, wait, events, list, prune, and probe. Parsing and shape validation only — each subcommand's behavior lives in its own module. A directory of nine files, split by subcommand family rather than by size so a new flag or subcommand lands in a focused file: mod.rs holds the top-level Cli/Command shapes and re-exports everything below it, so crate::cli::<Item> stays the single path to every CLI type and parser; run.rs holds RunArgs/CaptureOverflowPolicy; control.rs holds InspectArgs and the TargetArgs shared by cancel/kill; wait.rs, events.rs (whose implementing module is src/events_cmd/, the plain events name being taken by the emitter), list.rs, prune.rs, and probe.rs hold one argument struct each; and parse.rs owns the hand-written value parsers they share (durations, byte sizes, process counts, CPU quotas, exit-code bands, KEY=VALUE entries, run ids) — the functions the fuzz tier drives directly.
src/run/The run subcommand itself: spawns the child into a processkit::ProcessGroup this module owns, selects either default pipe-and-echo I/O or direct inherited stdio, temporarily hands a POSIX terminal to a separate child process group when required, races the child's exit against --timeout/--idle-timeout/a local stop signal (Ctrl-C, on Unix SIGTERM/SIGHUP, and on Windows Ctrl-Break/console close/logoff/system shutdown)/a control-plane command, and drives the shared teardown tiers — the graceful soft stop → grace → hard kill for timeout (whole-run or idle)/a signal cancel/cancel, and the immediate hard kill (no soft stop, no grace) for a control-plane kill. Exit-code fidelity — the child's exact code on a normal completion, a reserved-band code for every runner-imposed ending — is enforced here. A directory of four submodules under one entry point, split by responsibility rather than by size: mod.rs is the entry point (run::execute) plus the shared ending vocabulary (Ending/Termination/CancelSignal/TimeoutTrigger/SoftTerminate); launch.rs owns the run itself (run_async: container creation, spawn, I/O-mode selection, the terminal handoff, and the race); signals.rs owns the runner-imposed deadlines and the platform stop-signal listeners (wait_for_cancel_signal, effective_grace_for); teardown.rs owns the two teardown tiers, the JSONL emitters, and the ending-to-exit-code mapping; and detach.rs is the --detach wrapper (start_detached), which re-spawns this binary detached and returns once the run has provably started, without duplicating any of the above.
src/events.rsThe versioned JSONL lifecycle-event schema and its emitter — this repository's normative, golden-tested public event contract (see docs/schema.md). Also owns argv redaction: the default SHA-256 argv_sha256 fingerprint and the HINT_RULES worker-shape classifier, exposed as one shared CommandFingerprint so the registry record publishes the identical pair (see docs/registry.md, "Which run is which") instead of deriving its own.
src/capture.rs--capture-dir bounded per-stream stdout/stderr capture to files, riding the same tee run already echoes through (no second output-reading path). Records, per stream, a full byte counter, a SHA-256 of the bytes written, and independent explicit truncated/write_error flags, surfaced in the output_captured event.
src/hash.rsThe one hand-rolled incremental/one-shot SHA-256 (FIPS 180-4) both events (argv fingerprint) and capture (streamed transcript hashing) build on, so the project has a single digest primitive and rendering style.
src/text.rsShared human-output primitives: terminal-safe normalization for untrusted text and the column-aligned table renderer used by both list and inspect.
src/registry/mod.rsThe per-user run registry: one record per in-flight run in an owner-only-restricted directory, found by scanning and matching run_id (never a PID), carrying the run's redaction-safe command fingerprint/hint for discovery, with staleness detected via an OS advisory lock the live runner holds (see docs/registry.md). The first brick of the control plane.
src/control/The live-run control plane: a stable facade plus platform transports, bounded rendering, and isolated tests for its line-oriented inspect/cancel/kill protocol (see docs/control-plane.md).
src/list.rsThe list subcommand: a thin, read-only CLI wrapper over Registry::entries that renders every registry entry — whatever its health (live/stale/unprobed) — as a table or JSON Lines, for a caller that has lost (or never had) a run_id.
src/prune.rsThe prune subcommand: an equally thin wrapper over Registry::prune, which owns the whole confirm-before-delete reaping safety rule; this module only opens the registry and reports the tally.
src/wait.rsThe wait subcommand: blocks until a run is no longer live, for a supervisor that is not the runner's parent. Registry-only in both of its modes — it never contacts the runner. --run-id polls Registry::probe_run and has three outcomes (finished, its own --timeout elapsing, an ambiguous run_id); --all (T-216) instead polls a Registry::entries snapshot taken at the moment it starts and has only two — there is no aggregate ambiguity outcome, since --all never resolves an id at all. Both are decided entirely from the registry (see docs/registry.md, "Waiting — wait").
src/events_cmd/The events subcommand: the read-back counterpart to src/events.rs's emitter. Resolves a stream by --run-id through Registry::entries (or takes --file), hands out only complete lines as the file grows, and renders them (render.rs, through src/text.rs's terminal barrier), passes them through verbatim (--json), or checks them against the embedded schema document (--validate: validate.rs runs the pass and owns the exit verdict, schema.rs interprets the document over the keyword subset it uses — refusing to run on anything it does not implement rather than skipping it — and pattern.rs is the tiny anchored matcher its pattern keywords need; tests/events.rs holds that checker's verdict against the jsonschema dev-dependency's, so the binary links no JSON Schema engine of its own). Read-only in the same sense list/wait are: no control transport, no mutation.
src/probe.rsThe side-effect-free probe subcommand: reports (and, with --require-*, verifies) this binary's version/schema_version/exit-code band/CLI surface as one JSON line.
src/exit.rsThe reserved runner-own exit-code band (100119) constants — the exit-code half of the compatibility surface (see docs/exit-codes.md).

Target structure: library and binary

The crate builds two targets from one source tree:

  • an internal library (src/lib.rs, Cargo target processkit_cli) that owns every module under src/, and
  • a thin binary (src/main.rs, Cargo target processkit-cli) that does nothing but parse argv with clap and dispatch each subcommand into the library.

The library exists as a foundation for the crate's own tooling, not as a reusable dependency. Keeping the runner's internals in a [lib] target lets:

  • unit and property tests live in each module's #[cfg(test)] mod tests and run as the library's --lib tier under a plain cargo test, reaching module-private helpers directly — retiring the cargo test --bin workaround the old bin-only layout required;
  • fuzz targets (cargo-fuzz, fuzz/, T-186) link the library and drive parsers of untrusted/semi-trusted input — the registry's bytes → parse/ validate path, the control plane's request-line classifier and response-line decode, the CLI's --timeout/--grace/ --require-exit-code-band/--env/--run-id value parsers, and wait --report-outcome's terminal-outcome read-back over a run's JSONL events file (src/wait.rs, bounded head/tail scan for a terminal runner_exit, T-301); the events reader's own parsers (src/events_cmd/) are not in this tier — a boundary that CONTRIBUTING.md's "Fuzzing" section and docs/threat-model.md both state outright, rather than leaving it to be inferred from this list. A [lib] target is a hard prerequisite cargo-fuzz cannot work without (see CONTRIBUTING.md, "Fuzzing");
  • benchmarks (criterion, benches/, T-187) reach internal primitives — the hand-rolled SHA-256 hasher (src/hash.rs), bounded-capture absorb (src/capture.rs), and the argv-redaction hint classifier (src/events.rs) — directly, to measure them in isolation (see README.md, "Benchmarks").

Because the crate is published to crates.io, the library surface is deliberately not a stable public Rust API: every module is #[doc(hidden)], the crate-root rustdoc says so in as many words, and no item carries a semver guarantee. The only supported compatibility surface stays the binary's contract — the CLI flags (src/cli/), the reserved exit-code band (src/exit.rs, docs/exit-codes.md), and the JSONL schema_version (src/events.rs, docs/schema.md) — exactly as before this split. This document records only the target structure; the fuzz and benchmark tiers themselves (T-186, T-187) are documented in CONTRIBUTING.md ("Fuzzing") and README.md ("Benchmarks") respectively.

Data flow of one run

A processkit-cli run moves through the same sequence on every platform, implemented in run::execute (src/run/mod.rs) and run::launch::run_async (src/run/launch.rs):

  1. (--detach only) Hand the run to a detached copy. run::detach::start_detached re-spawns this binary on the caller's own argv with --detach removed (plus --run-id/--no-echo where the caller left them out) — in a new session on Unix (setsid), as a DETACHED_PROCESS on Windows, with null stdio either way — then waits until that copy's run_started line is readable in --jsonl and exits, reporting only whether the run started (see docs/exit-codes.md, "Detached runs"). The detached copy enters step 1 below and runs every subsequent step unchanged: detaching is a wrapper around this sequence, never a second implementation of it.
  2. Spawn. The child is built from processkit::Command and spawned into a ProcessGroup this process owns — not a shared or global one — so the group's kernel-backed kill-on-drop (Windows Job Object, Linux cgroup/POSIX-group, macOS process group) reaps the whole tree on every exit path this process lives to observe (normal completion, timeout, a local stop signal — Ctrl-C, on Unix SIGTERM/SIGHUP, or on Windows Ctrl-Break/console close/logoff/system shutdown — and a control-plane cancel/kill). That guarantee does not extend to this process's own abrupt death (crash/SIGKILL/TerminateProcess), which skips Drop entirely: reaping a leaked grandchild after the runner itself dies abruptly is a platform-derived tri-state — whole_tree on Windows (Job Object survives the owner's abrupt death), direct_child_only on Linux (kill_on_parent_death/PR_SET_PDEATHSIG, direct child only), none on macOS/BSD — surfaced per run as run_started's abrupt_cleanup field (see K-005). A run_started event (run id, root PID, containment mechanism, abrupt-cleanup tri-state, working directory) opens the JSONL stream.
  3. Select the I/O path. By default, processkit's line pump concurrently reads the child's stdout/stderr and drives two things off the same read: the live echo to this process's own stdout/stderr (src/run/launch.rs), and — when --capture-dir is set — the per-stream tee in src/capture.rs. --no-echo swaps only the echo sink for a discarding one (tokio::io::sink() in place of tokio::io::stdout()/stderr()); the pump, the --capture-dir tee, and the --idle-timeout clock's re-arming on every observed chunk are all unaffected — see src/run/launch.rs's sink-selection block and K-050. --detach reuses that same swap rather than adding a second suppression path: the detached copy is started with --no-echo (step 0). --inherit-stdio instead gives the child the runner's three handles directly; it conflicts with capture (and with --no-echo, which has no pump to act on in this mode, and with --detach, which has no terminal to hand over), so there is no pump or tee. When ProcessKit selects POSIX process-group containment and stdin is a terminal, run temporarily assigns the terminal's foreground group to the child and restores the original group during cleanup. A members_snapshot event records the container's member list — enriched with ppid/executable name/start_time via ProcessKit's members_info() wherever the platform can report them (docs/schema.md, "Enriched member fields") — in either path. With --snapshot-interval the same event is re-emitted on that cadence (reason: "interval") until the ending is decided, from an extra never-resolving arm of the run's existing select! race rather than a thread or a spawned task, and reading the container's member list rather than the pump — which is why it composes with --inherit-stdio too.
  4. Capture and hash. src/capture.rs's CaptureTee mirrors every byte the pump observes into a bounded capture file per stream — independent of whether that byte is also echoed (--no-echo does not change what is captured) — hashing what actually reached disk with src/hash.rs's incremental SHA-256 and recording an explicit ceiling-truncation flag and write-error flag — never inferred from the file's size. This stage is a no-op, with no capture files and no output_captured event, unless --capture-dir was passed; clap rejects --capture-dir together with --inherit-stdio before the child starts.
  5. Teardown. Runner-imposed endings split into two tiers, not one shared path. --timeout elapsing, a local stop signal (interactive Ctrl-C, on Unix a caught SIGTERM/SIGHUP, or on Windows a caught Ctrl-Break/ console close/logoff/system shutdown), and a control-plane cancel reaching the live runner over src/control/mod.rs all drive the same graceful path: a soft stop (SIGTERM to the tree on Unix; on Windows, which has no POSIX signal, a best-effort WM_CLOSE to any windowed member — nothing at all for the ordinary console child, in which case the grace window still elapses honestly with no soft stop delivered), a --grace wait, then the owning ProcessGroup's kernel-backed hard kill-on-drop. A control-plane kill is not part of that tier: it skips the soft stop and the grace window entirely and hard-kills the whole tree immediately via the same kill-on-drop mechanism — documented as immediate on purpose, not a shorter grace. A normal completion instead reaps via the same drop once the child's own exit is observed (root_exited, then cleanup_started/cleanup_finished).
  6. runner_exit. The terminal JSONL event closes the stream, always carrying the outcome — the child's own exit code on a normal completion, or the reserved code for whichever runner-imposed ending fired (timeout/cancelled/killed, or a control-plane cancelled/killed). The process's own exit code (see docs/exit-codes.md) mirrors that same outcome, so a shell that never reads the JSONL stream still gets a faithful, distinguishable signal.

Control-plane contour

inspect, cancel, and kill (src/control/mod.rs) never address a live run by PID; they resolve it through the run registry (src/registry/mod.rs):

  1. Registry scan. registry::Registry::entries lists every record in the per-user registry directory and classifies each by probing the record's advisory liveness lock — a dead runner's leftover record is detected this way, not by mere file existence — as [registry::Health::Live], confirmed-dead [registry::Health::Stale], or (when the probe itself could not run, e.g. permission denied) [registry::Health::Unprobed]; this control-plane contour only ever acts on Live, so Stale and Unprobed are equally unactionable here — but not equally reportable: the refusal names a stale entry as a gone runner and an unprobeable one as unprobed (liveness unknown), the same distinction list/prune/wait draw (see docs/registry.md).
  2. Endpoint resolution. control::resolve_live_endpoint matches the requested run_id against the live entries only. More than one live match is an ambiguous run id — a hard CONTROL (103) failure for every verb, never a guess at which entry the scan happened to return first. The mutating verbs (cancel/kill) additionally re-run this resolution immediately before writing the verb (mutate_async), narrowing — though not fully closing — the TOCTOU window against a duplicate registering mid-flight (see docs/control-plane.md, "Ambiguous run id").
  3. Verb over transport. The client connects to the resolved endpoint — a unix domain socket or a Windows named pipe, both owner-restricted — and speaks the shared line-oriented wire protocol: one request-verb line out, one JSON reply line in. inspect is read-only and prints a Snapshot; cancel/kill are mutating and reuse run's own teardown tiers exactly as described above — cancel the graceful soft-stop → grace → hard-kill tier, kill the immediate hard-kill tier — replying with a ControlAck before the run ends.

See docs/registry.md for the registry's location, record format, and staleness signal, and docs/control-plane.md for the full wire protocol and all three unreachable-runner cases (stale entry vs. unprobeable entry vs. died mid-conversation).

Boundary with processkit

processkit-cli is a thin, standalone wrapper: the processkit crate is the single source of truth for containment, teardown, PID-reuse discipline, and process-tree lifecycle semantics, and this repository builds strictly on its public API rather than reimplementing any of that — a genuine gap becomes an additive request in ProcessKit-rs's own backlog, never a local fork of the semantics (this is a settled, repository-wide decision the module docstrings cite verbatim). Concretely:

  • What processkit owns: the kernel-backed container (ProcessGroup) and its kill-on-drop teardown, the async child-output line pump (with a byte-capped OutputBufferPolicy), direct StdioMode::Inherit, and the environment builder (Command::env/env_remove/env_clear) run's --env* flags map onto directly.
  • What this runner owns: the CLI surface (src/cli/), the versioned JSONL event contract and argv redaction (src/events.rs), the reserved runner-own exit-code band (src/exit.rs), bounded diagnostic capture with hashing (src/capture.rs, src/hash.rs), the per-user run registry (src/registry/mod.rs), and the live-run control plane (src/control/mod.rs)/preflight probe (src/probe.rs) built on top of it, plus the temporary POSIX foreground-terminal handoff required by ProcessKit's separate process-group mechanism — none of which processkit itself provides.

README.md's introduction states the same division for a project outsider: processkit-cli "runs one program inside ProcessKit's kernel-backed containment boundary and reports the run lifecycle," while "ProcessKit-rs remains the sole owner of containment, teardown, PID-reuse discipline, and lifecycle semantics." Out of scope entirely: IPC-to-child protocols beyond the control plane above, scheduling/pooling/retries beyond what processkit::Command offers, a shell mode, and PTY support (deferred in the core crate).

Test tiers

Four tiers, increasing in weight and decreasing in how often they run:

  • Unit. Each module under src/ carries its own #[cfg(test)] mod tests (for example the SHA-256 vector tests in src/hash.rs, or the ProcessGroup/Emitter-driven helper tests in src/run/teardown.rs). The modules now live in the library (src/lib.rs), so these run as its --lib tier under a plain cargo test — no cargo test --bin processkit-cli workaround, which the earlier bin-only layout needed to reach module-private helpers. Internal helpers are tested against real processkit/Emitter objects, never a mock layer.
  • Integration. tests/ drives the built binary (env!("CARGO_BIN_EXE_processkit-cli")), not the library, because the value this crate adds over ProcessKit-rs's own suite is the binary plus its contracts: tests/run.rs, tests/events.rs, tests/registry.rs, tests/probe.rs, and tests/integration.rs cover through-the-binary scenarios, sharing fixtures/helpers from tests/common/mod.rs. This is the default cargo test tier.
  • End-to-end (e2e, feature-gated). tests/e2e.rs is heavier still: it spawns real multi-level process trees, observes liveness from outside the runner (an OS process-table probe, not the container's own member list), and stresses concurrent runs, nested Windows Job Objects, PID-reuse storms, abrupt runner death, and inherited stdio through a real Windows console or POSIX pseudo-terminal. It is gated behind the e2e Cargo feature (with its src/bin/e2e_helper.rs worker binary) so it stays off in the default cargo test and runs explicitly via cargo test --features e2e --test e2e -- --nocapture; CI runs it as a separate job. See CONTRIBUTING.md, "End-to-end tests".
  • Concurrency stress (stress, feature-gated). tests/stress.rs targets what the tiers above cannot reach by construction: the invariants that only break when many runs contend for the two resources every run shares — the per-user registry (src/registry/mod.rs) and the per-run control plane (src/control/). Where the e2e tier scripts a fixed handful of processes, this one launches dozens of simultaneous run invocations against one registry directory and drives parallel list/prune/wait/inspect/cancel/kill clients at them, asserting that prune never reaps a live entry (including one still inside its reservation window), that a registry scan never loses or duplicates a record under concurrent writes and deletions, that a control client aimed at an unreachable or dying runner refuses with CONTROL (103) inside a bounded deadline rather than hanging, and that wait never misses — or invents — a completion. Every scenario carries a positive control, so none of those "never" assertions can pass vacuously. Gated behind the stress Cargo feature and run via cargo test --features stress --test stress -- --nocapture; CI runs it as a separate, non-gating scheduled workflow (.github/workflows/stress.yml), not on every PR. See CONTRIBUTING.md, "Stress tests".

The property-based (proptest), fuzz (cargo-fuzz), mutation (cargo-mutants), and benchmark (criterion) tiers cut across this ladder rather than sitting on a rung of it — each re-examines code the tiers above already cover, from a different angle. They are documented in CONTRIBUTING.md ("Fuzzing", "Mutation testing") and README.md ("Benchmarks").

Normative documents

Each area of the compatibility surface has its own normative document; this overview only sketches how they connect:

  • docs/registry.md — the per-user run registry: location, record format, staleness signal.
  • docs/control-plane.md — the local transport, the wire protocol, and the inspect/cancel/kill clients.
  • docs/exit-codes.md — the reserved runner-own exit-code band and the child-fidelity rule.
  • docs/schema.md — the versioned JSONL lifecycle-event schema.
  • docs/threat-model.md — untrusted inputs, the trusted principal and boundary, and which security threats the mechanisms sketched above (owner-only registry/transport, argv redaction, bounded control-plane reads, fail-closed probe, bounded output capture, supply-chain scanning) actually close.
  • docs/ROADMAP.md — the delivery status and the remaining ProcessKit-rs dependencies (this document describes the implementation).