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: an omitted field takes its default, and an omitted block
is the same as omitting every field in it. A silent manifest means defaults, never "unlimited". 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 what a deadline does and
does not bound.
capabilities:
limits:
memory_bytes: 16777216 # 16 MiB
table_elements: 10000
instances: 100
deadline_seconds: 30
| Field crossed | What happens |
|---|---|
deadline_seconds |
The call is interrupted and fails with E-RUN-001 naming the deadline that fired |
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.
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. 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 |
| 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. RLIMIT_NPROC is a per-user ceiling, so a fork bomb of distinct, short-lived processes evades it; a cgroup's pids.max does not |
| A periodic workdir-size check | workdir_max_bytes |
Every platform | The workdir is walked every 10 seconds, and 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 and blocks any further subprocess |
max_processes is headroom, not a ceiling
RLIMIT_NPROC counts everything the user account already owns rather than the processes in the
capsule's tree, and the unit it counts differs by platform: threads on Linux, processes on macOS.
The runtime measures the account's live count in that unit once at launch and sets the limit to
that baseline plus max_processes, so the field means how much a capsule's tree may add to what
the host is already using. cgroup_pids_max needs no such adjustment — it counts only the tasks in
the capsule's own scope, which is why it, and not max_processes, is the bound that stops a fork
bomb.
Platform behavior
Linux. A capsule that can spawn any native subprocess — through capabilities.shell.allow,
capabilities.spawn.allow, 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 and cpu.max 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, pids.max and cpu.max are still enforced on it; 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 |
cgroup_pids_max |
The scope's pids.events max counter moved |
Every other case is left unnamed rather than guessed at: memory_bytes surfaces as an allocation
failure inside the process, max_processes 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.