Skip to content

Resource limits

Monty limits memory, time, sleep and recursion, while the host limits suspension events. Exceeding the memory, time, or suspension limit returns MemoryError, TimeoutError, or RuntimeError, respectively; sandboxed code cannot catch these exceptions. RecursionError is catchable, as in CPython.

Compilation

The duration limits start when the VM executes, so parsing, preparation, and bytecode compilation do not consume them. 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. The parser grows its native stack outside the sandbox allocator, so a source over 4 KiB is scanned for nesting before it is parsed (see language.md). 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.

Code compiled at runtime by eval() / exec() is the exception: it is parsed and compiled inside the VM, so that work is charged against max_feed_duration and max_turn_duration. Once a snippet starts executing, its source and bytecode stay allocated for the rest of the session. Snippets rejected before execution retain none of their compilation products (see eval_exec.md).

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 its hard ceiling with set_hard_limit(memory_limit_with_headroom(...)) 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), re.sub, padding (str.ljust, str.center, str.zfill, bytes.ljust, …), integer division and divmod, math.factorial, math.comb and math.perm, deque rotation, slicing and repeat, materialising an iterator into a container, and string formatting with dynamic width or precision, for f-strings (f"{v:>{w}}", f"{v:.{p}f}"), str.format() ("{0:>{1}}".format(v, w), "{0:.{1}f}".format(v, p)) and % formatting ("%*d" % (w, v), "%.*f" % (p, v)). 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, as is buffer growth a program drives one element at a time — append, insert, add or d[k] = v on a list, deque, set or dict, a parsed JSON array — and the argument buffers behind f(*args) and the pieces re.split collects. re.findall is covered only for a pattern with at most one capture group. A wider findall builds a tuple per match, and re.finditer a match object, and those accumulate between the checks on the result list itself, so a large enough subject still crosses the ceiling and kills the worker.
  • 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 cross the hard ceiling and kill the worker.
  • A value crossing to the host needs room for about three copies of itself. Returning a result, or passing an argument to a host function, holds the sandbox value, its converted host-side form, and the encoded frame at once. The effective ceiling for a single such value is therefore around a third of max_memory, not all of it — well under the limit for ordinary payloads, but a multi-MiB argument under a tight budget can cross the hard ceiling while announcing the call.
  • 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, plus the type checker once a session has needed it. Other 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 snapshot configuration), 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, which is indistinguishable from a stack overflow.

Integer-specific caps

  • pow(base, exp) / base ** exp with an exponent larger than u32::MAX (≈ 4.3 × 10⁹) raises OverflowError: "exponent too large", except for bases 0, 1 and -1, which are computed.
  • pow(base, exp, mod) requires all integer arguments and rejects negative exponents (ValueError). A call whose work estimate (exponent bits × modulus words²) is at most 2²⁷ runs to completion without polling the time limit, about 0.2 s on a laptop; larger calls poll between exponent bits, so a single squaring of the modulus is the longest uninterruptible step.
  • 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 defaults to 1000 frames; the 1001st nested call raises RecursionError. The host sets the ceiling per session via max_recursion_depth, but cannot remove it — unlike the time and memory limits, it has no “disabled” state.
  • 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__, non-plain-function __init__ values that recurse during construction, and calling a functools.partial. 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 the __repr__/__str__ entry in classes.md for the main user-visible divergence this causes.
  • Operations that walk a nested container in Rust — ==, <, repr(), hash(), isinstance(), json.dumps(), copy.deepcopy() — charge one recursion level per level of nesting, but each level costs real native stack (roughly 0.5-1.1 KiB, depending on the operation and the container). They are not capped separately the way native re-entry above is, so on a worker with a small stack a structure nested close to the 1000-frame limit can exhaust it before RecursionError is raised. A wasm worker (1 MiB) reaches that point at roughly 950 levels of nesting for the most expensive operations; the sandbox is not breached, but the worker dies and the pool replaces it rather than the session raising. Lowering max_recursion_depth moves the point at which the limit fires ahead of the stack.

Suspensions

  • max_suspensions bounds how many times a session may suspend to the host: external function calls, host-object method calls, attribute lookups and construction, OS calls, name lookups, and each ResolveFutures round trip (a partial future resolution that re-suspends counts again).
  • It defaults to 1000 and cannot be disabled (like max_recursion_depth): omitting it, or passing None, keeps the default; set a larger number for sessions that legitimately make more host calls.
  • monty-pool enforces it for pydantic_monty, the JavaScript napi pool and monty-server. The wasm worker pool and CLI also enforce it. A direct host must count suspensions and call abort itself.
  • The first suspension over budget is not returned to the caller. The host uses one extra worker round trip to raise RuntimeError: suspension limit N exceeded uncatchably at the suspension point with a traceback.
  • The count persists for the checkout. Once spent, every later feed ends on its first suspension. Non-suspending feeds still run, the heap stays consistent, and the session can still be dumped.
  • Only the limit travels in dumps. A restored session keeps max_suspensions but resets the count to zero; a limit configured on the restoring checkout caps the dump’s (the smaller of the two applies, and the configured one alone if the worker’s reply omits it).
  • There is no in-sandbox way to observe the budget or remaining count.

Sleep

  • max_total_sleep bounds cumulative system sleep durations (see time.md). It is disabled by default; sleeping loops remain bounded by max_suspensions. Bindings expose max_total_sleep_secs or maxTotalSleepSecs; the CLI uses --max-total-sleep.
  • Pools and the CLI charge each capped delay before waiting. Exceeding the total raises an uncatchable TimeoutError: sleep limit exceeded: <total> > <limit>. The reported total includes the refused sleep and uses Rust Duration formatting, such as 1.5s > 1s. An asyncio.sleep() costs its full capped delay when created, regardless of how long the host waits. Non-suspending MontyRun::run enforces no total sleep limit.
  • The time already slept travels in dumps with the limit, like execution time, so a restored session resumes its budget rather than restarting from zero.
  • Sleeps handed to the host under 'call_host' are not charged to it.

Time

  • The host can set a max_feed_duration or max_turn_duration budget; if either is exceeded the VM stops with a ResourceError at its next checkpoint. There is no cumulative per-session budget: both clocks restart, so nothing inside the sandbox bounds what a session costs over its lifetime.
  • When one checkpoint blows both budgets, the feed one is reported. The message names the scope — feed time limit exceeded, turn time limit exceeded.
  • 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 its budget before stopping.
  • Checkpoints are amortized rather than per-instruction: the dispatch loop reads the clock every 256th instruction, and the native loops that poll for themselves (iterator advancement, sequence repeats, comparisons, repr) do so every 64th item. Both are unconditional overshoots of ordinary time-limit enforcement, on top of the per-operation cases below.
  • A container narrower than that 64-item interval never reaches a poll at all. Structures that share sub-objects are walked once per path rather than once per object, so repr and == over one nested n levels deep do work exponential in n (x = (x, x) repeated, and the same through a generic alias). Neither limit is consulted until the walk finishes, and the two end differently: repr grows a result string until it crosses the allocator’s hard ceiling, while == allocates nothing proportional, so only the pool’s request_timeout ends it. hash is unaffected, each tuple caching its own.
  • Every host turn re-checks both limits as it returns, so a turn that finished without reaching a checkpoint still fails rather than returning its result. Two consequences: a turn whose Python code raised an exception reports the resource error instead of that exception, and an operation that swallows a timeout internally (repr truncating with ...[timeout]) still fails the turn that contained it.
  • 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 its duration budget 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 str case methods (lower, upper, casefold, capitalize, title, swapcase) and is*() predicates are not polled and run to completion. Their cost is linear in the input, so the overshoot is bounded by the largest string max_memory admits.
  • base64.a85decode() polls the clock every 64th byte that matches no Ascii85 digit and so reaches ignorechars. Each of those bytes is one in test against the container, so a large explicit ignorechars overshoots its duration budget in proportion to its length.
  • Both budgets cover 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.
  • There is no in-sandbox way to observe either budget or the time left in it.
  • max_feed_duration and max_turn_duration bound one clock over two scopes, differing only in when it restarts: at each feed, and at each feed or answered suspension respectively.
  • A feed that the host never resumes leaves its max_feed_duration clock where it stopped. The next feed resets it, so the abandoned feed’s time is charged to nothing.
  • MontyRepl::call_function counts as its own feed and its own turn, so a host-driven call is never charged for the feeds before it.
  • A turn’s clock restarts at the resume, not at the point the host answered, so it never includes the time the host spent deciding.
  • Continuations the VM resolves without the host — an await on an already-settled future, a task switch — stay inside the turn that started them and do not restart the turn clock.
  • A name lookup the host answers restarts the turn clock, including one answered as Undefined: the round trip happened either way.
  • max_feed_duration is serialized into dumps/snapshots, so a restored session resumes the feed budget it was dumped mid-way through; max_turn_duration’s clock is not, because a dump is only ever taken between turns.

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.