Chio/Docs
LOGIN · JOIN

Resource Limits

Cap each worker attempt's CPU time, descriptors, file size, and memory, and know which ceiling the operating system enforces.

Concept chio@5f63e6e

On this page

An operator can cap what one worker attempt consumes, even when every tool call it makes is authorized. A direct worker under chio process run on Linux takes five ceilings: CPU seconds, open descriptors, file size, virtual address space, and resident memory.

The operating system enforces the first four from the moment the worker's program starts. The runner samples the fifth while the attempt runs. These ceilings complement invocation allowances, because one permitted call can still use substantial CPU, memory, or descriptors.

Four ceilings become operating-system limits before the worker runs

The runner turns four plan fields into operating-system resource limits in the forked child, before the worker's program replaces the process image.

Worker ceilingsChoose a ceiling.
CPU time usedtimemax_cpu_secondsenforced by the OS asRLIMIT_CPU"signal"CPU time usedtimemax_cpu_secondsenforced by the OS as RLIMIT_CPUends as "signal"

The operating system ends the worker at its CPU ceiling

The runner installs max_cpu_seconds as RLIMIT_CPU with setrlimit before the worker's program starts. Crossing it ends the worker by signal, and the attempt's outcome is "signal". Operating-system limits.

One direct worker under chio process run on Linux, one ceiling at a time, against the runner's field names, limit names, and outcome strings. The usage curves and their scale are illustrative; descendants the worker starts and the cage's syscall filter are left out.

Resources::ceilings returns the four values, and the child's pre-exec step installs each with setrlimit: CPU seconds becomes RLIMIT_CPU, open files RLIMIT_NOFILE, file bytes RLIMIT_FSIZE, and address space bytes RLIMIT_AS. The operating system checks each one at the call that would cross it, so no runner check sits between the worker and its limit.

What the worker sees depends on the limit. Crossing CPU seconds ends it by signal. Crossing open files makes the call that would open another descriptor fail, and the worker's own exit status reports what it did next. File bytes and address space stop the write or allocation that would cross them, and the attempt then ends by signal or by the worker's own exit, depending on how the worker handles the refusal.

The runner samples resident memory every 50 milliseconds

Resident memory has no Ceiling variant and never reaches setrlimit. While a resident ceiling is configured, the runner polls the worker's resident set every RESIDENT_SAMPLE_INTERVAL, 50 milliseconds, and ends the attempt the first time a sampled peak crosses the ceiling.

A worker can therefore hold more than its resident ceiling for up to one interval. A worker that exits between samples still fails the attempt when the operating system's accounted peak at exit is above the ceiling. The runner samples the worker process's own resident set; processes the worker starts are outside that sample.

The worker plan declares each attempt's ceilings

Add a nonempty resources block to a direct worker in workers[] of the same chio.process.run.v1 plan that supervises the worker. This illustrative fragment allows 30 CPU seconds, 128 open descriptors, a 16 MiB file, 512 MiB of virtual address space, and 256 MiB of resident memory.

Fragment for a direct worker in run-plan.jsonjson
{
  "resources": {
    "max_cpu_seconds": 30,
    "max_open_files": 128,
    "max_file_bytes": 16777216,
    "max_address_space_bytes": 536870912,
    "max_resident_bytes": 268435456
  }
}

Choose ceilings from the worker's runtime and task. Virtual address space can be much larger than resident memory, and a language runtime may reserve space before it does useful work, so keep a successful workload beside the excessive case when you evaluate a setting. A ceiling applies per attempt: combine it with supervision deadlines and bounded attempts so a restart loop stays bounded too. A worker with container rejects custom resources and uses its fixed container profile.

Worker resource and OS-ceiling declarations
crates/products/chio-cli/src/cli/process_host/runner/plan.rs94–105rust
pub(super) struct Resources {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_cpu_seconds: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_open_files: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_file_bytes: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_address_space_bytes: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_resident_bytes: Option<u64>,
}
crates/products/chio-cli/src/cli/process_host/runner/plan.rs148–153rust
pub(super) enum Ceiling {
    CpuSeconds,
    OpenFiles,
    FileBytes,
    AddressSpaceBytes,
}

Each worker in a chio.process.run.v1 plan carries an optional Resources block with five ceilings. Resources::validate refuses a block that sets none of them. Resources::ceilings leaves resident bytes out of what it hands to the operating-system step, which is why resident memory has its own mechanism. chio process run supervises workers on Linux, so the ceilings apply there.

Read more in Bounded resources.

Each attempt reports what it used, under the ABI that wrote it

Each reaped attempt returns a Usage value, peak_resident_bytes and cpu_ms, drawn from the operating system's process accounting for the worker and any descendants the worker itself waited for. Operate and move a host reads this usage back for a completed attempt, beside its exit reason and identity.

Saved process-host state names the ABI it was written under. PROCESS_ABI fixes the build's value, chio.process.abi.v2, and require_abi compares the recorded string against it before the host reads the state back, including a worker's retained resource plan and usage history. A mismatch is refused with both versions named, so a ceiling or a recorded attempt is always read under the ABI that wrote it.

A sixteen-descriptor ceiling keeps one worker useful and stops the other

Two workers run the same probe under the same sixteen-descriptor ceiling; only the number of descriptors they request differs. Download the probe and the complete plan, and run them from the report walkthrough's directory, beside host.json, on a Linux process runner.

security-host/resource_probe.pypython
import errno
import json
import os
import sys

# The Chio runner supplies one bootstrap line; the probe needs no reusable token.
bootstrap = json.loads(sys.stdin.readline())
requested = bootstrap["input"]["open_files"]
if not isinstance(requested, int) or not 1 <= requested <= 256:
    raise SystemExit("open_files must be an integer from 1 to 256")
handles = []
try:
    for _ in range(requested):
        handles.append(os.open("/dev/null", os.O_RDONLY))
    print(json.dumps({"opened": len(handles), "completed": True}), flush=True)
except OSError as error:
    if error.errno != errno.EMFILE:
        raise
    print(json.dumps({"opened": len(handles), "refused": "EMFILE"}), flush=True)
    raise SystemExit(66)
finally:
    for handle in handles:
        os.close(handle)

The probe performs no tool calls. It reads the runner's bootstrap line, opens disposable /dev/null descriptors, and closes what it holds before it exits. The first command initializes a separate host state, .chio-resources, with the producer and consumer process IDs from the report fixture's configuration.

Terminalbash
chio process init --config host.json --state .chio-resources
chio process run --state .chio-resources --plan resource-plan.json
chio process status --state .chio-resources
chio process logs --state .chio-resources --process producer --attempt 1
chio process logs --state .chio-resources --process consumer --attempt 1

You should see the producer exit zero after opening two descriptors. The consumer reaches EMFILE, closes the descriptors it owns, and exits 66, so the runner retains exit_66. Inherited descriptors change the exact open count, so compare the error and the outcome rather than a count of thirteen opens. These are the expected Linux outcomes of the runner and probe; status shows each attempt's peak resident bytes and CPU milliseconds.

The complete plan
resource-plan.jsonjson
{
  "schema": "chio.process.run.v1",
  "max_parallel": 1,
  "failure_policy": "continue_independent",
  "workers": [
    {
      "process": "producer",
      "command": [
        "python3",
        "resource_probe.py"
      ],
      "cwd": ".",
      "input": {
        "open_files": 2
      },
      "depends_on": [],
      "max_attempts": 1,
      "timeout_seconds": 10,
      "resources": {
        "max_cpu_seconds": 2,
        "max_open_files": 16,
        "max_file_bytes": 1048576,
        "max_address_space_bytes": 134217728
      }
    },
    {
      "process": "consumer",
      "command": [
        "python3",
        "resource_probe.py"
      ],
      "cwd": ".",
      "input": {
        "open_files": 64
      },
      "depends_on": [],
      "max_attempts": 1,
      "timeout_seconds": 10,
      "resources": {
        "max_cpu_seconds": 2,
        "max_open_files": 16,
        "max_file_bytes": 1048576,
        "max_address_space_bytes": 134217728
      }
    }
  ]
}

The deliberate failure leaves the overall run unsuccessful and keeps the producer's independent result. Restarting with the same plan and state keeps the exhausted attempt; raising a limit takes an authorized new run, never deleting the original history.

The cage confines a tool to its own limits and its own signals

The cage is a separate launch path from chio process run workers. It confines native tool launches, and a tool launched under it answers to a syscall filter that the limits above do not provide.

build_seccomp_plan allows prlimit64 only when its first argument, the target PID, is zero, so a confined tool can inspect or adjust its own limits and no other process's. The filter never allows kill, tkill, tgkill, or pidfd_send_signal, so the tool can signal neither a sibling nor its supervisor. When the host process exits, PR_SET_PDEATHSIG delivers a kill signal to the tool.

Before the cage launches, seccomp_profile_is_fail_closed checks that the default action kills the process, that none of those signal syscalls is allowed, and that an allowed prlimit64 carries the PID-zero constraint. The test seccomp_confines_resource_limits_and_signals_to_the_cage exercises the line between a tool acting on itself and a tool reaching a peer. The cage enforces these restrictions for native tools on Linux x86_64 with kernel 6.7 or later.

Read more in Process confinement.

An invalid plan stops before the run; a crossing ends the attempt

Three refusals come from chio process run declining a plan or its saved state outright. The attempt outcomes are the worker's outcome field in the run report on stdout, taken from the attempt's Outcome.reason. The cage reports on its own launch record.

RefusalConditionOwner
error[CHIO-CLI-PLAN-INVALID]: process host: worker resources must set at least one ceiling
The Resources block sets no ceiling
Resources::validate
error[CHIO-CLI-FLAG-OUT-OF-RANGE]: process host: worker resource ceilings are outside their supported ranges
A set ceiling is out of range
Resources::validate
error[CHIO-CLI-STATE-ABI-MISMATCH]: process host: host state was recorded under process ABI ...; this build speaks chio.process.abi.v2
State recorded under another process ABI
require_abi
"signal"
A signal ended the worker
Operating system
"exit_<code>", such as "exit_66"
The worker exited nonzero on its own
Worker
"resident_memory_ceiling"
Sampled or exit-time peak above the ceiling
Runner
failure.code "seccomp_install_failed"; MCP server connection failed: cage-required MCP launch failed: SeccompInstallFailed at <stage>
Syscall plan not fail-closed, or not installed
Cage

The CLI rows build their message through the process host's shared error helper, which prepends process host: ; the CLI prints it to stderr in the standard four-line error, with a help line naming the ceiling or state to fix, and exits 1. The supported ranges are 1 to 86,400 CPU seconds, 16 to 1,048,576 open files, 1 byte to 1 TiB of file bytes, 64 MiB to 256 TiB of address space, and 16 MiB to 256 TiB of resident bytes. None of these refusals is a matchable ProcessError variant; that type's Limit case belongs to the process tree and its stores.

A CPU-seconds crossing ends the worker by signal, and a file-bytes or address-space crossing can too. The runner's open-files test worker exits 66 when a 16-descriptor ceiling refuses another open, so its outcome is "exit_66". The cage persists seccomp_install_failed when it refuses its own syscall plan or cannot install it, and the launch fails.

After a refused or ended attempt, read the retained worker outcome, peak_resident_bytes, and cpu_ms. Correct an empty or out-of-range plan before starting, and size an exhausted runtime from its observed attempt. A new limit never makes an uncertain external effect safe to repeat.

Next: supervise the workers these ceilings bound