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
Rust crate — not a reimplementation. Concretely:
┌───────────────────────────────────────────────────────────────────┐
│ 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).
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 (theruntime.rstrio, below), - map the crate's single
processkit::Erroronto a typed Python exception hierarchy (errors.rs'smap_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:
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 Commands 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:
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, │ future_into_py bridges the
│ polls the future on a fixed tick so a │ future onto the caller's
│ blocked Ctrl+C still raises on the main │ running asyncio loop
│ 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.rsis the only place a future is driven (block_on,drive_async, and the lower-levelblock_on_interruptiblethat both build on). A new verb should call one of these three, never hand-roll its ownrt().block_on(...)— that's how the reentrancy guard and the Ctrl+C polling stay uniform across the whole surface.map_erris the only funnel fromprocesskit::ErrortoPyErr. 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) viasetattr. A new crate error variant is covered automatically as long as it exposes the right accessor; no other module should construct aProcessErrorsubclass by hand from a crate error.
Conventions¶
- Per-module
register(m). Everysrc/*.rsmodule exposespub(crate) fn register(m: &Bound<'_, PyModule>) -> PyResult<()>that adds its own classes/functions (and, forerrors.rs, the whole exception hierarchy).lib.rsonly calls eachregister; it never lists an individual class or function itself. Adding a pyclass or function means adding it to its module'sregister, nothing inlib.rs. runner_pymethods!(runner.rs). PyO3'smultiple-pymethodsfeature 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 theira-prefixed async twins) forwarding to the genericrunner_*helper functions overProcessRunner. 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 (seePyProcessGroup::newingroup.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 ana-prefixed asyncio one (aoutput,arun,astart, …) that accepts the identical arguments and returns the identical wrapped type. This holds acrossCommand/Pipeline, every runner (real and test doubles),RunningProcess,ProcessGroup,Supervisor, andCliClient. A new verb should ship both halves together, wired throughblock_on/drive_asyncrespectively.drive_asyncreturns a lazy awaitable (PyLazyFuture): it does not hand the work tofuture_into_py— and so spawns nothing — until the firstawait, so ana-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 the realasyncio.Futurefor cancellation, soFuture.cancel()behaves exactly as before. 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) andWaitTimeout. These compose on the already-compiled async surface (aStdoutLinesiterator, 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._protocols.py— theProcessRunner/StreamingRunnerProtocolclasses: the typed dependency-injection seam that lets code written against "a runner" accept the realRunner, 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_processkittogether 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 fromprocesskit.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:
tests/test_api_surface.pyis an AST-based drift guard, run as part of the normal test suite. It parses_processkit.pyiand 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 aProcessErrorsubclass. 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.stubtest(mypy.stubtest), run in CI'stypecheckjob (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 leveltest_api_surface.py's hand-written checks don't reach.stubtest-allowlist.txtsuppresses only the small set of irreducible false positives this pairing produces (PyO3's__new__-only construction vs. the stub's__init__form, module-levelLiteralaliases 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-basesis passed but--ignore-unused-allowlistis 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-Durationcases,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 testruns them without theextension-modulefeature — the crate is deliberately structured (see the[features]comment inCargo.toml) socargo test/cargo checkwork without ever linking as a Python extension; the handful of these tests that do need the GIL (e.g.parse_signal'sBound<'_, PyAny>argument) callPython::initialize()first, since nothing else brings up the interpreter in a plain test binary.cargo testruns in CI'srust-lintjob alongsidecargo 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")raisingValueError), 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.