Three run flags request kernel-enforced limits over the whole contained tree:
| Flag | Scope | Grammar |
|---|---|---|
--max-memory SIZE |
Total tree memory | Bytes, or k / m / g binary units |
--max-processes N |
Live processes in the tree | Positive integer |
--cpu-quota CORES |
CPU relative to one core | Finite number greater than zero |
processkit-cli run \
--max-memory 1g \
--max-processes 32 \
--cpu-quota 1.5 \
--jsonl limited.jsonl \
-- compiler-workerOmitting a flag leaves that resource unbounded. The runner never invents a default cap.
A requested limit is a requirement, not a hint. If the active platform and
containment mechanism cannot enforce it, run:
- does not spawn the child;
- emits
limit_hitnamingmemory,processes, orcpu; - emits
container_failedand terminalrunner_exit; - exits
BACKEND(102).
An adapter must inspect limit_hit; code 102 also covers unrelated backend
failures.
| Mechanism | Memory | Process count | CPU | Notes |
|---|---|---|---|---|
| Windows Job Object | Yes | Yes | Yes | Whole-job enforcement. |
| Linux cgroup v2 with usable controllers | Yes | Yes | Yes | Requires controller delegation at the effective root. |
| Linux process-group fallback | No | No | No | Fails before spawn. |
| FreeBSD process reaper | No | No | No | ProcessKit 3.3 reports unsupported before spawn. |
| macOS / non-FreeBSD BSD process group | No | No | No | Fails before spawn. |
The run_started.mechanism field tells an observer what was actually obtained.
The current ProcessKit implementation can apply limits when the runner is a direct member of the real cgroup-v2 root and can enable the required controllers. This is common in a minimal init environment, but not in:
- a normal systemd user session, scope, or service;
- ordinary Docker or Kubernetes containers;
- typical GitHub Actions jobs;
- any environment that delegates a nested cgroup without writable controllers.
In those environments a limit request fails rather than falling back to an unenforced process-group run.
The cgroup pids controller reliably bounds descendants forked inside the
cgroup. It does not reject additional top-level launches into the same group in
the same way Windows Job Object active-process limits do.
For ProcessKit CLI, which launches one root per run, interpret
--max-processes as a cap on that tree's own growth. It protects against a
contained fork explosion; it is not a general admission controller for unrelated
launchers.
Units are binary:
| Input | Bytes |
|---|---|
1048576 |
1,048,576 |
512k |
524,288 |
256m |
268,435,456 |
2g |
2,147,483,648 |
Zero, malformed values, and overflow are usage failures (100). CPU quota also
rejects negatives, NaN, and infinities.
limit_hit proves only that a requested limit could not be applied before
launch. It does not describe a successfully installed cap firing later, and
its payload and meaning remain unchanged for compatibility.
With processkit 3.2.0, a run that requested at least one cap emits a separate
limit_evidence event after the child ending is known and immediately before
the teardown pair. It carries one verdict for each axis (memory, processes,
and cpu):
This event exists only when ProcessGroup::with_options successfully creates a
container. On FreeBSD's process reaper, on macOS and other BSD process groups,
and on the Linux process-group fallback, ProcessKit returns ResourceLimit
during group creation because that mechanism has no
whole-tree limit primitive. The runner therefore emits the existing pre-spawn
limit_hit event and its backend-error tail, but there is no group from which to
read limit_evidence; the event is not emitted on that path. unknown is
reserved for a successfully created group whose active mechanism cannot provide
the post-run answer.
- Three-valued, never a boolean. The JSONL
limit_evidenceevent representsTripped/NotTripped/Unknownastripped/not_tripped/unknown.Unknownnever collapses into "did not fire": that would silently misreport a platform's inability to answer as a clean run on every axis where evidence is unavailable. - Authoritative on Linux cgroup v2 only. There,
Tripped/NotTrippedcome from real kernel counters (memory.events'oom,pids.events'max,cpu.stat'snr_throttled). On Windows Job Object and on a POSIX process group that was successfully created, a capped axis instead reportsUnknownas a measured result, not an omission — those mechanisms keep no post-mortem record that a cap fired. In practice the POSIX limit request fails during group creation as described above, so POSIX fallback runs have no post-run event at all. Windows is a first-class platform for this CLI, and runtime limit attribution remainsunknownthere; this closes the gap on Linux cgroup v2 only. - Readable only while the container still exists. The evidence lives in
the container itself, so the runner reads it before
ProcessGroupis dropped or consumed by shutdown.limit_evidencetherefore precedescleanup_started, preserving thecleanup_started→cleanup_finishedordering.
The event is absent when no cap was requested. On an event that is present,
uncapped axes are reported as not_tripped by ProcessKit because nothing was
in force that could fire; unknown is reserved for a missing authoritative
answer from the active mechanism.
None of this changes what limit_hit means today: it stays the pre-spawn
"the requested cap could not be applied" event, and a cap-dependent adapter
still treats it as a hard failure signal in that scope (see
docs/schema.md).
limit_hit and limit_evidence are both about a cap: one says it could not be
installed, the other whether it fired. Neither says how much the tree actually used.
That is the separate resource_summary event, which every run that spawned a child
emits exactly once — no flag, no cap required, every platform (normative field list:
docs/schema.md). With processkit 3.3.0 it takes one
ProcessGroup::stats() reading of whatever the active mechanism accounts for — a Job
Object's own accounting block on Windows; on Linux the cgroup's io.stat/pids.peak
counters plus a per-member /proc sum for memory and CPU, which is why the matrix below
ties those two axes to the read point — at the same place in the teardown tail as
limit_evidence and immediately after it.
- It is not limit attribution. A
peak_memory_bytesat or near a requested--max-memoryis not evidence the cap engaged; onlylimit_evidence'strippedis, and only where that verdict is authoritative (Linux cgroup v2). Reading a high peak as "it was capped" would invent an attribution the kernel never made. - It is not a time series. One reading at the end of the run, not a sample. It
cannot say when the peak occurred, or what the tree looked like at any earlier
moment.
run --snapshot-intervalanswers the second of those — the tree's shape over time, and it is opt-in precisely because that has an ongoing cost — but it carries no resource numbers, so nothing in this stream is a consumption series. - It is not a per-process breakdown. Every number is whole-tree. A member's individual share is not recoverable from it.
- It does not measure disk. Bytes written to a capture file are
output_captured.bytes(seedocs/io-and-capture.md); the IO counters here are the tree's own read/write traffic and are a different quantity that happens to share a unit.
Every measurement is independently nullable, and null means this mechanism, at this
read point, does not account for it — never zero, and never a number the runner
improved by taking a maximum over its own periodic reads (that would report when the
runner looked, not what the tree did). Two of the axes below depend on the read point and
not on the platform alone, so the "mechanism" column is a necessary condition for a
number, never a sufficient one. This matrix is normative; do not read completeness into
the event's field list.
| Mechanism | peak_memory_bytes |
total_cpu_ms |
io_read_bytes / io_write_bytes |
peak_process_count |
|---|---|---|---|---|
| Windows Job Object | Yes — peak committed memory (PeakJobMemoryUsed) |
Yes — every process ever in the job, terminated ones included | Yes — IO_COUNTERS, all read/write traffic (file, pipe, device) |
Always null |
| Linux cgroup v2 | Only for members live at the read point — the sum of their VmHWM; null once the tree has exited, which is the natural-exit case (consequence 5) |
Only for members live at the read point; null once the tree has exited (consequence 5) |
Only with the io controller enabled — io.stat rbytes/wbytes, block layer only |
Only with the pids controller enabled — pids.peak |
| Linux process-group fallback | null |
null |
null |
null |
| FreeBSD process reaper | null |
null |
null |
null |
| macOS / non-FreeBSD BSD process group | null |
null |
null |
null |
Five consequences that are easy to misread as bugs:
-
peak_process_countis alwaysnullon Windows. A Job Object keepsActiveProcesses(how many are in it now) andTotalProcesses(how many were ever assigned to it). Neither is a high-water mark of concurrency, and this runner will not synthesize one from its ownstats()calls. -
IO bytes are always
nullon FreeBSD, macOS, non-FreeBSD BSDs, and the Linux process-group fallback. Those mechanisms contain a tree without accounting for it. On Linux cgroup v2 they additionally require theiocontroller to be enabled for the group's cgroup — which is what makesio.statexist at all. This CLI does not enable it:processkitenables exactly the controllers a requestedResourceLimitsneeds (memory,pids,cpu) and no others, soiois on only if the environment already delegated and enabled it. The same is true ofpids.peak, which needs thepidscontroller — in practice that means a run with--max-processes. -
The IO counters are not comparable across platforms. A Job Object counts bytes moved by every read/write the job's processes issued, whatever the target. A cgroup's
io.statcounts only what crossed the block layer, so a read served from the page cache, or any traffic over a pipe, socket, or tmpfs, is simply not in it. The same workload legitimately reports very different numbers on the two, and neither is wrong. Compare a series only against itself on one mechanism; readrun_started.mechanismto know which one produced it. -
On Linux,
io_write_bytescan undercount. A write reaches the block layer when the kernel writes the page back, which may be after the member that dirtied it exited — or never, if the page is still dirty when the group is torn down. A short write-and-exit run can report fewer bytes than it handed towrite(2). -
On Linux cgroup v2,
peak_memory_bytesandtotal_cpu_msarenullafter a natural exit — the most common ending, so this is the ordinary reading there and not a corner case. Unlike the two axes beside them, these are not counters the cgroup keeps: ProcessKit sums them out of/proc/<pid>over the members listed incgroup.procsat the moment of the read, and it does so whether or not a cap was requested (memory.peakandcpu.statare never consulted, so--max-memorydoes not change it). A process leavescgroup.procsas soon as it exits — a zombie never appears there — and this read happens after the ending is decided, so on the natural-exit path the child has already exited and been reaped and there is normally nothing left to sum. Both axes then come backnullwithread_error: false, which is a correct answer about that read point and not a failure. Two corollaries follow, and the second is the useful one:- A child that leaked a descendant which outlived it makes the two axes
non-
null— but they then cover only the survivor, arbitrarily far below what the tree as a whole used. A small number here is not a whole-tree total. - On a runner-imposed ending (
timeout,cancelled,killed,output_overflow) the read happens before the soft stop, while the tree is still running, so there both axes are populated. If a workload's memory or CPU is what you need on Linux, a run the runner itself ended is the only place this stream carries it: no other event does,members_snapshot(including--snapshot-interval's samples) carrying member identity only —pid,ppid,name,start_time.
Neither Windows nor Linux's other two axes are affected. A Job Object's accounting block outlives the processes charged to it, so its memory and CPU cover the whole job whatever the ending;
io_read_bytes/io_write_bytesandpeak_process_counton Linux are cgroup-kept counters and likewise survive their members — whether they are present is the controller question in consequence 2. - A child that leaked a descendant which outlived it makes the two axes
non-
peak_memory_bytes and total_cpu_ms are likewise platform-specific in meaning
(committed memory vs. resident high-water mark; the whole job's history vs. only the
members live at the read point), so the same caution applies to them: comparable
within a mechanism, not across.
If stats() fails, the event is still emitted with read_error: true and every
measurement null. Check that flag before drawing a conclusion from a null, because
an all-null summary is also a correct success — it is exactly what row 3 and row 4
of the matrix above report by design, and what row 2 reports as well for the
commonest case of all: a plain run on Linux cgroup v2 that ended by its child exiting
has no live member left to sum for memory and CPU (consequence 5) and — unless the
environment itself enabled the io controller, and --max-processes the pids one —
no container counter to answer for the other three (consequence 2), so all five
measurements are null with read_error: false. An all-null summary therefore
carries no information about whether the read worked, on any platform: read_error is
the only thing that separates "this mechanism, at this read point, accounts for
nothing" from "the read failed", and a foreground run's stderr warning does not help a
--detach run, whose stderr is null.
resource_summary is present on every build that has it, so a consumer pins the
event, not a platform:
processkit-cli probe --json --require-surface run:resource-summaryThat token's presence guarantees the event will be in the stream. It does not
promise any particular axis is populated — that is what the matrix above governs, and it
follows from run_started.mechanism, plus (on Linux cgroup v2, for memory and CPU) from
how the run ended. Never from a probe token.
An outer Docker/Kubernetes/systemd limit and a ProcessKit CLI limit are separate
layers. The stricter layer wins, but only the outer runtime can explain its own
termination reason. If the outer runtime kills the runner itself, the
platform-specific abrupt_cleanup contract applies.
Use outer-runtime limits when they are the authoritative scheduler policy. Use CLI limits only where ProcessKit can install them and the adapter needs the limit request attached to this specific run.
- Run
probe --jsonto verify the flags exist. - Launch a harmless limited command in the real deployment environment.
- Read
run_started.mechanismrather than assuming cgroup availability. - Treat pre-spawn
limit_hitas a hard configuration failure. - For a successfully created capped run, read
limit_evidencefor post-run attribution and preserveunknownas distinct fromnot_tripped. For a pre-spawnlimit_hit, do not expect post-run evidence: the container did not exist to be queried. - For actual consumption, read
resource_summary— present on every run that spawned a child, capped or not. Checkread_errorfirst, then treat eachnullas "this mechanism does not account for it at this read point" per the matrix in "What the tree consumed", never as zero, and never compare its IO counters across platforms. On Linux cgroup v2 in particular, do not expectpeak_memory_bytesortotal_cpu_msfrom a run whose child exited on its own — there they arenullby construction (consequence 5). - Keep a separate outer-runtime signal for limits imposed outside this run.
- Platform support — mechanism selection.
- Running in containers — cgroup delegation in images and orchestrators.
- JSONL event schema — normative event fields; see also
resource_summaryfor what the tree consumed. - Exit-code contract.