Skip to content

max_memory_mb: bound how much a run grows the process (#160) - #730

Merged
Masterplanner25 merged 2 commits into
mainfrom
feat/max-memory-mb
Sep 2, 2026
Merged

Masterplanner25 merged 2 commits into
mainfrom
feat/max-memory-mb

Conversation

@Masterplanner25

Copy link
Copy Markdown
Owner

Closes #160 (SEC-001).

NodusRuntime(max_memory_mb=256)

max_steps bounds instructions and timeout_ms bounds wall clock; neither stopped a script growing a list until the host got a MemoryError. Off by default like the other two.

Three of the issue's four approaches were measured and rejected

The numbers are the reason this reads RSS from the OS rather than counting allocations:

approach measured
tracemalloc 64x slowdown (0.026s → 1.681s on a 300k loop), 11.9 µs/read — unusable even opt-in, and #173 already names throughput as the blocker
sys.getallocatedblocks() 0.8 µs, but counts blocks not bytes, so it cannot back a megabyte limit without inventing a conversion. Absent on PyPy
counting in the VM's value constructors deterministic, but measures elements where an embedder asked for megabytes. Left on the table, not dismissed
RSS via the OS 4.4 µs, real megabytes, Windows and POSIX — chosen

Polled every 256 instructions: ~0.6% against Nodus's ~2.75 µs per instruction. It started at 2000 and came down because a string doubling in a loop reached a MemoryError between two checks.

What it cannot do

Said plainly in SECURITY_POSTURE.md §5 rather than left implied: polling bounds growth over time, not a single allocation. One enormous list is served before the next check fires. OS-level limits (ulimit -v, cgroups, a container cap) remain the answer for hard memory safety — this does not replace them. It also measures process RSS, so a host allocating on another thread is charged to the guest.

Refused at construction where the platform cannot be metered, rather than accepted and silently unenforced. A security control an operator believes they have is worse than none — the pattern #473 and #478 were both filed for.

The PyPy budget caught the first version

It added four VM instance attributes, and tests/test_vm_attribute_budget.py failed at 81 against the 80-attribute map limit — the cliff that cost ~9x throughput the last time it was crossed (#702, #488).

The fix was the one CLAUDE.md prescribes — shed attributes, don't raise the number. The runtime now reads the baseline at run start and hands the VM a single absolute ceiling, so four attributes became one, the interval became a class constant, and a modulo replaced the "last checked" counter. The design is better for the constraint.

Verification

The issue's own reproduction aborts with:

Memory limit exceeded: this run grew the process to 61 MB, past its 60 MB ceiling

(686s to get there — that is #173's throughput, not this check.)

Mutation Result
Remove the per-instruction check the same program is still running at 45s, where it stops in 1.7s
Accept an unenforceable limit instead of refusing the refusal test fails

mypy also caught the narrowing loss from extracting the check into a helper — the same trap CLAUDE.md records from the bytecode-cache refactor — and the POSIX branch reaches resource through getattr, so it type-checks on Windows and on the Linux runner alike.

nodus_gate --all green across nine phases; ruff and mypy clean; 191 memory/embedding/sandbox/limit tests pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn

Masterplanner25 and others added 2 commits September 2, 2026 15:35
…160)

    NodusRuntime(max_memory_mb=256)

`max_steps` bounds instructions and `timeout_ms` bounds wall clock; neither
stopped a script growing a list until the host got a MemoryError or the OS killed
the process. SEC-001 is that gap. Off by default like the other two -- how much
memory a run may have is host policy.

THREE OF THE ISSUE'S FOUR PROPOSED APPROACHES WERE MEASURED AND REJECTED, and the
numbers are the reason this reads RSS from the OS instead:

  tracemalloc              64x SLOWDOWN (0.026s -> 1.681s on a 300k loop) and
                           11.9 us/read. Unusable even opt-in, and #173 already
                           names throughput as the bootstrapping blocker.
  sys.getallocatedblocks   0.8 us, but counts BLOCKS NOT BYTES, so it cannot back
                           a limit in megabytes without inventing a conversion.
                           Absent on PyPy.
  counting in the VM's     deterministic, but measures ELEMENTS where an embedder
  value constructors       asked for megabytes. Left on the table, not dismissed.
  RSS via the OS           4.4 us, real megabytes, Windows and POSIX. Chosen.

Polled every 256 instructions -- ~0.6% against Nodus's ~2.75 us per instruction.
It started at 2000 and came down because a string doubling in a loop reached a
MemoryError BETWEEN two checks.

WHAT IT CANNOT DO, said plainly in SECURITY_POSTURE.md rather than left implied:
polling bounds growth over time, not a SINGLE allocation. One enormous list is
served before the next check. OS-level limits (ulimit -v, cgroups, a container
cap) remain the answer for hard memory safety, not something this replaces. It
also measures PROCESS RSS, so a host allocating on another thread is charged to
the guest.

REFUSED AT CONSTRUCTION where the platform cannot be metered, rather than accepted
and silently unenforced. A security control an operator believes they have is
worse than none -- the pattern #473 and #478 were both filed for.

THE PYPY BUDGET CAUGHT THE FIRST VERSION. It added four VM instance attributes and
tests/test_vm_attribute_budget.py failed at 81 against the 80-attribute map limit,
which cost ~9x throughput the last time it was crossed (#702, #488). The fix was
the one CLAUDE.md prescribes -- shed attributes, do not raise the number: the
runtime reads the baseline at run start and hands the VM a single absolute
CEILING, so four attributes became one, the interval became a class constant, and
a modulo replaced the "last checked" counter. The design is better for it.

Verified: the issue's own reproduction aborts with "Memory limit exceeded: this
run grew the process to 61 MB, past its 60 MB ceiling" (686s, which is #173's
throughput and not this check). Removing the check leaves the same program still
running at 45s where it stops in 1.7s with it. Accepting an unenforceable limit
instead of refusing fails the refusal test.

mypy also caught the narrowing loss from extracting the check into a helper --
the same trap CLAUDE.md records from the bytecode-cache refactor -- and the POSIX
branch reaches `resource` through getattr so it type-checks on Windows and on the
Linux runner alike.

nodus_gate --all green across nine phases. ruff and mypy clean. 191 memory/
embedding/sandbox/limit tests pass.

Closes #160

Claude-Session: https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_rss_is_readable_and_grows` allocated ~25 MB and asserted the reading rose.
It passed on Windows and failed on CI with:

    AssertionError: 179421184 not greater than 180469760

The reading went DOWN. Python satisfied the allocation from pages the process
already held while the surrounding 3192-test run freed others. Nothing was wrong
with the meter or the limit -- every behavioural test passed, including the one
that aborts a growing program. The assumption that a small allocation must move
RSS was wrong.

Split in two: one test that the reading is plausible, and one that allocates
~150 MB of fresh pages and requires at least 50 MB of movement. The margin is
what makes it a statement about the meter rather than about the runner's mood.

The granularity is worth knowing rather than papering over, so it is now recorded
in the test and the changelog: `max_memory_mb` bounds a run's FOOTPRINT, not its
allocation count, and small churn is invisible to it by design.

Claude-Session: https://claude.ai/code/session_01B4R857intVYD1fLgnN4uVn

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Masterplanner25
Masterplanner25 merged commit 1bbafeb into main Sep 2, 2026
4 checks passed
@Masterplanner25
Masterplanner25 deleted the feat/max-memory-mb branch September 2, 2026 22:57
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.

SEC-001: Memory allocation limits not enforced — heap exhaustion possible

1 participant