Skip to content

Getting Started with Rust

Installation

For running untrusted code, use monty-pool:

Terminal
cargo add monty-pool monty-types tokio --features tokio/macros,tokio/rt-multi-thread,monty-types/tzdb

Workers are monty CLI binaries: build one with cargo build -p monty-runtime from the Monty repository, or install it from PyPI as pydantic-monty-runtime.

The in-process interpreter is the monty crate:

Terminal
cargo add monty monty-types --features monty-types/tzdb
CrateWhat it is
montyThe core interpreter: Python parser, bytecode VM, sandbox
monty-typesShared boundary types: values, exceptions, OS calls, limits
monty-fsHost-side filesystem mounts
monty-runtimeThe monty binary: REPL, file runner, subprocess worker
monty-poolElastic pool of crash-isolated worker subprocesses
monty-protoThe protobuf wire protocol between clients and workers
monty-type-checkingType checking, powered by ty
monty-typeshedTrimmed typeshed stubs for Monty’s stdlib subset

Host-side crates depend on monty-types, not on monty, so the interpreter is not linked into your parent process at all; monty-proto links it only with its worker feature, which the workers enable. The Rust API pages document monty, monty-pool, monty-types, monty-fs, monty-proto and monty-type-checking.

Custom OS handlers can use monty_types::normalize_virtual_path after validating the received path. It shares the mounts’ lexical POSIX normalization; see filesystem callbacks for validation order and access checks.

Build inputs with MontyObject constructors and inspect them with as_ref(). The builders on CallArgs and NamedValues accept owned values; their iterators return ObjectRef views. Representation APIs under monty_types::unstable carry no API compatibility guarantee.

Two ways to run Monty

  • monty-pool runs the interpreter only in monty worker subprocesses. Use this for untrusted code. It is the same engine the Python and JavaScript packages are built on.
  • monty is the in-process interpreter. Use it when you control the code being run, or when subprocesses are impossible.

Execution limits use wall time by default. CPU mode, Linux only, uses sampled thread CPU time. The monty crate enables the monty-types/cpu-clock feature for native CPU reads. ExecutionClock::is_available() checks whether this thread can read the selected clock. Off Linux a CPU-mode ResourceTracker charges elapsed time instead, so check it before selecting CPU mode directly. CPU-mode PoolConfig::subprocess pools independently measure child process CPU. They require a finite PoolConfig::request_timeout as a wall deadline. Remote deployments enable CPU enforcement in the server’s local subprocess pool; remote clients adopt its clock. See resource limits for platform support, sampling, fallback, and snapshot compatibility.

A Monty process can never be made fully crash-proof against memory errors — a stack-overflow abort or an allocator abort takes the whole process down. That is the entire reason monty-pool exists: the crash kills a worker, the pool notices and replaces it, and your process is untouched.

Running untrusted code with monty-pool

use std::time::Duration;

use monty_pool::{Pool, PoolConfig, PoolError, ReplConfig, TurnEvent, on_print_sync};

#[tokio::main]
async fn main() -> Result<(), PoolError> {
    let mut config = PoolConfig::subprocess("path/to/monty");
    // no timeouts by default; set one before running untrusted code
    config.request_timeout = Some(Duration::from_secs(30));
    let pool = Pool::new(config).await?;

    let mut session = pool.checkout(&ReplConfig::default()).await?;
    let mut on_print = on_print_sync(|_stream, text| print!("{text}"));

    // session state persists between feeds on the same checkout
    session.feed("x = 21", vec![], vec![], false, &mut on_print).await?;
    let event = session.feed("x * 2", vec![], vec![], false, &mut on_print).await?;
    match event {
        TurnEvent::Complete(value) => println!("result: {value:?}"), // Int(42)
        // other events are suspensions (external function calls, OS calls,
        // name lookups, futures) answered with `resume` / `resume_name_lookup`
        // / `resume_futures` to continue the turn
        other => println!("suspended: {other:?}"),
    }

    // return the worker to the pool for reuse by the next checkout
    session.finish().await?;
    Ok(())
}

Use Checkout::finish to reset a reusable session. Use Checkout::terminate to discard a subprocess and wait for its exit, including after a cancelled turn. Only Some(ExitStatus) confirms process exit. None does not confirm physical termination. Dropping a checkout requests cleanup without waiting for that confirmation.

Checkout::feed takes the code, inputs (host values bound as sandbox globals), per-feed filesystem mounts (MountSpec), a skip_type_check flag and a print sink; Checkout::feed_with_cwd also sets the sandbox’s working directory, which otherwise defaults to the first mount. It returns a TurnEvent:

TurnEventMeaningAnswer with
Complete(value)The snippet finishednothing; feed again
FunctionCall { object_id, .. }The sandbox called a host function, or with object_id (the wrapper’s uuid) Some, a method on a host object or a host class’s construction (arriving as __call__)Checkout::resume
OsCall { .. }The sandbox performed an OS operationCheckout::resume_from_mounts or resume
NameLookup { name, object_id }The sandbox read an undefined name, or a lazy attribute of a host object when object_id is SomeCheckout::resume_name_lookup
ResolveFutures { .. }Every sandbox task is blocked on host futuresCheckout::resume_futures

Every suspension variant also carries position, a SourceRange locating the suspending expression: the call, the name, or the await the main task is blocked on.

With PoolConfig::stream_async_calls (on by default for remote pools, off by default for subprocess pools) and a handler passed to Checkout::set_streamed_call_handler, calls to async host functions that the sandbox does not await at once do not suspend: a MontyObject::async_function value, or a name passed to Checkout::set_async_functions for the feeds that follow. The worker sends them mid-turn and keeps running, and the turn hands each to the handler in order with prints. Start the call there, and settle it with resume_futures once ResolveFutures names it. Each counts against max_suspensions: once that budget is spent, the next call suspends instead and the feed is aborted there. Without a handler, or after set_async_functions(None), calls suspend like any other call. Calls whose results the host has not sent may hold at most a quarter of PoolConfig::max_decode_bytes of arguments; past that the session ends with a MemoryError.

A Checkout dropped without finish() kills its worker rather than returning it — mid-execution state cannot be trusted back into the pool.

ReplConfig carries the per-session sandbox ResourceLimits and type-checking options. A host module’s .pyi reaches the checker through the import_stubs OS call below. Checkout::worker_id() identifies the worker within its pool, independent of PID reuse and transport; it returns None after the worker is released or discarded. Checkout::pid() is the subprocess-only OS diagnostic.

Checkout::dump and Checkout::restore snapshot and restore a session, including onto a different worker or machine. Restore only unmodified snapshots whose provenance and integrity the caller has established; see snapshot security.

What the pool adds over in-process execution

  • Crash isolation — a segfault, stack-overflow abort or allocator abort in the sandbox becomes PoolError::Crashed; the pool discards the worker and spawns a replacement.
  • Hard timeouts — a client-side deadline kills any worker whose turn exceeds request_timeout (PoolError::Timeout), catching hangs the in-sandbox limits cannot see. With a max_feed_duration or max_turn_duration budget the deadline also enforces that from outside the worker, each plus its own grace (feed_duration_limit_grace, turn_duration_limit_grace). PoolConfig::subprocess sets neither request_timeout nor checkout_timeout by default; set request_timeout yourself for untrusted code.
  • Suspension limits — the pool counts external calls, OS calls, name lookups and future-resolution turns against ResourceLimits::max_suspensions. The first suspension over the limit ends the feed with an uncatchable RuntimeError.
  • Untrusted workers — every frame from a possibly compromised worker is validated; wire decoding never panics, and a protocol violation discards the worker.
  • Worker recycling — max_checkouts_per_worker replaces a worker after DEFAULT_MAX_CHECKOUTS_PER_WORKER (1000) checkouts; None never recycles, and with PoolConfig::subprocess Some(1) gives every session a fresh process. Remote checkouts are single-use, so the setting does not apply to them. While a subprocess pool is open, recycled, crashed and discarded workers are replaced in the background until min_processes are live again, so checkouts usually find a warm worker.

Runtime errors inside the sandbox (PoolError::Runtime) are not crashes: the worker and its session stay alive and usable. Memory and time limits return PoolError::Runtime with a MemoryError or TimeoutError, but no guarantees hold about heap state afterwards. max_feed_duration and max_turn_duration both restart, so a later feed runs against a heap you can no longer trust. Finish the checkout and take a fresh one.

max_suspensions also returns PoolError::Runtime, but leaves the session consistent. Later feeds run until they suspend; the count remains spent.

Transports

Custom protobuf transports should decode protocol messages through decode_frame or FrameReader. Both manage the per-frame allocation budget automatically, including cleanup on errors or unwinding. Raw prost::Message::decode calls fail if they attempt an allocation without a frame budget. Protocol repeated fields and byte buffers use BudgetVec; convert standard vectors with .into() and recover them with .into_inner() without copying. Reference containers use WireIndexes, WireNodePairs and WireNamedTuple, with domain NodeIds rather than raw protobuf integers. Host construction and cloning do not use the decode budget. See the monty-proto README for the budget’s scope.

PoolConfig::subprocess spawns local monty subprocess workers over framed stdio. These are the poolable workers: prewarmed, reused across checkouts, replaced on crash.

PoolConfig::remote reaches a remote worker over gRPC at an http:///https:// URL: one HTTP/2 connection per pool, one MontySession.Session stream per checkout. Those workers are single-use, never prewarmed or returned to the pool, and isolation becomes the remote host’s responsibility. See the security model before using it.

Host decode budget

PoolConfig::max_decode_bytes can lower the default 1 GiB allocation budget for each protocol frame from a subprocess or remote worker. Values above 1 GiB do not raise the ceiling. The budget excludes wire buffers and host-language conversion. Exhaustion discards the worker without replaying the request. On the remote transport it also caps each message’s size, clamped between 64 KiB and the 256 MiB frame limit, so a larger message is refused before it is buffered.

The in-process interpreter

MontyRun parses and compiles code once; run executes it with input values and returns the value of the final expression as a MontyObject:

use monty::MontyRun;
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};

let code = r#"
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

fib(x)
"#;

let mut runner = MontyRun::new(code.to_owned(), "fib.py", vec!["x".to_owned()], CompileOptions::default()).unwrap();
let result = runner.run(vec![MontyObject::int(10)], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
assert_eq!(result, MontyObject::int(55));

CompileOptions also carries source_scan_threshold, the source length in bytes above which a pre-parse nesting scan runs (4 KiB by default); see source nesting depth.

Errors come back as MontyException, with a traceback matching what CPython would produce. PrintWriter controls where print() output goes: Stdout, Disabled, or collected — into a String, or into a CollectedStreams buffer whose entries() label each run stdout or stderr.

Resource limits

use std::time::Duration;

use monty::MontyRun;
use monty_types::{CompileOptions, PrintWriter, ResourceLimits, ResourceTracker};

let limits = ResourceLimits {
    max_memory: Some(10 * 1024 * 1024),
    max_feed_duration: Some(Duration::from_millis(20)),
    ..ResourceLimits::default()
};

let mut runner = MontyRun::new("while True: pass".to_owned(), "spin.py", vec![], CompileOptions::default()).unwrap();
let err = runner.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout).unwrap_err();
assert!(err.to_string().contains("feed time limit exceeded"));

The clock, sleeping and entropy

Both run and start answer clock calls from the system clock and seed unseeded generators from OS entropy. Sleeps are capped at ten seconds per call. run waits inline; start returns RunProgress::OsCall with SystemSleep or AsyncSystemSleep. Wait for the requested delay and answer with None, or a future for asyncio.sleep():

use monty::MontyRun;
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};

let code = "from datetime import date\ndate.today().year";
let mut runner = MontyRun::new(code.to_owned(), "today.py", vec![], CompileOptions::default()).unwrap();
let year = runner.run(vec![], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
assert!(year.as_ref().as_int().is_some_and(|y| y >= 2026));

with_os_policy configures each operation. DateTimeSource::Fixed freezes the clock, SandboxTimeZone::Fixed sets the local UTC offset, SandboxTimeZone::named takes an IANA zone name, and RandomStart::Seed seeds random for reproducible runs. named needs a tz database, which is a monty-types feature: tzdb reads the OS copy (TZDIR, /usr/share/zoneinfo) and falls back to a bundled one, tzdb-bundled uses only the bundle. Without either, every name is unknown. SleepMode::Zero skips waits; SleepMode::System(max) sets their cap. ProcessTime::Zero (the default) keeps time.process_time() at zero; ProcessTime::Elapsed reports the session’s execution time. CallHost delegates through RunProgress::OsCall under start, or raises NotImplementedError under run:

use monty::MontyRun;
use monty_types::{
    OsPolicy, CompileOptions, DateTimeSource, MontyObject, PrintWriter, ProcessTime, RandomSeed, RandomStart,
    ResourceTracker, SandboxTimeZone, SleepMode,
};

let calls = OsPolicy {
    datetime: DateTimeSource::Fixed { unix_seconds: 1_700_000_000, microsecond: 0 },
    timezone: SandboxTimeZone::Fixed { offset_seconds: 0, name: Some("UTC".to_owned()) },
    sleep: SleepMode::Zero,
    process_time: ProcessTime::Zero,
    random_start: RandomStart::Seed(RandomSeed::Int(42.into())),
};
let code = "import random, time\nfrom datetime import date\ntime.sleep(3600)\n(date.today().year, random.random())";
let mut runner = MontyRun::new(code.to_owned(), "fixed.py", vec![], CompileOptions::default())
    .unwrap()
    .with_os_policy(calls);
let result = runner.run(vec![], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
// CPython: random.seed(42); random.random()
assert_eq!(result, MontyObject::tuple([MontyObject::int(2023), MontyObject::float(0.6394267984578837)]));

Every pool session takes the same struct as ReplConfig::os_policy (see the clock). A loaded dump keeps the policy it was taken under; call MontyRepl::set_os_policy, or ReplProgress::set_os_policy for a suspended dump, to apply your own, as pool workers do with the restoring checkout’s policy. os.urandom() is the one call with no in-process answer: under run it raises NotImplementedError, under start it pauses for the host (see random).

Host functions and pausing

MontyRun::start returns a RunProgress that pauses whenever the sandboxed code calls a function the host provides. The host runs the real function and resumes with the result:

use monty::{MontyRun, RunProgress};
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};

let code = "data = get_data(3)\ndata * 2";
let runner = MontyRun::new(code.to_owned(), "main.py", vec!["get_data".to_owned()], CompileOptions::default()).unwrap();

// pass the external function in as an input
let get_data = MontyObject::function("get_data".to_owned(), None);
let progress = runner.start(vec![get_data], ResourceTracker::default(), PrintWriter::Stdout).unwrap();

// execution pauses at the `get_data(3)` call
let RunProgress::FunctionCall(call) = progress else { panic!("expected a function call") };
assert_eq!(call.function_name, "get_data");
assert_eq!(call.args.arg(0).unwrap(), MontyObject::int(3));

// the host computes the result and resumes
let progress = call.resume(MontyObject::int(21), PrintWriter::Stdout).unwrap();
let RunProgress::Complete(result) = progress else { panic!("expected completion") };
assert_eq!(result, MontyObject::int(42));

typing.get_type_hints() of a host function or class suspends with OsFunctionCall::GetTypeHints, naming the function (TypeHintsTarget::Function) or the class’s id (TypeHintsTarget::Class). The host’s answer is the call’s result; OsFunctionCall::no_handler_result() answers {} for a host without hints:

use monty::{MontyRun, RunProgress};
use monty_types::{CompileOptions, MontyObject, MontyType, OsFunctionCall, PrintWriter, ResourceTracker, TypeHintsTarget};

let code = "import typing\ntyping.get_type_hints(fetch)['url']";
let runner = MontyRun::new(code.to_owned(), "main.py", vec!["fetch".to_owned()], CompileOptions::default()).unwrap();
let fetch = MontyObject::function("fetch", None);
let progress = runner.start(vec![fetch], ResourceTracker::default(), PrintWriter::Stdout).unwrap();

let RunProgress::OsCall(call) = progress else { panic!("expected an OS call") };
let OsFunctionCall::GetTypeHints(TypeHintsTarget::Function(name)) = &call.function_call else { panic!() };
assert_eq!(name, "fetch");
let hints = MontyObject::dict([(MontyObject::string("url"), MontyObject::type_object(MontyType::Str))]);
let progress = call.resume(hints, PrintWriter::Stdout).unwrap();
assert_eq!(progress.into_complete().unwrap(), MontyObject::type_object(MontyType::Str));

Async host functions work the same way: FunctionCall::resume_pending continues with a pending future the sandboxed code can await, and when every task is blocked the run yields RunProgress::ResolveFutures for the host to settle. When FunctionCall::allow_eager_await is true the call is awaited immediately and no other task can run, so a host that already has the result can pass it to FunctionCall::resume_eager and skip the ResolveFutures round trip.

Unknown imports suspend with OsFunctionCall::Import, whose ImportArgs carry the dotted name and the module’s search path. The host’s answer becomes the module; dotted imports read subsequent components as attributes. Hosts usually return a host-backed class instance containing the module’s attributes. A host with no such module may answer with the module’s .py file instead, ResumeValue::ModuleFile (in-process, ExtFunctionResult::ModuleFile), which the sandbox runs as the module. ImportArgs::candidates lists the files to try, Checkout::read_from_mounts reads one through the feed’s mounts without resuming, and find_module_file runs the whole search with a read function the host supplies. ResumeValue::NotFound and NotHandled raise ModuleNotFoundError; in-process, OsCall::resume with ExtFunctionResult::NotFound does the same, as does a run with no host. A future answer raises RuntimeError at the import. Before type checking, OsFunctionCall::ImportStubs requests imported host modules the session has not asked about. Answer with a dict of ModuleStub names to .pyi sources, using None for missing stubs, or NotHandled for no stubs. The session retains these stubs in its dumps.

FunctionCall, OsCall, NameLookup and ResolveFutures expose abort, which raises a host-supplied MontyException uncatchably at the suspension point and unwinds the run with a traceback. A host driving the interpreter directly must count suspensions and call abort to enforce max_suspensions; ResourceTracker stores that limit but does not enforce it.

Serialization

The free function monty::dump serializes a session — idle between feeds (SessionRef::Idle) or suspended mid-run (SessionRef::Suspended) — together with its script name and type-check state. Dump::load restores it, in the same process or a different one. Both this method and direct serde deserialization require unmodified bytes from a trusted, compatible Monty producer. The caller must establish provenance and integrity; invalid snapshots have no correctness or availability guarantees. See snapshot security.

use monty::{Dump, MontyRepl, Session, SessionRef, dump};
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};

let mut repl = MontyRepl::new("repl.py", ResourceTracker::default(), CompileOptions::default());
repl.feed_run("x = 40", vec![], PrintWriter::Stdout).unwrap();

// dumping is read-only: the live session can keep feeding
let bytes = dump("repl.py", None, &[], SessionRef::Idle(&repl)).unwrap();

// later, restore and keep going
let Session::Idle(mut restored) = Dump::load(&bytes).unwrap().state else { panic!() };
let result = restored.feed_run("x + 2", vec![], PrintWriter::Stdout).unwrap();
assert_eq!(result, MontyObject::int(42));

Other pieces

Remote disconnects

PoolError::Disconnected retains optional remote details with the gRPC status that ended the stream (code and message, plus the server’s RemoteCause when it sent one), or the transport error. The peer reason is unverified text, and a disconnect does not establish whether the request ran. The display text includes that reason. PoolError::Refused identifies a server refusal before a session exists. Its details provide retryable() and retry_after (a Duration from Retry-After, capped at one hour), with no automatic retry. PoolError::Connect retains the status or transport error for other connection failures. PoolError::Configuration identifies invalid or incompatible settings, such as no finite request_timeout for a CPU-mode pool or server.