Repository navigation
cgroup v2 memory limits for the judge sandbox (#86) - #114
Conversation
A per-run `memory.max` is the first memory limit that bounds every language. `ulimit -v` caps address space, which the JVM and .NET reserve gigabytes of before running a line of submitted code -- measured on this image at a 256 MB problem limit, the smallest barrier java will boot under is 2048 MB, kotlin 4096 MB and C# 3072 MB -- which is why config/autojudge.php gives them none at all. The peak-RSS verdict added in #108 gave those languages an MLE, but only after the fact: a Kotlin program allocating 640 MB took 640 MB off the machine and was judged afterwards. A cgroup memory.max caps RESIDENT memory, which those runtimes tolerate. Measured here under bwrap at a 256 MB cap: a trivial Java program peaks at 30 MB and a Kotlin one at 27 MB, while an allocating program in either is OOM-killed at exactly 256 MB. - docker/judge/entrypoint.sh delegates /sys/fs/cgroup/judge to uid 1000 as root, then drops to `judge` with setpriv. The judge process is moved into the subtree first: cgroup v2 only permits a migration when the caller can write the cgroup.procs of the COMMON ANCESTOR of source and destination, so from the container's cgroup root every per-run migration would fail with EPERM. Only the four files the kernel's delegation interface names are chowned -- not memory.max, which would let the judge raise the ceiling it is capped under. - CgroupMemoryLimiter creates one cgroup per run, and wraps the command so a shell joins it and only then execs, putting bwrap and everything under it inside the cap before startup. Afterwards it reads memory.peak and memory.events and removes the cgroup. - MLE on `oom_kill > 0`, since memory.max does not guarantee a kill -- a refused allocation can just return NULL. The peak is the backstop, but only for a run that failed: memory.peak counts page cache, so a run that merely read a large input can touch the ceiling without ever being denied a byte. Every step degrades to a no-op: no cgroup v2, an unprivileged container, no memory controller, and judging carries on with the peak-RSS verdict, which is kept unchanged as the fallback. The unprivileged suite is green with 57 skips; nothing refuses to start. `ulimit -v` is dropped where a cgroup is live rather than stacked under it. The barrier sits at limit+grace and memory.max at the limit, so it can never bind first -- measured, a Python hog is OOM-killed at exactly 256 MB with `ulimit -v 393216` and without it alike -- while it can still fail a runtime that reserves address space it never touches. The grace table stays for deployments with no cgroup to be had. The per-run cgroup is released in a `finally`. Both ways out of executeProgram() that are not a return throw -- Process::run() raises ProcessTimedOutException when the backstop timeout fires, and assertSandboxStarted() raises when bwrap never started -- and those are exactly the runs most likely to still have a process inside the cgroup. A cgroup with a live process in it is holding the memory the cap exists to bound. create() reuses a stale directory only when the same name recurs and the name carries the pid, so once a worker is recycled it never gets that chance. Requires the judge container to run privileged, which docker-compose.yml and the CI job already do: Docker mounts /sys/fs/cgroup read-only otherwise (moby#46763). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pvf2R8xtgbWQm1wRvjBqQY
Verification after the rebase, run locallyThe branch was rebased onto master (now carrying #112 and #113), so I re-ran the checks rather than relying on the pre-rebase numbers in the description. Full suite in the judge image, Zero skipped. That is the number that matters here — it means the cgroup delegation actually happened and all 5 integration cgroup tests plus the sandbox suite really executed, rather than skipping themselves into a green build. The security property is not vacuousSubmitted code runs as the same uid 1000 the subtree is delegated to, so the only thing stopping a submission from raising its own
So if anyone ever adds The release-on-throw fix is not vacuous eitherMoving Still outstandingPoint 4 in the description — whether GitHub's runners can delegate the subtree — is the one thing a local run cannot answer. The judge-image job on this PR is the test. If it comes back red with skips, that is the honest signal and not a reason to loosen |
Closes #86.
The gap this closes
ulimit -vcaps address space, and the JVM and .NET reserve gigabytes of it before running a line of submitted code. Measured on this image at a 256 MB problem limit, the smallest barrier each runtime will even boot under:That is why
config/autojudge.phpgives them no barrier at all. The peak-RSS verdict from #108 gave those languages an MLE, but only after the fact: a Kotlin program allocating 640 MB took 640 MB off the machine and was judged afterwards.A cgroup
memory.maxcaps resident memory, which those runtimes tolerate fine.Measured, at a 256 MB cap under bwrap
-Xmx256m)oom_kill 1-Xmx)oom_kill 1java -jar)oom_kill 1oom_kill 1oom_kill 1The languages that need 2–4 GB of address space to boot cost ~30 MB of resident memory.
Controlled experiment through the real judge, judging actual submissions end to end:
Bottom-right is the issue. Top-right proves the verdict comes from the cgroup and not the fallback.
How it works
docker/judge/entrypoint.shdelegates/sys/fs/cgroup/judgeto uid 1000 as root, then drops tojudgewithsetpriv.Dockerfile.judgetradesUSER judgefor anENTRYPOINT.cgroup.procsof the common ancestor of source and destination; from the container's cgroup root that ancestor is the root, so every per-run migration would failEPERM. It cannot sit injudge/directly either — "no internal processes" — hencejudge/main.memory.max: owning that would let the judge raise the ceiling it is capped under. Verified negatively — the judge user cannot writejudge/memory.max, andtouchinside the subtree is refused.CgroupMemoryLimitercreates one cgroup per run, writesmemory.max+memory.swap.max=0, and wraps the command so a shell joins the cgroup and then execs. bwrap and everything under it is inside the cap before the runtime starts — the difference between bounding and measuring.oom_kill > 0, sincememory.maxdoes not guarantee a kill (a refused allocation can just return NULL).memory.peakis the backstop, but only for a run that failed:memory.peakcounts page cache, so a run that merely read a large input can touch the ceiling without ever being denied a byte. Note>=, not>—memory.peakis clamped bymemory.max, so a capped run reports the ceiling exactly and>could never fire.Degradation is the default
No cgroup v2, an unprivileged container, no memory controller, a developer's laptop — every step becomes a no-op and judging carries on with the peak-RSS verdict, kept unchanged as the fallback. Refusing to start would turn a missing kernel feature into a dead contest. The unprivileged suite is green with 57 skips; nothing refuses to start.
ulimit -vis dropped where a cgroup is live, not stacked under itMeasured rather than assumed: a Python hog at 256 MB is OOM-killed at exactly 256 MB with
ulimit -v 393216and without it alike. The barrier sits at limit+grace whilememory.maxsits at the limit, so it can never bind first — redundant, not harmful. But it retains the ability to fail a runtime that reserves address space it never touches, which is the exact failure thenullrows exist to avoid. The grace table stays for deployments with no cgroup to be had.Review fix on top of the original branch
The per-run cgroup was released only on the happy path. Both ways out of
executeProgram()that are not areturnthrow —Process::run()raisesProcessTimedOutExceptionwhen the backstop timeout fires (confirmed against the installed Laravel, not assumed), andassertSandboxStarted()raises when bwrap never started. Those are exactly the runs most likely to still have a process inside the cgroup, and a cgroup with a live process in it is holding the memory the cap exists to bound.create()reuses a stale directory only when the same name recurs, and the name carries the pid, so once a worker is recycled it never gets that chance.Now released in a
finally, with a regression test that goes red when thefinallyis removed.Verification
--privileged --fail-on-skipped: 923 tests, OK (on the pre-rebase branch).CgroupMemoryLimiterTest), 5 integration against a real cgroup (including delegation containment and cgroup removal), 2 E2E Kotlin (MLE, plus a regression guard that a valid Kotlin solution still passes under the cap), 1 on the grace change, 1 on the release-on-throw fix.sh -nand shellcheck (one informational SC2013 on a pid loop).Known limits, stated plainly
byte[]but touching one byte each completes at peak 57 MB, correctly. Faulting every page gives the OOM kill in the table. The peak-RSS fallback has the identical property; onlyulimit -vbounded untouched reservations, and that is exactly what these runtimes cannot tolerate./sysis load-bearing. Outside bwrap the JVM readsmemory.max, sizes its heap to ¼ of it, and throwsOutOfMemoryError(rc 1, peak 247 MB) below the cap — which would be RE, not MLE. Inside bwrap/sysis absent, so it sizes from host RAM and the kernel wins. If anyone ever adds/systosandbox_paths, JVM MLE detection silently degrades. There is a test that submitted code cannot reach/sys/fs/cgroupat all, which guards the security property and would also catch this.--fail-on-skippedturns the job red. That is the honest signal — an uncapped judge should not pass as green — but it may need a follow-up if the runners' cgroup layout differs from Docker Desktop's LinuxKit VM.🤖 Generated with Claude Code
https://claude.ai/code/session_01Pvf2R8xtgbWQm1wRvjBqQY