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).

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

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:

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.

# 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

FlagMaps toNotes
--timeout SECONDSCommand.timeout(seconds)Kills the whole tree once the deadline passes.
--timeout-grace SECONDSCommand.timeout_grace(seconds)Signal first, hard-kill after SECONDS. Requires --timeout; a usage error otherwise.
--idle-timeout SECONDSCommand.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.
--max-memory BYTESProcessGroup(max_memory=...)Whole-tree memory cap; accepts 1..=2^64-1 bytes.
--max-processes NProcessGroup(max_processes=...)Fork-bomb ceiling for the tree; accepts 1..=2^32-1.
--cpu-quota FLOATProcessGroup(cpu_quota=...)Fraction of a single core (0.5 = half, 2.0 = two cores).
--env-clearCommand.env_clear()Start the child with an empty environment.
--inherit-env NAMECommand.inherit_env([...])Allow-list a parent variable through (implies --env-clear). Repeatable.
--env-file PATHRepeated Command.env(key, value)Load docker-style KEY=VALUE lines. Blank lines and lines beginning with # are ignored. Repeatable.
--env KEY=VALUECommand.env(key, value)Set/override a child environment variable. Repeatable. A value without = is a usage error.
--cwd DIRCommand.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-windowCommand.create_no_window()Do not create a console window for the child. No-op outside Windows (same as the underlying binding method).
--output-limit BYTESCommand.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-vtCommand.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 PATHCommand.stdout_file(path)Redirect stdout directly to a newly created or truncated file.
--stderr-file PATHCommand.stderr_file(path)Redirect stderr directly to a newly created or truncated file.
--kill-on-parent-deathCommand.kill_on_parent_death()Best-effort abrupt-owner-death cleanup; platform scope is unchanged from the API.
--priority LEVELCommand.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.
--ptyCommand.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 NCommand.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:

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). --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:

FieldTypeMeaning
duration_secondsfloatWall-clock time the run took.
cpu_time_secondsfloat | nullUser + kernel CPU time consumed by the whole run.
peak_memory_bytesint | nullPeak memory observed during the run.
avg_cpu_coresfloat | nullcpu_time_seconds / duration_seconds — e.g. 1.7 means ~1.7 cores kept busy on average.
samplesintHow many resource samples were taken while the child ran.
codeint | nullSame meaning as the process's own exit code (null if the run ended some other way).
signalint | nullSet if the child was killed by a signal (POSIX only).
timed_outboolWhether --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. 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 codeMeaning
(the child's own code)Normal completion — passed through unchanged.
119This 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.
125An internal / containment failure (see below).
126The program was found but could not be executed.
127The program could not be found.
128 + NThe 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, 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:

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.

FlagDescription
--restart {always,on_crash,never}Restart policy passed to Supervisor.
--max-restarts NStop after N restarts; accepts 1..=2^32-1 (u32).
--backoff-initial SECONDSInitial delay before a restart. Must be positive.
--backoff-factor FLOATMultiplier for successive restart delays. Must be at least 1.
--max-backoff SECONDSUpper bound for restart delay. Must be positive.
--no-jitterDisable restart-delay jitter; jitter is enabled by default.
--timeout SECONDSApply Command.timeout(seconds) independently to every incarnation. A final timed-out incarnation exits 124.
--max-memory BYTESCap every incarnation's whole process tree memory; accepts 1..=2^64-1 bytes (u64).
--max-processes NCap every incarnation's process-tree size; accepts 1..=2^32-1 (u32).
--cpu-quota FLOATCap 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-windowApply Command.create_no_window() to every incarnation.
--health-port HOST:PORTProbe a TCP endpoint; bracket IPv6 literals, for example [::1]:8080. Mutually exclusive with --health-http.
--health-http URLProbe an absolute HTTP(S) URL; any 2xx response is healthy. Mutually exclusive with --health-port.
--health-interval SECONDSProbe cadence, default 5; requires a health probe.
--health-timeout SECONDSPer-probe network timeout, default 1; requires a health probe.
--env-clearStart the child with an empty environment.
--inherit-env NAMEAllow-list a parent variable (implies --env-clear). Repeatable.
--env-file PATHLoad docker-style KEY=VALUE lines. Repeatable; later files win and --env wins over files.
--env KEY=VALUESet or override a child variable. Repeatable.
--cwd DIRRun the child with DIR as its working directory.
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 codeMeaning
(the final child result's code)Supervision stopped because the restart policy was satisfied.
119This wrapper could not deliver its own buffered output — the entry-point-wide code from How the wrapper terminates, not a supervise one.
120An internal command/supervisor failure, including a missing or unexecutable program.
121The restart policy required another attempt, but --max-restarts was exhausted.
122Supervision gave up due to a give_up_when condition (reserved for API-driven outcomes).
124The final incarnation hit its per-incarnation --timeout.
128 + NThe 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 and Platform support). 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:

python -m processkit doctor
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):

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 codeMeaning
0Resource limits are available (containment and all three caps hold).
1Containment 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.
3Containment itself is unavailable (should not happen on any supported platform).
4A 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 terminates119 (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",
  "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), 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.


Next: Process groups · Timeouts & cancellation · Cookbook