docs/limitations/resource_limits.md
Monty limits memory, time, 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.
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. 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.
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.'x' * n), replacement
(str.replace, bytes.replace), re.sub, padding (str.ljust, str.center,
str.zfill, bytes.ljust, …), integer division and divmod, deque
rotation, slicing and repeat, materialising an iterator into a
container, and string formatting with dynamic width or precision, for
both f-strings (f"{v:>{w}}", f"{v:.{p}f}") and str.format()
("{0:>{1}}".format(v, w), "{0:.{1}f}".format(v, p)). 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.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.
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.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.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.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.load_session /
load_snapshot restore the dump's own limits (see
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().MemoryError, but exceeding the hard limit traps the instance and the host
reports [MontyCrashedError][pydantic_monty.MontyCrashedError]. Its usize is also 32 bits, so a limit near
4 GiB leaves the module uncapped.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.
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).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.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.sys.setrecursionlimit() as a lowering-only fixture hook; it cannot
raise the host-configured ceiling.await boundary is treated
as one frame, so await-chains do not amplify depth.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.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).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.RuntimeError: suspension limit N exceeded uncatchably at the
suspension point with a traceback.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).max_duration budget; if exceeded the VM stops with a
ResourceError at its next checkpoint.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.repr)
do so every 64th item. Both are unconditional overshoots of ordinary
max_duration enforcement, on top of the per-operation cases below.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 max_duration in proportion to
its length.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).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 max_duration in proportion to its length.json.loads rejects input nested deeper than 200 levels with
json.JSONDecodeError (independent of the Python recursion limit).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.