Getting Started with Rust
For running untrusted code, use monty-pool:
cargo add monty-pool monty-types tokio --features tokio/macros,tokio/rt-multi-thread
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:
cargo add monty monty-types
| Crate | What it is |
|---|---|
monty | The core interpreter: Python parser, bytecode VM, sandbox |
monty-types | Shared boundary types: values, exceptions, OS calls, limits |
monty-fs | Host-side filesystem mounts |
monty-runtime | The monty binary: REPL, file runner, subprocess worker |
monty-pool | Elastic pool of crash-isolated worker subprocesses |
monty-proto | The protobuf wire protocol between pool parents and workers |
monty-type-checking | Type checking, powered by ty |
monty-typeshed | Trimmed 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.
monty-poolruns the interpreter only inmontyworker subprocesses. Use this for untrusted code. It is the same engine the Python and JavaScript packages are built on.montyis the in-process interpreter. Use it when you control the code being run, or when subprocesses are impossible.
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.
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(())
}
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.
It returns a TurnEvent:
TurnEvent | Meaning | Answer with |
|---|---|---|
Complete(value) | The snippet finished | nothing; 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 operation | Checkout::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 Some | Checkout::resume_name_lookup |
ResolveFutures { .. } | Every sandbox task is blocked on host futures | Checkout::resume_futures |
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.
Checkout::dump and Checkout::restore snapshot and restore a session, including onto a different worker or machine.
- 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 parent-side deadline kills any worker whose turn exceeds
request_timeout(PoolError::Timeout), catching hangs the in-sandbox limits cannot see. With amax_durationbudget the deadline also enforces that from outside the child, plusduration_limit_grace.PoolConfig::subprocesssets neitherrequest_timeoutnorcheckout_timeoutby default; setrequest_timeoutyourself 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 uncatchableRuntimeError. - Untrusted children — 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_workerbounds the impact of a slow leak.
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.
A spent max_duration rejects every later feed.
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.
PoolConfig::subprocess spawns local monty subprocess children over framed stdio.
These are the poolable workers: prewarmed, reused across checkouts, replaced on crash.
PoolConfig::websocket dials a remote child over ws:///wss://.
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.
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 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));
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.
use std::time::Duration;
use monty::MontyRun;
use monty_types::{CompileOptions, PrintWriter, ResourceLimits, ResourceTracker};
let limits = ResourceLimits {
max_memory: Some(10 * 1024 * 1024),
max_duration: Some(Duration::from_millis(20)),
..ResourceLimits::default()
};
let 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("time limit exceeded"));
run has no host to ask, so it answers date.today() and datetime.now() from a clock of its own — this machine’s,
unless you choose otherwise:
use monty::MontyRun;
use monty_types::{CompileOptions, MontyObject, PrintWriter, ResourceTracker};
let code = "from datetime import date\ndate.today().year";
let runner = MontyRun::new(code.to_owned(), "today.py", vec![], CompileOptions::default()).unwrap();
let year = runner.run(vec![], ResourceTracker::default(), PrintWriter::Stdout).unwrap();
assert!(matches!(year, MontyObject::Int(y) if y >= 2026));
with_host_clock changes that: HostClock::Denied takes the clock away, for embedders who would rather sandboxed code
could not read their wall time at all, and HostClock::Fixed freezes an instant, for runs that have to be reproducible.
start ignores this: there the call pauses and the host answers it, like any other OS call, and the same is true of
every pool session (see the clock).
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 { name: "get_data".to_owned(), docstring: 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, vec![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));
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.
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.
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:
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));
MontyRepl— feed code snippet by snippet with state persisting between snippets.- The
fsmodule — mount host directories into the sandbox at virtual paths, with path resolution hardened against escapes. See filesystem access. RunProgress::OsCallandRunProgress::NameLookup— the filesystem/osoperations and undefined-name reads the host intercepts.FunctionCall::object_idandNameLookup::object_id— set for method calls and lazy attribute lookups routed to a host object sent asMontyObject::ClassInstanceorMontyObject::Type; the receiver is not inargs.