Command-line usage
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).
- Basic usage
- Flags
--profile: machine-readable resource usage- Exit codes
- supervise
- Resource limits: hard cap or best effort?
doctor: preflight-diagnose the environment- What you don't get here
Basic usage
python -m 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:
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 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.
# 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. See below. |
--max-memory BYTES | ProcessGroup(max_memory=...) | Whole-tree memory cap. |
--max-processes N | ProcessGroup(max_processes=...) | Fork-bomb ceiling for the tree. |
--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 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. |
--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). |
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.
--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). --profile
exposes exactly that, for a CI step that wants a machine-readable resource
accounting of a containerized run without writing any Python:
# 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? 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.
# 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.
--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). 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.
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, ordoctor. It exits128 + SIGINT(130) with the same one-lineprocesskit: interruptedmessage the guarded paths print, on every platform. Fordoctorthis matters beyond tidiness:1is a validdoctorverdict, so an interrupted probe must never be reported as one.
supervise
Basic usage:
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. N must be positive. |
--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. |
--env-clear | Start the child with an empty environment. |
--inherit-env NAME | Allow-list a parent variable (implies --env-clear). Repeatable. |
--env KEY=VALUE | Set or override a child variable. Repeatable. |
--cwd DIR | Run the child with DIR as its working directory. |
python -m processkit supervise --restart always --max-restarts 5 -- some_command
| 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, 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). |
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?
--max-memory / --max-processes / --cpu-quota need a real container — a
Windows Job Object or a Linux cgroup-v2 root (see
Process groups and
Platform support). Inside an ordinary container, a systemd
user session, or on macOS, the kernel refuses these caps outright.
Rather than fail the whole run over a cap the environment can't grant, this
CLI degrades: it prints a warning to stderr and re-runs 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:
python -m processkit doctor
processkit doctor
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):
processkit doctor
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 — 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:
{
"mechanism": "cgroup_v2",
"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;
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), advanced supervision callbacks such as stop_when and give_up_when
(Supervision), line-by-line streaming (Streaming &
interactive I/O), 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. There is also no --output-limit
flag: stdio here is always inherited straight through to your terminal, so
there is no captured-output buffer for
Command.output_limit(...) to bound in the first place — that method only
matters when a caller captures output via the Python API instead.
Next: Process groups · Timeouts & cancellation · Cookbook