Skip to content

Enforce process-backend quotas instead of silently dropping them - #286

Merged
seanwevans merged 2 commits into
mainfrom
claude/process-backend-quotas
Aug 15, 2026
Merged

Enforce process-backend quotas instead of silently dropping them#286
seanwevans merged 2 commits into
mainfrom
claude/process-backend-quotas

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

The problem

Supervisor.spawn accepts cpu_ms, wall_time_ms, open_files_max, network_ops_max, output_bytes_max, child_work_max, and numa_node. _spawn_process forwarded only mem_bytes. Everything else was accepted and discarded — no error, no warning. Measured on main:

spawn(backend="process", cpu_ms=50, wall_time_ms=100)  → 30M-iteration loop runs to completion
spawn(cpu_ms=50, wall_time_ms=100)                     → WallTimeExceeded

The sub-interpreter backend — the one documented as not a security boundary — enforced limits that the process backend ignored. SECURITY.md §2 meanwhile claimed "Resource quotas (CPU/RAM/I/O) are enforced by rlimit and cgroup v2 controls." A limit that looks configured and does nothing is the worst failure shape for this project.

The change

Forward what this backend can enforce.

Quota Mechanism
cpu_ms RLIMIT_CPU in the child, before guest code runs
mem_bytes RLIMIT_AS (already worked)
open_files_max RLIMIT_NOFILE
wall_time_ms supervisor-side timer

Wall time can't be an rlimit: a guest blocked on I/O burns no CPU, so RLIMIT_CPU never fires. The timer is armed when an operation is dispatched and disarmed by the child's completion frame, re-arming while operations are still pending.

Reject what it cannot. network_ops_max, output_bytes_max, child_work_max, and numa_node are in-process counters in the thread backend with no equivalent across an address-space boundary. They now raise NotImplementedError naming the parameter and pointing at the alternatives, rather than pretending to apply.

Verified against the original repro:

confinement rlimits: ['core=0', 'cpu=5', 'nofile=24']
enforced after 0.40s: WallTimeExceeded
termination_reason: wall_time_exceeded | alive: False

Three details worth review

  • RLIMIT_CPU has one-second granularity. A sub-second cpu_ms rounds up — silently applying a 20× weaker limit than requested would recreate this bug in miniature, so the effective value is logged at WARNING. Precise sub-second CPU needs cgroup cpu.max (not in this PR).
  • RLIMIT_NOFILE reserves headroom (_NOFILE_CHANNEL_HEADROOM = 16) for stdio and the supervisor channel. Without it, a small open_files_max stops the child from even reporting its confinement.
  • Termination had a race. The wall-clock timer and the reader thread both observe a dying guest; the reader's generic "terminated unexpectedly" could beat the specific WallTimeExceeded into the queue. Termination now surfaces exactly one error — the specific one — and the kill completes before it is raised, so a caller catching WallTimeExceeded doesn't find the guest still burning CPU. That ordering is asserted in the test.

Tests

12 new tests: wall-clock kills a runaway guest (and the guest is dead when the error lands), in-budget work is untouched and the timer is disarmed, the timer re-arms across sequential ops, each rlimit is observable from inside the guest, sub-second cpu_ms rounds up and warns, and each unenforceable quota is refused.

Full suite 517 passed / 6 skipped (was 505). Wall-clock tests re-run 5× for flakiness — stable. flake8 clean.

SECURITY.md §2 now states the quota position precisely instead of the blanket claim.

Scope

Depends on nothing; environment scrubbing (#285) and the fail-open filesystem default are separate branches. Both touch process_backend.py in different regions.


Generated by Claude Code

claude and others added 2 commits August 15, 2026 20:42
Supervisor.spawn accepts cpu_ms, wall_time_ms, open_files_max,
network_ops_max, output_bytes_max, child_work_max and numa_node, but
_spawn_process forwarded only mem_bytes. Every other quota was accepted
and discarded with no error and no warning, so

    iso.spawn("x", backend="process", cpu_ms=50, wall_time_ms=100)

ran an unbounded loop to completion, while the same arguments on the
sub-interpreter backend raise WallTimeExceeded. The weaker backend enforced
limits that the one documented as the security boundary ignored.

Forward what this backend can actually enforce:

* cpu_ms -> RLIMIT_CPU, mem_bytes -> RLIMIT_AS, open_files_max ->
  RLIMIT_NOFILE, all applied in the child before guest code runs and
  recorded in the confinement report.
* wall_time_ms -> a supervisor-side timer, armed when an operation is
  dispatched and disarmed by the completion frame. This cannot be an
  rlimit: a guest blocked on I/O burns no CPU, so RLIMIT_CPU never fires.

Reject what it cannot. network_ops_max, output_bytes_max, child_work_max
and numa_node are in-process counters in the thread backend with no
equivalent across an address-space boundary, so they now raise
NotImplementedError naming the parameter rather than pretending to apply.

Two details that are easy to get wrong:

* RLIMIT_CPU has one-second granularity, so a sub-second cpu_ms rounds up.
  Rounding up silently would recreate the bug in miniature, so the weaker
  effective limit is logged.
* RLIMIT_NOFILE reserves headroom for stdio and the supervisor channel;
  without it a small open_files_max stops the child reporting confinement.
* The wall-clock timer and the reader thread race to observe a dying guest.
  Termination now surfaces exactly one error, the specific one, and the
  kill completes before it is raised so a caller catching WallTimeExceeded
  does not find the guest still running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ebvMQ3vLxdK3joymz6Feg
@seanwevans
seanwevans merged commit 6ba465e into main Aug 15, 2026
9 of 18 checks passed
@seanwevans
seanwevans deleted the claude/process-backend-quotas branch August 15, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants