Resource Limits
capabilities.limits bounds the components the runtime runs; capabilities.resources bounds the
operating-system processes it spawns. Field types, defaults and validation for both blocks are in
the manifest reference; this page covers what enforces them, what
happens when one is crossed, and what each platform can enforce.
Both blocks are optional field by field, and an omitted block is the same as omitting every field in it. What an omitted field means:
| Field | Omitted |
|---|---|
capabilities.resources.cpu_seconds |
No CPU-time limit: a process keeps the RLIMIT_CPU mur was started with |
capabilities.resources.cgroup_cpu_percent |
No CPU cap: the scope's cpu.max stays at the kernel's max |
| Every other field | Its default, listed in the manifest reference |
A field declared as 0 is rejected when the manifest is parsed, before any component runs:
error[E-MAN-003]: murmur.yaml: invalid capability config for 'capabilities.limits.memory_bytes': must be greater than zero
Execution limits
capabilities.limits bounds every component call — a capsule run, a tool or driver run, and
each hook lifecycle call — in wall-clock time and in the memory, table space and instances it may
take. See Execution limits for how a deadline stops a
call that is computing and one that is waiting on the host.
capabilities:
limits:
memory_bytes: 16777216 # 16 MiB
table_elements: 10000
instances: 100
deadline_seconds: 30
| Field crossed | What happens |
|---|---|
deadline_seconds |
The call is stopped, whether it is computing or waiting on the host, and fails with E-RUN-001 naming the deadline that fired. A call stopped while waiting on the host is recorded as call_deadline |
memory_bytes, table_elements |
The growth is refused and the call fails with E-RUN-001 naming the limit and the size it tried to reach |
Both are reported distinctly from a plain crash — see CLI error codes.
A hook's own artifacts: entry can set capabilities.limits.deadline_seconds for that hook alone.
Its other limits fields are inert there and draw W-SEC-006.
| Call | Deadline |
|---|---|
Capsule run, tool or driver run |
capabilities.limits.deadline_seconds, else 600 |
| Hook lifecycle call | capabilities.limits.deadline_seconds on the hook's entry, else the capsule-wide value, else 30 |
Host resource limits
capabilities.resources bounds the operating-system processes the runtime spawns:
capabilities.shell.allow binaries, capabilities.spawn.allow sub-capsules, and
native-implementation tool artifacts. A capsule that cannot escape its containment can still
wedge the host it runs on by forking, allocating, opening files or writing without bound; this
block is what stops that.
capabilities:
shell:
allow: [bash]
resources:
max_processes: 32
max_open_files: 64
cpu_seconds: 60
cgroup_pids_max: 64
workdir_max_bytes: 1073741824 # 1 GiB
Three mechanisms enforce the block, in descending order of portability:
| Mechanism | Fields | Platforms | Notes |
|---|---|---|---|
setrlimit(2) ceilings, applied to each spawned process before it execs |
max_processes, max_open_files, max_file_size_bytes, cpu_seconds, memory_bytes |
Every platform | Set as hard limits, so a process cannot raise them from inside. cpu_seconds is the exception: the process gets SIGXCPU at cpu_seconds and SIGKILL one second later, so it can neither raise the bound nor run more than one second past it. With no cpu_seconds, RLIMIT_CPU is left as mur inherited it. A declared value above the ceiling mur itself inherited is clamped down to that ceiling rather than rejected. Core dumps are disabled outright, with no manifest field. max_processes applies only to a process with no cgroup scope; see max_processes by platform |
| A cgroup v2 scope around the whole subprocess tree | cgroup_memory_bytes, cgroup_pids_max, cgroup_cpu_percent, cgroup_io_bytes_per_sec |
Linux only | The only bound that applies to the tree in aggregate. cgroup_memory_bytes is the scope's memory.max, and its memory.swap.max is 0, so the ceiling bounds the tree's whole footprint rather than only its resident memory. On a kernel built without swap support, or booted with swap accounting off, a scope has no memory.swap.max: the launch proceeds with swap unbounded, and mur doctor reports it. cgroup_pids_max is the process bound for a subprocess in a scope: it counts only the capsule's own tree, where RLIMIT_NPROC counts every thread the user account owns on the host. cgroup_cpu_percent is the scope's cpu.max, written only when declared |
| A periodic workdir-size check | workdir_max_bytes |
Every platform | The work folder, the session's temporary folder and its home folder are walked every 10 seconds and their sizes summed. The cadence has no manifest field, so a breach is caught within one interval rather than at the moment it happens. It ends the session with E-RUN-013 — work folder, $HOME and $TMPDIR together grew to <N> bytes, past the <M> byte ceiling (capabilities.resources.workdir_max_bytes) — and blocks any further subprocess. The breach's logs/bootstrap.log line appends ; measured: <work folder>, <tmp>, <home> |
max_processes by platform
RLIMIT_NPROC counts everything the user account owns on the host rather than the processes in the
capsule's tree, so max_processes is headroom above that count, and only where no cgroup scope
bounds the tree:
| Subprocess | Process bound | Effect of max_processes |
|---|---|---|
| Linux, in a cgroup scope (every capsule that can spawn a subprocess) | cgroup_pids_max, counted over the capsule's own tree |
None. The subprocess keeps the RLIMIT_NPROC that mur inherited |
| Linux, with no cgroup scope | RLIMIT_NPROC |
The account's thread count at that spawn, plus max_processes |
| macOS | RLIMIT_NPROC |
The account's process count at that spawn, plus max_processes |
The count is taken at each spawn, so threads and processes the rest of the host starts during a session do not use up the capsule's headroom.
Platform behavior
Linux. A capsule that can spawn any native subprocess — through capabilities.shell.allow,
capabilities.spawn.allow, capabilities.install.tool (a tool it may pull can be native), or a
native-implementation artifact — refuses to launch with
E-RUN-012 when the host cannot delegate a cgroup, rather than running that tree with no aggregate
ceiling. On launch, mur asks the systemd user session for a fresh delegated scope of its own —
the same mechanism rootless Podman and Docker use — so it works from an ordinary shell without any
setup. When no systemd user session is reachable, it falls back to whatever cgroup it already
inherited, which must itself carry Delegate=yes for memory pids cpu io (for example, by running
under a unit configured that way, or via systemd-run --user --scope --property=Delegate=yes). A
capsule that declares no subprocess capability is never blocked. See
Verification for how these bounds are checked by hand.
macOS and other non-Linux hosts. No cgroup can exist, so the launch proceeds with rlimits alone
and W-SEC-010 names the residual gap: no aggregate bound across the
tree, and no per-process memory bound either, because macOS has no RLIMIT_AS and its kernel does
not enforce RLIMIT_DATA.
Whether the I/O ceiling applied
cgroup_io_bytes_per_sec is the one cgroup limit whose failure does not refuse a launch.
memory.max, pids.max, cpu.max when cgroup_cpu_percent is set, and memory.swap.max
wherever the kernel has it, are settable on any cgroup v2 host once the controllers are delegated,
so a failure there means the bound genuinely does not exist and the session must not start. io.max names a block device by
MAJ:MIN, and a filesystem with no block device behind it — tmpfs, overlayfs, FUSE and network
mounts — has none to name. A capsule that saturates disk
bandwidth is slow; one that exhausts memory or pids is fatal.
Every session reports what became of the ceiling, in an io_max object carried by
mur run --explain-scope --json and by session_start.effective_grants in trace.jsonl:
| Field | Type | Meaning |
|---|---|---|
declared_bytes_per_sec |
integer | The effective capabilities.resources.cgroup_io_bytes_per_sec, after the default is applied. Present whatever the status |
status |
string | One of the four below |
reason |
string | Why, for every status but enforced. Absent when there is nothing to say |
status |
Means |
|---|---|
enforced |
The io.max write succeeded against the device backing the workdir. The ceiling is on the scope |
unavailable |
A scope exists and the write did not succeed. memory.max and pids.max are still enforced on it, and so is cpu.max when cgroup_cpu_percent is set; I/O bandwidth is not bounded. W-SEC-021 reports it |
not-required |
No scope was asked for: the capsule can reach no native subprocess, or this is not Linux |
not-probed |
The write was never attempted, so nothing is claimed either way |
mur run --explain-scope reports the status a launch would reach: it creates a throwaway cgroup,
performs the same write, and removes the directory again.
The device that write names is resolved in three steps, because neither of the two numbers closest to hand is one the block layer accepts:
| Step | Read from | Why |
|---|---|---|
| The filesystem behind the workdir | st_dev of the nearest existing ancestor of the path |
Under --explain-scope the workdir does not exist yet, and a launch's workdir sits on the same filesystem as the project directory |
| The device it was mounted from | /proc/self/mountinfo |
btrfs, overlayfs, tmpfs and every FUSE mount are given an anonymous device number, which names no device |
| The whole disk carrying that device | /sys/dev/block/MAJ:MIN |
An io.max entry binds to a request queue and only a whole disk carries one, so the kernel refuses a partition such as /dev/nvme0n1p7 |
A filesystem that survives all three has a ceiling written against it; one that reaches the second
step with a source such as tmpfs has no device, and that is what unavailable reports.
To see both statuses on one host, run the same capsule from a project on a tmpfs and from one on a block-device-backed filesystem:
$ mkdir -p /dev/shm/io-demo && cp murmur.yaml /dev/shm/io-demo/
$ mur run --manifest /dev/shm/io-demo/murmur.yaml --explain-scope --json | jq .io_max.status
"unavailable"
$ mur run --manifest ./murmur.yaml --explain-scope --json | jq .io_max.status
"enforced"
Which limit a subprocess hit
When a subprocess dies or fails on a resource ceiling and the kernel's own evidence names exactly
one limit, the shell event in trace.jsonl carries a resource_limit field:
resource_limit |
Evidence |
|---|---|
cpu_seconds |
The process was killed by SIGXCPU |
max_file_size_bytes |
The process was killed by SIGXFSZ |
cgroup_memory_bytes |
The scope's memory.events oom_kill counter moved, and no other shell command or native tool ran in the scope while this one did |
cgroup_pids_max |
The scope's pids.events max counter moved, and no other shell command or native tool ran in the scope while this one did |
A process that ignores SIGXCPU is killed one second later with SIGKILL, which is left unnamed.
Every other case is left unnamed rather than guessed at: memory_bytes surfaces as an allocation
failure inside the process, max_processes, where it applies, as a fork() failing with
EAGAIN, and max_open_files as an open() failing with EMFILE — none of which kills anything
the runtime can attribute. An absent resource_limit means the limit could not be identified, not that no limit
was involved.
The model reads the same attribution as a resource_limit: line that names the field and what to
do about it:
| How the command ended | Where the line appears |
|---|---|
| Finished within the shell call | After the fenced tool output, on its own line |
| Moved to the background | In the completion message for that command |
For cgroup_pids_max the line reads:
resource_limit: cgroup_pids_max — the capsule reached its process limit while this command ran, so starting a new process failed (EAGAIN). Retry with fewer parallel processes, or ask the operator to raise capabilities.resources.cgroup_pids_max.
The tool result's status is the same with or without the line.