Back to Monty

Resource limits

limitations/resource_limits.md

0.0.219.9 KB
Original Source

Resource limits

Monty enforces limits on memory, time, and recursion to keep untrusted code bounded. Memory limits surface to the host as MemoryErrors and time limits as TimeoutErrors; sandboxed code cannot catch either resource error. RecursionError is catchable, as in CPython.

Compilation

max_duration starts when the VM executes, so parsing, preparation, and bytecode compilation do not consume it. In workers, allocations retained by compiled code do count toward max_memory; transient compilation allocations are released before execution reaches its first memory checkpoint. Compilation has separate structural caps for parser nesting, bytecode operand sizes, comprehension nesting, and repeated finally expansion. In particular, a code object requiring more than 1,024 emitted copies of finally bodies is rejected with SyntaxError; CPython has no equivalent limit. Production hosts should still isolate compilation when accepting untrusted source, as the subprocess and WebAssembly runtimes do.

Memory / size limits

  • Memory usage is measured by the worker's process-global allocator, while the configured budget belongs to one session.
  • Workers count bytes requested from their global allocator. Direct Rust users must install monty-alloc as the global allocator and arm it with set_limit before using max_memory; without it usage always reads as zero and the limit is silently not enforced.
  • Operations whose result is bounded by simple arithmetic on input sizes are pre-checked before allocating: integer multiplication, left shift, integer power, sequence repeat ('x' * n), replacement (str.replace, bytes.replace), padding (str.ljust, str.center, str.zfill, bytes.ljust, …), and f-string formatting (both dynamic width f"{v:>{w}}" and dynamic precision on float formats f"{v:.{p}f}" / e / %). The pre-check threshold is 100 KB — estimates above that are checked against the remaining budget and rejected with MemoryError before allocation when they would exceed it.
  • bigint.pow(base, exp) estimates result size as bits(base) * exp with a 4× safety multiplier to cover repeated-squaring intermediate values.

Exceeding max_memory in a worker (pools)

A worker counts every byte requested from its global allocator. Nothing extra is enabled by the host: setting max_memory on a session applies it, and a session without one is unlimited.

  • The configured limit is soft. The interpreter reads current allocator usage at execution checkpoints and reports a terminal MemoryError to the host after crossing it. The incomplete operation is unwound and the worker and session survive, although sandboxed Python cannot catch resource errors.
  • A burst can still kill the worker. A hard ceiling sits above the configured limit so exception and traceback machinery can run. Crossing that ceiling between checkpoints exits the subprocess with its dedicated OOM status, or traps wasm. The pool replaces the worker and the session is lost. Large result operations are pre-checked to avoid this path when their size is known.
  • Work outside Python execution is hard-limit-only. Request framing, input decoding, loading snapshots, and type checking do not reach an interpreter checkpoint. A sufficiently large allocation there can therefore cross the hard ceiling and kill the worker.
  • It binds the worker's allocator, not the process. Only bytes requested from Rust's global allocator are counted, which is everything sandboxed code can cause to be allocated, but not memory obtained another way: thread stacks, the binary's own mapped image, or a direct mmap. It is not a kernel-enforced bound on process memory — an inherited ulimit -v or cgroup limit is the tool for that, and still applies independently (a worker whose allocation the kernel then refuses reports the same MemoryError).
  • It counts requested bytes, not resident ones. Per-allocation overhead and fragmentation sit between the count and the process's real footprint, so RSS runs somewhat above the limit.
  • max_memory alone does not bound worker memory. The hard ceiling includes the worker's baseline plus a fixed gap above the soft limit — a few MiB, more with type checking. Use max_processes and an OS-level limit to bound a host.
  • Per session, but against a fixed baseline. A worker serves many checkouts and re-derives the cap for each session, always from the leanest the process has been. Memory retained between sessions therefore consumes the headroom rather than raising the cap, and a worker whose residue outgrows it is killed and replaced rather than allowed to grow indefinitely.
  • Restoring a dump is bounded by the checkout it lands in. load_session / load_snapshot restore the dump's own limits (see limitations/pool-architecture.md), and the cap is re-derived from them once the session exists — but the load itself runs under the limit the checkout() config applied. Restoring a large dump into a checkout with a much smaller max_memory can therefore exceed it while loading; pass a comparable limit to checkout().
  • The wasm worker cannot classify a hard breach. A soft breach is a normal MemoryError, but exceeding the hard limit traps the instance and the host reports MontyCrashedError. Its usize is also 32 bits, so a limit near 4 GiB leaves the module uncapped.
  • WebSocket workers get no allocator-enforced limit at all: they are remote processes this pool does not spawn.

Independently of any limit, any allocation a worker's allocator refuses — plain host OOM, or a request beyond the usable address space such as ' ' * (1 << 60) — takes this same path: on a worker with an exit status the host sees that MemoryError with its session gone, and on wasm the same refusal traps, reported as MontyCrashedError per the bullet above. CPython raises a catchable MemoryError in-process and carries on. Monty cannot: the failure happens below the interpreter, where no Python-level exception can be raised, so the worker classifies the failure into a dedicated exit code and dies. (Without that, the process would abort with SIGABRT — indistinguishable from a stack overflow, which is why the sandbox exits deliberately instead.)

Integer-specific caps

  • pow(base, exp) / base ** exp with an exponent larger than u32::MAX (≈ 4.3 × 10⁹) raises OverflowError: "exponent too large".
  • pow(base, exp, mod) requires all integer arguments and rejects negative exponents (ValueError).
  • int(str_or_bytes, base) rejects inputs over 4,300 digits before the potentially quadratic BigInt parse when the effective base is not a power of two. The fixed cap matches CPython's sys.int_info.default_max_str_digits.

Recursion

  • Python-level call depth is hardcoded at 1000 frames. The 1001st nested call raises RecursionError.
  • Production sandbox code cannot change the recursion limit. Test builds may expose sys.setrecursionlimit() as a lowering-only fixture hook; it cannot raise the host-configured ceiling.
  • Async stacks count toward the limit but each await boundary is treated as one frame, so await-chains do not amplify depth.
  • Callbacks evaluated synchronously by the interpreter itself re-enter on the native Rust call stack rather than the heap-allocated frame stack used by ordinary function calls. This includes map(), filter(), sorted()/list.sort(key=...), min()/max(key=...), recursive __repr__/__str__, and non-plain-function __init__ values that recurse during construction. Native re-entry is capped independently at a lower fixed depth than the 1000-frame Python limit, so Monty raises RecursionError before a native stack overflow would abort the process. See limitations/classes.md's __repr__/__str__ entry for the main user-visible divergence this causes.

Time

  • The host can set a max_duration budget; if exceeded the VM stops on the next bytecode boundary with ResourceError.
  • Enforcement is polled, not preemptive: a single bytecode instruction may run a long native operation (a bytes substring scan, a sort, an iterator drain), and those poll the clock at a coarse granularity. A run can therefore overshoot max_duration before stopping.
  • bytes operations that search for a sub-sequence (in with a bytes-like probe, find, count, split, partition, replace and their variants) poll the clock every 64KiB, or every two lengths of the searched-for sequence if that is longer. Searching for a sequence over 64KiB therefore overshoots max_duration in proportion to its length.
  • The neighbouring bytes operations that scan without a sub-sequence are not polled and run to completion however large the input: in with an integer probe (a single-byte scan) and split()/rsplit() left to their default sep=None (whitespace splitting).
  • The budget covers cumulative execution time, not wall-clock time: the clock runs only while the interpreter executes bytecode, and is paused while execution is suspended waiting on the host (external function calls, OS callbacks) and between REPL feeds. It accumulates across feeds for the life of the session.
  • The accumulated time is serialized into dumps/snapshots, so a restored session resumes its budget where it left off rather than restarting from zero.
  • There is no in-sandbox way to observe the budget or remaining time.

JSON

  • json.loads rejects input nested deeper than 200 levels with json.JSONDecodeError (independent of the Python recursion limit).

After a terminal resource error

A worker remains responsive after a soft memory or time limit and its session can receive another feed, but execution is not transactional and no guarantees are made about heap state or reference counts. Hosts should discard the session; the worker itself remains reusable. A caught RecursionError may continue normally inside the sandbox.