Skip to content

Resource Limits

Untrusted code will eventually try to allocate forever or loop forever. Monty enforces hard limits on memory, execution time and recursion depth, configured per session.

from pydantic_monty import Monty, MontyRuntimeError

limits = {
    'max_memory': 10_000_000,
    'max_feed_duration_secs': 1.0,
    'max_recursion_depth': 100,
}

with Monty() as pool:
    with pool.checkout(limits=limits) as session:
        try:
            session.feed_run('x = [0] * 100_000_000')
        except MontyRuntimeError as exc:
            print(exc.display(format='type-msg').split(':')[0])
            #> MemoryError

Settings

KeyMeaning
max_memoryMaximum heap memory in bytes
max_feed_duration_secsMaximum execution time per feed_run, in seconds
max_turn_duration_secsMaximum execution time per host round trip, in seconds
max_recursion_depthMaximum function call stack depth (default 1000)
gc_intervalRun garbage collection every N allocations
max_suspensionsMaximum host round trips (external calls, os callbacks, name lookups, future resolution) per session (default 1000), counting calls to async host functions started without one
max_total_sleep_secsMaximum cumulative time the host waits on time.sleep() and asyncio.sleep(), in seconds

Every key is optional. Omit max_memory, either duration key or max_total_sleep_secs, or set them to None, to disable that limit. max_recursion_depth and max_suspensions cannot be disabled: omitting either, or passing None, leaves its 1000 default. gc_interval omitted or None uses the built-in schedule of every 100,000 allocations; collection cannot be turned off.

In JavaScript the same fields are maxMemory, maxFeedDurationSecs, maxTurnDurationSecs, maxRecursionDepth, gcInterval, maxSuspensions and maxTotalSleepSecs, passed as limits to pool.checkout(). In Rust they are the fields of monty_types::ResourceLimits, where the durations are Durations named max_feed_duration, max_turn_duration and max_total_sleep.

Memory

max_memory budgets the bytes a worker requests from its global allocator, counted from the leanest the worker process has been. Everything the session allocates counts against it, including retained compiled code and interpreter internals. It is not a ceiling on process RSS. Allocations are counted as requested, so per-allocation overhead and fragmentation sit outside the count, as does memory obtained without the allocator: thread stacks, the binary’s mapped image, a direct mmap. Size the limit with headroom, and use an OS or cgroup limit to bound the process itself.

Operations whose result size is predictable from their inputs are pre-checked before allocating, above a 100 KB threshold, including integer multiplication, division and divmod, left shift, integer power, sequence repeat ('x' * n), str.replace / bytes.replace, re.sub, the padding methods, deque rotation and slicing, materialising an iterator into a container, f-string, str.format() or % formatting with a dynamic width or precision, and the binascii and base64 conversions that can outgrow their input. So 'x' * 10**12 fails immediately rather than after consuming the machine’s memory.

Containers a program grows one element at a time are pre-checked as well, at the point the buffer would reallocate rather than on every push: list.append and list.insert, deque.append and deque.appendleft, set.add, and assigning a new dict key. So are the value buffers a single call fills: the argument pack behind f(*args), the array json.loads parses, the text json.dumps and json.dump build, the pieces str.split, bytes.split and re.split collect, the matches re.finditer builds, and the list re.findall builds for a pattern with at most one capture group. A wider findall allocates a tuple per match and is not covered — see the limitations note.

A few integer operations carry their own caps regardless of max_memory:

  • base ** exp with an exponent above u32::MAX raises OverflowError, except for bases 0, 1 and -1.
  • int(s, base) rejects strings over 4,300 digits before the quadratic BigInt parse when the base is not a power of two, matching CPython’s sys.int_info.default_max_str_digits.

Time

Both duration limits count execution time.

Wall mode counts elapsed time while the VM runs. It is the default. CPU mode uses the executing thread’s CPU counter. It is supported only on Linux. Native OS counters exclude scheduling delays, CPU throttling, and work on other threads. Virtualized counters can approximate CPU consumption. gVisor can still misattribute time and report coarse samples. Local subprocess pools on macOS and Windows reject CPU mode, as do WebAssembly workers, which have no thread CPU counter. A remote client on any platform can still use a Linux server’s CPU clock. Workers reject CPU configuration if the initial thread CPU counter read fails. If the counter later fails or reverses during execution, Monty conservatively charges elapsed time for that window.

Cooperative checks reuse a CPU sample for at most 1 ms of elapsed time. A smaller remaining budget shortens that interval when the execution window starts. The VM takes a fresh sample at every execution boundary, even for shorter windows. Limit detection can overshoot by the polling interval, OS counter resolution, and work between checks.

  • The clock runs only while the interpreter executes bytecode or its native builtins.
  • It is paused while execution is suspended waiting on the host — a host function that takes a minute costs nothing, and neither does a time.sleep() or asyncio.sleep(), which the host waits out. Under the default 'system' sleep mode the pool charges those sleeps to max_total_sleep_secs instead, refusing a sleep that would take the total over with an uncatchable TimeoutError; each is also a suspension, so max_suspensions bounds a sleeping loop as well. Under 'call_host' the host waits uncharged, and a 'zero' sleep does not suspend at all. See security.
  • There is no way for sandboxed code to observe a budget or the time remaining. os_policy={'process_time': 'elapsed'} exposes this same execution clock as time.process_time(); the default 'zero' keeps it hidden.

They read the same clock and differ only in when it restarts:

KeyRestartsBounds
max_feed_duration_secsat each feedone feed, spanning every host round trip it makes
max_turn_duration_secsat each host answerthe stretch of code between two host round trips

So a turn’s time is also charged to its feed, and whichever budget is tightest fires first. When one check blows both, the feed limit is reported.

There is no cumulative per-session budget: every limit restarts, so a long-lived session is bounded per request rather than over its lifetime. Cap what a session may cost in total from the host side, by counting the execution time each turn reports and ending the checkout yourself.

max_feed_duration_secs is serialized into snapshots, so a restored session resumes that budget rather than restarting from zero. Older snapshots retain their recorded consumption, including previously charged elapsed time. The snapshot stores its clock. Loading with a different configured clock fails. Snapshots without a clock field use wall time. No consumed budget is reset or estimated backwards. A snapshot is only ever taken between turns, so max_turn_duration_secs has nothing to carry.

Reach for max_feed_duration_secs to keep a long-lived session responsive per request, and max_turn_duration_secs to bound one uninterrupted stretch of sandbox code, so a host driving the session gets control back after a bounded amount of execution. Use request_timeout for a wall deadline. Neither bounds how long a host callback itself may take: the clock is paused for exactly that, and a host that needs to bound its own waiting wants request_timeout.

Exceeding either raises TimeoutError in the sandbox. The next feed resets both clocks, so the worker keeps serving the session — but a time limit stops the sandbox mid-operation, leaving no guarantees about its heap. Discard the session rather than feeding it again; see after a terminal resource error.

Host-side backstops

The in-sandbox check runs at interpreter checkpoints, so it cannot catch code that wedges the interpreter itself. Host-side deadlines cover that:

  • request_timeout on the pool is a hard per-turn deadline. A worker that exceeds it is killed and the call raises MontyCrashedError with timed_out=True. Each resume after a host-function or mount call starts a new deadline, so a program that suspends often can outlive any single timeout. This deadline does not set the sleep cap: default system sleeps return normally after at most ten seconds, without a warning, even when sandbox code asks for longer, unless the capped delay would exceed max_total_sleep_secs. Configure sleep_system_max (sleepSystemMax in JavaScript) in the session’s OS policy to change that cap. See waiting.
  • A duration backstop per limit. For each duration budget a session sets, the worker reports its consumed time on every protocol turn. The host uses the selected execution clock for the remaining budget plus grace. Local CPU supervision reads the child’s OS counter rather than trusting its reported time.

The grace is what the sandbox gets to raise TimeoutError itself and keep the session alive; missing it costs the session, so set it well above how long a checkpoint may be away. Each grace defaults to 1 second and is a pool option: feed_duration_limit_grace and turn_duration_limit_grace in Python, feedDurationLimitGrace and turnDurationLimitGrace in JavaScript. None (null in JavaScript) disables that backstop, leaving only the in-sandbox check and request_timeout.

The explicit request_timeout always uses wall time. CPU mode does not derive wall deadlines from CPU budgets.

Set a duration limit for untrusted code that may suspend repeatedly; request_timeout alone does not bound the overall call. These deadlines are polled: synchronous host telemetry callbacks and decoding a large reply can delay enforcement. Neither deadline covers host mount I/O.

CPU execution clock

Select Monty(execution_clock='cpu', request_timeout=2) in Python, Monty.create({executionClock: 'cpu', requestTimeout: 2}) in JavaScript, or PoolConfig::execution_clock = ExecutionClock::Cpu in Rust. The pool applies this policy to every session. Session limits select numeric budgets, not clocks. Wall time remains the default. CPU mode requires a Linux subprocess worker and a finite local pool request_timeout. Local subprocess pools on macOS and Windows and WebAssembly workers reject CPU mode.

The subprocess pool independently measures child process CPU, including compilation and protocol work during execution turns. It stops measuring at each completion or suspension. Host callback time is outside the window. The existing feed and turn grace settings use CPU time in this mode. Feed consumption and grace do not renew on resume.

When a CPU backstop is active, the pool polls the child’s CPU clock every 5 ms during execution turns. CPU exhaustion kills and reaps the worker. The explicit wall deadline handles blocked workers. A supervisor clock failure discards the worker instead of weakening enforcement. CPU mode needs /proc mounted for the pool’s own PID namespace, as in a standard container; spawning a worker fails otherwise. The pool records the child’s start time at spawn and checks it before acting on a reading, so another process’s CPU time never decides a kill or charges the feed; a reused PID counts as a clock failure. OS counter resolution and scheduling can delay detection. This is approximate accounting, not a hard overshoot guarantee.

Restore starts from the trusted snapshot’s saved VM consumption and takes a fresh OS baseline. Snapshots do not store protocol CPU or spent CPU grace separately. For remote sessions, the server’s pool or profile selects the clock beside the actual child. Clients do not select or override it. The server reports its clock with the configuration reply. The client uses that metadata to avoid deriving wall deadlines from CPU budgets. Use clients that understand the clock metadata with CPU-mode server profiles for this noisy-neighbor protection. Older clients ignore this metadata and can retain premature wall-time backstops under contention. Default wall-mode servers remain compatible with those clients. A CPU-mode server requires a finite client request_timeout, independent of its own supervisor deadline. Python and Node remote clients default to 10 seconds. Rust clients have no default deadline. Choose this wall deadline for the expected budgets and scheduling delays. It can expire before a CPU budget under severe contention. CPU accounting does not extend it. A missing clock means a legacy wall-time server. Unknown clock values fail configuration. Local CPU pools require the worker to acknowledge CPU mode before executing code. A remote client cannot independently measure a remote process. See PoolConfig for the pool settings.

Recursion

Python-level call depth defaults to 1000 frames; the 1001st nested call raises RecursionError. Internal Python frames implementing builtins do not count toward this limit; user callbacks still do. Unlike the memory and time limits, RecursionError is catchable inside the sandbox, matching CPython. Sandboxed code cannot raise the ceiling — sys.setrecursionlimit is not available in production builds.

Each await boundary counts as one frame, so await chains do not amplify depth.

Callbacks the interpreter evaluates synchronously — map(), filter(), sorted(key=...), min/max(key=...), recursive __repr__/__str__ — re-enter on the native Rust stack rather than the heap-allocated frame stack. Those are capped independently at a lower fixed depth, so Monty raises RecursionError before a native stack overflow could abort the process.

Suspensions

max_suspensions counts external calls, host-object method calls and construction, lazy attribute lookups, os callbacks (the sleeps among them in every mode but 'zero', the clock only under 'call_host'), name lookups and future-resolution events. Each call to an async host function counts too, though it starts without a suspension. Once a session has started max_suspensions such calls, the next one suspends, so the limit still ends a loop that never awaits them. When those calls start without suspending, the pool also caps the arguments they hold on the host: within a feed, calls whose results it has not yet sent back may hold at most a quarter of its per-frame decode budget between them (256 MiB by default; max_decode_bytes sets the budget on AsyncMontyRemote). A call past that never reaches the host: the pool discards the worker, so unlike a MemoryError from max_memory, the session cannot run later feeds. These host round trips are outside max_memory; each ClassType construction with init=True also adds an instance-store entry. Because the duration limits pause during suspensions, a snippet could otherwise retry rejected calls indefinitely.

The pool enforces the limit per checkout; the default is 1000, and a host that needs more sets a larger number. A host driving the interpreter directly counts suspensions and calls abort itself; the limit only travels in the ResourceTracker, see the Rust quickstart. The first suspension over the budget aborts the feed with an uncatchable RuntimeError: suspension limit 3 exceeded at the call site. The session stays consistent and can be dumped; later feeds run until they suspend. Restoring a dump preserves the limit but resets the count to zero; a max_suspensions set on the restoring checkout caps the dump’s, so a worker cannot report a looser one.

Worker recycling

Local pools replace a worker after 1000 checkouts, bounding how long a slow leak or a compromised worker survives. Set max_checkouts_per_worker (maxCheckoutsPerWorker in JavaScript) to change the count: 0 and 1 both give every session a fresh worker, and None (null) never recycles. Remote checkouts are single-use, so the count does not apply to them.

What is not covered

  • Compilation time. Parsing and bytecode compilation happen before the VM exists and are not charged to the duration budget; memory retained by compiled code does count toward max_memory in workers. Compilation has its own structural caps (AST nesting at 200 levels, bytecode operand sizes, comprehension nesting, and a 1,024-copy cap on finally expansion that raises SyntaxError). The nesting cap is also checked before parsing for sources over 4 KiB, because the parser grows its native stack outside the allocator’s accounting; see source nesting depth. A host accepting untrusted source should still isolate compilation, as the subprocess and WebAssembly runtimes do. Source compiled at runtime by eval() / exec() is charged against the duration budget. Once a snippet starts executing, its compilation products stay allocated for the rest of the session; snippets rejected before execution retain none of them (see eval_exec.md).
  • Protocol decoding. Protobuf frames have a separate cumulative allocation budget, covering generated messages and decoded values before allocation; see message limits. It applies per frame, not to total host memory or subsequent host conversions.
  • Print collectors. CollectString and CollectStreams live in the host process, so their 10 MiB default cap is separate from max_memory.
  • Mount memory. Each mount has its own memory_usage_limit, defaulting to DEFAULT_MEMORY_USAGE_LIMIT, shared between retained overlay data and transient results.
  • json.loads nesting, capped at 200 levels independently of the recursion limit.
  • tomllib nesting, capped at 100 levels of inline arrays and tables, and at 1,000 parts per dotted key, both independently of max_recursion_depth (see tomllib.md).
  • Host entropy. Python’s AbstractOS.urandom() raises MemoryError before allocating when a request exceeds max_urandom_bytes, 1 MiB by default (OSAccess(max_urandom_bytes=...)). A custom entropy callback allocates in the host process, so it must apply its own cap.
  • OSAccess files. MemoryFile content lives in the host process; OSAccess caps all of it at max_total_file_bytes (50 MB) by default (see write limits).
  • The host instance store. Every ClassInstance/ClassType wrapper sent into a session (nested wrappers, init=True constructions and convert_value wraps included) is retained in the host process until the session ends; re-sending a wrapper with the same id reuses its entry, distinct wrappers accumulate; see host objects.

After a limit fires

A memory or time limit is terminal. Sandboxed code cannot catch it, and once it fires no guarantees are made about heap state or reference counts — the heap may hold orphaned objects with wrong refcounts. Discard the session rather than continuing to run code in it. max_suspensions also raises uncatchably, but ends the feed cleanly. The session remains usable until code suspends again.

The pool does not do this for you. The checkout stays open and accepts further feed_run calls. Both duration budgets restart, so a later feed runs — against a heap that a trip still leaves untrustworthy. After a max_memory trip a later feed may likewise quietly succeed against a heap you can no longer trust. Ending the session is your job. A caught RecursionError is the exception; it does not invalidate anything and execution may continue.

Full details, including the exact pre-check thresholds, live in limitations/resource_limits.md.