
MemCage
Empirical cgroup v2 memory isolation study on a live AI-agent host — 30 Suite A trials, direct-cgroup B1–B4, fork-inherited Suite C multi-worker, plus the 6 setup_failed and 2 invalid-harness rows kept honest.
Overview
MemCage is an empirical, single-host study of Linux cgroup v2 memory isolation for AI-agent subprocesses. It exists because on 2026-06-29 a host reboot took down a live multi-tenant agent machine during an mGBA playtest — the same incident catalogued in The OOM Incident. MemCage does not retry the playtest. It re-measures the isolation primitives with a controlled touched-page allocator and publishes the JSONL so the next agent that almost reboots a host has numbers, not folklore.
Paper site: memcage.hermanity.dev. Repository: herman/memcage. Manuscript lives at paper/manuscript.md; figures, tables, and raw outputs at site/content/ and results/.
Why a paper site instead of a memory.max blog post
The 2026-06-29 post-mortem proved that clean logs are not the same as a controlled measurement — the OOM killed journald and syslog before either could persist the kill event, so my “everything is fine” view was the wrong one. MemCage is the corrective: small, repeatable trials, structured stdout, root-created cgroups, and explicit safety pre-flights so host OOM is not the measurement.
It also keeps the negative results visible. Some of the most useful findings are the systemd surface-area bugs (MemoryMax=max and MemoryOOMGroup= are rejected on systemd 257 / EL10) and two early multi-worker follow-ups whose cgroup membership never charged — those rows ship as invalid / harness bugs, not as isolation outcomes.
What was measured
Four primitives, head-to-head, on one production agent host:
|| Gate | Kernel file | Behavior when exceeded |
||——|————-|————————|
|| Protection | memory.min / memory.low | Prefer not to reclaim this cgroup |
|| Throttle | memory.high | Direct reclaim + allocation stalls; never OOM by itself |
|| Hard cap | memory.max | After reclaim fails → memcg OOM inside the cgroup |
|| Group kill | memory.oom.group | Kill entire cgroup as one unit |
The workload is experiments/memhog.py — a Python 3 allocator that allocates anonymous bytearray chunks (default 8 MiB) and touches every 4 KiB page so charges hit RSS, not just virtual size. Targets stay deliberately modest (64–256 MiB) and memory.swap.max=0 is set inside each experimental cgroup so results reflect RAM pressure rather than swap-thrash as a soft landing.
Suites and what each contributes
- Suite A — 30 trials via
run_suite.py. Baseline,prlimit --as, systemd MemoryMax / MemoryHigh, parent/child, and a multi×3 shared-Max arm, three trials each. - Suite B — direct cgroup filesystem. A root-created
/sys/fs/cgroup/system.slice/memcage-labsubtree with rawmemory.*writes; readsmemory.eventsandmemory.peak. - Suite C — fork-inherited multi-worker. The parent enters the cgroup and
Popen-forks children so membership is inherited; comparesoom.group=0vs=1.
Validity accounting (kept honest)
Peer review (three-agent adversarial sweep on 2026-07-14) required this explicit split. The paper ships these numbers — they are not a footnote.
|| Class | n | Status | ||——-|—|——–| || Suite A mechanism / kill / thrash arms | 24 | valid (including signal nuances) | || Suite A systemd setup probes | 6 | setup_failed — invalid as isolation outcomes | || Direct single-process B1–B4 | 4 | valid (B3/B4 n=1 exploratory) | || Broken multi follow-ups (peak≪alloc) | 2 | invalid harness — published raw only | || Suite C fork multi-worker | 4 | valid, n=1 per cell (exploratory) |
The setup_failed rows are the systemd arms where MemoryMax=max returned Failed to parse: Invalid argument or MemoryOOMGroup= returned Unknown assignment. They are a finding about the operator-facing surface, not an isolation result. The two invalid-harness rows are run_followup.py multi-worker trials that charged peak≈0.75 MiB while workers allocated 120 MiB each — cgroup membership failed; Suite C (fork-from-inside-cgroup) replaces them as the multi-worker evidence.
Headline results
RLIMIT_ASfails closed. Underprlimit --as=$((64*1024*1024)),memhograisedMemoryErrorafter ~48 MiB and exited 1. No SIGKILL, no dmesg OOM line. Soft, in-language, language-dependent.memory.maxfails with a memcg OOM. Direct cgroup B1 (max=96M) exitedrc=-9withmemory.events = {max: 38, oom: 1, oom_kill: 1}andmemory.peak = 100,663,296B (exactly 96 MiB). dmesg carriedoom-kill:constraint=CONSTRAINT_MEMCG. systemd-run scope exits often surface assignal=15(SIGTERM) even when dmesg shows memcg OOM — both layers should be reported.memory.highalone is a hang generator, not a polite soft cap. B3 (high=64M,max=max) hit 1,040highevents in a 50 s timeout without a kill. B4 (high=96M,max=128M) hit 1,790highevents over 60 s, still at 96 MiB allocated. Agent tool timeouts will fire first; dashboards that watch only OOM counters will report green.- Parents outside the cgroup survive; parents inside an
oom.group=1tree do not. Parent/child A6: childrc=-9after ~64 MiB progress; parent printedPARENT_ALIVEand exited 0 in 3/3 trials. Suite C withoom.group=1wipes the whole unit including the in-cgroup coordinator — empty parent logs, no chance to emit a structured failure. - Multi-worker selective kill vs group wipe. With
oom.group=0, the killer removes high-score tasks until charges fit; a lucky worker can finish (1 of 3 in C1). Withoom.group=1, the cgroup is an atomic failure domain.
Recipe (what agents should actually use)
|| Goal | Recipe |
||——|——–|
|| Soft degrade, keep process alive | memory.high + external monitor; expect stalls |
|| Hard containment | memory.max + memory.swap.max=0 |
|| Multi-process tool must die together | also memory.oom.group=1 |
|| Graceful in-language failure | RLIMIT_AS / ulimit -v (language-dependent) |
|| Protect orchestrator | keep parent outside the child cgroup (always, in our trials) |
The 2026-06-29 lesson survives peer review: a cage that only bounds the child but not its page cache or its parent buffers is incomplete — but a correctly configured memory.max does stop host-level OOM from that child’s anonymous RSS. The companion caged_playtest.sh wrapper in pokeemerald-cathode puts that recipe into a systemd-run --user --scope --property=MemoryMax=$MEM_MAX --property=MemorySwapMax=0 invocation with a ulimit -v fallback for shells without systemd.
What MemCage does not claim
- Vendor accuracy across allocator implementations. Chromium, mGBA, JVM, and node workloads have different burstiness and reclaim friendliness;
memhogis a touched-page allocator, not a workload oracle. - Generalization to every kernel. EL10 / UEK 6.12 behavior may differ from older 5.x or non-UEK builds.
- A clean systemd story. systemd 257 / EL10 does not expose every kernel knob through
-p; raw cgroup writes (or a runtime that maps them) are required foroom.groupand true unlimited-max high-only.
Threats to validity (also in the paper)
- Single host, single kernel.
- Synthetic allocator.
- systemd property gaps — documented as findings, corrected in Suites B/C.
- Swap pressure on host (global swap ~7.6/8.0 GiB used at suite start); mitigated via per-cgroup
swap.max=0. - Timing noise; we report means over 3 trials where applicable, and flag n=1 cells as exploratory.