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:
| Module | Responsibility |
|---|---|
src/lib.rs | The 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.rs | The 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.rs | The 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.rs | The 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.rs | Shared human-output primitives: terminal-safe normalization for untrusted text and the column-aligned table renderer used by both list and inspect. |
src/registry/mod.rs | The 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.rs | The 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.rs | The 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.rs | The 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.rs | The 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.rs | The reserved runner-own exit-code band (100–119) 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 targetprocesskit_cli) that owns every module undersrc/, and - a thin binary (
src/main.rs, Cargo targetprocesskit-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 testsand run as the library's--libtier under a plaincargo test, reaching module-private helpers directly — retiring thecargo test --binworkaround 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-idvalue parsers, andwait --report-outcome's terminal-outcome read-back over a run's JSONL events file (src/wait.rs, bounded head/tail scan for a terminalrunner_exit, T-301); theeventsreader's own parsers (src/events_cmd/) are not in this tier — a boundary thatCONTRIBUTING.md's "Fuzzing" section anddocs/threat-model.mdboth state outright, rather than leaving it to be inferred from this list. A[lib]target is a hard prerequisitecargo-fuzzcannot work without (seeCONTRIBUTING.md, "Fuzzing"); - benchmarks (criterion,
benches/, T-187) reach internal primitives — the hand-rolled SHA-256 hasher (src/hash.rs), bounded-captureabsorb(src/capture.rs), and the argv-redaction hint classifier (src/events.rs) — directly, to measure them in isolation (seeREADME.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):
- (
--detachonly) Hand the run to a detached copy.run::detach::start_detachedre-spawns this binary on the caller's own argv with--detachremoved (plus--run-id/--no-echowhere the caller left them out) — in a new session on Unix (setsid), as aDETACHED_PROCESSon Windows, withnullstdio either way — then waits until that copy'srun_startedline is readable in--jsonland exits, reporting only whether the run started (seedocs/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. - Spawn. The child is built from
processkit::Commandand spawned into aProcessGroupthis 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 UnixSIGTERM/SIGHUP, or on WindowsCtrl-Break/console close/logoff/system shutdown — and a control-planecancel/kill). That guarantee does not extend to this process's own abrupt death (crash/SIGKILL/TerminateProcess), which skipsDropentirely: reaping a leaked grandchild after the runner itself dies abruptly is a platform-derived tri-state —whole_treeon Windows (Job Object survives the owner's abrupt death),direct_child_onlyon Linux (kill_on_parent_death/PR_SET_PDEATHSIG, direct child only),noneon macOS/BSD — surfaced per run asrun_started'sabrupt_cleanupfield (see K-005). Arun_startedevent (run id, root PID, containment mechanism, abrupt-cleanup tri-state, working directory) opens the JSONL stream. - 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-diris set — the per-stream tee insrc/capture.rs.--no-echoswaps only the echo sink for a discarding one (tokio::io::sink()in place oftokio::io::stdout()/stderr()); the pump, the--capture-dirtee, and the--idle-timeoutclock's re-arming on every observed chunk are all unaffected — seesrc/run/launch.rs's sink-selection block and K-050.--detachreuses that same swap rather than adding a second suppression path: the detached copy is started with--no-echo(step 0).--inherit-stdioinstead 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,runtemporarily assigns the terminal's foreground group to the child and restores the original group during cleanup. Amembers_snapshotevent records the container's member list — enriched withppid/executablename/start_timevia ProcessKit'smembers_info()wherever the platform can report them (docs/schema.md, "Enriched member fields") — in either path. With--snapshot-intervalthe 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 existingselect!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-stdiotoo. - Capture and hash.
src/capture.rs'sCaptureTeemirrors every byte the pump observes into a bounded capture file per stream — independent of whether that byte is also echoed (--no-echodoes not change what is captured) — hashing what actually reached disk withsrc/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 nooutput_capturedevent, unless--capture-dirwas passed; clap rejects--capture-dirtogether with--inherit-stdiobefore the child starts. - Teardown. Runner-imposed endings split into two tiers, not one shared
path.
--timeoutelapsing, a local stop signal (interactiveCtrl-C, on Unix a caughtSIGTERM/SIGHUP, or on Windows a caughtCtrl-Break/ console close/logoff/system shutdown), and a control-planecancelreaching the live runner oversrc/control/mod.rsall drive the same graceful path: a soft stop (SIGTERMto the tree on Unix; on Windows, which has no POSIX signal, a best-effortWM_CLOSEto 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--gracewait, then the owningProcessGroup's kernel-backed hard kill-on-drop. A control-planekillis 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, thencleanup_started/cleanup_finished). 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-planecancelled/killed). The process's own exit code (seedocs/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):
- Registry scan.
registry::Registry::entrieslists 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 onLive, soStaleandUnprobedare equally unactionable here — but not equally reportable: the refusal names a stale entry as a gone runner and an unprobeable one asunprobed(liveness unknown), the same distinctionlist/prune/waitdraw (seedocs/registry.md). - Endpoint resolution.
control::resolve_live_endpointmatches the requestedrun_idagainst the live entries only. More than one live match is an ambiguous run id — a hardCONTROL(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 (seedocs/control-plane.md, "Ambiguous run id"). - 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.
inspectis read-only and prints aSnapshot;cancel/killare mutating and reuserun's own teardown tiers exactly as described above —cancelthe graceful soft-stop → grace → hard-kill tier,killthe immediate hard-kill tier — replying with aControlAckbefore 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
processkitowns: the kernel-backed container (ProcessGroup) and its kill-on-drop teardown, the async child-output line pump (with a byte-cappedOutputBufferPolicy), directStdioMode::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 whichprocesskititself 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 insrc/hash.rs, or theProcessGroup/Emitter-driven helper tests insrc/run/teardown.rs). The modules now live in the library (src/lib.rs), so these run as its--libtier under a plaincargo test— nocargo test --bin processkit-cliworkaround, which the earlier bin-only layout needed to reach module-private helpers. Internal helpers are tested against realprocesskit/Emitterobjects, 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, andtests/integration.rscover through-the-binary scenarios, sharing fixtures/helpers fromtests/common/mod.rs. This is the defaultcargo testtier. - End-to-end (
e2e, feature-gated).tests/e2e.rsis 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 thee2eCargo feature (with itssrc/bin/e2e_helper.rsworker binary) so it stays off in the defaultcargo testand runs explicitly viacargo test --features e2e --test e2e -- --nocapture; CI runs it as a separate job. SeeCONTRIBUTING.md, "End-to-end tests". - Concurrency stress (
stress, feature-gated).tests/stress.rstargets 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 thee2etier scripts a fixed handful of processes, this one launches dozens of simultaneousruninvocations against one registry directory and drives parallellist/prune/wait/inspect/cancel/killclients at them, asserting thatprunenever 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 withCONTROL(103) inside a bounded deadline rather than hanging, and thatwaitnever misses — or invents — a completion. Every scenario carries a positive control, so none of those "never" assertions can pass vacuously. Gated behind thestressCargo feature and run viacargo test --features stress --test stress -- --nocapture; CI runs it as a separate, non-gating scheduled workflow (.github/workflows/stress.yml), not on every PR. SeeCONTRIBUTING.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 theinspect/cancel/killclients.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-closedprobe, 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).